diff options
Diffstat (limited to 'opt/obsproxy')
| -rwxr-xr-x | opt/obsproxy/server.py | 123 |
1 files changed, 79 insertions, 44 deletions
diff --git a/opt/obsproxy/server.py b/opt/obsproxy/server.py index 242e223..27933b8 100755 --- a/opt/obsproxy/server.py +++ b/opt/obsproxy/server.py @@ -12,17 +12,22 @@ import atexit # Setup Flask and load environment variables app = Flask(__name__) +application = app # WSGI servers like gunicorn look for 'application' load_dotenv() # Configuration -INGEST_PSK = os.environ.get('OBS_STREAM_KEY') -DASH_SEGMENT_TIME = float(os.environ.get('DASH_SEGMENT_TIME', '1')) -DASH_FRAGMENT_TIME = float(os.environ.get('DASH_FRAGMENT_TIME', '0.5')) +INGEST_PSK = os.environ.get('OBS_STREAM_KEY') or os.environ.get('STREAM_PSK') +HLS_SEGMENT_TIME = float(os.environ.get('HLS_SEGMENT_TIME', '2')) +HLS_PLAYLIST_SIZE = int(os.environ.get('HLS_PLAYLIST_SIZE', '6')) BASE_DIR = Path(os.environ.get('STREAM_DIR', '/var/www/streams')) SERVER_DOMAIN = os.environ.get('SERVER_DOMAIN', 'yummers.dev') STREAM_HEX = secrets.token_hex(16) STREAM_PATH = BASE_DIR / 'live' / STREAM_HEX -DASH_ROUTE_PREFIX = f"/dash/{STREAM_HEX}" +HLS_ROUTE_PREFIX = f"/hls/{STREAM_HEX}" +# Media output settings tuned for VRChat playback +AUDIO_BITRATE = '256k' +AUDIO_CHANNELS = 2 +AUDIO_SAMPLE_RATE = 48000 # Setup logging logging.basicConfig( @@ -37,7 +42,7 @@ ffmpeg_process = None # Validate configuration if not INGEST_PSK: - logger.error("STREAM_PSK is not set") + logger.error("OBS_STREAM_KEY/STREAM_PSK is not set") exit(1) # Create required directories @@ -51,7 +56,7 @@ def reset_stream_path(): def start_ffmpeg_process(): - """Start FFmpeg process to convert RTMP ingest into low-latency DASH.""" + """Start FFmpeg to convert RTMP ingest into HLS.""" global ffmpeg_process, stream_active reset_stream_path() @@ -70,25 +75,23 @@ def start_ffmpeg_process(): command = [ 'ffmpeg', '-nostdin', - '-loglevel', 'warning', + '-hide_banner', + '-loglevel', os.environ.get('FFMPEG_LOGLEVEL', 'info'), + '-fflags', '+genpts', '-i', f'rtmp://localhost/live/{INGEST_PSK}', '-map', '0:v:0?', '-map', '0:a:0?', '-c:v', 'copy', - '-c:a', 'copy', - '-f', 'dash', - '-seg_duration', str(DASH_SEGMENT_TIME), - '-frag_duration', str(DASH_FRAGMENT_TIME), - '-window_size', '6', - '-extra_window_size', '10', - '-remove_at_exit', '1', - '-streaming', '1', - '-ldash', '1', - '-use_template', '1', - '-use_timeline', '1', - '-init_seg_name', 'init-stream$RepresentationID$.m4s', - '-media_seg_name', 'chunk-stream$RepresentationID$-$Number%05d$.m4s', - str(STREAM_PATH / 'manifest.mpd') + '-c:a', 'aac', + '-b:a', AUDIO_BITRATE, + '-ac', str(AUDIO_CHANNELS), + '-ar', str(AUDIO_SAMPLE_RATE), + '-f', 'hls', + '-hls_time', str(HLS_SEGMENT_TIME), + '-hls_list_size', str(HLS_PLAYLIST_SIZE), + '-hls_flags', 'delete_segments+independent_segments', + '-hls_segment_filename', str(STREAM_PATH / 'segment-%05d.ts'), + str(STREAM_PATH / 'stream.m3u8') ] logger.info("Starting FFmpeg for live stream") @@ -105,19 +108,35 @@ def start_ffmpeg_process(): ffmpeg_process = process stream_active = True + def pipe_logger(pipe, level): + """Continuously drain an ffmpeg pipe and log each line.""" + with pipe: + for line in iter(pipe.readline, ''): + line = line.strip() + if line: + logger.log(level, "ffmpeg: %s", line) + + threading.Thread( + target=pipe_logger, + args=(process.stderr, logging.WARNING), + daemon=True + ).start() + + threading.Thread( + target=pipe_logger, + args=(process.stdout, logging.DEBUG), + daemon=True + ).start() + def monitor_process(): """Collect ffmpeg result and reset state when it exits.""" global ffmpeg_process, stream_active - logger.info(f"FFmpeg process started with PID {process.pid}") + logger.info("FFmpeg process started with PID %s", process.pid) exit_code = process.wait() - stderr = process.stderr.read() - if stderr: - logger.error(f"FFmpeg error output: {stderr}") - if exit_code != 0: - logger.error(f"FFmpeg exited with code {exit_code}") + logger.error("FFmpeg exited with code %s", exit_code) else: logger.info("FFmpeg process completed successfully") @@ -135,7 +154,7 @@ def start_ffmpeg_process(): def cleanup_stream(): - """Stop FFmpeg and purge any cached DASH fragments.""" + """Stop FFmpeg and purge any cached HLS segments.""" global ffmpeg_process, stream_active if ffmpeg_process: @@ -159,22 +178,22 @@ def cleanup_stream(): # Routes -@app.route(f"{DASH_ROUTE_PREFIX}/manifest.mpd") -def serve_dash_manifest(): - """Serve the MPEG-DASH manifest""" +@app.route(f"{HLS_ROUTE_PREFIX}/stream.m3u8") +def serve_hls_manifest(): + """Serve the HLS master playlist""" if not stream_active: return "Stream not found", 404 - manifest_file = STREAM_PATH / "manifest.mpd" + manifest_file = STREAM_PATH / "stream.m3u8" if not manifest_file.exists(): return "Manifest not ready", 503 - return send_from_directory(str(STREAM_PATH), "manifest.mpd") + return send_from_directory(str(STREAM_PATH), "stream.m3u8") -@app.route(f"{DASH_ROUTE_PREFIX}/<path:filename>") -def serve_dash_segments(filename): - """Serve the MPEG-DASH segment files""" +@app.route(f"{HLS_ROUTE_PREFIX}/<path:filename>") +def serve_hls_segments(filename): + """Serve the HLS segment files""" if not stream_active: return "Stream not found", 404 @@ -200,9 +219,9 @@ def on_publish(): return "Failed to start stream", 500 logger.info( - "Stream active; manifest available at https://%s%s/manifest.mpd", + "Stream active; playlist available at https://%s%s/stream.m3u8", SERVER_DOMAIN, - DASH_ROUTE_PREFIX + HLS_ROUTE_PREFIX ) return "OK" @@ -237,7 +256,7 @@ def health_check(): def print_instructions(): """Print usage instructions""" obs_url = f"rtmp://{SERVER_DOMAIN}/live" - dash_url = f"https://{SERVER_DOMAIN}{DASH_ROUTE_PREFIX}/manifest.mpd" + hls_url = f"https://{SERVER_DOMAIN}{HLS_ROUTE_PREFIX}/stream.m3u8" print("\n" + "="*80) print(f"{'OBS TO VRCHAT STREAMING PROXY':^80}") @@ -245,7 +264,7 @@ def print_instructions(): print("\n[URLS]") print(f" OBS ingest: {obs_url}") - print(f" DASH: {dash_url}") + print(f" HLS: {hls_url}") print("\n[STATUS]") print(f" Stream is {'ACTIVE' if ffmpeg_process else 'INACTIVE'}") @@ -253,9 +272,25 @@ def print_instructions(): print(f" On-disk path: {STREAM_PATH}") print("="*80 + "\n") +# Register cleanup once the module is imported so any WSGI server benefits. +atexit.register(cleanup_stream) + + +def main(): + """Entry point for running with a production WSGI server.""" + try: + from waitress import serve + except ImportError as exc: # pragma: no cover - defensive guardrail + raise RuntimeError( + "Waitress is required to run this service. Install it with 'pip install waitress'." + ) from exc -# Register cleanup and start server -if __name__ == '__main__': - atexit.register(cleanup_stream) print_instructions() - app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 5000))) + host = os.environ.get('HOST', '0.0.0.0') + port = int(os.environ.get('PORT', 5000)) + logger.info("Starting Waitress on %s:%s", host, port) + serve(app, host=host, port=port) + + +if __name__ == '__main__': + main() |
