| 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 |
|
|
| |
| 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 |
|
|
| |
| 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", |
| "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() |
|
|
| |
| current_ffmpeg_process = None |
| current_video_path = None |
| current_video_info = None |
| current_video_start_time = 0 |
| scene_restart_in_progress = False |
| standby_ffmpeg_process = None |
|
|
| |
| 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 |
|
|
| |
| class ConnectionManager: |
| def __init__(self): |
| self.active_connections: list[WebSocket] = [] |
|
|
| async def connect(self, websocket: WebSocket): |
| await websocket.accept() |
| self.active_connections.append(websocket) |
| |
| 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) |
| |
| 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) |
| |
| try: |
| asyncio.create_task(manager.broadcast(get_state())) |
| except: |
| pass |
|
|
| 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_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 [] |
| |
| |
| 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') |
| |
| |
| 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() |
| |
| 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"] |
| |
| |
| 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) |
|
|
| |
| print("β οΈ System DNS Failed. Trying Google DoH...", flush=True) |
| |
| |
| target_host = "a.rtmp.youtube.com" |
| ip = resolve_doh(target_host) |
| if ip: |
| print(f"π Using Direct IP Override: {ip}", flush=True) |
| |
| 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 |
| |
| |
| 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" |
| ) |
|
|
| |
| 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) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" |
| |
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| ticker_text = "FOCUS β’ DISCIPLINE β’ PSYCHOLOGY β’ SELF CONTROL" |
|
|
| |
| |
| |
| clock_text = "'%{localtime}'" |
| |
| |
| |
| |
| 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", "") |
| |
| |
| 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) |
| |
| |
| filters = [] |
| |
| |
| filters.append("scale=1280:720:flags=fast_bilinear,setsar=1") |
| |
| if brb_active: |
| |
| filters.append("colorchannelmixer=.3:.3:.3") |
| 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: |
| |
| |
| filters.append(f"drawtext=fontfile='{font_path}':text={clock_text}:fontcolor=white:fontsize=16:x=w-tw-12:y=42:borderw=1:bordercolor=black") |
| |
| filters.append(f"drawtext=fontfile='{font_path}':text='{ticker_text}':fontcolor=yellow:fontsize=15:y=h-th-32:x=12:borderw=1:bordercolor=black") |
| |
| 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") |
| |
| 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") |
| |
| |
| if logo_active and os.path.exists(SCENE_FILES["logo_image"]): |
| |
| |
| 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}" |
| ] |
| |
| |
| global current_ffmpeg_process, current_video_path, current_video_info, current_video_start_time |
| |
| print(f"π¬ Starting FFmpeg (720p @ 1500k + Scene Overlays)...", flush=True) |
| |
| |
| 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 = {} |
| |
| |
| 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)) |
| |
| |
| time_match = re.search(r'time=(\d{2}:\d{2}:\d{2}\.\d{2})', line) |
| if time_match: |
| metrics['time'] = time_match.group(1) |
| |
| |
| bitrate_match = re.search(r'bitrate=\s*([\d.]+)kbits/s', line) |
| if bitrate_match: |
| metrics['bitrate'] = float(bitrate_match.group(1)) |
| |
| |
| 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 |
| |
| |
| update_state(metrics={**get_state()["metrics"], "connection_status": "connected"}) |
| |
| |
| for line in process.stdout: |
| if stop_event.is_set(): |
| print("π Stop requested, terminating stream...", flush=True) |
| process.terminate() |
| process.wait(timeout=5) |
| break |
| |
| |
| 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: |
| |
| update_state(current_video=None) |
|
|
| def main(): |
| print("π Starting Drive-to-YouTube Streamer (Session Mode)...", flush=True) |
| |
| |
| 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" |
| |
| 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 |
| |
| |
| while True: |
| try: |
| |
| 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 |
| |
| |
| if SHUFFLE_VIDEOS: |
| random.shuffle(videos) |
| print(f"π Shuffled {len(videos)} videos", flush=True) |
| |
| for video in videos: |
| |
| 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']) |
| |
| |
| if not os.path.exists(local_path): |
| if not download_video(video['id'], local_path): |
| continue |
| |
| |
| 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) |
| |
| |
| if os.path.exists(local_path): |
| os.remove(local_path) |
| print(f"π§Ή Cleaned up {local_path}", flush=True) |
| |
| |
| time.sleep(2) |
| |
| |
| 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) |
| |
| |
| 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) |
|
|
| |
| app = FastAPI(title="YouTube Live Streamer API", version="1.0") |
|
|
| |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| 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() |
| |
| |
| 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") |
| |
| |
| 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" |
| |
| |
| stop_event.clear() |
| |
| |
| 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)) |
|
|
| |
| @app.websocket("/ws") |
| async def websocket_endpoint(websocket: WebSocket): |
| await manager.connect(websocket) |
| try: |
| while True: |
| |
| 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() |
| 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() |
| 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() |
| |
| except json.JSONDecodeError: |
| await websocket.send_json({"error": "Invalid JSON"}) |
| except WebSocketDisconnect: |
| manager.disconnect(websocket) |
|
|
| |
| @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}) |
| |
| |
| 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}) |
| |
| |
| 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_custom_text(text) |
| update_state_and_broadcast(scene={**state["scene"], "overlay_text": text}) |
| |
| |
| 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 |
| |
| |
| scene_restart_in_progress = True |
| |
| |
| print("π Applying scene change - restarting current video...", flush=True) |
| try: |
| current_ffmpeg_process.terminate() |
| |
| except: |
| pass |
| |
| return True |
|
|
| def streaming_worker(): |
| """Background worker that runs the streaming session""" |
| global scene_restart_in_progress |
| 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( |
| 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 = [] |
| |
| while not stop_event.is_set(): |
| |
| 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 |
| |
| |
| 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_state(videos_queue=[{"id": v["id"], "name": v["name"]} for v in videos]) |
| |
| if SHUFFLE_VIDEOS: |
| random.shuffle(videos) |
| |
| current_video_index = 0 |
| |
| |
| if current_video_index >= len(videos): |
| current_video_index = 0 |
| if SHUFFLE_VIDEOS: |
| random.shuffle(videos) |
| |
| video = videos[current_video_index] |
| |
| if stop_event.is_set(): |
| break |
| |
| |
| elapsed = time.time() - start_time |
| if elapsed >= SESSION_DURATION_SECONDS: |
| break |
| |
| local_path = os.path.join(temp_dir, video['name']) |
| |
| |
| 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 |
| |
| |
| video_start = time.time() |
| stream_video(local_path, video_info=video) |
| video_duration = time.time() - video_start |
| |
| if stop_event.is_set(): |
| break |
| |
| |
| if scene_restart_in_progress: |
| |
| print("π Scene restart detected - replaying same video with new scene", flush=True) |
| |
| scene_restart_in_progress = False |
| |
| else: |
| |
| total_stream_time += video_duration |
| videos_played += 1 |
| |
| update_state( |
| videos_played=videos_played, |
| total_stream_time=total_stream_time |
| ) |
| |
| |
| 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() |
| |
| |
| API_MODE = os.getenv("API_MODE", "true").lower() == "true" |
| |
| if API_MODE: |
| |
| port = int(os.getenv("PORT", 7860)) |
| uvicorn.run(app, host="0.0.0.0", port=port) |
| else: |
| |
| main() |
|
|