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
+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