summaryrefslogtreecommitdiffstats
path: root/opt/obsproxy/server.py
diff options
context:
space:
mode:
Diffstat (limited to 'opt/obsproxy/server.py')
-rwxr-xr-xopt/obsproxy/server.py123
1 files changed, 79 insertions, 44 deletions
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()