first commit
This commit is contained in:
+31
@@ -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"]
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
streamlit
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
pychromecast
|
||||||
|
ffmpeg-python
|
||||||
|
pydantic
|
||||||
|
httpx
|
||||||
|
python-multipart
|
||||||
|
python-dotenv
|
||||||
@@ -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 $?
|
||||||
Reference in New Issue
Block a user