""" game_stream.py - OBS Python script for the game-stream-app project. Add this file in OBS via Tools -> Scripts -> +. Configure the paths to the MediaMTX binary, MediaMTX config file, and the frontend directory in the script properties panel. Responsibilities ---------------- - When OBS starts streaming: 1. Enable the Windows Firewall rule for UDP 8189 (WebRTC media). 2. Launch MediaMTX as a subprocess using the configured mediamtx.yml. 3. Start a background HTTP server that serves the frontend/ directory on 0.0.0.0:8080 (NPM reverse-proxies stream.hetherman.cloud to it). - While streaming: * Poll the MediaMTX API every few seconds for path status and viewer count, surface a summary via script_log(). - When OBS stops streaming (or exits / the script is unloaded): 1. Terminate the MediaMTX subprocess. 2. Stop the HTTP server. 3. Disable the Windows Firewall rule. The firewall rule must already exist (created by scripts/install.ps1). The script toggles its `enabled` state with netsh so the UDP port is only exposed while a stream is actually live. """ import atexit import http.server import os import socketserver import subprocess import sys import threading import time import urllib.error import urllib.request import obspython as obs # type: ignore # provided by OBS at runtime # --------------------------------------------------------------------------- # Configuration (populated from OBS script settings) # --------------------------------------------------------------------------- CONFIG = { "mediamtx_binary": "", "mediamtx_config": "", "frontend_dir": "", "http_port": 8080, "firewall_rule_name": "GameStream-UDP-8189", "public_url": "https://stream.hetherman.cloud", "api_url": "http://127.0.0.1:9997", } # --------------------------------------------------------------------------- # Runtime state # --------------------------------------------------------------------------- class _State: mediamtx_proc: "subprocess.Popen | None" = None http_server: "socketserver.TCPServer | None" = None http_thread: "threading.Thread | None" = None poll_timer_registered: bool = False firewall_enabled: bool = False last_status: str = "offline" last_viewers: int = 0 STATE = _State() # --------------------------------------------------------------------------- # Logging helper # --------------------------------------------------------------------------- def log(msg: str) -> None: """Log to OBS script log and stderr so messages show in both places.""" line = f"[game_stream] {msg}" try: obs.script_log(obs.LOG_INFO, line) except Exception: pass print(line, file=sys.stderr) # --------------------------------------------------------------------------- # Windows Firewall toggle # --------------------------------------------------------------------------- def _run_netsh(enable: bool) -> bool: """Enable or disable the firewall rule via netsh. Returns True on success.""" rule = CONFIG["firewall_rule_name"] state = "yes" if enable else "no" try: result = subprocess.run( [ "netsh", "advfirewall", "firewall", "set", "rule", f"name={rule}", "new", f"enable={state}", ], capture_output=True, text=True, timeout=10, ) if result.returncode != 0: log( f"netsh failed ({result.returncode}): " f"stdout={result.stdout.strip()!r} stderr={result.stderr.strip()!r}" ) return False return True except Exception as exc: log(f"netsh exception: {exc}") return False def enable_firewall_rule() -> None: if STATE.firewall_enabled: return if _run_netsh(True): STATE.firewall_enabled = True log(f"Firewall rule '{CONFIG['firewall_rule_name']}' ENABLED (UDP 8189 open)") else: log("WARNING: failed to enable firewall rule; viewers may not receive media") def disable_firewall_rule() -> None: if not STATE.firewall_enabled: return if _run_netsh(False): STATE.firewall_enabled = False log(f"Firewall rule '{CONFIG['firewall_rule_name']}' DISABLED (UDP 8189 closed)") else: log("WARNING: failed to disable firewall rule; port may remain open") # --------------------------------------------------------------------------- # MediaMTX subprocess # --------------------------------------------------------------------------- def start_mediamtx() -> None: if STATE.mediamtx_proc is not None and STATE.mediamtx_proc.poll() is None: log("MediaMTX already running") return binary = CONFIG["mediamtx_binary"] config = CONFIG["mediamtx_config"] if not binary or not os.path.isfile(binary): log(f"ERROR: MediaMTX binary not found at {binary!r}") return if not config or not os.path.isfile(config): log(f"ERROR: MediaMTX config not found at {config!r}") return try: # CREATE_NO_WINDOW keeps a console window from popping up on Windows. creationflags = 0 if os.name == "nt": creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) STATE.mediamtx_proc = subprocess.Popen( [binary, config], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=creationflags, ) log(f"MediaMTX started (pid={STATE.mediamtx_proc.pid})") except Exception as exc: log(f"ERROR: failed to start MediaMTX: {exc}") STATE.mediamtx_proc = None def stop_mediamtx() -> None: proc = STATE.mediamtx_proc if proc is None: return if proc.poll() is not None: STATE.mediamtx_proc = None return try: proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: log("MediaMTX did not exit in 5s, killing") proc.kill() proc.wait(timeout=5) log("MediaMTX stopped") except Exception as exc: log(f"Error stopping MediaMTX: {exc}") finally: STATE.mediamtx_proc = None # --------------------------------------------------------------------------- # HTTP server for the frontend # --------------------------------------------------------------------------- class _QuietHandler(http.server.SimpleHTTPRequestHandler): """SimpleHTTPRequestHandler that logs to the OBS log instead of stderr.""" def log_message(self, format, *args): # noqa: A002 - match signature # Keep OBS script log clean; only log errors. if args and isinstance(args[0], str) and args[0].startswith(("4", "5")): log(f"http {self.address_string()} {format % args}") class _ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): daemon_threads = True allow_reuse_address = True def start_http_server() -> None: if STATE.http_server is not None: log("HTTP server already running") return frontend_dir = CONFIG["frontend_dir"] if not frontend_dir or not os.path.isdir(frontend_dir): log(f"ERROR: frontend directory not found at {frontend_dir!r}") return port = int(CONFIG["http_port"]) # SimpleHTTPRequestHandler serves from CWD; bind a subclass with a fixed # directory so we don't have to chdir the whole OBS process. handler_cls = type( "BoundHandler", (_QuietHandler,), {"directory": frontend_dir}, ) def _factory(*args, **kwargs): return handler_cls(*args, directory=frontend_dir, **kwargs) try: STATE.http_server = _ThreadingHTTPServer(("0.0.0.0", port), _factory) except OSError as exc: log(f"ERROR: failed to bind HTTP server on :{port}: {exc}") STATE.http_server = None return STATE.http_thread = threading.Thread( target=STATE.http_server.serve_forever, name="game-stream-http", daemon=True, ) STATE.http_thread.start() log(f"Frontend HTTP server listening on 0.0.0.0:{port} (serving {frontend_dir})") def stop_http_server() -> None: server = STATE.http_server if server is None: return try: server.shutdown() server.server_close() log("Frontend HTTP server stopped") except Exception as exc: log(f"Error stopping HTTP server: {exc}") finally: STATE.http_server = None STATE.http_thread = None # --------------------------------------------------------------------------- # Status polling (MediaMTX API -> OBS log) # --------------------------------------------------------------------------- def poll_status() -> None: """Called by OBS timer every few seconds while streaming.""" if STATE.mediamtx_proc is None: return url = f"{CONFIG['api_url']}/v3/paths/get/game" try: with urllib.request.urlopen(url, timeout=2) as resp: import json data = json.loads(resp.read().decode("utf-8")) ready = bool(data.get("ready")) readers = data.get("readers") or [] viewers = len(readers) status = "live" if ready else "waiting" except urllib.error.HTTPError as exc: if exc.code == 404: status, viewers = "offline", 0 else: log(f"MediaMTX API HTTP error {exc.code}") return except Exception: # Transient failures during startup/shutdown are expected; stay quiet. return if status != STATE.last_status or viewers != STATE.last_viewers: log(f"Stream {status} - viewers: {viewers}") STATE.last_status = status STATE.last_viewers = viewers # --------------------------------------------------------------------------- # OBS frontend event hook # --------------------------------------------------------------------------- def _on_frontend_event(event): if event == obs.OBS_FRONTEND_EVENT_STREAMING_STARTED: log("OBS streaming started -> bringing up game-stream-app") enable_firewall_rule() start_mediamtx() start_http_server() if not STATE.poll_timer_registered: obs.timer_add(poll_status, 5000) STATE.poll_timer_registered = True log(f"Viewers can watch at: {CONFIG['public_url']}") elif event in ( obs.OBS_FRONTEND_EVENT_STREAMING_STOPPED, obs.OBS_FRONTEND_EVENT_EXIT, ): log("OBS streaming stopped -> tearing down game-stream-app") if STATE.poll_timer_registered: obs.timer_remove(poll_status) STATE.poll_timer_registered = False stop_mediamtx() stop_http_server() disable_firewall_rule() STATE.last_status = "offline" STATE.last_viewers = 0 # --------------------------------------------------------------------------- # Atexit safety net # --------------------------------------------------------------------------- def _cleanup_atexit(): # If OBS crashes or exits abnormally, make sure we don't leave # MediaMTX running or the firewall rule enabled. try: stop_mediamtx() except Exception: pass try: stop_http_server() except Exception: pass try: disable_firewall_rule() except Exception: pass atexit.register(_cleanup_atexit) # --------------------------------------------------------------------------- # OBS script entry points # --------------------------------------------------------------------------- def script_description() -> str: return ( "Game Stream App
" "Automatically manages MediaMTX, a local HTTP server for the viewer " "frontend, and a Windows Firewall rule for WebRTC UDP media whenever " "OBS starts/stops streaming.

" "Configure the paths below, then just click Start Streaming." ) def script_properties(): props = obs.obs_properties_create() obs.obs_properties_add_path( props, "mediamtx_binary", "MediaMTX binary (mediamtx.exe)", obs.OBS_PATH_FILE, "Executable (*.exe)", None, ) obs.obs_properties_add_path( props, "mediamtx_config", "MediaMTX config (mediamtx.yml)", obs.OBS_PATH_FILE, "YAML (*.yml *.yaml)", None, ) obs.obs_properties_add_path( props, "frontend_dir", "Frontend directory (contains index.html)", obs.OBS_PATH_DIRECTORY, None, None, ) obs.obs_properties_add_int( props, "http_port", "Frontend HTTP port", 1024, 65535, 1, ) obs.obs_properties_add_text( props, "firewall_rule_name", "Windows Firewall rule name", obs.OBS_TEXT_DEFAULT, ) obs.obs_properties_add_text( props, "public_url", "Public URL (for logs/copy)", obs.OBS_TEXT_DEFAULT, ) obs.obs_properties_add_text( props, "api_url", "MediaMTX API URL", obs.OBS_TEXT_DEFAULT, ) return props def script_defaults(settings): obs.obs_data_set_default_string(settings, "mediamtx_binary", "") obs.obs_data_set_default_string(settings, "mediamtx_config", "") obs.obs_data_set_default_string(settings, "frontend_dir", "") obs.obs_data_set_default_int(settings, "http_port", 8080) obs.obs_data_set_default_string( settings, "firewall_rule_name", "GameStream-UDP-8189", ) obs.obs_data_set_default_string( settings, "public_url", "https://stream.hetherman.cloud", ) obs.obs_data_set_default_string( settings, "api_url", "http://127.0.0.1:9997", ) def script_update(settings): CONFIG["mediamtx_binary"] = obs.obs_data_get_string(settings, "mediamtx_binary") CONFIG["mediamtx_config"] = obs.obs_data_get_string(settings, "mediamtx_config") CONFIG["frontend_dir"] = obs.obs_data_get_string(settings, "frontend_dir") CONFIG["http_port"] = obs.obs_data_get_int(settings, "http_port") CONFIG["firewall_rule_name"] = obs.obs_data_get_string( settings, "firewall_rule_name", ) CONFIG["public_url"] = obs.obs_data_get_string(settings, "public_url") CONFIG["api_url"] = obs.obs_data_get_string(settings, "api_url") def script_load(settings): script_update(settings) obs.obs_frontend_add_event_callback(_on_frontend_event) log("game_stream.py loaded") def script_unload(): log("game_stream.py unloading - cleaning up") if STATE.poll_timer_registered: try: obs.timer_remove(poll_status) except Exception: pass STATE.poll_timer_registered = False stop_mediamtx() stop_http_server() disable_firewall_rule()