"""
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()