import os import time import subprocess import io import random import threading import re import json import asyncio import tempfile from datetime import datetime, timedelta # FastAPI imports - MUST be at top before class definitions from fastapi import FastAPI, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field import uvicorn from src.drive_uploader import get_drive_service from googleapiclient.http import MediaIoBaseDownload # Global state for API access stream_state = { "is_running": False, "current_video": None, "videos_queue": [], "videos_played": 0, "session_start": None, "session_end": None, "total_stream_time": 0, "status": "idle", # idle, running, paused, completed "last_updated": None, "metrics": { "bitrate": 0, "fps": 0, "frame": 0, "time": "00:00:00", "speed": "1x", "connection_status": "disconnected" }, "scene": { "brb_active": False, "logo_active": True, "overlay_text": "" } } state_lock = threading.Lock() stream_thread = None stop_event = threading.Event() scene_change_event = threading.Event() # Triggered when scene changes # Global for current FFmpeg process (for real-time scene switching) current_ffmpeg_process = None current_video_path = None current_video_info = None current_video_start_time = 0 # For resume from same position scene_restart_in_progress = False # Flag to prevent worker from advancing to next video standby_ffmpeg_process = None # Scene control file paths (for dynamic FFmpeg overlays) SCENE_FILES = { "brb_image": os.path.join(tempfile.gettempdir(), "brb_screen.png"), "logo_image": os.path.join(tempfile.gettempdir(), "logo_overlay.png"), "custom_text": os.path.join(tempfile.gettempdir(), "custom_overlay.txt") } def generate_brb_screen(): """Generate a BRB (Be Right Back) screen using FFmpeg""" font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" cmd = [ "ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=black:s=1280x720:d=1", "-vf", f"drawtext=fontfile='{font_path}':text='BRB':fontcolor=white:fontsize=44:x=(w-text_w)/2:y=(h-text_h)/2:box=1:boxcolor=black@0.5:boxborderw=8,drawtext=fontfile='{font_path}':text='Back soon':fontcolor=yellow:fontsize=18:x=(w-text_w)/2:y=(h-text_h)/2+58", "-frames:v", "1", SCENE_FILES["brb_image"] ] try: subprocess.run(cmd, capture_output=True, timeout=30) return True except Exception as e: print(f"⚠️ Failed to generate BRB screen: {e}", flush=True) return False def generate_logo_overlay(): """Generate a logo overlay image""" font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" cmd = [ "ffmpeg", "-y", "-f", "lavfi", "-i", "color=c=black:s=1280x720:d=1,format=rgba", "-vf", f"drawtext=fontfile='{font_path}':text='LIVE':fontcolor=red:fontsize=22:x=18:y=18:box=1:boxcolor=black@0.65:boxborderw=4", "-frames:v", "1", SCENE_FILES["logo_image"] ] try: subprocess.run(cmd, capture_output=True, timeout=30) return True except Exception as e: print(f"⚠️ Failed to generate logo: {e}", flush=True) return False def write_custom_text(text): """Write custom overlay text to file""" try: with open(SCENE_FILES["custom_text"], "w", encoding="utf-8") as f: f.write(text) return True except Exception as e: print(f"⚠️ Failed to write custom text: {e}", flush=True) return False # WebSocket connection manager class ConnectionManager: def __init__(self): self.active_connections: list[WebSocket] = [] async def connect(self, websocket: WebSocket): await websocket.accept() self.active_connections.append(websocket) # Send current state immediately await websocket.send_json(get_state()) def disconnect(self, websocket: WebSocket): if websocket in self.active_connections: self.active_connections.remove(websocket) async def broadcast(self, message: dict): disconnected = [] for connection in self.active_connections: try: await connection.send_json(message) except: disconnected.append(connection) # Clean up disconnected clients for conn in disconnected: self.disconnect(conn) manager = ConnectionManager() def update_state_and_broadcast(**kwargs): """Update state and broadcast to all connected clients""" update_state(**kwargs) # Async broadcast from sync context try: asyncio.create_task(manager.broadcast(get_state())) except: pass # No event loop running def update_state(**kwargs): """Thread-safe state update""" with state_lock: stream_state.update(kwargs) stream_state["last_updated"] = datetime.now().isoformat() def get_state(): """Thread-safe state read""" with state_lock: return stream_state.copy() # YouTube RTMP URL YOUTUBE_URL = "rtmps://a.rtmps.youtube.com/live2" def get_drive_videos(): """Fetches list of MP4 files from the configured Drive folder.""" service = get_drive_service() placeholder_folder = os.getenv('GOOGLE_DRIVE_FOLDER_ID') if not service: print("❌ Google Drive service unavailable.", flush=True) return [] if not placeholder_folder: print("❌ GOOGLE_DRIVE_FOLDER_ID is not set.", flush=True) return [] # 1. Try Configured Folder (with Shared Drive Support) print(f"� Scanning Drive Folder: {placeholder_folder}...", flush=True) try: results = service.files().list( q=f"'{placeholder_folder}' in parents and trashed=false", fields="files(id, name, mimeType, parents)", supportsAllDrives=True, includeItemsFromAllDrives=True ).execute() all_files = results.get('files', []) except Exception as e: print(f"❌ Error scanning folder: {e}", flush=True) all_files = [] video_files = [] if not all_files: print(f"📭 Folder is empty or not accessible.", flush=True) print(f"🔎 Analyzed {len(all_files)} files from folder...", flush=True) for f in all_files: name = f.get('name', 'Unknown') mime = f.get('mimeType', 'Unknown') # Filter: Is it a video? if mime.startswith("video/") or name.lower().endswith(".mp4"): print(f" ✅ MATCH: {name} (ID: {f['id']})", flush=True) video_files.append(f) else: print(f" ❌ IGNORED: {name} [{mime}]", flush=True) return video_files def download_video(file_id, filename): """Downloads a video file from Drive to local storage.""" service = get_drive_service() if not service: return None request = service.files().get_media(fileId=file_id) try: with io.FileIO(filename, 'wb') as fh: downloader = MediaIoBaseDownload(fh, request) done = False print(f"⬇️ Downloading {filename}...", end='', flush=True) while done is False: status, done = downloader.next_chunk() # print(".", end='', flush=True) # Reduce log spam if os.path.exists(filename) and os.path.getsize(filename) > 0: print(" Done!", flush=True) return filename print(" Failed: empty file.", flush=True) return None except Exception as e: print(f" Failed: {e}", flush=True) if os.path.exists(filename): try: os.remove(filename) except: pass return None import socket import requests def check_connection(host, port): """Checks if a TCP connection can be established to the host:port.""" try: sock = socket.create_connection((host, port), timeout=5) sock.close() return True except Exception as e: print(f"⚠️ Connection check to {host}:{port} failed: {e}", flush=True) return False def resolve_doh(hostname): """Resolves IP using Google DNS over HTTPS (DoH). Bypasses local DNS.""" try: url = f"https://dns.google/resolve?name={hostname}&type=A" print(f"🔍 DoH Lookup: {url}", flush=True) response = requests.get(url, timeout=5) if response.status_code == 200: data = response.json() if 'Answer' in data: ip = data['Answer'][0]['data'] print(f"✅ DoH Resolved {hostname} -> {ip}", flush=True) return ip except Exception as e: print(f"❌ DoH Lookup Failed: {e}", flush=True) return None def resolve_rtr_server(): """Resolves YouTube RTMP server using multiple methods.""" servers = ["a.rtmps.youtube.com", "b.rtmps.youtube.com"] # 1. Try Standard System DNS for server in servers: print(f"🔍 System DNS: Resolving {server}...", flush=True) try: ip = socket.gethostbyname(server) print(f"✅ Resolved {server} to {ip}", flush=True) if check_connection(server, 443): return f"rtmps://{server}/live2" except Exception: print(f"❌ System DNS Failed for {server}", flush=True) # 2. Try DNS over HTTPS (DoH) - Hardcore Bypass print("⚠️ System DNS Failed. Trying Google DoH...", flush=True) # Try non-SSL hostname 'a.rtmp.youtube.com' for IP-based streaming # We use non-SSL 'rtmp' protocol with IP to avoid SSL SNI mismatch target_host = "a.rtmp.youtube.com" ip = resolve_doh(target_host) if ip: print(f"🚀 Using Direct IP Override: {ip}", flush=True) # Construct RTMP URL using IP directly (port 1935, non-SSL) url = f"rtmp://{ip}:1935/live2" if check_connection(ip, 1935): print(f"✅ Connection to {ip}:1935 successful!", flush=True) return url print("❌ All Resolution Methods Failed.", flush=True) return None def run_standby_stream(reason="Preparing next video", duration_seconds=30): """Streams a short standby screen to keep YouTube RTMP alive during gaps.""" global standby_ffmpeg_process stream_key = os.getenv("YOUTUBE_STREAM_KEY") if not stream_key or stop_event.is_set(): return rtmp_url = resolve_rtr_server() if not rtmp_url: print("⚠️ Standby skipped: RTMP server unavailable.", flush=True) return safe_reason = re.sub(r"[^a-zA-Z0-9 .:_-]", "", reason)[:80] or "Preparing next video" font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" vf_graph = ( f"drawtext=fontfile='{font_path}':text='LIVE':fontcolor=red:fontsize=18:x=14:y=14:borderw=1:bordercolor=black," f"drawtext=fontfile='{font_path}':text='{safe_reason}':fontcolor=white:fontsize=24:x=(w-text_w)/2:y=(h-text_h)/2:borderw=2:bordercolor=black," f"drawtext=fontfile='{font_path}':text='Stream continues':fontcolor=cyan:fontsize=16:x=(w-text_w)/2:y=(h-text_h)/2+42:borderw=1:bordercolor=black" ) cmd = [ "ffmpeg", "-re", "-f", "lavfi", "-i", "color=c=black:s=1280x720:r=24", "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-t", str(duration_seconds), "-vf", vf_graph, "-c:v", "libx264", "-preset", "ultrafast", "-b:v", "1500k", "-minrate", "1500k", "-maxrate", "1500k", "-bufsize", "3000k", "-pix_fmt", "yuv420p", "-r", "24", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0", "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-f", "flv", "-flvflags", "no_duration_filesize", f"{rtmp_url}/{stream_key}" ] print(f"🛟 Standby keepalive: {safe_reason} ({duration_seconds}s)", flush=True) update_state(status="standby", current_video={"name": safe_reason, "id": None, "started_at": datetime.now().isoformat()}) try: standby_ffmpeg_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) for line in standby_ffmpeg_process.stdout: if stop_event.is_set(): standby_ffmpeg_process.terminate() break if "frame=" in line: print(line, end='', flush=True) standby_ffmpeg_process.wait() except Exception as e: print(f"⚠️ Standby stream failed: {e}", flush=True) finally: standby_ffmpeg_process = None def stream_video(video_path, video_info=None): """Streams a local video file to YouTube via RTMP.""" stream_key = os.getenv("YOUTUBE_STREAM_KEY") if not stream_key: print("❌ Error: YOUTUBE_STREAM_KEY not found.", flush=True) update_state(status="error", error="No stream key") return # Update state with current video video_name = os.path.basename(video_path) update_state( current_video={ "name": video_name, "id": video_info.get('id') if video_info else None, "started_at": datetime.now().isoformat() }, status="streaming" ) # Dynamic Server Resolution rtmp_url = resolve_rtr_server() if not rtmp_url: print("❌ CRITICAL: Could not resolve YouTube RTMPS server. Skipping stream.", flush=True) return print(f"🔴 Streaming {video_path} to {rtmp_url}...", flush=True) # Using ffmpeg-static or system ffmpeg # -re read input at native frame rate (crucial for streaming) # Force IPv4 (-4) to avoid IPv6 issues # Anti-Repetition Filters: # 1. Scale to 720p # 2. Clock (Top Right) # 3. Scrolling Ticker (Bottom) font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" # --- Random Insight Library (Anti-Repetition) --- insight_library = [ "Clarity beats motivation.", "Comfort quietly trains weakness.", "Focus is a trained skill.", "Discipline starts before mood arrives.", "Your habits reveal your standards.", "Attention is your real currency.", "Confidence changes social reality.", "Small choices compound silently.", "Overthinking is delayed action.", "Most distractions look harmless.", "Consistency creates identity.", "Pressure exposes preparation.", "Respect follows self-control.", "Your routine predicts your future.", "Comfort is the most addictive trap.", "Status is often silent behavior.", "The mind avoids unclear tasks.", "Action reduces anxiety.", "Deep work feels boring first.", "Self-control is personal power.", "Distraction is rented attention.", "Your environment shapes discipline.", "Momentum starts with one move.", "Avoidance makes fear bigger.", "Standards are built in private.", "People notice patterns, not promises.", "Your focus decides your direction." ] import random random_insight = random.choice(insight_library) print(f"✨ Selected Overlay Insight: {random_insight}", flush=True) # Write Fact to Temp File (Bypasses FFmpeg Escaping Hell) fact_file = os.path.join(tempfile.gettempdir(), "current_fact.txt") try: with open(fact_file, "w", encoding="utf-8") as f: f.write(random_insight) except Exception as e: print(f"⚠️ Failed to write fact file: {e}", flush=True) fact_file = None # Fallback # Short Ticker ticker_text = "FOCUS • DISCIPLINE • PSYCHOLOGY • SELF CONTROL" # Simplest Clock Format (Default Localtime) # Custom format with colons/dots was causing persistent parsing errors. # Default %{localtime} is safe and sufficient. clock_text = "'%{localtime}'" # 720p @ 1500k — HD with YouTube-friendly bitrate # Get current scene state scene_state = get_state()["scene"] brb_active = scene_state.get("brb_active", False) logo_active = scene_state.get("logo_active", True) overlay_text = scene_state.get("overlay_text", "") # Generate scene assets if needed if logo_active and not os.path.exists(SCENE_FILES["logo_image"]): generate_logo_overlay() if overlay_text and not os.path.exists(SCENE_FILES["custom_text"]): write_custom_text(overlay_text) # Build filtergraph based on scene state filters = [] # Base scaling filters.append("scale=1280:720:flags=fast_bilinear,setsar=1") if brb_active: # BRB mode: Dim video and show BRB overlay filters.append("colorchannelmixer=.3:.3:.3") # Dim to 30% filters.append(f"drawtext=fontfile='{font_path}':text='BRB':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2:borderw=3:bordercolor=black") filters.append(f"drawtext=fontfile='{font_path}':text='Back soon':fontcolor=yellow:fontsize=18:x=(w-text_w)/2:y=(h-text_h)/2+66:borderw=2:bordercolor=black") else: # Normal mode with overlays # Clock top-right filters.append(f"drawtext=fontfile='{font_path}':text={clock_text}:fontcolor=white:fontsize=16:x=w-tw-12:y=42:borderw=1:bordercolor=black") # Ticker bottom filters.append(f"drawtext=fontfile='{font_path}':text='{ticker_text}':fontcolor=yellow:fontsize=15:y=h-th-32:x=12:borderw=1:bordercolor=black") # Insight if fact_file: filters.append(f"drawtext=fontfile='{font_path}':textfile='{fact_file}':fontcolor=cyan:fontsize=15:x=12:y=h-58:borderw=1:bordercolor=black") # Custom overlay text if overlay_text and os.path.exists(SCENE_FILES["custom_text"]): filters.append(f"drawtext=fontfile='{font_path}':textfile='{SCENE_FILES['custom_text']}':fontcolor=orange:fontsize=18:x=(w-text_w)/2:y=78:borderw=2:bordercolor=black") # Logo overlay (applies to both BRB and normal modes) if logo_active and os.path.exists(SCENE_FILES["logo_image"]): # Logo is added via separate overlay filter, requires complex filtergraph # For now, just add a LIVE indicator via drawtext filters.append(f"drawtext=fontfile='{font_path}':text='LIVE':fontcolor=red:fontsize=18:x=14:y=14:borderw=1:bordercolor=black") vf_graph = ",".join(filters) cmd = [ "ffmpeg", "-re", "-i", video_path, "-vf", vf_graph, "-c:v", "libx264", "-preset", "ultrafast", "-x264-params", "threads=2", "-b:v", "1500k", "-minrate", "1500k", "-maxrate", "1500k", "-bufsize", "3000k", "-pix_fmt", "yuv420p", "-r", "24", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0", "-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-f", "flv", "-flvflags", "no_duration_filesize", f"{rtmp_url}/{stream_key}" ] # Run FFmpeg with Real-Time Logging global current_ffmpeg_process, current_video_path, current_video_info, current_video_start_time print(f"🎬 Starting FFmpeg (720p @ 1500k + Scene Overlays)...", flush=True) # Track globals for scene switching current_video_path = video_path current_video_info = video_info current_video_start_time = time.time() process = None def parse_ffmpeg_metrics(line): """Extract metrics from FFmpeg output line""" metrics = {} # Parse frame= 123 fps= 24 q=28.0... frame_match = re.search(r'frame=\s*(\d+)', line) if frame_match: metrics['frame'] = int(frame_match.group(1)) fps_match = re.search(r'fps=\s*(\d+)', line) if fps_match: metrics['fps'] = int(fps_match.group(1)) # Parse time=00:05:23.45 time_match = re.search(r'time=(\d{2}:\d{2}:\d{2}\.\d{2})', line) if time_match: metrics['time'] = time_match.group(1) # Parse bitrate= 1234.5kbits/s bitrate_match = re.search(r'bitrate=\s*([\d.]+)kbits/s', line) if bitrate_match: metrics['bitrate'] = float(bitrate_match.group(1)) # Parse speed=1.02x speed_match = re.search(r'speed=\s*([\d.]+)x', line) if speed_match: metrics['speed'] = speed_match.group(1) + 'x' return metrics last_metrics_update = 0 try: process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) current_ffmpeg_process = process # Track for scene switching # Update connection status to connected update_state(metrics={**get_state()["metrics"], "connection_status": "connected"}) # Stream logs line-by-line with stop check for line in process.stdout: if stop_event.is_set(): print("🛑 Stop requested, terminating stream...", flush=True) process.terminate() process.wait(timeout=5) break # Parse metrics from FFmpeg output (throttled to every 2 seconds) current_time = time.time() if current_time - last_metrics_update >= 2: parsed = parse_ffmpeg_metrics(line) if parsed: state = get_state() new_metrics = {**state["metrics"], **parsed, "connection_status": "connected"} update_state(metrics=new_metrics) update_state_and_broadcast(metrics=new_metrics) last_metrics_update = current_time print(line, end='', flush=True) process.wait() if process.returncode != 0: print(f"⚠️ Stream interrupted or failed with code {process.returncode}", flush=True) update_state(metrics={**get_state()["metrics"], "connection_status": "error"}) else: update_state(metrics={**get_state()["metrics"], "connection_status": "disconnected"}) except KeyboardInterrupt: print("🛑 Stream stopped manually.", flush=True) if process: process.terminate() update_state(metrics={**get_state()["metrics"], "connection_status": "stopped"}) except Exception as e: print(f"❌ unexpected error running ffmpeg: {e}", flush=True) update_state(metrics={**get_state()["metrics"], "connection_status": "error"}) finally: # Clear current video from state update_state(current_video=None) def main(): print("🚀 Starting Drive-to-YouTube Streamer (Session Mode)...", flush=True) # Create temp directory temp_dir = os.path.join(tempfile.gettempdir(), "temp_stream") if not os.path.exists(temp_dir): os.makedirs(temp_dir) # Session configuration SESSION_DURATION_HOURS = float(os.getenv("SESSION_DURATION_HOURS", "6")) # Default 6 hours SESSION_DURATION_SECONDS = SESSION_DURATION_HOURS * 3600 SHUFFLE_VIDEOS = os.getenv("SHUFFLE_VIDEOS", "true").lower() == "true" print(f"⏱️ Session Duration: {SESSION_DURATION_HOURS} hours ({SESSION_DURATION_SECONDS} seconds)", flush=True) print(f"🔀 Shuffle Videos: {SHUFFLE_VIDEOS}", flush=True) start_time = time.time() videos_played = 0 total_stream_time = 0 # Main session loop - runs until time limit or all videos played while True: try: # Check if session time exceeded elapsed = time.time() - start_time if elapsed >= SESSION_DURATION_SECONDS: print(f"✅ Session time limit reached ({elapsed/3600:.1f} hours). Stopping stream.", flush=True) break remaining_time = SESSION_DURATION_SECONDS - elapsed print(f"⏱️ Remaining session time: {remaining_time/3600:.1f} hours", flush=True) videos = get_drive_videos() if not videos: print("📭 No videos found in Drive Folder. Waiting 60s...", flush=True) time.sleep(60) continue # Shuffle videos if enabled (each session gets different order) if SHUFFLE_VIDEOS: random.shuffle(videos) print(f"🔀 Shuffled {len(videos)} videos", flush=True) for video in videos: # Check time before each video elapsed = time.time() - start_time if elapsed >= SESSION_DURATION_SECONDS: print(f"✅ Session time limit reached before next video. Stopping.", flush=True) break local_path = os.path.join(temp_dir, video['name']) # Skip if already downloaded if not os.path.exists(local_path): if not download_video(video['id'], local_path): continue # Stream and track duration video_start = time.time() stream_video(local_path) video_duration = time.time() - video_start total_stream_time += video_duration videos_played += 1 print(f"📊 Videos played: {videos_played} | Total stream time: {total_stream_time/3600:.1f} hours", flush=True) # Cleanup if os.path.exists(local_path): os.remove(local_path) print(f"🧹 Cleaned up {local_path}", flush=True) # Small buffer between videos time.sleep(2) # After playing all videos once, check if we should continue or stop elapsed = time.time() - start_time if elapsed < SESSION_DURATION_SECONDS: print(f"🔄 All videos played. {remaining_time/3600:.1f} hours remaining. Will shuffle and continue...", flush=True) continue else: print(f"✅ All videos played. Session complete.", flush=True) break except Exception as e: print(f"❌ Critical Error in streaming loop: {e}", flush=True) import traceback traceback.print_exc() time.sleep(10) # Final stats print(f"🎉 SESSION COMPLETE", flush=True) print(f" Total Duration: {(time.time() - start_time)/3600:.1f} hours", flush=True) print(f" Videos Played: {videos_played}", flush=True) print(f" Total Stream Time: {total_stream_time/3600:.1f} hours", flush=True) # Create FastAPI app app = FastAPI(title="YouTube Live Streamer API", version="1.0") # Enable CORS for Vercel frontend app.add_middleware( CORSMiddleware, allow_origins=["*"], # Configure this properly for production allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) class StartRequest(BaseModel): duration_hours: float = Field(default=20, ge=1, le=24) shuffle: bool = True youtube_stream_key: str = None class StopResponse(BaseModel): success: bool message: str class StatusResponse(BaseModel): is_running: bool status: str current_video: dict videos_queue: list videos_played: int session_start: str session_end: str total_stream_time: float remaining_time_minutes: float @app.get("/") def root(): return {"status": "YouTube Live Streamer API", "version": "1.0"} @app.get("/api/status", response_model=StatusResponse) def get_status(): """Get current streaming status""" state = get_state() # Calculate remaining time remaining = 0 if state["session_start"] and state["is_running"]: start = datetime.fromisoformat(state["session_start"]) if state["session_end"]: end = datetime.fromisoformat(state["session_end"]) remaining = max(0, (end - datetime.now()).total_seconds() / 60) return StatusResponse( is_running=state["is_running"], status=state["status"], current_video=state["current_video"], videos_queue=state["videos_queue"], videos_played=state["videos_played"], session_start=state["session_start"], session_end=state["session_end"], total_stream_time=state["total_stream_time"], remaining_time_minutes=remaining ) @app.post("/api/start") def start_stream(request: StartRequest, background_tasks: BackgroundTasks): """Start a new streaming session""" global stream_thread state = get_state() if state["is_running"]: raise HTTPException(status_code=400, detail="Stream already running") # Set temporary env vars if provided if request.youtube_stream_key: os.environ["YOUTUBE_STREAM_KEY"] = request.youtube_stream_key os.environ["SESSION_DURATION_HOURS"] = str(request.duration_hours) os.environ["SHUFFLE_VIDEOS"] = "true" if request.shuffle else "false" # Reset stop event stop_event.clear() # Start streaming in background thread stream_thread = threading.Thread(target=streaming_worker, daemon=True) stream_thread.start() return { "success": True, "message": f"Stream started for {request.duration_hours} hours", "duration_hours": request.duration_hours, "shuffle": request.shuffle } @app.post("/api/stop", response_model=StopResponse) def stop_stream(): """Stop the current streaming session""" global standby_ffmpeg_process state = get_state() if not state["is_running"]: return StopResponse(success=False, message="No stream is running") stop_event.set() if standby_ffmpeg_process: try: standby_ffmpeg_process.terminate() except: pass update_state(is_running=False, status="stopping") return StopResponse(success=True, message="Stop signal sent. Stream will end after current video.") @app.get("/api/videos") def get_available_videos(): """Get list of available videos from Drive""" try: videos = get_drive_videos() return { "count": len(videos), "videos": [{"id": v["id"], "name": v["name"]} for v in videos] } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # WebSocket endpoint for real-time updates @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): await manager.connect(websocket) try: while True: # Receive commands from client data = await websocket.receive_text() try: message = json.loads(data) action = message.get("action") if action == "ping": await websocket.send_json({"type": "pong"}) elif action == "get_state": await websocket.send_json(get_state()) elif action == "toggle_brb": current = get_state()["scene"]["brb_active"] update_state_and_broadcast(scene={**get_state()["scene"], "brb_active": not current}) restart_stream_with_new_scene() # Real-time restart elif action == "toggle_logo": current = get_state()["scene"]["logo_active"] update_state_and_broadcast(scene={**get_state()["scene"], "logo_active": not current}) restart_stream_with_new_scene() # Real-time restart elif action == "set_overlay": text = message.get("text", "") write_custom_text(text) update_state_and_broadcast(scene={**get_state()["scene"], "overlay_text": text}) restart_stream_with_new_scene() # Real-time restart except json.JSONDecodeError: await websocket.send_json({"error": "Invalid JSON"}) except WebSocketDisconnect: manager.disconnect(websocket) # Scene control endpoints @app.post("/api/scene/brb") def toggle_brb(): """Toggle BRB/Intermission screen - RESTARTS STREAM for real-time effect""" state = get_state() new_brb = not state["scene"]["brb_active"] update_state_and_broadcast(scene={**state["scene"], "brb_active": new_brb}) # Restart stream immediately for real-time scene change restarted = restart_stream_with_new_scene() return { "success": True, "brb_active": new_brb, "stream_restarted": restarted, "note": "Stream restarted with BRB screen" if new_brb else "Stream restarted, BRB disabled" } @app.post("/api/scene/logo") def toggle_logo(): """Toggle logo overlay - RESTARTS STREAM for real-time effect""" state = get_state() new_logo = not state["scene"]["logo_active"] update_state_and_broadcast(scene={**state["scene"], "logo_active": new_logo}) # Restart stream immediately for real-time scene change restarted = restart_stream_with_new_scene() return { "success": True, "logo_active": new_logo, "stream_restarted": restarted, "note": "Stream restarted with logo" if new_logo else "Stream restarted, logo hidden" } @app.post("/api/scene/overlay") def set_overlay(text: str): """Set custom overlay text - RESTARTS STREAM for real-time effect""" state = get_state() # Write text to file for FFmpeg to read write_custom_text(text) update_state_and_broadcast(scene={**state["scene"], "overlay_text": text}) # Restart stream immediately for real-time scene change restarted = restart_stream_with_new_scene() return { "success": True, "overlay_text": text, "stream_restarted": restarted, "note": f"Stream restarted with text: {text[:30]}..." if len(text) > 30 else f"Stream restarted with text: {text}" } @app.get("/api/metrics") def get_metrics(): """Get current stream metrics""" return get_state()["metrics"] def restart_stream_with_new_scene(): """Restart current stream with updated scene settings (REAL-TIME)""" global current_ffmpeg_process, current_video_path, current_video_info, current_video_start_time, scene_restart_in_progress if not current_ffmpeg_process or not current_video_path: print("⚠️ No active stream to restart", flush=True) return False # Set flag BEFORE killing - this prevents worker from advancing to next video scene_restart_in_progress = True # Kill current FFmpeg - this will cause stream_video() to return print("🔄 Applying scene change - restarting current video...", flush=True) try: current_ffmpeg_process.terminate() # Don't wait here - let stream_video() handle cleanup except: pass return True def streaming_worker(): """Background worker that runs the streaming session""" global scene_restart_in_progress # Access global flag for scene changes temp_dir = os.path.join(tempfile.gettempdir(), "temp_stream") if not os.path.exists(temp_dir): os.makedirs(temp_dir) SESSION_DURATION_HOURS = float(os.getenv("SESSION_DURATION_HOURS", "6")) SESSION_DURATION_SECONDS = SESSION_DURATION_HOURS * 3600 SHUFFLE_VIDEOS = os.getenv("SHUFFLE_VIDEOS", "true").lower() == "true" start_time = time.time() session_end = datetime.now() + timedelta(hours=SESSION_DURATION_HOURS) # Update state update_state( is_running=True, status="starting", session_start=datetime.now().isoformat(), session_end=session_end.isoformat(), videos_played=0, total_stream_time=0 ) print(f"🚀 Stream worker started. Duration: {SESSION_DURATION_HOURS}h", flush=True) videos_played = 0 total_stream_time = 0 try: current_video_index = 0 videos = [] # Cache videos list while not stop_event.is_set(): # Check session time elapsed = time.time() - start_time if elapsed >= SESSION_DURATION_SECONDS: print(f"✅ Session time complete", flush=True) break if stop_event.is_set(): print("🛑 Stop requested", flush=True) break # Get videos (only once or when empty) if not videos: videos = get_drive_videos() if not videos: print("📭 No videos found", flush=True) run_standby_stream("Waiting for Drive videos", duration_seconds=60) continue # Update queue in state update_state(videos_queue=[{"id": v["id"], "name": v["name"]} for v in videos]) if SHUFFLE_VIDEOS: random.shuffle(videos) current_video_index = 0 # Get current video if current_video_index >= len(videos): current_video_index = 0 # Loop back to start if SHUFFLE_VIDEOS: random.shuffle(videos) video = videos[current_video_index] if stop_event.is_set(): break # Check time before video elapsed = time.time() - start_time if elapsed >= SESSION_DURATION_SECONDS: break local_path = os.path.join(temp_dir, video['name']) # Download if not os.path.exists(local_path): if not download_video(video['id'], local_path): run_standby_stream("Skipping failed download", duration_seconds=30) current_video_index += 1 continue # Stream - check if scene restart happened video_start = time.time() stream_video(local_path, video_info=video) video_duration = time.time() - video_start if stop_event.is_set(): break # Check if this was a scene restart (don't count as new video) if scene_restart_in_progress: # Scene restart - same video will replay, don't advance index print("🔄 Scene restart detected - replaying same video with new scene", flush=True) # Clear flag so next time we advance normally scene_restart_in_progress = False # Don't delete the file - we need to replay it else: # Normal flow - advance to next video total_stream_time += video_duration videos_played += 1 update_state( videos_played=videos_played, total_stream_time=total_stream_time ) # Cleanup and advance to next video if os.path.exists(local_path): os.remove(local_path) current_video_index += 1 if not stop_event.is_set() and not scene_restart_in_progress: run_standby_stream("Loading next video", duration_seconds=8) except Exception as e: print(f"❌ Stream worker error: {e}", flush=True) import traceback traceback.print_exc() finally: update_state( is_running=False, status="completed", current_video=None ) print(f"🎉 Stream worker finished. Played: {videos_played} videos", flush=True) if __name__ == "__main__": from dotenv import load_dotenv load_dotenv() # Check if we should run API server or direct stream API_MODE = os.getenv("API_MODE", "true").lower() == "true" if API_MODE: # Run FastAPI server port = int(os.getenv("PORT", 7860)) uvicorn.run(app, host="0.0.0.0", port=port) else: # Direct streaming mode (legacy) main()