From 8a852c013733d0170ac80a761e6b9414c02a2078 Mon Sep 17 00:00:00 2001 From: yum Date: Tue, 14 Oct 2025 13:36:14 -0700 Subject: switch to HLS, works better --- README.md | 17 +++-- etc/nginx/sites-available/yummers.dev | 7 +- etc/systemd/system/obsproxy.service | 2 +- opt/obsproxy/server.py | 123 ++++++++++++++++++++++------------ push.sh | 2 + 5 files changed, 95 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 9c21585..d84fb76 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,19 @@ -Shitty service to proxy data from OBS into a low-latency MPEG-DASH stream VRChat understands. +Shitty service to proxy data from OBS into an HTTP Live Streaming (HLS) feed VRChat understands. ## Usage 1. Configure OBS with a custom server pointing at `rtmp:///live` and the pre-shared key stored in `STREAM_PSK`. -2. Start the Python service (see `etc/systemd/system/obsproxy.service` for a sample unit). -3. When the service starts it prints a session-specific manifest URL like `https:///dash//manifest.mpd`; share that exact URL with your VRChat video player. Multiple viewers can consume the feed concurrently. +2. Install runtime dependencies: `pip install -r opt/obsproxy/requirements.txt`. +3. Start the Python service (see `etc/systemd/system/obsproxy.service` for a sample unit). The bundled entrypoint now runs under [Waitress](https://docs.pylonsproject.org/projects/waitress/en/stable/) so you get a production-grade WSGI server out of the box. +4. When the service starts it prints a session-specific playlist URL like `https:///hls//stream.m3u8`; share that exact URL with your VRChat video player. Multiple viewers can consume the feed concurrently. Environmental knobs: -- `STREAM_PSK`: required PSK for the single ingest client. -- `DASH_SEGMENT_TIME` / `DASH_FRAGMENT_TIME`: tweak DASH segment/fragment durations to balance latency vs resilience. +- `OBS_STREAM_KEY` / `STREAM_PSK`: required PSK for the single ingest client. +- `HLS_SEGMENT_TIME`: length (in seconds) of the `.ts` segments emitted by FFmpeg (defaults to `2`). +- `HLS_PLAYLIST_SIZE`: number of segments retained in the rolling playlist (defaults to `6`). +- `HOST`: interface Waitress binds to (defaults to `0.0.0.0`). +- `PORT`: TCP port Waitress listens on (defaults to `5000`). +- `FFMPEG_LOGLEVEL`: override the log verbosity passed to FFmpeg (defaults to `info`). -The server seeds a fresh 128-bit session ID on every restart and writes DASH fragments under `/live/`. The manifest and segments are only exposed under `/dash//`, making it infeasible to guess a live session path. +The server seeds a fresh 128-bit session ID on every restart and writes HLS artifacts under `/live/`. The playlist and segments are only exposed under `/hls//`, making it infeasible to guess a live session path. diff --git a/etc/nginx/sites-available/yummers.dev b/etc/nginx/sites-available/yummers.dev index 0fddcfe..0b40a41 100644 --- a/etc/nginx/sites-available/yummers.dev +++ b/etc/nginx/sites-available/yummers.dev @@ -36,17 +36,14 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # OBS Proxy DASH manifest + segments - location /dash/ { + # OBS Proxy HLS playlist + segments + location /hls/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # Disable buffering/caching so DASH clients see fresh segments immediately - proxy_buffering off; - add_header Cache-Control "no-cache" always; add_header Access-Control-Allow-Origin "*" always; proxy_connect_timeout 1h; diff --git a/etc/systemd/system/obsproxy.service b/etc/systemd/system/obsproxy.service index f2a957b..f60177d 100644 --- a/etc/systemd/system/obsproxy.service +++ b/etc/systemd/system/obsproxy.service @@ -1,5 +1,5 @@ [Unit] -Description=OBS to low-latency DASH streaming proxy +Description=OBS to HLS streaming proxy After=network.target [Service] 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}/") -def serve_dash_segments(filename): - """Serve the MPEG-DASH segment files""" +@app.route(f"{HLS_ROUTE_PREFIX}/") +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() diff --git a/push.sh b/push.sh index 7853dbb..bb49b3c 100755 --- a/push.sh +++ b/push.sh @@ -23,6 +23,8 @@ sudo cp etc/nginx/modules-available/rtmp.conf /etc/nginx/modules-available/ sudo cp etc/nginx/sites-available/yummers.dev /etc/nginx/sites-available/ sudo ln -sf /etc/nginx/sites-available/yummers.dev /etc/nginx/sites-enabled/yummers.dev sudo cp opt/obsproxy/server.py /opt/obsproxy/ +sudo cp opt/obsproxy/requirements.txt /opt/obsproxy/ +sudo /opt/obsproxy/venv/bin/pip install --upgrade -r /opt/obsproxy/requirements.txt sudo rm -rf /var/www/streams/* # Reload systemd daemon and restart obsproxy service -- cgit v1.2.3