From d0dba8183b0bffe8e8e66d42001049106dcac7e0 Mon Sep 17 00:00:00 2001 From: pie Date: Sun, 3 May 2026 16:48:18 +0100 Subject: [PATCH] first commit --- Dockerfile | 31 +++++++++ GEMINI.md | 64 +++++++++++++++++++ app/api.py | 50 +++++++++++++++ app/cast_logic.py | 97 ++++++++++++++++++++++++++++ app/library.py | 127 +++++++++++++++++++++++++++++++++++++ app/main.py | 153 +++++++++++++++++++++++++++++++++++++++++++++ app/utils.py | 45 +++++++++++++ docker-compose.yml | 19 ++++++ requirements.txt | 9 +++ start.sh | 15 +++++ 10 files changed, 610 insertions(+) create mode 100644 Dockerfile create mode 100644 GEMINI.md create mode 100644 app/api.py create mode 100644 app/cast_logic.py create mode 100644 app/library.py create mode 100644 app/main.py create mode 100644 app/utils.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 start.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1cefc87 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..401daed --- /dev/null +++ b/GEMINI.md @@ -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. diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..071f2a3 --- /dev/null +++ b/app/api.py @@ -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() diff --git a/app/cast_logic.py b/app/cast_logic.py new file mode 100644 index 0000000..485b5d5 --- /dev/null +++ b/app/cast_logic.py @@ -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() diff --git a/app/library.py b/app/library.py new file mode 100644 index 0000000..4aef039 --- /dev/null +++ b/app/library.py @@ -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 + } diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..53bc99f --- /dev/null +++ b/app/main.py @@ -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(""" + + """, 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() diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..97750f5 --- /dev/null +++ b/app/utils.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..48b59f8 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9160299 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +streamlit +fastapi +uvicorn +pychromecast +ffmpeg-python +pydantic +httpx +python-multipart +python-dotenv diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..51d5ccc --- /dev/null +++ b/start.sh @@ -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 $?