live / app.py
ayush77uh's picture
ss
f675afb
Raw
History Blame Contribute Delete
40.7 kB
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()