first commit

This commit is contained in:
pie
2026-05-03 16:48:18 +01:00
commit d0dba8183b
10 changed files with 610 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
FROM python:3.11-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
ffmpeg \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy requirements and install
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ ./app/
COPY start.sh .
RUN chmod +x start.sh
# Create necessary directories
RUN mkdir -p config/thumbnails videos
# Expose ports (FastAPI: 8003, Streamlit: 8503)
EXPOSE 8003 8503
# Set Environment Variables
ENV CONFIG_DIR=/app/config
ENV VIDEOS_DIR=/app/videos
ENV PYTHONUNBUFFERED=1
CMD ["./start.sh"]
+64
View File
@@ -0,0 +1,64 @@
# Boys_Streaming 📺
> A simplified, kid-friendly Chromecast controller for the bedtime ritual.
## 🎯 Overview
Boys_Streaming is a Python-based Streamlit application designed to run in a Docker container. It interfaces with a NAS-mounted video library to cast a specific sequence of content to a Chromecast device: **3 Stories (< 15m) followed by 1 Calm Music track (> 2h).**
## 🛠 Tech Stack
* **Language:** Python 3.11+
* **Framework:** Streamlit (UI/UX)
* **Casting Lib:** `pychromecast`
* **Media Handling:** `ffmpeg-python` (for thumbnails and duration)
* **Infrastructure:** Docker & Docker Compose
* **Storage:** Local Bind Mounts (NAS source)
## 📂 Project Structure
```text
.
├── app/
│ ├── main.py # Streamlit entry point & UI
│ ├── cast_logic.py # Chromecast discovery & playback functions
│ ├── library.py # File scanning & metadata (thumbnail) logic
│ ├── server.py # Local HTTP server to serve media
│ └── utils.py # DevOps helpers (logging, env vars)
├── config/ # Persistent storage mount (thumbnails, state)
├── videos/ # Mounted NAS directory (Read-only)
├── Dockerfile # Multi-stage build
├── docker-compose.yml # Portainer-ready deployment
└── GEMINI.md # Project soul & context
```
## 📜 Development Rules
### 🚀 DevOps Mindset
* Clean, functional Python with strict type hints.
* Robust logging for network discovery and Chromecast connectivity issues.
* Environment-based configuration (use `.env` for local dev).
### 🧠 State Management
* Use `st.session_state` to track the playlist selection and casting status.
* Cache library scans (using `st.cache_data`) to mitigate NAS latency.
* Persistence: Use the `/config` folder to track "already watched" or "random seed" state.
### 🎨 UI for Kids
* **Thumbnails:** Primary selection tool. Large, high-quality previews.
* **Large Buttons:** Touch-friendly and easy to read.
* **Workflow:** Pick 3 Stories -> Auto-append Music -> Single "Cast" button.
### ⚖️ Logic Constraints
* **"Stories":** Files strictly < 15 minutes.
* **"Calm Music":** Files strictly > 2 hours.
* **Sequence:** Always exactly 3 Stories + 1 Music (forced).
## 🚀 Commands
| Task | Command |
| :--- | :--- |
| **Build** | `docker build -t boys_streaming:latest .` |
| **Deploy** | `docker-compose up -d` |
| **Logs** | `docker logs -f boys_streaming` |
| **Stop** | `docker-compose down` |
## 🧠 Context & Edge Cases
* **Networking:** Must use `network_mode: host` in Docker to allow mDNS/Chromecast discovery.
* **Media Server:** Chromecast requires a URL. We must run a sidecar or internal HTTP server to serve `/videos`.
* **Thumbnails:** Generate and store thumbnails in `/config/thumbnails` to avoid re-processing.
* **Non-Negotiable:** The calm music video is the "exit strategy" and cannot be unselected.
+50
View File
@@ -0,0 +1,50 @@
from fastapi import FastAPI, BackgroundTasks, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import List
import os
from .library import scan_library
from .cast_logic import cast_manager
from .utils import get_logger, VIDEOS_DIR, THUMBNAILS_DIR, get_host_ip
logger = get_logger(__name__)
app = FastAPI(title="Boys_Streaming API")
# Serve videos and thumbnails
app.mount("/media", StaticFiles(directory=str(VIDEOS_DIR)), name="media")
app.mount("/thumbs", StaticFiles(directory=str(THUMBNAILS_DIR)), name="thumbs")
class CastRequest(BaseModel):
video_paths: List[str]
@app.get("/library")
async def get_library():
return scan_library()
@app.post("/cast")
async def start_casting(request: CastRequest, background_tasks: BackgroundTasks):
host_ip = get_host_ip()
# Port is 8000 by default for uvicorn in our setup
urls = [f"http://{host_ip}:8000/media/{path}" for path in request.video_paths]
logger.info(f"Received cast request for {len(urls)} videos")
cast_manager.play_queue(urls)
return {"status": "started", "queue_len": len(urls)}
@app.get("/status")
async def get_status():
return cast_manager.get_status()
@app.post("/stop")
async def stop_casting():
cast_manager.stop()
return {"status": "stopped"}
@app.on_event("startup")
async def startup_event():
# Pre-discover Chromecast on startup to be ready
logger.info("Application starting up...")
# Run discovery in background to not block startup
import threading
threading.Thread(target=cast_manager.discover, daemon=True).start()
+97
View File
@@ -0,0 +1,97 @@
import pychromecast
from pychromecast.controllers.media import MediaController
import time
import threading
from typing import List, Optional
from .utils import get_logger
logger = get_logger(__name__)
class CastManager:
def __init__(self):
self.chromecast = None
self.mc: Optional[MediaController] = None
self.queue: List[str] = []
self.current_index = -1
self.is_playing = False
self._lock = threading.Lock()
self._discover_thread = None
self.browser = None
def discover(self, timeout=5):
logger.info("Discovering Chromecasts...")
chromecasts, browser = pychromecast.get_chromecasts(timeout=timeout)
self.browser = browser
if chromecasts:
# For simplicity, pick the first one found or allow selection later
# In a real scenario, we might want to target a specific name
self.chromecast = chromecasts[0]
logger.info(f"Found Chromecast: {self.chromecast.name}")
self.chromecast.wait()
self.mc = self.chromecast.media_controller
self.mc.register_status_listener(self)
else:
logger.error("No Chromecasts found.")
def play_queue(self, urls: List[str]):
with self._lock:
self.queue = urls
self.current_index = 0
self._play_current()
def _play_current(self):
if 0 <= self.current_index < len(self.queue):
url = self.queue[self.current_index]
logger.info(f"Playing {self.current_index + 1}/{len(self.queue)}: {url}")
if not self.chromecast:
self.discover()
if self.chromecast:
self.chromecast.wait()
# Use generic video type, or try to guess from URL
self.mc.play_media(url, 'video/mp4')
self.is_playing = True
else:
logger.error("Cannot play: No Chromecast connected.")
else:
logger.info("Queue finished.")
self.is_playing = False
self.current_index = -1
def stop(self):
with self._lock:
if self.mc:
self.mc.stop()
self.queue = []
self.current_index = -1
self.is_playing = False
def new_media_status(self, status):
"""Callback from pychromecast when media status changes."""
logger.debug(f"Media status update: {status.player_state}")
# Check if the current video finished
if status.player_state == "IDLE" and status.idle_reason == "FINISHED":
logger.info("Current track finished. Moving to next...")
with self._lock:
if self.current_index != -1:
self.current_index += 1
# Small delay to ensure state transitions
threading.Timer(1.0, self._play_current).start()
def get_status(self):
status = {
"is_playing": self.is_playing,
"queue_len": len(self.queue),
"current_index": self.current_index,
}
if self.mc and self.mc.status:
status.update({
"player_state": self.mc.status.player_state,
"current_time": self.mc.status.current_time,
"duration": self.mc.status.duration,
})
return status
# Singleton instance
cast_manager = CastManager()
+127
View File
@@ -0,0 +1,127 @@
import os
import json
import ffmpeg
import hashlib
from pathlib import Path
from typing import List, Dict, Optional
from .utils import get_logger, VIDEOS_DIR, THUMBNAILS_DIR, CONFIG_DIR
logger = get_logger(__name__)
LIBRARY_CACHE = CONFIG_DIR / "library_cache.json"
class MediaItem:
def __init__(self, path: Path, duration: float, thumbnail_path: str):
self.path = path
self.duration = duration
self.thumbnail_path = thumbnail_path
self.id = hashlib.md5(str(path).encode()).hexdigest()
def to_dict(self):
return {
"id": self.id,
"filename": self.path.name,
"path": str(self.path),
"duration": self.duration,
"thumbnail": self.thumbnail_path
}
def get_video_duration(path: Path) -> float:
try:
probe = ffmpeg.probe(str(path))
video_info = next(s for s in probe['streams'] if s['codec_type'] == 'video')
return float(probe['format']['duration'])
except Exception as e:
logger.error(f"Error probing {path}: {e}")
return 0.0
def generate_thumbnail(path: Path) -> Optional[str]:
thumb_name = hashlib.md5(str(path).encode()).hexdigest() + ".jpg"
thumb_path = THUMBNAILS_DIR / thumb_name
if thumb_path.exists():
return thumb_name
try:
# Get duration to pick a frame from the middle
duration = get_video_duration(path)
# Take a frame at 50% or 10s if duration is long
time = min(10, duration / 2) if duration > 0 else 0
(
ffmpeg
.input(str(path), ss=time)
.filter('scale', 640, -1)
.output(str(thumb_path), vframes=1)
.overwrite_output()
.run(quiet=True)
)
return thumb_name
except Exception as e:
logger.error(f"Error generating thumbnail for {path}: {e}")
return None
def scan_library() -> Dict[str, List[Dict]]:
logger.info(f"Scanning library in {VIDEOS_DIR}...")
cache = {}
if LIBRARY_CACHE.exists():
try:
with open(LIBRARY_CACHE, 'r') as f:
cache = json.load(f)
except Exception:
logger.warning("Could not read library cache.")
valid_extensions = {'.mp4', '.mkv', '.avi', '.mov', '.m4v'}
stories = []
music = []
for root, _, files in os.walk(VIDEOS_DIR):
for file in files:
path = Path(root) / file
if path.suffix.lower() not in valid_extensions:
continue
rel_path = str(path.relative_to(VIDEOS_DIR))
# Check cache
item_data = cache.get(rel_path)
if item_data and os.path.getmtime(path) == item_data.get('mtime'):
# Check if thumbnail still exists
if (THUMBNAILS_DIR / item_data['thumbnail']).exists():
item = item_data
else:
item_data = None # Force re-gen
if not item_data or os.path.getmtime(path) != item_data.get('mtime'):
duration = get_video_duration(path)
thumb_name = generate_thumbnail(path)
if thumb_name:
item = {
"id": hashlib.md5(str(path).encode()).hexdigest(),
"filename": path.name,
"path": rel_path,
"duration": duration,
"thumbnail": thumb_name,
"mtime": os.path.getmtime(path)
}
cache[rel_path] = item
else:
continue
# Categorize
# Stories < 15m (900s)
# Music > 2h (7200s)
if item['duration'] < 900:
stories.append(item)
elif item['duration'] > 7200:
music.append(item)
# Save cache
with open(LIBRARY_CACHE, 'w') as f:
json.dump(cache, f)
return {
"stories": stories,
"music": music
}
+153
View File
@@ -0,0 +1,153 @@
import streamlit as st
import httpx
import time
from pathlib import Path
from utils import get_logger, get_host_ip
logger = get_logger(__name__)
# Config - Use host IP so the browser can find the API
HOST_IP = get_host_ip()
API_URL = f"http://{HOST_IP}:8000"
st.set_page_config(
page_title="Boys Streaming 📺",
page_icon="📺",
layout="wide"
)
# Custom CSS for big buttons and clean look
st.markdown("""
<style>
.stButton>button {
width: 100%;
height: 5em;
font-size: 20px !important;
font-weight: bold !important;
}
.thumb-container {
border: 2px solid #ddd;
border-radius: 10px;
padding: 5px;
text-align: center;
cursor: pointer;
}
.thumb-selected {
border: 5px solid #ff4b4b;
}
</style>
""", unsafe_allow_html=True)
# Session State Initialization
if 'selected_stories' not in st.session_state:
st.session_state.selected_stories = []
if 'library' not in st.session_state:
st.session_state.library = None
def fetch_library():
try:
response = httpx.get(f"{API_URL}/library")
if response.status_code == 200:
st.session_state.library = response.json()
else:
st.error("Failed to fetch library from API.")
except Exception as e:
st.error(f"Could not connect to API: {e}")
def toggle_story(story_path):
if story_path in st.session_state.selected_stories:
st.session_state.selected_stories.remove(story_path)
else:
if len(st.session_state.selected_stories) < 3:
st.session_state.selected_stories.append(story_path)
else:
st.warning("Only 3 stories allowed!")
def start_bedtime():
if len(st.session_state.selected_stories) != 3:
st.error("Please pick exactly 3 stories first!")
return
# Auto-pick one music track
if not st.session_state.library['music']:
st.error("No music tracks found in library (> 2h)!")
return
import random
music_track = random.choice(st.session_state.library['music'])
playlist = st.session_state.selected_stories + [music_track['path']]
try:
response = httpx.post(f"{API_URL}/cast", json={"video_paths": playlist})
if response.status_code == 200:
st.success("Bedtime ritual started! Casting to Chromecast...")
else:
st.error("Failed to start casting.")
except Exception as e:
st.error(f"Error starting bedtime: {e}")
# --- UI Layout ---
st.title("📺 Boys Streaming - Bedtime!")
if st.button("🔄 Refresh Library"):
fetch_library()
if not st.session_state.library:
fetch_library()
if st.session_state.library:
stories = st.session_state.library.get('stories', [])
st.subheader(f"📖 Pick 3 Stories ({len(st.session_state.selected_stories)}/3)")
# Display stories in a grid
cols_per_row = 4
for i in range(0, len(stories), cols_per_row):
cols = st.columns(cols_per_row)
for j, col in enumerate(cols):
if i + j < len(stories):
story = stories[i + j]
is_selected = story['path'] in st.session_state.selected_stories
with col:
# Use a unique key for each button
thumb_url = f"{API_URL}/thumbs/{story['thumbnail']}"
st.image(thumb_url, use_container_width=True)
label = f"{story['filename']}" if is_selected else story['filename']
if st.button(label, key=f"btn_{story['id']}"):
toggle_story(story['path'])
st.rerun()
st.divider()
# Action area
col1, col2 = st.columns(2)
with col1:
if st.button("🚀 START BEDTIME", type="primary", use_container_width=True):
start_bedtime()
with col2:
if st.button("🛑 STOP CASTING", use_container_width=True):
httpx.post(f"{API_URL}/stop")
st.warning("Casting stopped.")
# Status Monitor
st.divider()
status_placeholder = st.empty()
# Auto-refresh status
try:
status_resp = httpx.get(f"{API_URL}/status")
if status_resp.status_code == 200:
status = status_resp.json()
if status.get('is_playing'):
status_placeholder.info(f"Currently Playing item index: {status.get('current_index', 0) + 1} of {status.get('queue_len', 0)}")
else:
status_placeholder.text("Chromecast is idle.")
except:
pass
else:
st.info("Waiting for library scan...")
time.sleep(2)
st.rerun()
+45
View File
@@ -0,0 +1,45 @@
import logging
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# Path configuration
BASE_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = Path(os.getenv("CONFIG_DIR", BASE_DIR / "config"))
VIDEOS_DIR = Path(os.getenv("VIDEOS_DIR", BASE_DIR / "videos"))
THUMBNAILS_DIR = CONFIG_DIR / "thumbnails"
# Ensure directories exist
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
# Logging configuration
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(),
logging.FileHandler(CONFIG_DIR / "app.log")
]
)
def get_logger(name: str):
return logging.getLogger(name)
def get_host_ip():
"""Returns the host IP to be used for Chromecast.
In host networking mode, we can use a socket to find the primary interface IP."""
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
# doesn't even have to be reachable
s.connect(('10.255.255.255', 1))
IP = s.getsockname()[0]
except Exception:
IP = '127.0.0.1'
finally:
s.close()
return IP
+19
View File
@@ -0,0 +1,19 @@
version: '3.8'
services:
boys_streaming:
build: .
container_name: boys_streaming
network_mode: host
restart: unless-stopped
volumes:
- ./config:/app/config
- ./videos:/app/videos
environment:
- VIDEOS_DIR=/app/videos
- CONFIG_DIR=/app/config
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8501/_stcore/health"]
interval: 30s
timeout: 10s
retries: 3
+9
View File
@@ -0,0 +1,9 @@
streamlit
fastapi
uvicorn
pychromecast
ffmpeg-python
pydantic
httpx
python-multipart
python-dotenv
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Start FastAPI backend in the background
echo "Starting FastAPI backend..."
uvicorn app.api:app --host 0.0.0.0 --port 8003 &
# Start Streamlit frontend
echo "Starting Streamlit frontend..."
streamlit run app/main.py --server.port 8503 --server.address 0.0.0.0
# Wait for any process to exit
wait -n
# Exit with status of process that exited first
exit $?