summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoryum <yum.food.vr@gmail.com>2025-10-13 18:38:58 -0700
committeryum <yum.food.vr@gmail.com>2025-10-28 17:19:35 -0700
commit8aca05a7e644f3d4aff6bcf636514882dd2ae934 (patch)
treef2b6ad908083affb13dca32f803180e60d67638f
parent906f53826285a713512f199b1c99fd68bc1dbc52 (diff)
meow
-rw-r--r--README.md14
-rw-r--r--etc/nginx/sites-available/yummers.dev95
-rw-r--r--etc/systemd/system/obsproxy.service32
-rwxr-xr-xopt/obsproxy/server.py253
-rwxr-xr-xpush.sh37
5 files changed, 431 insertions, 0 deletions
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://<your-domain>/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://<your-domain>/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 `<STREAM_DIR>/live/<session-hex>`. 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/<path:filename>')
+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!"