From 8aca05a7e644f3d4aff6bcf636514882dd2ae934 Mon Sep 17 00:00:00 2001 From: yum Date: Mon, 13 Oct 2025 18:38:58 -0700 Subject: meow --- README.md | 14 ++ etc/nginx/sites-available/yummers.dev | 95 +++++++++++++ etc/systemd/system/obsproxy.service | 32 +++++ opt/obsproxy/server.py | 253 ++++++++++++++++++++++++++++++++++ push.sh | 37 +++++ 5 files changed, 431 insertions(+) create mode 100644 README.md create mode 100644 etc/nginx/sites-available/yummers.dev create mode 100644 etc/systemd/system/obsproxy.service create mode 100755 opt/obsproxy/server.py create mode 100755 push.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..5faee83 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +Shitty service to proxy data from OBS into a low-latency MPEG-DASH stream 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. Share `https:///dash/manifest.mpd` 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. + +The server seeds a fresh 128-bit session ID on every restart and writes DASH fragments under `/live/`. The public manifest route stays fixed at `/dash/manifest.mpd`. diff --git a/etc/nginx/sites-available/yummers.dev b/etc/nginx/sites-available/yummers.dev new file mode 100644 index 0000000..4f34ff7 --- /dev/null +++ b/etc/nginx/sites-available/yummers.dev @@ -0,0 +1,95 @@ +server { + root /var/www/html; + + # Add index.php to the list if you are using PHP + index index.html index.htm index.nginx-debian.html; + + server_name yummers.dev www.yummers.dev; + + location / { + # First attempt to serve request as file, then + # as directory, then fall back to displaying a 404. + try_files $uri $uri/ =404; + } + + # Add WebSocket proxy for HR proxy server + location /hrproxy { + proxy_pass https://127.0.0.1:2096; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + 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; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + proxy_buffering off; + } + + # OBS Proxy API endpoints + location /api/ { + 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; + } + + # OBS Proxy DASH manifest + segments + location /dash/ { + 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; + proxy_send_timeout 1h; + proxy_read_timeout 1h; + } + + # OBS Proxy health check + location /health { + proxy_pass http://127.0.0.1:5000/health; + proxy_set_header Host $host; + } + + # Add RTMP callbacks route + location /rtmp_callbacks/ { + 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; + } + + listen [::]:443 ssl ipv6only=on; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/yummers.dev/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/yummers.dev/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot +} + +server { + if ($host = www.yummers.dev) { + return 301 https://$host$request_uri; + } # managed by Certbot + + + if ($host = yummers.dev) { + return 301 https://$host$request_uri; + } # managed by Certbot + + + listen 80; + listen [::]:80; + + server_name yummers.dev www.yummers.dev; + return 404; # managed by Certbot +} diff --git a/etc/systemd/system/obsproxy.service b/etc/systemd/system/obsproxy.service new file mode 100644 index 0000000..f2a957b --- /dev/null +++ b/etc/systemd/system/obsproxy.service @@ -0,0 +1,32 @@ +[Unit] +Description=OBS to low-latency DASH streaming proxy +After=network.target + +[Service] +User=www-data +Group=www-data +WorkingDirectory=/opt/obsproxy +ExecStart=/opt/obsproxy/venv/bin/python /opt/obsproxy/server.py +Restart=on-failure +RestartSec=5s + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=obsproxy + +# Environment variables +Environment=PYTHONUNBUFFERED=1 +Environment=STREAM_DIR=/var/www/streams +Environment=PORT=5000 +Environment=STREAM_PSK=your_pre_shared_key +Environment=LOG_LEVEL=INFO + +# Security settings +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/opt/obsproxy/server.py b/opt/obsproxy/server.py new file mode 100755 index 0000000..8191d30 --- /dev/null +++ b/opt/obsproxy/server.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +from dotenv import load_dotenv +import os +import secrets +import subprocess +import threading +import shutil +from pathlib import Path +from flask import Flask, request, send_from_directory +import logging +import atexit + +# Setup Flask and load environment variables +app = Flask(__name__) +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')) +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 + +# Setup logging +logging.basicConfig( + level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO')), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('obs_proxy') + +# Global state +stream_active = False +ffmpeg_process = None + +# Validate configuration +if not INGEST_PSK: + logger.error("STREAM_PSK is not set") + exit(1) + +# Create required directories +BASE_DIR.mkdir(parents=True, exist_ok=True) +STREAM_PATH.mkdir(parents=True, exist_ok=True) + +def reset_stream_path(): + """Ensure the live stream directory is empty and ready.""" + shutil.rmtree(STREAM_PATH, ignore_errors=True) + STREAM_PATH.mkdir(parents=True, exist_ok=True) + + +def start_ffmpeg_process(): + """Start FFmpeg process to convert RTMP ingest into low-latency DASH.""" + global ffmpeg_process, stream_active + + reset_stream_path() + logger.info(f"Stream directory ready at {STREAM_PATH}") + + # Sanity check that we can write to the path before spawning ffmpeg + try: + test_file = STREAM_PATH / "write_test.txt" + with open(test_file, "w", encoding="utf-8") as probe: + probe.write("ok") + if test_file.exists(): + test_file.unlink() + except Exception as exc: # pragma: no cover - best effort logging + logger.error(f"Could not write to stream directory: {exc}") + + command = [ + 'ffmpeg', + '-nostdin', + '-loglevel', 'warning', + '-i', f'rtmp://localhost/live/{INGEST_PSK}', + '-map', '0:v:0?', + '-map', '0:a:0?', + '-c:v', 'copy', + '-c:a', 'aac', '-b:a', '192k', + '-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') + ] + + logger.info("Starting FFmpeg for live stream") + + try: + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1 + ) + + ffmpeg_process = process + stream_active = True + + 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}") + + 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}") + else: + logger.info("FFmpeg process completed successfully") + + ffmpeg_process = None + stream_active = False + + threading.Thread(target=monitor_process, daemon=True).start() + return True + + except Exception as exc: + logger.error(f"Failed to start FFmpeg: {exc}") + ffmpeg_process = None + stream_active = False + return False + + +def cleanup_stream(): + """Stop FFmpeg and purge any cached DASH fragments.""" + global ffmpeg_process, stream_active + + if ffmpeg_process: + try: + ffmpeg_process.terminate() + ffmpeg_process.wait(timeout=5) + except Exception as exc: # pragma: no cover - best effort logging + logger.error(f"Error stopping FFmpeg: {exc}") + try: + ffmpeg_process.kill() + except Exception: # pragma: no cover + pass + finally: + ffmpeg_process = None + + stream_active = False + try: + reset_stream_path() + except Exception as exc: # pragma: no cover - best effort logging + logger.error(f"Error resetting stream directory: {exc}") + + +# Routes +@app.route('/dash/manifest.mpd') +def serve_dash_manifest(): + """Serve the MPEG-DASH manifest""" + if not stream_active: + return "Stream not found", 404 + + return send_from_directory(str(STREAM_PATH), "manifest.mpd") + + +@app.route('/dash/') +def serve_dash_segments(filename): + """Serve the MPEG-DASH segment files""" + if not stream_active: + return "Stream not found", 404 + + return send_from_directory(str(STREAM_PATH), filename) + + +@app.route('/rtmp_callbacks/on_publish', methods=['POST']) +def on_publish(): + """Callback when a stream starts""" + global stream_active + + stream_key = request.form.get('name') + + if not stream_key or stream_key != INGEST_PSK: + logger.warning("Unauthorized stream key attempted to publish") + return "Unauthorized", 403 + + if stream_active: + logger.info("Stream already active, recycling existing session") + cleanup_stream() + + if not start_ffmpeg_process(): + return "Failed to start stream", 500 + + logger.info("Stream started successfully") + logger.info(f"Access stream at https://{SERVER_DOMAIN}/dash/manifest.mpd") + + return "OK" + + +@app.route('/rtmp_callbacks/on_publish_done', methods=['POST']) +def on_publish_done(): + """Callback when a stream ends""" + global stream_active + + stream_key = request.form.get('name') + + if not stream_key or stream_key != INGEST_PSK: + return "Bad request", 400 + + if stream_active: + cleanup_stream() + + logger.info("Stream publishing ended") + return "OK" + + +@app.route('/health') +def health_check(): + """Health check endpoint""" + return { + "status": "healthy", + "streaming": ffmpeg_process is not None + } + + +def print_instructions(): + """Print usage instructions""" + obs_url = f"rtmp://{SERVER_DOMAIN}/live" + vrc_url = f"rtmp://{SERVER_DOMAIN}/live/{STREAM_HEX}" + + print("\n" + "="*80) + print(f"{'OBS TO VRCHAT STREAMING PROXY':^80}") + print("="*80) + + print("\n[URLS]") + print(f" OBS: {obs_url}") + print(f" VRChat: {vrc_url}") + + print("\n[STATUS]") + print(f" Stream is {'ACTIVE' if ffmpeg_process else 'INACTIVE'}") + print(f" Session ID: {STREAM_HEX}") + print(f" On-disk path: {STREAM_PATH}") + print("="*80 + "\n") + + +# 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))) diff --git a/push.sh b/push.sh new file mode 100755 index 0000000..e144182 --- /dev/null +++ b/push.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +HOST="yummers.dev" +DEPLOY_DIR="~/obsproxy" + +echo "Creating deploy directory on remote host..." +ssh "$HOST" "mkdir -p $DEPLOY_DIR/etc/systemd/system $DEPLOY_DIR/etc/nginx/modules-available $DEPLOY_DIR/etc/nginx/sites-available $DEPLOY_DIR/opt/obsproxy" + +echo "Copying files to remote host..." +scp -r * "$HOST:$DEPLOY_DIR/" + +echo "Installing files with sudo and restarting services..." +ssh "$HOST" << 'EOF' +set -o errexit +set -o xtrace + +cd ~/obsproxy + +# Install files to their final destinations +sudo cp etc/systemd/system/obsproxy.service /etc/systemd/system/ +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/ + +# Reload systemd daemon and restart obsproxy service +sudo systemctl daemon-reload +sudo systemctl restart obsproxy + +# Reload nginx +sudo nginx -t && sudo systemctl reload nginx + +echo "Deployment complete!" +EOF + +echo "Done!" -- cgit v1.2.3