yum/3ner
A toon shader for Unity's BIRP.
git clone https://git.yummers.dev/yum/3ner
a91dd1d
master
1#!/usr/bin/env python3 2from dotenv import load_dotenv 3import os 4import secrets 5import subprocess 6import threading 7import shutil 8import time 9from pathlib import Path 10from typing import Optional 11from flask import Flask ,request 12import logging 13import atexit 14 15# Setup Flask and load environment variables 16app = Flask (__name__ ) 17application = app # WSGI servers like gunicorn look for 'application' 18load_dotenv () 19 20# Configuration 21INGEST_PSK = os .environ .get ('OBS_STREAM_KEY' )or os .environ .get ('STREAM_PSK' ) 22INGEST_RTMP_HOST = os .environ .get ('INGEST_RTMP_HOST' ,'127.0.0.1' ) 23try : 24INGEST_RTMP_PORT = int (os .environ .get ('INGEST_RTMP_PORT' ,'1936' )) 25except ValueError as exc : 26raise ValueError ('INGEST_RTMP_PORT must be an integer' )from exc 27INGEST_THREAD_QUEUE_SIZE = int (os .environ .get ('INGEST_THREAD_QUEUE_SIZE' ,'4096' )) 28HLS_SEGMENT_TIME = float (os .environ .get ('HLS_SEGMENT_TIME' ,'2' )) 29HLS_PLAYLIST_SIZE = int (os .environ .get ('HLS_PLAYLIST_SIZE' ,'3' )) 30HLS_DELETE_THRESHOLD = int (os .environ .get ('HLS_DELETE_THRESHOLD' ,'20' )) 31HLS_PLAYLIST_TIMEOUT = float (os .environ .get ('HLS_PLAYLIST_TIMEOUT' ,'5' )) 32HLS_PLAYLIST_POLL_INTERVAL = float (os .environ .get ('HLS_PLAYLIST_POLL_INTERVAL' ,'0.1' )) 33BASE_DIR = Path (os .environ .get ('STREAM_DIR' ,'/var/www/streams' )) 34SERVER_DOMAIN = os .environ .get ('SERVER_DOMAIN' ,'yummers.b-cdn.net' ) 35STREAM_HEX = secrets .token_hex (16 ) 36STREAM_PATH = BASE_DIR / 'live' / STREAM_HEX 37HLS_ROUTE_PREFIX = f"/hls/ { STREAM_HEX } " 38SESSION_KEY_NAME = 'session.key' 39SESSION_KEYINFO_NAME = 'session.keyinfo' 40SESSION_KEY_URI :Optional [str ]= None 41# Media output settings tuned for VRChat playback 42AUDIO_BITRATE = '256k' 43AUDIO_CHANNELS = 2 44AUDIO_SAMPLE_RATE = 48000 45 46# Setup logging 47logging .basicConfig ( 48level = getattr (logging ,os .environ .get ('LOG_LEVEL' ,'INFO' )), 49format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' 50) 51logger = logging .getLogger ('obs_proxy' ) 52 53# Global state 54ffmpeg_process = None 55ffmpeg_worker_thread = None 56ffmpeg_stop_event = threading .Event () 57 58 59def _worker_running ()-> bool : 60"""Return True if the ffmpeg worker thread is currently active.""" 61return ffmpeg_worker_thread is not None and ffmpeg_worker_thread .is_alive () 62 63# Validate configuration 64if not INGEST_PSK : 65logger .error ("OBS_STREAM_KEY/STREAM_PSK is not set" ) 66exit (1 ) 67 68# Create required directories 69BASE_DIR .mkdir (parents = True ,exist_ok = True ) 70STREAM_PATH .mkdir (parents = True ,exist_ok = True ) 71 72 73def _session_key_path ()-> Path : 74return STREAM_PATH / SESSION_KEY_NAME 75 76 77def _session_keyinfo_path ()-> Path : 78return STREAM_PATH / SESSION_KEYINFO_NAME 79 80 81def _write_key_material ()-> None : 82"""Generate and persist AES-128 key + keyinfo for the current session.""" 83global SESSION_KEY_URI 84 85key_bytes = secrets .token_bytes (16 ) 86iv_bytes = secrets .token_bytes (16 ) 87key_path = _session_key_path () 88key_path .write_bytes (key_bytes ) 89 90key_uri = f"https:// { SERVER_DOMAIN } { HLS_ROUTE_PREFIX } / { SESSION_KEY_NAME } " 91keyinfo_path = _session_keyinfo_path () 92iv_hex = format (int .from_bytes (iv_bytes ,'big' ),'032x' ) 93keyinfo_path .write_text ( 94f" { key_uri } \n { key_path } \n { iv_hex } \n" , 95encoding = "utf-8" , 96 ) 97SESSION_KEY_URI = key_uri 98 99def reset_stream_path (): 100"""Ensure the live stream directory is empty and ready.""" 101shutil .rmtree (STREAM_PATH ,ignore_errors = True ) 102STREAM_PATH .mkdir (parents = True ,exist_ok = True ) 103_write_key_material () 104 105 106def _safe_reset_stream_path (context :str )-> bool : 107"""Reset the stream directory and log any failure.""" 108try : 109reset_stream_path () 110return True 111except Exception as exc :# pragma: no cover - best effort logging 112logger .error ("Error resetting stream directory %s: %s" ,context ,exc ) 113return False 114 115 116def _verify_stream_path_writable ()-> None : 117"""Ensure the stream directory is writable before launching FFmpeg.""" 118test_file = STREAM_PATH / "write_test.txt" 119try : 120with open (test_file ,"w" ,encoding = "utf-8" )as probe : 121probe .write ("ok" ) 122except Exception as exc :# pragma: no cover - best effort logging 123logger .error ("Could not write to stream directory: %s" ,exc ) 124finally : 125if test_file .exists (): 126test_file .unlink () 127 128 129def _wait_for_playlist (process :subprocess .Popen [str ],attempt :int ): 130"""Block until the playlist appears or we hit the configured timeout.""" 131playlist_path = STREAM_PATH / 'stream.m3u8' 132start = time .monotonic () 133 134while True : 135if playlist_path .exists (): 136elapsed = time .monotonic ()- start 137segments = sorted (seg .name for seg in playlist_path .parent .glob ('segment-*.ts' )) 138logger .info ( 139"HLS playlist materialized after %.2fs at %s; segments=%s" , 140elapsed , 141playlist_path , 142segments , 143 ) 144return True ,'ready' 145 146if ffmpeg_stop_event .is_set (): 147return False ,'stop_requested' 148 149if process .poll ()is not None : 150return False ,'ffmpeg_exited' 151 152elapsed = time .monotonic ()- start 153if elapsed >= HLS_PLAYLIST_TIMEOUT : 154logger .warning ( 155"HLS playlist still missing after %.2fs on attempt %s; recycling FFmpeg" , 156elapsed , 157attempt , 158 ) 159return False ,'timeout' 160 161if ffmpeg_stop_event .wait (HLS_PLAYLIST_POLL_INTERVAL ): 162return False ,'stop_requested' 163 164 165def _start_pipe_logger (pipe ,level ): 166"""Drain an ffmpeg pipe on a background thread to avoid deadlocks.""" 167 168def pipe_logger (): 169with pipe : 170for line in iter (pipe .readline ,'' ): 171line = line .strip () 172if line : 173logger .log (level ,"ffmpeg: %s" ,line ) 174 175threading .Thread (target = pipe_logger ,daemon = True ).start () 176 177 178def _terminate_ffmpeg_process (process :subprocess .Popen [str ],* ,timeout :float = 5.0 ,log_errors :bool = True )-> None : 179"""Terminate an ffmpeg process, falling back to kill if needed.""" 180try : 181process .terminate () 182process .wait (timeout = timeout ) 183except Exception as exc :# pragma: no cover - best effort logging 184if log_errors : 185logger .error (f"Error stopping FFmpeg: { exc } " ) 186try : 187process .kill () 188except Exception :# pragma: no cover 189pass 190 191 192def _build_ffmpeg_command ()-> list [str ]: 193"""Construct the ffmpeg command line we execute for each attempt.""" 194 195keyinfo_path = _session_keyinfo_path () 196if not keyinfo_path .exists (): 197_write_key_material () 198 199keyinfo_path = _session_keyinfo_path () 200return [ 201'ffmpeg' , 202'-nostdin' , 203'-hide_banner' , 204'-loglevel' ,os .environ .get ('FFMPEG_LOGLEVEL' ,'warning' ), 205'-fflags' ,'+genpts' , 206'-thread_queue_size' ,str (INGEST_THREAD_QUEUE_SIZE ), 207'-i' ,f'rtmp:// { INGEST_RTMP_HOST } : { INGEST_RTMP_PORT } /live/ { INGEST_PSK } ' , 208'-map' ,'0:v:0?' , 209'-map' ,'0:a:0?' , 210'-c:v' ,'copy' , 211'-c:a' ,'aac' , 212'-b:a' ,AUDIO_BITRATE , 213'-ac' ,str (AUDIO_CHANNELS ), 214'-ar' ,str (AUDIO_SAMPLE_RATE ), 215'-f' ,'hls' , 216'-hls_time' ,str (HLS_SEGMENT_TIME ), 217'-hls_list_size' ,str (HLS_PLAYLIST_SIZE ), 218'-hls_flags' ,'delete_segments+independent_segments' , 219'-hls_delete_threshold' ,str (HLS_DELETE_THRESHOLD ), 220'-hls_key_info_file' ,str (keyinfo_path ), 221'-hls_segment_filename' ,str (STREAM_PATH / 'segment-%05d.ts' ), 222str (STREAM_PATH / 'stream.m3u8' ), 223 ] 224 225 226def _run_ffmpeg_once (attempt :int )-> bool : 227"""Start ffmpeg, wait for it to exit, and report whether it ran cleanly.""" 228global ffmpeg_process 229 230logger .info ("Starting FFmpeg for live stream (attempt %s)" ,attempt ) 231 232try : 233process = subprocess .Popen ( 234_build_ffmpeg_command (), 235stdout = subprocess .PIPE , 236stderr = subprocess .PIPE , 237text = True , 238bufsize = 1 , 239 ) 240except Exception as exc :# pragma: no cover - best effort logging 241logger .error ("Failed to start FFmpeg: %s" ,exc ) 242return False 243 244ffmpeg_process = process 245start_time = time .monotonic () 246 247logger .info ("FFmpeg process started with PID %s" ,process .pid ) 248logger .info ('Stream active; waiting for playlist to appear' ) 249 250_start_pipe_logger (process .stderr ,logging .WARNING ) 251_start_pipe_logger (process .stdout ,logging .DEBUG ) 252 253playlist_ready ,playlist_reason = _wait_for_playlist (process ,attempt ) 254 255if not playlist_ready and not ffmpeg_stop_event .is_set (): 256if playlist_reason == 'timeout' and process .poll ()is None : 257logger .info ("Terminating FFmpeg attempt %s after playlist timeout" ,attempt ) 258_terminate_ffmpeg_process (process ,log_errors = False ) 259elif playlist_reason == 'ffmpeg_exited' : 260logger .debug ("FFmpeg exited before playlist became available on attempt %s" ,attempt ) 261 262exit_code = process .wait () 263elapsed = time .monotonic ()- start_time 264 265ffmpeg_process = None 266 267if ffmpeg_stop_event .is_set (): 268logger .info ( 269"FFmpeg stop requested; process exited with code %s after %.2fs" , 270exit_code , 271elapsed , 272 ) 273return True 274 275if not playlist_ready : 276logger .error ( 277"FFmpeg attempt %s ended (exit %s) before playlist became available (reason=%s)" , 278attempt , 279exit_code , 280playlist_reason , 281 ) 282return False 283 284if exit_code != 0 : 285logger .error ("FFmpeg exited with code %s after %.2fs" ,exit_code ,elapsed ) 286return False 287 288logger .info ("FFmpeg process completed successfully after %.2fs" ,elapsed ) 289return True 290 291 292def _ffmpeg_worker_loop ()-> None : 293"""Keep spawning FFmpeg until it runs cleanly or a stop is requested.""" 294attempt = 0 295 296while not ffmpeg_stop_event .is_set (): 297attempt += 1 298if _run_ffmpeg_once (attempt ): 299break 300 301if ffmpeg_stop_event .is_set (): 302break 303 304_safe_reset_stream_path ("between FFmpeg attempts" ) 305 306logger .debug ("FFmpeg worker exiting" ) 307 308 309def start_ffmpeg_process (): 310"""Start FFmpeg to convert RTMP ingest into HLS.""" 311global ffmpeg_worker_thread 312 313if _worker_running (): 314logger .warning ("FFmpeg worker already running; skipping duplicate start" ) 315return True 316 317if _safe_reset_stream_path ("before FFmpeg start" ): 318logger .info (f"Stream directory ready at { STREAM_PATH } " ) 319_verify_stream_path_writable () 320 321ffmpeg_stop_event .clear () 322 323try : 324ffmpeg_worker_thread = threading .Thread ( 325target = _ffmpeg_worker_loop , 326daemon = True , 327name = "ffmpeg-worker" , 328 ) 329ffmpeg_worker_thread .start () 330return True 331except Exception as exc :# pragma: no cover - defensive logging 332logger .error (f"Failed to start FFmpeg worker thread: { exc } " ) 333ffmpeg_worker_thread = None 334return False 335 336 337def cleanup_stream (): 338"""Stop FFmpeg and purge any cached HLS segments.""" 339global ffmpeg_process ,ffmpeg_worker_thread 340 341ffmpeg_stop_event .set () 342 343if ffmpeg_process : 344_terminate_ffmpeg_process (ffmpeg_process ) 345ffmpeg_process = None 346 347worker = ffmpeg_worker_thread 348if worker and worker .is_alive (): 349worker .join (timeout = 5 ) 350if worker .is_alive ():# pragma: no cover - diagnostic 351logger .warning ("FFmpeg worker thread did not exit cleanly" ) 352ffmpeg_worker_thread = None 353 354_safe_reset_stream_path ("during cleanup" ) 355 356ffmpeg_stop_event .clear () 357 358 359# Routes 360@ app . route ( '/rtmp_callbacks/on_publish' , methods = [ 'POST' ]) 361def on_publish (): 362"""Callback when a stream starts""" 363stream_key = request .form .get ('name' ) 364logger .info ("on_publish received for key=%s" ,stream_key ) 365 366if not stream_key or stream_key != INGEST_PSK : 367logger .warning ("Unauthorized stream key attempted to publish: %s" ,stream_key ) 368return "Unauthorized" ,403 369 370if ffmpeg_process or _worker_running (): 371logger .info ("Stream already active, recycling existing session" ) 372cleanup_stream () 373 374if not start_ffmpeg_process (): 375return "Failed to start stream" ,500 376 377playlist_path = STREAM_PATH / 'stream.m3u8' 378if playlist_path .exists (): 379logger .info ("Existing playlist found at %s" ,playlist_path ) 380 381return "OK" 382 383 384@ app . route ( '/rtmp_callbacks/on_publish_done' , methods = [ 'POST' ]) 385def on_publish_done (): 386"""Callback when a stream ends""" 387stream_key = request .form .get ('name' ) 388logger .info ("on_publish_done received for key=%s" ,stream_key ) 389 390if not stream_key or stream_key != INGEST_PSK : 391logger .warning ("on_publish_done received for unknown key: %s" ,stream_key ) 392return "Bad request" ,400 393 394if ffmpeg_process or _worker_running (): 395cleanup_stream () 396 397logger .info ("Stream publishing ended" ) 398return "OK" 399 400 401@ app . route ( '/health' ) 402def health_check (): 403"""Health check endpoint""" 404return { 405"status" :"healthy" , 406"streaming" :ffmpeg_process is not None 407 } 408 409 410def print_instructions (): 411"""Print usage instructions""" 412obs_url = f"rtmps:// { SERVER_DOMAIN } :1935/live" 413hls_url = f"https:// { SERVER_DOMAIN } { HLS_ROUTE_PREFIX } /stream.m3u8" 414 415"\n" + "=" * 80 ) 416f" { 'OBS TO VRCHAT STREAMING PROXY' :^80 } " ) 417"=" * 80 ) 418 419"\n[URLS]" ) 420f" OBS ingest: { obs_url } " ) 421f" HLS: { hls_url } " ) 422if SESSION_KEY_URI : 423f" HLS key: { SESSION_KEY_URI } " ) 424 425"\n[STATUS]" ) 426f" Stream is { 'ACTIVE' if ffmpeg_process else 'INACTIVE' } " ) 427f" Session ID: { STREAM_HEX } " ) 428f" On-disk path: { STREAM_PATH } " ) 429"=" * 80 + "\n" ) 430 431# Register cleanup once the module is imported so any WSGI server benefits. 432atexit .register (cleanup_stream ) 433 434 435def main (): 436"""Entry point for running with a production WSGI server.""" 437try : 438from waitress import serve 439except ImportError as exc :# pragma: no cover - defensive guardrail 440raise RuntimeError ( 441"Waitress is required to run this service. Install it with 'pip install waitress'." 442 )from exc 443 444print_instructions () 445host = os .environ .get ('HOST' ,'0.0.0.0' ) 446port = int (os .environ .get ('PORT' ,5000 )) 447logger .info ("Starting Waitress on %s:%s" ,host ,port ) 448serve (app ,host = host ,port = port ) 449 450 451if __name__ == '__main__' : 452main ()