180e95f74d
- Switch OBS output to RTMP; add FFmpeg AAC->Opus transcoding via MediaMTX runOnReady so WebRTC can carry audio (WebRTC requires Opus, not AAC) - Enable RTSP on localhost so FFmpeg reads game path without publisher conflict; viewers connect to game-opus path (H264+Opus) - Fix WHEP/HLS path prefix stripping in NPM advanced config; move all custom locations (/whep, /hls, /v3) out of NPM GUI and into advanced conf so trailing-slash proxy_pass correctly strips prefixes before hitting MediaMTX - Fix MediaMTX API port 49997->19997 (49997 was in Windows ephemeral range) - Add /status proxy endpoint to OBS HTTP server so frontend can poll stream readiness without hitting /v3/ through NPM where auth_request blocked it - Fix authInternalUsers: split publish (localhost only) from read (any IP) so WHEP viewers are not challenged with Basic Auth by MediaMTX - Remove muted attribute from video element; show unmute/play button on autoplay block so viewers get audio after one click - Fix webrtcAdditionalHosts to include LAN IP 192.168.50.254 - Fix hlsAllowOrigin->hlsAllowOrigins deprecation warning - Move MediaMTX/HTTP server startup to script_load (not streaming started) so MediaMTX is ready before OBS attempts RTMP connection - Log MediaMTX output to bin/mediamtx.log for easier debugging Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
487 lines
16 KiB
Python
487 lines
16 KiB
Python
"""
|
|
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": 48080,
|
|
"firewall_rule_name": "GameStream-UDP-48189",
|
|
"public_url": "https://stream.hetherman.cloud",
|
|
"api_url": "http://127.0.0.1:19997",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
log_path = os.path.join(os.path.dirname(binary), "mediamtx.log")
|
|
log(f"MediaMTX binary: {binary!r}")
|
|
log(f"MediaMTX config: {config!r}")
|
|
log(f"MediaMTX log: {log_path}")
|
|
mediamtx_log = open(log_path, "w")
|
|
STATE.mediamtx_proc = subprocess.Popen(
|
|
[binary, config],
|
|
stdout=mediamtx_log,
|
|
stderr=mediamtx_log,
|
|
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 serves static files and proxies /status."""
|
|
|
|
def do_GET(self):
|
|
if self.path == "/status":
|
|
# Proxy the MediaMTX path status so the browser doesn't need to
|
|
# hit /v3/ through NPM (where auth_request blocks it).
|
|
api_url = f"{CONFIG['api_url']}/v3/paths/get/game-opus"
|
|
try:
|
|
with urllib.request.urlopen(api_url, timeout=2) as resp:
|
|
body = resp.read()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
except urllib.error.HTTPError as exc:
|
|
body = b'{"ready":false}'
|
|
self.send_response(exc.code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
except Exception:
|
|
body = b'{"ready":false}'
|
|
self.send_response(503)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
return
|
|
super().do_GET()
|
|
|
|
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-opus"
|
|
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:
|
|
# MediaMTX is already running (started on script load / settings save).
|
|
# Just enable the firewall, start the status poller, and log the URL.
|
|
enable_firewall_rule()
|
|
if not STATE.poll_timer_registered:
|
|
obs.timer_add(poll_status, 5000)
|
|
STATE.poll_timer_registered = True
|
|
log(f"OBS streaming started -> viewers can watch at: {CONFIG['public_url']}")
|
|
elif event == obs.OBS_FRONTEND_EVENT_STREAMING_STOPPED:
|
|
log("OBS streaming stopped -> disabling firewall rule")
|
|
if STATE.poll_timer_registered:
|
|
obs.timer_remove(poll_status)
|
|
STATE.poll_timer_registered = False
|
|
disable_firewall_rule()
|
|
STATE.last_status = "offline"
|
|
STATE.last_viewers = 0
|
|
elif event == obs.OBS_FRONTEND_EVENT_EXIT:
|
|
if STATE.poll_timer_registered:
|
|
obs.timer_remove(poll_status)
|
|
STATE.poll_timer_registered = False
|
|
stop_mediamtx()
|
|
stop_http_server()
|
|
disable_firewall_rule()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 (
|
|
"<b>Game Stream App</b><br>"
|
|
"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.<br><br>"
|
|
"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", 48080)
|
|
obs.obs_data_set_default_string(
|
|
settings, "firewall_rule_name", "GameStream-UDP-48189",
|
|
)
|
|
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:19997",
|
|
)
|
|
|
|
|
|
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)
|
|
# Start MediaMTX and HTTP server immediately so they are ready before
|
|
# OBS attempts the WHIP connection. OBS fires STREAMING_STARTED only
|
|
# after a successful connect, so MediaMTX must be up first.
|
|
start_mediamtx()
|
|
start_http_server()
|
|
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()
|