summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoryum <yum.food.vr@gmail.com>2025-10-14 13:36:14 -0700
committeryum <yum.food.vr@gmail.com>2025-10-28 17:19:35 -0700
commit8a852c013733d0170ac80a761e6b9414c02a2078 (patch)
tree11ac026ac5018c364e4b1861fb70c91398cd93dc
parentc3fa121f1a3ec74c5980bc8981e4836ca3a708f2 (diff)
switch to HLS, works better
-rw-r--r--README.md17
-rw-r--r--etc/nginx/sites-available/yummers.dev7
-rw-r--r--etc/systemd/system/obsproxy.service2
-rwxr-xr-xopt/obsproxy/server.py123
-rwxr-xr-xpush.sh2
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://<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. When the service starts it prints a session-specific manifest URL like `https://<your-domain>/dash/<session-hex>/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://<your-domain>/hls/<session-hex>/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 `<STREAM_DIR>/live/<session-hex>`. The manifest and segments are only exposed under `/dash/<session-hex>/`, 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 `<STREAM_DIR>/live/<session-hex>`. The playlist and segments are only exposed under `/hls/<session-hex>/`, 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}/<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()
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