diff --git a/.gitignore b/.gitignore index 12f8790..3374427 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .env.local .env.*.local .streamdrop_auth +.streamdrop_secret # User data streams.db diff --git a/capture_html.py b/capture_html.py new file mode 100644 index 0000000..68fc28f --- /dev/null +++ b/capture_html.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Chrome DevTools Protocol screencast capture for StreamDrop. + +Attaches to a headless Chrome/Chromium instance via its remote debugging +port, starts a Page.startScreencast session, and writes JPEG frames to +stdout at a fixed frame rate (duplicating the last frame when the page is +idle). Designed to be piped straight into FFmpeg: + + capture_html.py --port 9222 --fps 30 | ffmpeg -f image2pipe -framerate 30 -i - ... + +All diagnostics go to stderr; stdout carries only JPEG bytes. +""" + +import argparse +import base64 +import json +import sys +import threading +import time + +import requests +import websocket + + +def log(message): + print(f"[capture_html] {message}", file=sys.stderr, flush=True) + + +def get_page_websocket_url(port, timeout=30): + """Find the first page target's WebSocket debugger URL, waiting for Chrome to come up.""" + deadline = time.time() + timeout + last_error = None + while time.time() < deadline: + try: + response = requests.get(f"http://127.0.0.1:{port}/json/list", timeout=3) + targets = response.json() + for target in targets: + if target.get("type") == "page" and target.get("webSocketDebuggerUrl"): + return target["webSocketDebuggerUrl"] + except Exception as e: + last_error = e + time.sleep(0.5) + raise RuntimeError(f"No debuggable page found on port {port}: {last_error}") + + +class ScreencastCapture: + """Maintains the latest screencast frame from a CDP WebSocket connection.""" + + def __init__(self, ws_url, width, height, quality=80): + self.ws_url = ws_url + self.width = width + self.height = height + self.quality = quality + self.latest_frame = None + self.frame_lock = threading.Lock() + self.connected = threading.Event() + self.closed = threading.Event() + self._message_id = 0 + self._id_lock = threading.Lock() + self.ws = None + + def _next_id(self): + with self._id_lock: + self._message_id += 1 + return self._message_id + + def _send(self, method, params=None): + self.ws.send(json.dumps({ + "id": self._next_id(), + "method": method, + "params": params or {}, + })) + + def _on_open(self, ws): + log("WebSocket connected, starting screencast") + self._send("Page.enable") + self._send("Page.startScreencast", { + "format": "jpeg", + "quality": self.quality, + "maxWidth": self.width, + "maxHeight": self.height, + "everyNthFrame": 1, + }) + self.connected.set() + + def _on_message(self, ws, message): + try: + data = json.loads(message) + except ValueError: + return + if data.get("method") == "Page.screencastFrame": + params = data.get("params", {}) + frame_data = params.get("data") + session_id = params.get("sessionId") + if frame_data: + frame = base64.b64decode(frame_data) + with self.frame_lock: + self.latest_frame = frame + if session_id is not None: + self._send("Page.screencastFrameAck", {"sessionId": session_id}) + + def _on_error(self, ws, error): + log(f"WebSocket error: {error}") + + def _on_close(self, ws, status_code, message): + log("WebSocket closed") + self.closed.set() + + def start(self): + self.ws = websocket.WebSocketApp( + self.ws_url, + on_open=self._on_open, + on_message=self._on_message, + on_error=self._on_error, + on_close=self._on_close, + ) + # suppress_origin: Chrome rejects WebSocket upgrades that carry an + # Origin header unless launched with --remote-allow-origins + thread = threading.Thread( + target=lambda: self.ws.run_forever(suppress_origin=True), + daemon=True, + ) + thread.start() + + def get_frame(self): + with self.frame_lock: + return self.latest_frame + + +def main(): + parser = argparse.ArgumentParser(description="CDP screencast to JPEG frame pipe") + parser.add_argument("--port", type=int, required=True, help="Chrome remote debugging port") + parser.add_argument("--fps", type=int, default=30, help="Output frame rate") + parser.add_argument("--width", type=int, default=1280, help="Max frame width") + parser.add_argument("--height", type=int, default=720, help="Max frame height") + parser.add_argument("--quality", type=int, default=80, help="JPEG quality (0-100)") + args = parser.parse_args() + + ws_url = get_page_websocket_url(args.port) + log(f"Attaching to {ws_url}") + + capture = ScreencastCapture(ws_url, args.width, args.height, args.quality) + capture.start() + + if not capture.connected.wait(timeout=15): + log("Timed out waiting for WebSocket connection") + sys.exit(1) + + # Wait for the first frame before starting the fixed-rate output loop + deadline = time.time() + 30 + while capture.get_frame() is None: + if time.time() > deadline or capture.closed.is_set(): + log("No screencast frames received") + sys.exit(1) + time.sleep(0.1) + + log(f"Streaming frames at {args.fps} fps") + out = sys.stdout.buffer + frame_interval = 1.0 / args.fps + next_frame_time = time.monotonic() + frames_written = 0 + + while not capture.closed.is_set(): + frame = capture.get_frame() + try: + out.write(frame) + out.flush() + except (BrokenPipeError, OSError): + log("Output pipe closed, exiting") + break + + frames_written += 1 + if frames_written % (args.fps * 60) == 0: + log(f"{frames_written} frames written") + + next_frame_time += frame_interval + sleep_for = next_frame_time - time.monotonic() + if sleep_for > 0: + time.sleep(sleep_for) + else: + # Fell behind; reset the schedule instead of bursting + next_frame_time = time.monotonic() + + +if __name__ == "__main__": + main() diff --git a/capture_pygame.py b/capture_pygame.py new file mode 100644 index 0000000..73749af --- /dev/null +++ b/capture_pygame.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Headless Pygame frame capture for StreamDrop. + +Runs a user's pygame script with SDL's dummy video driver (no display +required) and intercepts pygame.display.flip()/update() to capture the +rendered surface. A background thread writes JPEG frames to stdout at a +FIXED frame rate - duplicating the last frame when the game renders +slower or idles - so the consuming FFmpeg's timeline stays in sync with +wall-clock time: + + capture_pygame.py --fps 30 game.py | ffmpeg -f image2pipe -framerate 30 -i - ... + +The wrapped script's own stdout is redirected to stderr so print() +calls can't corrupt the frame stream. +""" + +import argparse +import io +import os +import runpy +import sys +import threading +import time + +# Must be set before pygame is imported anywhere +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") + + +def main(): + parser = argparse.ArgumentParser(description="Headless pygame to JPEG frame pipe") + parser.add_argument("--fps", type=int, default=30, help="Output frame rate") + parser.add_argument("--quality", type=int, default=85, help="JPEG quality (0-100)") + parser.add_argument("script", help="Path to the pygame script to run") + args = parser.parse_args() + + if not os.path.exists(args.script): + print(f"[capture_pygame] Script not found: {args.script}", file=sys.stderr) + sys.exit(1) + + frame_output = sys.stdout.buffer + # The game script must not write to our frame pipe + sys.stdout = sys.stderr + + import pygame + from PIL import Image + + frame_interval = 1.0 / args.fps + frame_lock = threading.Lock() + state = {"frame": None, "last_capture": 0.0} + + def capture_frame(): + """Encode the current display surface as JPEG (rate-limited).""" + now = time.monotonic() + if now - state["last_capture"] < frame_interval: + return + surface = pygame.display.get_surface() + if surface is None: + return + try: + raw = pygame.image.tostring(surface, "RGB") + image = Image.frombytes("RGB", surface.get_size(), raw) + buffer = io.BytesIO() + image.save(buffer, format="JPEG", quality=args.quality) + with frame_lock: + state["frame"] = buffer.getvalue() + state["last_capture"] = now + except Exception as e: + print(f"[capture_pygame] Frame capture error: {e}", file=sys.stderr) + + def emitter(): + """Write the latest frame to stdout at a fixed rate, duplicating the + last frame while the game isn't rendering new ones.""" + next_frame_time = time.monotonic() + while True: + with frame_lock: + frame = state["frame"] + if frame is not None: + try: + frame_output.write(frame) + frame_output.flush() + except (BrokenPipeError, OSError): + print("[capture_pygame] Output pipe closed, exiting", file=sys.stderr) + os._exit(0) + next_frame_time += frame_interval + sleep_for = next_frame_time - time.monotonic() + if sleep_for > 0: + time.sleep(sleep_for) + else: + next_frame_time = time.monotonic() + + original_flip = pygame.display.flip + original_update = pygame.display.update + + def patched_flip(*a, **kw): + result = original_flip(*a, **kw) + capture_frame() + return result + + def patched_update(*a, **kw): + result = original_update(*a, **kw) + capture_frame() + return result + + pygame.display.flip = patched_flip + pygame.display.update = patched_update + + threading.Thread(target=emitter, daemon=True).start() + + script_path = os.path.abspath(args.script) + sys.path.insert(0, os.path.dirname(script_path)) + sys.argv = [script_path] + + print(f"[capture_pygame] Running {script_path} at {args.fps} fps output", file=sys.stderr) + try: + runpy.run_path(script_path, run_name="__main__") + except SystemExit: + pass + print("[capture_pygame] Script finished", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/headless_streamer.py b/headless_streamer.py index 7876aee..aa1b5bc 100755 --- a/headless_streamer.py +++ b/headless_streamer.py @@ -1,52 +1,137 @@ #!/usr/bin/env python3 """ Optimized Headless Streamer - NO X11 REQUIRED -True headless streaming using direct frame capture methods: -- HTML: Chrome DevTools Protocol for direct screenshots -- Pygame: Surface data directly to FFmpeg stdin +True headless streaming using direct frame capture: +- HTML: Chrome DevTools Protocol screencast (via capture_html.py) +- Pygame: SDL dummy-driver surface capture (via capture_pygame.py) + +Both capture helpers emit JPEG frames on stdout, which are piped straight +into FFmpeg for encoding and RTMP delivery. """ import os import sys -import json import time +import shutil import signal import logging -import asyncio import subprocess -import threading -from pathlib import Path -from io import BytesIO - -import requests -from PIL import Image -import numpy as np # Setup logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) -class HeadlessHTMLStreamer: - """True headless HTML streaming using Chrome DevTools Protocol""" - - def __init__(self, stream_key, content_path="https://example.com"): +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def find_browser(): + """Locate an installed Chromium/Chrome binary.""" + override = os.environ.get('STREAMDROP_BROWSER') + if override and (os.path.exists(override) or shutil.which(override)): + return override + for candidate in ('chromium-browser', 'chromium', 'google-chrome', + 'google-chrome-stable', 'chrome'): + path = shutil.which(candidate) + if path: + return path + return None + + +class PipelineStreamer: + """Base class: a frame-capture subprocess piped into FFmpeg.""" + + def __init__(self, stream_key, rtmp_base='rtmp://a.rtmp.youtube.com/live2/'): self.stream_key = stream_key - self.content_path = content_path - self.chrome_process = None + self.rtmp_url = f'{rtmp_base}{stream_key}' + self.capture_process = None self.ffmpeg_process = None self.streaming = False + + def _start_ffmpeg(self, framerate=30, bitrate='2500k'): + """Start FFmpeg reading JPEG frames from the capture process.""" + buf_kbits = int(bitrate.rstrip('k')) * 2 + ffmpeg_cmd = [ + 'ffmpeg', + '-f', 'image2pipe', + '-framerate', str(framerate), + '-i', '-', + '-f', 'lavfi', + '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', + '-c:v', 'libx264', + '-preset', 'veryfast', + '-b:v', bitrate, + '-maxrate', bitrate, + '-bufsize', f'{buf_kbits}k', + '-pix_fmt', 'yuv420p', + '-g', str(framerate * 2), + '-r', str(framerate), + '-c:a', 'aac', + '-b:a', '128k', + '-shortest', + '-f', 'flv', + self.rtmp_url, + ] + + logger.info("Starting FFmpeg (frame pipe -> RTMP)...") + self.ffmpeg_process = subprocess.Popen( + ffmpeg_cmd, + stdin=self.capture_process.stdout, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + # FFmpeg holds its own copy of the pipe now; drop ours so the capture + # helper gets EPIPE if FFmpeg dies + try: + self.capture_process.stdout.close() + except OSError: + pass + time.sleep(2) + return self.ffmpeg_process.poll() is None + + def stop_streaming(self): + """Stop all streaming processes""" + self.streaming = False + + for process in (self.ffmpeg_process, self.capture_process): + if process: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + logger.info("Headless streaming stopped") + + def is_running(self): + return (self.streaming + and self.capture_process and self.capture_process.poll() is None + and self.ffmpeg_process and self.ffmpeg_process.poll() is None) + + +class HeadlessHTMLStreamer(PipelineStreamer): + """True headless HTML streaming using the Chrome DevTools screencast API""" + + def __init__(self, stream_key, content_path="https://example.com"): + super().__init__(stream_key) + self.content_path = content_path + self.chrome_process = None self.debug_port = 9222 - + def start_chromium_headless(self): """Start Chromium in true headless mode with remote debugging""" + browser = find_browser() + if not browser: + logger.error("No Chromium/Chrome browser found") + return False + # Detect available memory for optimization import psutil total_memory_mb = psutil.virtual_memory().total // (1024 * 1024) - + chromium_cmd = [ - 'chromium-browser', - '--headless=new', # New headless mode (more efficient) - '--no-gpu', + browser, + '--headless=new', + '--disable-gpu', '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', # CRITICAL for low memory @@ -56,314 +141,164 @@ def start_chromium_headless(self): '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-features=TranslateUI', - '--disable-sync', # No Google account sync - '--disable-background-networking', # No telemetry + '--disable-sync', + '--disable-background-networking', '--disable-default-apps', '--disable-component-update', - '--remote-debugging-port=' + str(self.debug_port), + '--mute-audio', + '--hide-scrollbars', + f'--remote-debugging-port={self.debug_port}', '--remote-debugging-address=127.0.0.1', - '--virtual-time-budget=5000' + f'--remote-allow-origins=http://127.0.0.1:{self.debug_port}', ] - + # Add memory optimizations based on available RAM if total_memory_mb < 1024: chromium_cmd.extend([ - '--single-process', # Run in single process mode - '--no-zygote', # Don't use zygote process - '--max_old_space_size=96', # Limit V8 heap - '--js-flags="--max-old-space-size=96 --max-semi-space-size=2"', - '--aggressive-cache-discard', - '--aggressive-tab-discard', + '--single-process', + '--no-zygote', + '--js-flags=--max-old-space-size=96', '--enable-low-end-device-mode', '--disable-site-isolation-trials', '--disable-features=site-per-process', - '--window-size=854,480' # Smaller window for less memory + '--window-size=854,480', # Smaller window for less memory ]) logger.info(f"Applied low-memory optimizations (system has {total_memory_mb}MB)") elif total_memory_mb < 2048: chromium_cmd.extend([ - '--max_old_space_size=256', - '--window-size=1280,720' + '--js-flags=--max-old-space-size=256', + '--window-size=1280,720', ]) else: chromium_cmd.append('--window-size=1280,720') - + chromium_cmd.append(self.content_path) - + logger.info("Starting Chromium in optimized headless mode...") self.chrome_process = subprocess.Popen( chromium_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) - + # Wait for Chrome to be ready time.sleep(3) return self.chrome_process.poll() is None - - def get_chromium_tab_id(self): - """Get the tab ID from Chromium DevTools API""" - try: - response = requests.get(f'http://127.0.0.1:{self.debug_port}/json/list', timeout=5) - tabs = response.json() - if tabs: - return tabs[0]['id'] - except Exception as e: - logger.error(f"Failed to get Chromium tab ID: {e}") - return None - - def capture_screenshot(self, tab_id): - """Capture screenshot using Chromium DevTools Protocol""" - try: - # Take screenshot via DevTools - screenshot_cmd = { - "id": 1, - "method": "Page.captureScreenshot", - "params": {"format": "png", "quality": 90} - } - - response = requests.post( - f'http://127.0.0.1:{self.debug_port}/json/runtime/evaluate', - json=screenshot_cmd, - timeout=2 - ) - - if response.status_code == 200: - result = response.json() - if 'result' in result and 'data' in result['result']: - return result['result']['data'] - except Exception as e: - logger.error(f"Screenshot capture failed: {e}") - return None - - def start_ffmpeg_stream(self): - """Start FFmpeg with stdin input for direct frame feeding""" - ffmpeg_cmd = [ - 'ffmpeg', - '-f', 'image2pipe', # Input from pipe - '-vcodec', 'png', # Input codec - '-framerate', '30', # Input framerate - '-i', '-', # Read from stdin - '-c:v', 'libx264', # Output video codec - '-preset', 'veryfast', # Encoding speed - '-b:v', '2500k', # Video bitrate - '-maxrate', '2500k', # Max bitrate - '-bufsize', '5000k', # Buffer size - '-pix_fmt', 'yuv420p', # Pixel format - '-g', '60', # GOP size - '-f', 'flv', # Output format - f'rtmp://a.rtmp.youtube.com/live2/{self.stream_key}' - ] - - logger.info("Starting optimized FFmpeg stream (no X11 capture)...") - self.ffmpeg_process = subprocess.Popen( - ffmpeg_cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - - return self.ffmpeg_process.poll() is None - - def stream_loop(self): - """Main streaming loop - captures and feeds frames to FFmpeg""" - tab_id = self.get_chromium_tab_id() - if not tab_id: - logger.error("Could not get Chromium tab ID") - return - - logger.info("Starting headless streaming loop...") - frame_count = 0 - - while self.streaming and self.ffmpeg_process.poll() is None: - try: - # Capture screenshot from Chromium - screenshot_data = self.capture_screenshot(tab_id) - - if screenshot_data: - # Convert base64 to image bytes - import base64 - image_bytes = base64.b64decode(screenshot_data) - - # Feed directly to FFmpeg stdin - self.ffmpeg_process.stdin.write(image_bytes) - self.ffmpeg_process.stdin.flush() - - frame_count += 1 - if frame_count % 300 == 0: # Log every 10 seconds at 30fps - logger.info(f"Streamed {frame_count} frames (headless)") - - # 30 FPS timing - time.sleep(1/30) - - except Exception as e: - logger.error(f"Streaming error: {e}") - time.sleep(0.1) - + def start_streaming(self): """Start the complete headless streaming process""" if not self.start_chromium_headless(): return False, "Failed to start Chromium" - - if not self.start_ffmpeg_stream(): + + capture_cmd = [ + sys.executable, os.path.join(BASE_DIR, 'capture_html.py'), + '--port', str(self.debug_port), + '--fps', '30', + '--width', '1280', + '--height', '720', + ] + self.capture_process = subprocess.Popen( + capture_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + + if not self._start_ffmpeg(framerate=30, bitrate='2500k'): + self.stop_streaming() return False, "Failed to start FFmpeg" - + self.streaming = True - - # Start streaming in separate thread - stream_thread = threading.Thread(target=self.stream_loop, daemon=True) - stream_thread.start() - return True, "Headless streaming started successfully" - + def stop_streaming(self): - """Stop all streaming processes""" - self.streaming = False - - if self.ffmpeg_process: - self.ffmpeg_process.terminate() - try: - self.ffmpeg_process.wait(timeout=5) - except subprocess.TimeoutExpired: - self.ffmpeg_process.kill() - + super().stop_streaming() if self.chrome_process: self.chrome_process.terminate() try: self.chrome_process.wait(timeout=5) except subprocess.TimeoutExpired: self.chrome_process.kill() - - logger.info("Headless streaming stopped") -class HeadlessPygameStreamer: +class HeadlessPygameStreamer(PipelineStreamer): """True headless Pygame streaming - direct surface capture""" - + def __init__(self, stream_key, pygame_script="example_game.py"): - self.stream_key = stream_key + super().__init__(stream_key) self.pygame_script = pygame_script - self.ffmpeg_process = None - self.streaming = False - - def start_pygame_headless(self): - """Start Pygame in headless mode using dummy video driver""" - # Set SDL to use dummy video driver (no display needed) - os.environ['SDL_VIDEODRIVER'] = 'dummy' - - # Import pygame after setting video driver - try: - import pygame - pygame.init() - pygame.display.set_mode((1280, 720)) - return True - except Exception as e: - logger.error(f"Failed to initialize headless Pygame: {e}") - return False - - def capture_pygame_surface(self): - """Capture pygame surface as raw image data""" - try: - import pygame - surface = pygame.display.get_surface() - if surface: - # Convert surface to RGB array - rgb_array = pygame.surfarray.array3d(surface) - # Transpose for correct orientation - rgb_array = np.transpose(rgb_array, (1, 0, 2)) - # Convert to PIL Image - image = Image.fromarray(rgb_array.astype('uint8'), 'RGB') - - # Convert to PNG bytes - img_bytes = BytesIO() - image.save(img_bytes, format='PNG') - return img_bytes.getvalue() - except Exception as e: - logger.error(f"Surface capture failed: {e}") - return None - + def start_streaming(self): """Start headless Pygame streaming""" - if not self.start_pygame_headless(): - return False, "Failed to initialize headless Pygame" - - # Start FFmpeg for direct frame input - ffmpeg_cmd = [ - 'ffmpeg', - '-f', 'image2pipe', - '-vcodec', 'png', - '-framerate', '60', - '-i', '-', - '-c:v', 'libx264', - '-preset', 'veryfast', - '-b:v', '3000k', - '-maxrate', '3000k', - '-bufsize', '6000k', - '-pix_fmt', 'yuv420p', - '-g', '120', - '-f', 'flv', - f'rtmp://a.rtmp.youtube.com/live2/{self.stream_key}' + if not os.path.exists(self.pygame_script): + return False, f"Pygame script not found: {self.pygame_script}" + + capture_cmd = [ + sys.executable, os.path.join(BASE_DIR, 'capture_pygame.py'), + '--fps', '30', + self.pygame_script, ] - - self.ffmpeg_process = subprocess.Popen( - ffmpeg_cmd, - stdin=subprocess.PIPE, + self.capture_process = subprocess.Popen( + capture_cmd, stdout=subprocess.PIPE, - stderr=subprocess.PIPE + stderr=subprocess.DEVNULL, ) - - # Load and run the pygame script - if os.path.exists(self.pygame_script): - exec(open(self.pygame_script).read()) - + time.sleep(2) + if self.capture_process.poll() is not None: + return False, "Failed to start pygame capture" + + if not self._start_ffmpeg(framerate=30, bitrate='3000k'): + self.stop_streaming() + return False, "Failed to start FFmpeg" + self.streaming = True logger.info("Headless Pygame streaming started") return True, "Headless Pygame streaming started" def main(): - """Main function for testing headless streaming""" + """Main function for standalone headless streaming""" stream_key = os.environ.get('YOUTUBE_STREAM_KEY') if not stream_key: logger.error("YOUTUBE_STREAM_KEY environment variable required") sys.exit(1) - + content_path = os.environ.get('CONTENT_PATH') if not content_path: logger.error("CONTENT_PATH environment variable required") - logger.error("Example: CONTENT_PATH='https://clock.zone' python3 smart_streamer.py") + logger.error("Example: CONTENT_PATH='https://clock.zone' python3 headless_streamer.py") sys.exit(1) - + # Auto-detect mode from content path if content_path.endswith('.py'): - mode = 'pygame' streamer = HeadlessPygameStreamer(stream_key, content_path) else: - mode = 'html' streamer = HeadlessHTMLStreamer(stream_key, content_path) - + # Handle graceful shutdown def signal_handler(sig, frame): logger.info("Stopping headless streaming...") streamer.stop_streaming() sys.exit(0) - + signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) - + # Start streaming success, message = streamer.start_streaming() if success: logger.info(f"✅ {message}") logger.info("🚀 Headless streaming active - no GUI/X11 needed!") logger.info("💰 Perfect for cheap VPS instances") - - # Keep running + + # Keep running while the pipeline is healthy try: - while True: + while streamer.is_running(): time.sleep(1) + logger.error("Streaming pipeline exited") + streamer.stop_streaming() + sys.exit(1) except KeyboardInterrupt: - pass + streamer.stop_streaming() else: logger.error(f"❌ Failed to start: {message}") sys.exit(1) diff --git a/main.py b/main.py index 2549990..a535693 100644 --- a/main.py +++ b/main.py @@ -24,6 +24,7 @@ class HTMLStreamer: def __init__(self, stream_key='', content_path=''): self.process = None self.display_process = None + self.ffmpeg_process = None self.status = "stopped" self.stream_key = stream_key self.content_path = content_path diff --git a/monitor.sh b/monitor.sh index 7aa0a98..7c253cd 100755 --- a/monitor.sh +++ b/monitor.sh @@ -1,6 +1,8 @@ #!/bin/bash # StreamDrop Health Check Script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + echo "=== StreamDrop Health Check ===" echo "Timestamp: $(date)" echo @@ -14,38 +16,42 @@ sudo journalctl -u streamdrop.service --since "10 minutes ago" --no-pager | grep echo echo "Stream Statuses:" -curl -s http://localhost:5000/api/streams 2>/dev/null | jq -r '.[] | "\(.name): \(.status) (\(.uptime))"' 2>/dev/null || echo "API not accessible or jq not installed" +# /api/streams requires a logged-in session; a login redirect means the app is up +http_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/api/streams 2>/dev/null) +if [ "$http_code" = "302" ]; then + echo "Web interface is up (log in at http://localhost:5000 for stream details)" +elif [ "$http_code" = "200" ]; then + curl -s http://localhost:5000/api/streams | jq -r '.[] | "\(.name): \(.status) (\(.uptime))"' 2>/dev/null +else + echo "API not accessible (HTTP ${http_code:-no response})" +fi echo echo "Active FFmpeg Processes:" -ffmpeg_count=$(pgrep -f ffmpeg | wc -l)s +ffmpeg_count=$(pgrep -c -f ffmpeg) echo "Count: $ffmpeg_count" -if [ $ffmpeg_count -gt 0 ]; then +if [ "$ffmpeg_count" -gt 0 ]; then echo "PIDs: $(pgrep -f ffmpeg | tr '\n' ' ')" fi echo echo "Memory Usage (Top Python Processes):" -ps -o pid,ppid,cmd,%mem,%cpu --sort=-%mem -C python 2>/dev/null | head -5 || echo "No Python processes found" +ps -o pid,ppid,cmd,%mem,%cpu --sort=-%mem -C python,python3 2>/dev/null | head -5 || echo "No Python processes found" echo echo "Disk Space:" -df -h /home/toor/StreamDrop | tail -1 -echo - -echo "Recent Database Events (last 20):" -sqlite3 /home/toor/StreamDrop/streams.db " -SELECT datetime(timestamp, 'localtime') as time, - stream_id, - event_type, - CASE - WHEN length(details) > 50 THEN substr(details, 1, 50) || '...' - ELSE details - END as details -FROM stream_events -WHERE timestamp > datetime('now', '-1 hour') -ORDER BY timestamp DESC -LIMIT 10;" 2>/dev/null || echo "Could not access database" +df -h "$SCRIPT_DIR" | tail -1 +echo + +echo "Recent Stream Events (last hour):" +sqlite3 "$SCRIPT_DIR/streams.db" " +SELECT datetime(timestamp, 'localtime') as time, + substr(stream_id, 1, 8) as stream, + event_type +FROM stream_analytics +WHERE timestamp > datetime('now', '-1 hour') +ORDER BY timestamp DESC +LIMIT 10;" 2>/dev/null || echo "Could not access database (is sqlite3 installed?)" echo echo "=== End Health Check ===" diff --git a/requirements.txt b/requirements.txt index 816966e..7fc9655 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,4 @@ requests==2.31.0 psutil==5.9.5 # Headless streaming dependencies Pillow>=10.4.0 -numpy>=1.24.0 -opencv-python-headless>=4.8.0 -selenium>=4.15.0 +websocket-client>=1.6.0 diff --git a/setup.sh b/setup.sh index 5f26f7c..57accf0 100755 --- a/setup.sh +++ b/setup.sh @@ -231,7 +231,7 @@ if detect_headless; then echo -e "${BLUE}🎬 Installing minimal dependencies for headless streaming...${NC}" # Install packages with fallback for older Ubuntu versions - PACKAGES="ffmpeg libnss3 libdrm2 libgbm1" + PACKAGES="ffmpeg libnss3 libdrm2 libgbm1 fonts-liberation fonts-dejavu-core fontconfig" # Handle Ubuntu 24.04+ package name changes (t64 suffix) if apt-cache show libatk-bridge2.0-0t64 >/dev/null 2>&1; then @@ -259,7 +259,7 @@ else echo -e "${BLUE}🎬 Installing full dependencies with X11 support...${NC}" # Full packages with dev libraries (for X11 support) - FULL_PACKAGES="xvfb ffmpeg libnss3-dev libdrm-dev libxcomposite-dev libxdamage-dev libxrandr-dev libgbm-dev libxss-dev" + FULL_PACKAGES="xvfb ffmpeg fonts-liberation fonts-dejavu-core fontconfig libnss3-dev libdrm-dev libxcomposite-dev libxdamage-dev libxrandr-dev libgbm-dev libxss-dev" # Handle Ubuntu 24.04+ package name changes for dev packages if apt-cache show libatk-bridge2.0-dev >/dev/null 2>&1; then @@ -280,7 +280,7 @@ else echo -e "${BLUE}🎬 Installing minimal dependencies for headless streaming...${NC}" # Same headless logic as above - PACKAGES="ffmpeg libnss3 libdrm2 libgbm1" + PACKAGES="ffmpeg libnss3 libdrm2 libgbm1 fonts-liberation fonts-dejavu-core fontconfig" if apt-cache show libatk-bridge2.0-0t64 >/dev/null 2>&1; then PACKAGES="$PACKAGES libatk-bridge2.0-0t64" diff --git a/smart_streamer.py b/smart_streamer.py index fa755ff..efb227c 100755 --- a/smart_streamer.py +++ b/smart_streamer.py @@ -132,7 +132,7 @@ def start_optimal_streaming(): streamer = PygameStreamer() else: from main import HTMLStreamer - streamer = HTMLStreamer() + streamer = HTMLStreamer(stream_key, content_path) # Start streaming success, message = streamer.start_streaming() diff --git a/stream_manager.py b/stream_manager.py index eb7f7cd..9685535 100644 --- a/stream_manager.py +++ b/stream_manager.py @@ -9,23 +9,44 @@ import json import time import uuid +import shutil +import hashlib import logging import signal +import socket import sqlite3 import subprocess from pathlib import Path from datetime import datetime, timedelta -from threading import Thread, Lock +from threading import Thread, RLock from functools import wraps from flask import Flask, render_template, request, jsonify, Response, session, redirect, url_for, flash # Setup logging -logging.basicConfig(level=logging.INFO, +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger('stream-manager') -# Global lock for thread-safe operations -stream_lock = Lock() +# Global lock for thread-safe operations. Reentrant: StreamManager methods +# that hold it (update_stream, delete_stream) call stop_stream/start_stream, +# which acquire it again on the same thread. +stream_lock = RLock() + +# Directory containing this file (capture helper scripts live alongside it) +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def find_browser(): + """Locate an installed Chromium/Chrome binary.""" + override = os.environ.get('STREAMDROP_BROWSER') + if override and (os.path.exists(override) or shutil.which(override)): + return override + for candidate in ('chromium-browser', 'chromium', 'google-chrome', + 'google-chrome-stable', 'chrome'): + path = shutil.which(candidate) + if path: + return path + return None class StreamDatabase: """SQLite database manager for stream configurations""" @@ -54,6 +75,7 @@ def init_database(self): thumbnail TEXT DEFAULT '', rtmp_url TEXT DEFAULT '', custom_settings TEXT DEFAULT '', + orientation TEXT DEFAULT 'auto', project_id TEXT DEFAULT NULL, audio_config TEXT DEFAULT '{}', schedule_config TEXT DEFAULT '{}', @@ -176,20 +198,55 @@ def init_database(self): conn.commit() conn.close() - + + # Migrate older databases that predate the orientation column + self._ensure_column('streams', 'orientation', "TEXT DEFAULT 'auto'") + # Initialize platform configurations self.initialize_platform_configs() - + + def _ensure_column(self, table, column, definition): + """Add a column to an existing table if it's missing (simple migration)""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute(f'PRAGMA table_info({table})') + columns = [row[1] for row in cursor.fetchall()] + if column not in columns: + cursor.execute(f'ALTER TABLE {table} ADD COLUMN {column} {definition}') + conn.commit() + conn.close() + + @staticmethod + def _parse_stream_row(row): + """Convert a stream DB row to a dict with JSON fields decoded""" + stream = dict(row) + json_fields = { + 'custom_settings': {}, + 'audio_config': {}, + 'schedule_config': {}, + 'multi_stream_targets': [], + } + for field, default in json_fields.items(): + value = stream.get(field) + if isinstance(value, str) and value: + try: + stream[field] = json.loads(value) + except (ValueError, TypeError): + stream[field] = default + elif not isinstance(value, (dict, list)): + stream[field] = default + return stream + def create_stream(self, stream_data): """Create a new stream configuration""" conn = sqlite3.connect(self.db_path) cursor = conn.cursor() - + stream_id = str(uuid.uuid4()) cursor.execute(''' - INSERT INTO streams (id, name, type, platform, stream_key, source, - quality, title, description, rtmp_url, custom_settings) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO streams (id, name, type, platform, stream_key, source, + quality, title, description, rtmp_url, custom_settings, orientation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( stream_id, stream_data['name'], @@ -201,61 +258,41 @@ def create_stream(self, stream_data): stream_data.get('title', ''), stream_data.get('description', ''), stream_data.get('rtmp_url', ''), - json.dumps(stream_data.get('custom_settings', {})) + json.dumps(stream_data.get('custom_settings', {})), + stream_data.get('orientation', 'auto') )) - + conn.commit() conn.close() return stream_id - + def get_all_streams(self): """Get all stream configurations""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() - + cursor.execute('SELECT * FROM streams ORDER BY created_at DESC') - streams = [] - for row in cursor.fetchall(): - stream = dict(row) - # Parse custom_settings JSON - if stream.get('custom_settings'): - try: - stream['custom_settings'] = json.loads(stream['custom_settings']) - except: - stream['custom_settings'] = {} - else: - stream['custom_settings'] = {} - streams.append(stream) - + streams = [self._parse_stream_row(row) for row in cursor.fetchall()] + conn.close() return streams - + def get_stream(self, stream_id): """Get a specific stream configuration""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() - + cursor.execute('SELECT * FROM streams WHERE id = ?', (stream_id,)) row = cursor.fetchone() - + conn.close() - + if not row: return None - - stream = dict(row) - # Parse custom_settings JSON - if stream.get('custom_settings'): - try: - stream['custom_settings'] = json.loads(stream['custom_settings']) - except: - stream['custom_settings'] = {} - else: - stream['custom_settings'] = {} - - return stream + + return self._parse_stream_row(row) def update_stream_status(self, stream_id, status, start_time=None): """Update stream status""" @@ -306,13 +343,19 @@ def update_stream(self, stream_id, stream_data): # Build dynamic update query based on provided data update_fields = [] values = [] - - allowed_fields = ['name', 'title', 'description', 'quality', 'source', 'stream_key', 'rtmp_url'] - - # Handle custom_settings as JSON - if 'custom_settings' in stream_data: - update_fields.append('custom_settings = ?') - values.append(json.dumps(stream_data['custom_settings'])) + + allowed_fields = ['name', 'title', 'description', 'quality', 'source', + 'stream_key', 'rtmp_url', 'platform', 'type', 'orientation'] + json_fields = ['custom_settings', 'audio_config', 'schedule_config', 'multi_stream_targets'] + + # Handle JSON-encoded fields (accept dict/list or a pre-encoded string) + for field in json_fields: + if field in stream_data: + value = stream_data[field] + if not isinstance(value, str): + value = json.dumps(value) + update_fields.append(f'{field} = ?') + values.append(value) for field in allowed_fields: if field in stream_data: update_fields.append(f'{field} = ?') @@ -829,10 +872,209 @@ def get_platform_configs(self): else: platform['recommended_settings'] = {} platforms.append(platform) - + conn.close() return platforms + def get_template(self, template_id): + """Get a specific stream template""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute('SELECT * FROM stream_templates WHERE id = ?', (template_id,)) + row = cursor.fetchone() + conn.close() + + if not row: + return None + + template = dict(row) + try: + template['template_config'] = json.loads(template['template_config'] or '{}') + except (ValueError, TypeError): + template['template_config'] = {} + return template + + def update_template(self, template_id, template_data): + """Update a stream template""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + update_fields = [] + values = [] + + for field in ['name', 'description', 'category']: + if field in template_data: + update_fields.append(f'{field} = ?') + values.append(template_data[field]) + + if 'template_config' in template_data: + update_fields.append('template_config = ?') + values.append(json.dumps(template_data['template_config'])) + + if not update_fields: + conn.close() + return False + + values.append(template_id) + cursor.execute(f'UPDATE stream_templates SET {", ".join(update_fields)} WHERE id = ?', values) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + return success + + def delete_template(self, template_id): + """Delete a stream template""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute('DELETE FROM stream_templates WHERE id = ?', (template_id,)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + return success + + def create_stream_from_template(self, template_id, stream_data): + """Create a new stream from a template, with per-stream overrides""" + template = self.get_template(template_id) + if not template: + return None + + merged = dict(template.get('template_config', {})) + merged.update(stream_data or {}) + + # A usable stream needs these at minimum + required = ['name', 'type', 'platform', 'stream_key', 'source'] + if not all(merged.get(field) for field in required): + return None + + return self.create_stream(merged) + + def create_platform_config(self, platform_data): + """Create a new platform configuration""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + recommended = platform_data.get('recommended_settings', {}) + if not isinstance(recommended, str): + recommended = json.dumps(recommended) + + cursor.execute(''' + INSERT INTO platform_configs + (platform_name, display_name, rtmp_url, supports_auth, max_bitrate, recommended_settings) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + platform_data['platform_name'], + platform_data.get('display_name', platform_data['platform_name']), + platform_data['rtmp_url'], + platform_data.get('supports_auth', False), + platform_data.get('max_bitrate', 6000), + recommended + )) + + platform_id = cursor.lastrowid + conn.commit() + conn.close() + return platform_id + + def get_platform_config(self, platform_name): + """Get a specific platform configuration""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute('SELECT * FROM platform_configs WHERE platform_name = ?', (platform_name,)) + row = cursor.fetchone() + conn.close() + + if not row: + return None + + platform = dict(row) + try: + platform['recommended_settings'] = json.loads(platform['recommended_settings'] or '{}') + except (ValueError, TypeError): + platform['recommended_settings'] = {} + return platform + + def update_platform_config(self, platform_name, platform_data): + """Update a platform configuration""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + update_fields = [] + values = [] + + for field in ['display_name', 'rtmp_url', 'supports_auth', 'max_bitrate', 'active']: + if field in platform_data: + update_fields.append(f'{field} = ?') + values.append(platform_data[field]) + + if 'recommended_settings' in platform_data: + recommended = platform_data['recommended_settings'] + if not isinstance(recommended, str): + recommended = json.dumps(recommended) + update_fields.append('recommended_settings = ?') + values.append(recommended) + + if not update_fields: + conn.close() + return False + + values.append(platform_name) + cursor.execute(f'UPDATE platform_configs SET {", ".join(update_fields)} WHERE platform_name = ?', values) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + return success + + def delete_platform_config(self, platform_name): + """Delete a platform configuration""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute('DELETE FROM platform_configs WHERE platform_name = ?', (platform_name,)) + + success = cursor.rowcount > 0 + conn.commit() + conn.close() + return success + + def get_project_streams(self, project_id): + """Get all streams belonging to a project""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute('SELECT * FROM streams WHERE project_id = ? ORDER BY created_at DESC', (project_id,)) + streams = [self._parse_stream_row(row) for row in cursor.fetchall()] + + conn.close() + return streams + + def reset_stale_statuses(self): + """Mark all streams stopped. Called at startup: no stream can be live + before the manager has started any processes.""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + UPDATE streams + SET status = 'stopped', start_time = NULL, updated_at = CURRENT_TIMESTAMP + WHERE status != 'stopped' + ''') + + reset_count = cursor.rowcount + conn.commit() + conn.close() + + if reset_count: + logger.info(f"Reset {reset_count} stale stream status(es) to stopped") + class StreamInstance: """Individual stream instance with process management""" @@ -850,7 +1092,15 @@ def __init__(self, stream_config, db): self.last_recovery_attempt = 0 self.recovery_in_progress = False self.active_recovery_id = None - + + # Derive a stable per-stream number for the X display / Chrome debug + # port. Stream IDs are UUIDs, so hash them to a small integer instead + # of assuming the last character is a digit. + digest = hashlib.sha1(str(self.config['id']).encode()).hexdigest() + self.instance_num = int(digest[:6], 16) % 500 + self.display_name = f":{100 + self.instance_num}" + self.debug_port = 9300 + self.instance_num + # Quality settings (horizontal presets) self.quality_presets = { 'low': {'resolution': '854x480', 'bitrate': '1000k', 'framerate': '24'}, @@ -874,59 +1124,33 @@ def __init__(self, stream_config, db): self.platform_configs = {} self._load_platform_configs() - # Audio configuration - self.audio_config = self.config.get('audio_config', {}) - self.multi_stream_targets = self.config.get('multi_stream_targets', []) - - def _load_platform_configs(self): - """Load platform configurations from database""" - try: - platforms = self.db.get_platform_configs() - for platform in platforms: - self.platform_configs[platform['platform_name']] = platform - except Exception as e: - logger.error(f"Failed to load platform configs: {e}") - # Fallback to basic configs - self.platform_configs = { - 'youtube': {'rtmp_url': 'rtmp://a.rtmp.youtube.com/live2/'}, - 'twitch': {'rtmp_url': 'rtmp://live.twitch.tv/live/'}, - 'facebook': {'rtmp_url': 'rtmps://live-api-s.facebook.com:443/rtmp/'}, - 'tiktok': {'rtmp_url': 'rtmp://push.tiktokcdn.com/live/'}, - 'instagram': {'rtmp_url': 'rtmps://live-upload.instagram.com/rtmp/'} - } - + # Audio configuration (config values are decoded by StreamDatabase, + # but guard against raw JSON strings from older code paths) + audio_config = self.config.get('audio_config', {}) + if isinstance(audio_config, str): + try: + audio_config = json.loads(audio_config) + except (ValueError, TypeError): + audio_config = {} + self.audio_config = audio_config if isinstance(audio_config, dict) else {} + + targets = self.config.get('multi_stream_targets', []) + if isinstance(targets, str): + try: + targets = json.loads(targets) + except (ValueError, TypeError): + targets = [] + self.multi_stream_targets = targets if isinstance(targets, list) else [] + + def start_streaming(self): """Start the streaming process""" if self.status == "live": return False, "Stream is already running" try: - # Handle custom quality settings - if self.config.get('quality') == 'custom' and 'custom_settings' in self.config: - custom = self.config['custom_settings'] - quality = { - 'resolution': custom.get('resolution', '1280x720'), - 'bitrate': custom.get('bitrate', '2500') + 'k', - 'framerate': custom.get('framerate', '30') - } - else: - # Choose quality preset based on orientation preference - orientation = self.config.get('orientation', 'auto') - platform = self.config.get('platform', '') - - # Determine effective orientation - if orientation == 'auto': - use_vertical = platform in self.vertical_platforms - elif orientation == 'vertical': - use_vertical = True - else: # horizontal - use_vertical = False - - if use_vertical: - quality = self.vertical_quality_presets.get(self.config.get('quality', 'medium')) - else: - quality = self.quality_presets.get(self.config.get('quality', 'medium')) - + quality = self._get_effective_quality() + # Use smart streaming approach - detects headless vs X11 automatically self._start_smart_streaming(quality) @@ -944,6 +1168,31 @@ def start_streaming(self): self.cleanup() return False, f"Error starting stream: {e}" + def _get_effective_quality(self): + """Resolve the stream's quality settings: custom values if configured, + otherwise the preset matching the effective orientation""" + if self.config.get('quality') == 'custom' and self.config.get('custom_settings'): + custom = self.config['custom_settings'] + return { + 'resolution': custom.get('resolution', '1280x720'), + 'bitrate': str(custom.get('bitrate', '2500')).rstrip('k') + 'k', + 'framerate': str(custom.get('framerate', '30')) + } + + # Choose quality preset based on orientation preference + orientation = self.config.get('orientation', 'auto') + platform = self.config.get('platform', '') + + if orientation == 'auto': + use_vertical = platform in self.vertical_platforms + elif orientation == 'vertical': + use_vertical = True + else: # horizontal + use_vertical = False + + presets = self.vertical_quality_presets if use_vertical else self.quality_presets + return presets.get(self.config.get('quality', 'medium')) or presets['medium'] + def _start_smart_streaming(self, quality): """Start streaming using smart detection with progressive fallbacks""" try: @@ -956,6 +1205,9 @@ def _start_smart_streaming(self, quality): self._start_headless_streaming(quality) except Exception as e: logger.error(f"Headless streaming failed: {e}") + # Kill any partially-started pipeline before falling back, + # or an orphaned FFmpeg keeps fighting for the RTMP endpoint + self.cleanup() # Fallback to simple test pattern streaming for low-memory VPS logger.info("Falling back to test pattern streaming for low-memory system...") self._start_test_pattern_streaming(quality) @@ -965,39 +1217,38 @@ def _start_smart_streaming(self, quality): self._start_x11_streaming(quality) except Exception as e: logger.error(f"X11 streaming failed: {e}") + self.cleanup() # Fallback to headless logger.info("Falling back to headless streaming...") try: self._start_headless_streaming(quality) except Exception as e2: logger.error(f"Headless fallback also failed: {e2}") + self.cleanup() # Final fallback to test pattern logger.info("Final fallback to test pattern streaming...") self._start_test_pattern_streaming(quality) - + except Exception as e: logger.error(f"Error in smart streaming setup: {e}") # Emergency fallback logger.info("Emergency fallback to test pattern streaming...") + self.cleanup() self._start_test_pattern_streaming(quality) def _detect_headless_system(self): - """Detect if we're running on a headless system""" + """Detect if we're running on a headless system (no X11 available). + Headless is preferred: it's cheaper and doesn't need Xvfb.""" try: - # Check if DISPLAY is set and accessible - if os.environ.get('DISPLAY'): - # Try to connect to X server + # An active X display means we can use x11grab directly + if os.environ.get('DISPLAY') and shutil.which('xset'): result = subprocess.run(['xset', 'q'], capture_output=True, timeout=5) if result.returncode == 0: return False # X11 available - - # Check if we're in a known headless environment - if os.path.exists('/usr/bin/chromium-browser') and not os.path.exists('/usr/bin/Xorg'): - return True - + # Default to headless if uncertain return True - + except Exception: # If detection fails, assume headless return True @@ -1018,49 +1269,61 @@ def _start_headless_streaming(self, quality): def _start_x11_streaming(self, quality): """Start traditional X11 streaming with Xvfb""" logger.info("Starting X11 streaming with virtual display...") - - # Start virtual display - display_port = f":9{self.config['id'][-1]}" + + if not shutil.which('Xvfb'): + raise Exception("Xvfb is not installed") + + # Start virtual display (display number derived from the stream ID) + display_port = self.display_name self.processes['display'] = subprocess.Popen([ 'Xvfb', display_port, '-screen', '0', f"{quality['resolution']}x24", '-ac' ]) - + time.sleep(2) # Allow Xvfb to start - + + if self.processes['display'].poll() is not None: + raise Exception(f"Xvfb failed to start on display {display_port}") + env = os.environ.copy() env['DISPLAY'] = display_port - + # Start content renderer if self.config['type'] == 'html': self._start_html_renderer(env, quality) elif self.config['type'] == 'pygame': self._start_pygame_renderer(env) - + else: + raise Exception(f"Unsupported content type: {self.config['type']}") + time.sleep(3) # Allow content to start - + # Start FFmpeg streaming self._start_ffmpeg_stream(env, quality) def _start_headless_html_streaming(self, quality): - """Start headless HTML streaming using Chrome DevTools Protocol""" + """Start headless HTML streaming: Chrome renders the page, the + capture_html.py helper pulls real frames out via the DevTools + screencast API, and FFmpeg encodes them to RTMP.""" try: - # Start Chrome in headless mode with remote debugging - chrome_port = 9222 + int(self.config['id'][-1]) # Unique port per stream - + browser = find_browser() + if not browser: + raise Exception("No Chromium/Chrome browser found. Install chromium-browser.") + + chrome_port = self.debug_port # Unique port per stream + # Detect available memory for optimization import psutil total_memory_mb = psutil.virtual_memory().total // (1024 * 1024) - + chrome_cmd = [ - 'chromium-browser', - '--headless', + browser, + '--headless=new', '--disable-gpu', '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', # CRITICAL for low memory '--disable-extensions', '--disable-plugins', - '--disable-images', # Optimize for streaming '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', @@ -1073,103 +1336,142 @@ def _start_headless_html_streaming(self, quality): '--disable-sync', '--memory-pressure-off', '--mute-audio', + '--hide-scrollbars', f'--window-size={quality["resolution"].replace("x", ",")}', f'--remote-debugging-port={chrome_port}', - '--enable-logging', - '--log-level=0' + '--remote-debugging-address=127.0.0.1', + f'--remote-allow-origins=http://127.0.0.1:{chrome_port}', + f'--user-data-dir=/tmp/streamdrop-chrome-{self.instance_num}', ] - + # Add aggressive memory optimizations for low-memory systems if total_memory_mb < 1024: chrome_cmd.extend([ '--single-process', # Run in single process mode '--no-zygote', # Don't use zygote process - '--max_old_space_size=96', # Limit V8 heap to 96MB - '--js-flags="--max-old-space-size=96 --max-semi-space-size=2"', - '--aggressive-cache-discard', - '--aggressive-tab-discard', + '--js-flags=--max-old-space-size=96', '--enable-low-end-device-mode', '--disable-site-isolation-trials', '--disable-features=site-per-process' ]) logger.info(f"Applied low-memory optimizations (system has {total_memory_mb}MB)") elif total_memory_mb < 2048: - chrome_cmd.extend([ - '--max_old_space_size=256', # Limit V8 heap to 256MB - '--js-flags="--max-old-space-size=256"' - ]) - else: - chrome_cmd.append('--max_old_space_size=512') - + chrome_cmd.append('--js-flags=--max-old-space-size=256') + chrome_cmd.append(self.config['source']) - + logger.info(f"Starting headless Chrome: {' '.join(chrome_cmd[:8])}...") - self.processes['chrome'] = subprocess.Popen(chrome_cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE) - + # Send Chrome output to a log file: an unread PIPE fills up and + # blocks Chrome after a while, killing 24/7 streams. + chrome_log_path = f'/tmp/streamdrop-chrome-{self.instance_num}.log' + self._chrome_log = open(chrome_log_path, 'wb') + self.processes['chrome'] = subprocess.Popen( + chrome_cmd, stderr=self._chrome_log, stdout=self._chrome_log) + # Wait for Chrome to start and check if it's still running time.sleep(3) - + if self.processes['chrome'].poll() is not None: # Chrome died immediately - stderr_output = self.processes['chrome'].stderr.read().decode() if self.processes['chrome'].stderr else "No error output" + try: + with open(chrome_log_path, 'r', errors='replace') as f: + stderr_output = f.read() + except OSError: + stderr_output = "No error output" logger.error(f"Chrome died immediately. Error: {stderr_output[:1000]}") - + # Check for common memory-related errors if "Shared memory" in stderr_output or "/dev/shm" in stderr_output: logger.error("Chrome crashed due to shared memory limits. System may need more RAM or larger /dev/shm") logger.info("Falling back to test pattern streaming...") + self.cleanup() return self._start_test_pattern_streaming(quality) elif "memory" in stderr_output.lower() or "oom" in stderr_output.lower(): logger.error("Chrome crashed due to out of memory. System needs more RAM.") logger.info("Falling back to test pattern streaming...") + self.cleanup() return self._start_test_pattern_streaming(quality) - + raise Exception(f"Chrome failed to start: {stderr_output[:500]}") - + # Verify Chrome is responding on debug port - import socket - max_retries = 10 + max_retries = 15 for i in range(max_retries): try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(1) - result = sock.connect_ex(('localhost', chrome_port)) + result = sock.connect_ex(('127.0.0.1', chrome_port)) sock.close() if result == 0: logger.info(f"Chrome debug port {chrome_port} is responding") break - except: + except OSError: pass time.sleep(1) else: - logger.warning(f"Chrome debug port {chrome_port} not responding after {max_retries}s") - - # Start FFmpeg to capture from Chrome via CDP and stream - self._start_headless_ffmpeg_stream(quality, chrome_port) - + raise Exception(f"Chrome debug port {chrome_port} not responding after {max_retries}s") + + # Start the frame capture helper (JPEG frames on its stdout) + width, height = quality['resolution'].split('x') + capture_cmd = [ + sys.executable, os.path.join(BASE_DIR, 'capture_html.py'), + '--port', str(chrome_port), + '--fps', str(quality['framerate']), + '--width', width, + '--height', height, + ] + logger.info("Starting DevTools screencast capture...") + self.processes['capture'] = subprocess.Popen( + capture_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL + ) + + # FFmpeg encodes the captured frames and pushes to RTMP + self._start_pipe_ffmpeg_stream(quality, self.processes['capture'].stdout) + + # Confirm the pipeline survived startup + time.sleep(3) + for name in ('capture', 'ffmpeg'): + if self.processes.get(name) and self.processes[name].poll() is not None: + raise Exception(f"Headless pipeline process '{name}' exited during startup") + except Exception as e: logger.error(f"Failed to start headless HTML streaming: {e}") raise def _start_headless_pygame_streaming(self, quality): - """Start headless Pygame streaming using memory surfaces""" + """Start headless Pygame streaming: capture_pygame.py runs the game + with SDL's dummy driver and pipes real frames to FFmpeg.""" try: - # Set SDL to use dummy video driver (no display needed) - env = os.environ.copy() - env['SDL_VIDEODRIVER'] = 'dummy' - env['SDL_AUDIODRIVER'] = 'dummy' - - # Start the pygame application - pygame_cmd = ['python3', self.config['source']] - - logger.info(f"Starting headless Pygame: {' '.join(pygame_cmd)}") - self.processes['pygame'] = subprocess.Popen(pygame_cmd, env=env) - + if not os.path.exists(self.config['source']): + raise Exception(f"Pygame script not found: {self.config['source']}") + + capture_cmd = [ + sys.executable, os.path.join(BASE_DIR, 'capture_pygame.py'), + '--fps', str(quality['framerate']), + self.config['source'], + ] + + logger.info(f"Starting headless Pygame capture: {self.config['source']}") + self.processes['capture'] = subprocess.Popen( + capture_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL + ) + time.sleep(2) # Allow pygame to start - - # Start FFmpeg to capture pygame output and stream - self._start_headless_pygame_ffmpeg(quality) - + + if self.processes['capture'].poll() is not None: + raise Exception("Pygame capture process exited during startup") + + # Start FFmpeg to encode the captured frames and stream + self._start_pipe_ffmpeg_stream(quality, self.processes['capture'].stdout) + + time.sleep(3) + if self.processes['ffmpeg'].poll() is not None: + raise Exception("FFmpeg exited during startup") + except Exception as e: logger.error(f"Failed to start headless Pygame streaming: {e}") raise @@ -1178,24 +1480,30 @@ def _start_test_pattern_streaming(self, quality): """Start simple test pattern streaming - reliable fallback for low-memory systems""" try: logger.info("Starting test pattern streaming - reliable mode for low-memory VPS") - + # Create a simple but informative test pattern pattern_type = "testsrc2" if "html" in self.config.get('type', 'html').lower() else "mandelbrot" - - # Generate stream info overlay - stream_name = self.config.get('name', 'StreamDrop Stream').replace(':', '-') + + # Generate stream info overlay (strip characters that break drawtext) + stream_name = self.config.get('name', 'StreamDrop Stream') + for bad_char in (':', "'", '"', '%', '\\'): + stream_name = stream_name.replace(bad_char, '') + stream_name = stream_name or 'StreamDrop Stream' platform = self.config.get('platform', 'Unknown') - + bottom_y = int(quality["resolution"].split("x")[1]) - 40 + ffmpeg_cmd = [ 'ffmpeg', + '-re', # pace generated input at realtime, like a live source '-f', 'lavfi', '-i', f'{pattern_type}=size={quality["resolution"]}:rate={quality["framerate"]}', - '-f', 'lavfi', - '-i', f'anullsrc=channel_layout=stereo:sample_rate=44100', - '-vf', f'drawtext=text="{stream_name}":x=10:y=10:fontsize=24:fontcolor=white,' - f'drawtext=text="Platform: {platform}":x=10:y=50:fontsize=18:fontcolor=yellow,' - f'drawtext=text="StreamDrop Active":x=10:y=80:fontsize=18:fontcolor=lime,' - f'drawtext=text="%{{localtime\\:%Y-%m-%d %H\\:%M\\:%S}}":x=10:y={quality["resolution"].split("x")[1].rstrip()-40}:fontsize=16:fontcolor=white', + '-re', + '-f', 'lavfi', + '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', + '-vf', f'drawtext=text=\'{stream_name}\':x=10:y=10:fontsize=24:fontcolor=white,' + f'drawtext=text=\'Platform {platform}\':x=10:y=50:fontsize=18:fontcolor=yellow,' + f'drawtext=text=\'StreamDrop Active\':x=10:y=80:fontsize=18:fontcolor=lime,' + f'drawtext=text=\'%{{localtime\\:%Y-%m-%d %X}}\':x=10:y={bottom_y}:fontsize=16:fontcolor=white', '-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', @@ -1203,83 +1511,99 @@ def _start_test_pattern_streaming(self, quality): '-b:v', quality['bitrate'], '-b:a', '128k', '-maxrate', quality['bitrate'], - '-bufsize', str(int(quality['bitrate'].rstrip('k')) * 2) + 'k', - '-g', '60', + '-bufsize', str(int(str(quality['bitrate']).rstrip('k')) * 2) + 'k', + '-g', str(int(quality['framerate']) * 2), '-r', str(quality['framerate']), - '-f', 'flv', - self._build_rtmp_url(self.config['platform'], self.config['stream_key'], self.config.get('rtmp_url')) - ] - + ] + self._ffmpeg_output_args() + logger.info("Starting test pattern FFmpeg stream - this will work on any VPS size") - env = os.environ.copy() - self.processes['ffmpeg'] = subprocess.Popen(ffmpeg_cmd, env=env) - + self.processes['ffmpeg'] = subprocess.Popen( + ffmpeg_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + # No Chrome process needed for test pattern logger.info(f"Test pattern stream started successfully for {stream_name}") - + except Exception as e: logger.error(f"Failed to start test pattern streaming: {e}") raise - - def _start_headless_ffmpeg_stream(self, quality, chrome_port): - """Start FFmpeg for headless HTML streaming""" - # For headless HTML, we'll use a simpler approach - generate test pattern for now - # TODO: Implement proper Chrome DevTools Protocol screenshot capture + + def _ffmpeg_output_args(self): + """Build the FFmpeg output section: single RTMP target, or tee for multi-streaming""" + targets = self._get_stream_targets() + if len(targets) == 1: + return ['-f', 'flv', targets[0]] + tee_outputs = '|'.join(f'[f=flv:onfail=ignore]{target}' for target in targets) + return ['-map', '0:v', '-map', '1:a', '-f', 'tee', tee_outputs] + + def _start_pipe_ffmpeg_stream(self, quality, frame_pipe): + """Start FFmpeg reading JPEG frames from a capture process pipe and + pushing encoded video (with a silent audio track) to RTMP""" + # Normalize frames to the exact preset resolution: capture sources + # (browser viewports, pygame windows) pick their own sizes, while + # streaming platforms expect a fixed, even-dimensioned canvas. + width, height = quality['resolution'].split('x') ffmpeg_cmd = [ 'ffmpeg', + '-f', 'image2pipe', + '-framerate', str(quality['framerate']), + '-i', '-', '-f', 'lavfi', - '-i', f'testsrc=size={quality["resolution"]}:rate={quality["framerate"]}', - '-pix_fmt', 'yuv420p', - '-c:v', 'libx264', - '-preset', 'veryfast', - '-b:v', quality['bitrate'], - '-maxrate', quality['bitrate'], - '-bufsize', str(int(quality['bitrate'].rstrip('k')) * 2) + 'k', - '-g', '60', - '-f', 'flv', - self._build_rtmp_url(self.config['platform'], self.config['stream_key'], self.config.get('rtmp_url')) + '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100', + '-vf', f'scale={width}:{height}:force_original_aspect_ratio=decrease,' + f'pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black', ] - - logger.info(f"Starting headless FFmpeg stream...") - env = os.environ.copy() - self.processes['ffmpeg'] = subprocess.Popen(ffmpeg_cmd, env=env) - - def _start_headless_pygame_ffmpeg(self, quality): - """Start FFmpeg for headless Pygame streaming""" - # For headless pygame, generate test pattern for now - # TODO: Implement proper pygame surface capture - ffmpeg_cmd = [ - 'ffmpeg', - '-f', 'lavfi', - '-i', f'testsrc=size={quality["resolution"]}:rate={quality["framerate"]}', - '-pix_fmt', 'yuv420p', + + ffmpeg_cmd.extend([ '-c:v', 'libx264', '-preset', 'veryfast', '-b:v', quality['bitrate'], '-maxrate', quality['bitrate'], - '-bufsize', str(int(quality['bitrate'].rstrip('k')) * 2) + 'k', - '-g', '60', - '-f', 'flv', - self._build_rtmp_url(self.config['platform'], self.config['stream_key'], self.config.get('rtmp_url')) - ] - - logger.info(f"Starting headless Pygame FFmpeg stream...") - env = os.environ.copy() - self.processes['ffmpeg'] = subprocess.Popen(ffmpeg_cmd, env=env) - - + '-bufsize', str(int(str(quality['bitrate']).rstrip('k')) * 2) + 'k', + '-pix_fmt', 'yuv420p', + '-g', str(int(quality['framerate']) * 2), + '-r', str(quality['framerate']), + '-c:a', 'aac', + '-b:a', '128k', + '-ar', '44100', + # anullsrc is infinite: without -shortest, FFmpeg would keep + # streaming silence forever after the frame pipe closes + '-shortest', + ] + self._ffmpeg_output_args()) + + logger.info("Starting FFmpeg (frame pipe -> RTMP)...") + self.processes['ffmpeg'] = subprocess.Popen( + ffmpeg_cmd, + stdin=frame_pipe, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + # FFmpeg holds its own copy of the pipe now; drop ours + try: + frame_pipe.close() + except OSError: + pass + + def _start_html_renderer(self, env, quality): - """Start Chrome for HTML content""" + """Start Chrome for HTML content, rendering into the virtual X display. + Must NOT be headless: FFmpeg captures the display with x11grab.""" + browser = find_browser() + if not browser: + raise Exception("No Chromium/Chrome browser found. Install chromium-browser.") + chrome_cmd = [ - 'chromium-browser', # Use chromium-browser consistently - '--headless', + browser, + '--kiosk', + '--window-position=0,0', '--disable-gpu', '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-extensions', '--disable-plugins', - '--disable-images', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', @@ -1290,28 +1614,30 @@ def _start_html_renderer(self, env, quality): '--disable-component-update', '--disable-default-apps', '--disable-sync', + '--disable-infobars', + '--no-first-run', '--memory-pressure-off', - '--max_old_space_size=128', + '--mute-audio', + '--hide-scrollbars', f'--window-size={quality["resolution"].replace("x", ",")}', - '--remote-debugging-port=9222', - '--remote-debugging-address=0.0.0.0', + f'--user-data-dir=/tmp/streamdrop-chrome-{self.instance_num}', self.config['source'] ] - + self.processes['renderer'] = subprocess.Popen( chrome_cmd, env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL ) - + def _start_pygame_renderer(self, env): """Start pygame script""" if not os.path.exists(self.config['source']): raise Exception(f"Pygame script not found: {self.config['source']}") - + self.processes['renderer'] = subprocess.Popen([ - 'python3', self.config['source'] + sys.executable, self.config['source'] ], env=env) def _start_ffmpeg_stream(self, env, quality): @@ -1363,26 +1689,17 @@ def _start_ffmpeg_stream(self, env, quality): ]) else: ffmpeg_cmd.extend(['-c:a', 'aac', '-b:a', '128k']) - - # Multi-streaming support - targets = self._get_stream_targets() - - if len(targets) == 1: - # Single stream - ffmpeg_cmd.extend(['-f', 'flv', targets[0]]) - else: - # Multi-streaming using tee muxer - ffmpeg_cmd.extend(['-f', 'tee']) - tee_outputs = '|'.join([f'[f=flv]{target}' for target in targets]) - ffmpeg_cmd.append(tee_outputs) - + + # Output target(s): single RTMP URL or tee muxer for multi-streaming + ffmpeg_cmd.extend(self._ffmpeg_output_args()) + logger.info(f"Starting FFmpeg with command: {' '.join(ffmpeg_cmd[:10])}... (truncated)") - + self.processes['ffmpeg'] = subprocess.Popen( ffmpeg_cmd, env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL ) def _get_stream_targets(self): @@ -1437,8 +1754,17 @@ def cleanup(self): pass except: pass - + self.processes.clear() + + # Close the Chrome log file handle if one was opened + chrome_log = getattr(self, '_chrome_log', None) + if chrome_log: + try: + chrome_log.close() + except OSError: + pass + self._chrome_log = None def get_uptime(self): """Get current stream uptime""" @@ -1728,48 +2054,64 @@ def _execute_recovery_strategy(self, strategy, failure_types): def _restart_ffmpeg(self): """Restart only the FFmpeg process""" try: + # FFmpeg can only be restarted in isolation on the X11 path + # (x11grab reattaches to the still-running Xvfb display). Headless + # pipelines feed FFmpeg's stdin, and test-pattern streams have no + # display at all - restart those pipelines whole. + if 'capture' in self.processes or 'display' not in self.processes: + return self._full_restart() + # Stop FFmpeg if 'ffmpeg' in self.processes and self.processes['ffmpeg']: self.processes['ffmpeg'].terminate() self.processes['ffmpeg'].wait(timeout=5) - + time.sleep(2) - - # Restart FFmpeg + + # Restart FFmpeg (X11 capture path) env = os.environ.copy() - env['DISPLAY'] = f":9{self.config['id'][-1]}" - - quality = self.quality_presets.get(self.config.get('quality', 'medium')) - self._start_ffmpeg_stream(env, quality) - + env['DISPLAY'] = self.display_name + + self._start_ffmpeg_stream(env, self._get_effective_quality()) + + # Confirm it actually survived startup + time.sleep(2) + if self.processes['ffmpeg'].poll() is not None: + logger.error("Restarted FFmpeg exited immediately") + return False + return True - + except Exception as e: logger.error(f"Failed to restart FFmpeg: {e}") return False - + def _restart_renderer(self): """Restart the content renderer process""" try: + # Headless pipelines have no standalone renderer to bounce + if 'capture' in self.processes: + return self._full_restart() + # Stop renderer if 'renderer' in self.processes and self.processes['renderer']: self.processes['renderer'].terminate() self.processes['renderer'].wait(timeout=5) - + time.sleep(2) - + # Restart renderer env = os.environ.copy() - env['DISPLAY'] = f":9{self.config['id'][-1]}" - quality = self.quality_presets.get(self.config.get('quality', 'medium')) - + env['DISPLAY'] = self.display_name + quality = self._get_effective_quality() + if self.config['type'] == 'html': self._start_html_renderer(env, quality) elif self.config['type'] == 'pygame': self._start_pygame_renderer(env) - + return True - + except Exception as e: logger.error(f"Failed to restart renderer: {e}") return False @@ -1847,14 +2189,17 @@ def _full_restart(self): try: # Clean up all processes self.cleanup() - + + # start_streaming() refuses to run while status is "live" + self.status = "stopped" + # Wait before restarting time.sleep(3) - + # Restart the entire stream success, message = self.start_streaming() return success - + except Exception as e: logger.error(f"Failed to perform full restart: {e}") return False @@ -1864,6 +2209,9 @@ class StreamManager: def __init__(self): self.db = StreamDatabase() + # Any 'live'/'error' rows in the DB are stale after a restart: no + # processes exist yet, so reflect reality before serving requests. + self.db.reset_stale_statuses() self.active_streams = {} self.monitor_thread = None self.monitoring = False @@ -2008,6 +2356,11 @@ def _check_performance_alerts(self, stream_id, metrics): def create_stream(self, stream_data): """Create a new stream""" try: + required = ['name', 'type', 'platform', 'stream_key', 'source'] + missing = [field for field in required if not stream_data.get(field)] + if missing: + return False, None, f"Missing required fields: {', '.join(missing)}" + stream_id = self.db.create_stream(stream_data) logger.info(f"Created stream {stream_data['name']} with ID {stream_id}") return True, stream_id, "Stream created successfully" @@ -2136,10 +2489,31 @@ def cleanup_all(self): self.stop_monitoring() logger.info("All streams cleaned up") +def _load_or_create_secret_key(): + """Persist the Flask session key so restarts don't log everyone out""" + env_key = os.environ.get('FLASK_SECRET_KEY') + if env_key: + return env_key + + secret_path = os.path.join(BASE_DIR, '.streamdrop_secret') + try: + if os.path.exists(secret_path): + with open(secret_path, 'r') as f: + key = f.read().strip() + if key: + return key + key = os.urandom(24).hex() + with open(secret_path, 'w') as f: + f.write(key) + os.chmod(secret_path, 0o600) + return key + except OSError as e: + logger.warning(f"Could not persist secret key ({e}); sessions will reset on restart") + return os.urandom(24).hex() + # Create Flask app app = Flask(__name__) -# Set secret key for sessions (generate random key if not exists) -app.secret_key = os.environ.get('FLASK_SECRET_KEY', os.urandom(24).hex()) +app.secret_key = _load_or_create_secret_key() stream_manager = StreamManager() def check_auth(username, password): @@ -2453,9 +2827,9 @@ def api_update_stream_audio(stream_id): """Update stream audio configuration""" try: audio_config = request.json - + success = stream_manager.db.update_stream(stream_id, { - 'audio_input': json.dumps(audio_config) + 'audio_config': audio_config }) return jsonify({"success": success, "message": "Audio configuration updated" if success else "Failed to update audio"}) diff --git a/streams.db b/streams.db deleted file mode 100644 index 08b8e33..0000000 Binary files a/streams.db and /dev/null differ