Fix audio, routing, auth, and stream lifecycle

- 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>
This commit is contained in:
2026-04-06 03:42:38 -04:00
parent c23e8799fe
commit 180e95f74d
9 changed files with 160 additions and 68 deletions
+41 -25
View File
@@ -16,30 +16,22 @@ writeQueueSize: 512
############################################################################### ###############################################################################
api: yes api: yes
apiAddress: 127.0.0.1:49997 apiAddress: 127.0.0.1:19997
############################################################################### ###############################################################################
# WebRTC (WHIP ingest + WHEP playback) # WebRTC (WHIP ingest + WHEP playback)
############################################################################### ###############################################################################
webrtc: yes webrtc: yes
# HTTP listener for WHIP/WHEP signaling (SDP exchange).
# NPM proxies /whep/* and the OBS WHIP target (localhost) to this.
webrtcAddress: :48889 webrtcAddress: :48889
webrtcEncryption: no # TLS is handled at NPM; this listener is LAN/localhost only webrtcEncryption: no
# Dedicated UDP port for SRTP media. NPM Stream forwards public UDP 48189 here.
webrtcLocalUDPAddress: :48189 webrtcLocalUDPAddress: :48189
# No TCP fallback - we only want a single UDP path for simplicity.
webrtcLocalTCPAddress: '' webrtcLocalTCPAddress: ''
# Tell browsers to send media to the public hostname.
# Replace stream.hetherman.cloud if your public hostname differs.
webrtcAdditionalHosts: webrtcAdditionalHosts:
- stream.hetherman.cloud - stream.hetherman.cloud
# Public STUN helps browsers discover their own reflexive candidates when - 192.168.50.254
# behind NAT; the server side does not need it but it speeds up ICE.
webrtcICEServers2: webrtcICEServers2:
- url: stun:stun.l.google.com:19302 - url: stun:stun.l.google.com:19302
# Disable trickle handshake complications - plain offer/answer is enough.
webrtcHandshakeTimeout: 10s webrtcHandshakeTimeout: 10s
webrtcTrackGatherTimeout: 2s webrtcTrackGatherTimeout: 2s
@@ -56,15 +48,22 @@ hlsSegmentCount: 7
hlsSegmentDuration: 200ms hlsSegmentDuration: 200ms
hlsPartDuration: 200ms hlsPartDuration: 200ms
hlsSegmentMaxSize: 50M hlsSegmentMaxSize: 50M
hlsAllowOrigin: '*' hlsAllowOrigins: ['*']
hlsTrustedProxies: [] hlsTrustedProxies: []
############################################################################### ###############################################################################
# Disabled protocols (reduce attack surface) # Protocols
############################################################################### ###############################################################################
rtsp: no # RTSP on localhost only - used internally so FFmpeg can read the game path
rtmp: no # as a consumer (not a publisher) without conflicting with OBS.
rtsp: yes
rtspAddress: 127.0.0.1:8554
# RTMP for OBS ingest. Localhost only.
rtmp: yes
rtmpAddress: 127.0.0.1:1935
srt: no srt: no
############################################################################### ###############################################################################
@@ -72,18 +71,35 @@ srt: no
############################################################################### ###############################################################################
pathDefaults: pathDefaults:
# Drop publishers that connect but never send media.
sourceOnDemand: no sourceOnDemand: no
authInternalUsers:
- user: any
pass: ""
ips: [127.0.0.1/32, ::1/128]
permissions:
- action: publish
- action: api
- user: any
pass: ""
ips: []
permissions:
- action: read
paths: paths:
# The single stream path. OBS publishes here via WHIP # OBS publishes H264+AAC here via RTMP.
# (http://localhost:48889/game/whip), friends watch via WHEP # runOnReady spawns FFmpeg which reads via RTSP (as a reader, no publisher
# (https://stream.hetherman.cloud/whep/game/whep). # conflict) and re-publishes to game-opus with audio transcoded to Opus.
game: game:
source: publisher source: publisher
# Only the local OBS instance is allowed to publish. runOnReady: >-
# External hijack attempts are blocked at this layer, independent of NPM. ffmpeg
publishIPs: -i rtsp://127.0.0.1:8554/game
- 127.0.0.1/32 -c:v copy
- ::1/128 -c:a libopus -b:a 128k -ar 48000 -ac 2
# No reader restrictions - NPM + Authentik gate reads at the edge. -f rtsp rtsp://127.0.0.1:8554/game-opus
runOnReadyRestart: yes
# Transcoded path: H264 + Opus. Viewers connect here via WHEP/HLS.
game-opus:
source: publisher
+38 -11
View File
@@ -1,32 +1,61 @@
# Paste this snippet into the "Advanced" tab of the NPM Proxy Host for # Paste this snippet into the "Advanced" tab of the NPM Proxy Host for
# stream.hetherman.cloud. It enables Authentik Forward Auth via the # stream.hetherman.cloud. It enables Authentik Forward Auth via the
# goauthentik.io outpost, so every request is gated before hitting the # goauthentik.io outpost, so every request is gated before hitting the
# frontend, WHEP signaling, HLS, or the MediaMTX API. # frontend, WHEP signaling, and HLS.
#
# IMPORTANT: Remove all Custom Locations from the NPM GUI (/whep, /hls, /v3).
# They are defined here instead so that:
# - /whep/ and /hls/ use trailing-slash proxy_pass to strip the prefix
# before forwarding to MediaMTX (otherwise MediaMTX sees the wrong path).
# - /v3/ has auth_request off without a duplicate location block conflict.
# Duplicate location blocks (GUI + advanced) cause the wrong one to win.
# #
# Requires an Authentik Proxy Provider of type "Forward auth (single # Requires an Authentik Proxy Provider of type "Forward auth (single
# application)" with external host https://stream.hetherman.cloud and an # application)" with external host https://stream.hetherman.cloud and an
# Application bound to the `stream-viewers` group. # Application bound to the `stream-viewers` group.
port_in_redirect off; port_in_redirect off;
# Forward every incoming request to the Authentik outpost for validation. # Forward every other request to the Authentik outpost for validation.
# Must be declared before the location blocks so it applies server-wide.
auth_request /outpost.goauthentik.io/auth/nginx; auth_request /outpost.goauthentik.io/auth/nginx;
error_page 401 = @goauthentik_proxy_signin; error_page 401 = @goauthentik_proxy_signin;
# Propagate user identity headers set by the outpost back to the browser # Propagate user identity headers set by the outpost back to the browser.
# (and optionally to upstream if you ever want to read the user in MediaMTX).
auth_request_set $auth_cookie $upstream_http_set_cookie; auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie; add_header Set-Cookie $auth_cookie;
auth_request_set $authentik_username $upstream_http_x_authentik_username; auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups; auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email; auth_request_set $authentik_email $upstream_http_x_authentik_email;
# WHEP signaling (WebRTC SDP exchange).
# Trailing slash on both sides strips /whep prefix: /whep/game/whep -> /game/whep
location /whep/ {
proxy_pass http://192.168.50.254:48889/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# HLS fallback.
# Trailing slash strips /hls prefix: /hls/game/index.m3u8 -> /game/index.m3u8
location /hls/ {
proxy_pass http://192.168.50.254:48888/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
# MediaMTX API - no auth required, stream status only, no sensitive data.
# Trailing slash strips /v3 prefix: /v3/paths/get/game -> /paths/get/game
location /v3/ {
auth_request off;
proxy_pass http://192.168.50.254:19997/;
}
# The outpost endpoint itself must be reachable un-gated so that the # The outpost endpoint itself must be reachable un-gated so that the
# auth_request subrequest and the sign-in redirect can complete. # auth_request subrequest and the sign-in redirect can complete.
location /outpost.goauthentik.io { location /outpost.goauthentik.io {
# All traffic to /outpost.goauthentik.io is proxied to the Authentik host.
# Point this at your Authentik outpost URL.
# Use the internal Authentik address directly (HTTP, no TLS) to avoid
# routing back through NPM and the SSL SNI issues that come with it.
proxy_pass http://192.168.50.224:30140/outpost.goauthentik.io; proxy_pass http://192.168.50.224:30140/outpost.goauthentik.io;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
@@ -40,9 +69,7 @@ location /outpost.goauthentik.io {
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
} }
# When auth_request returns 401, send the browser to the outpost sign-in page # When auth_request returns 401, redirect to the outpost sign-in page.
# and preserve the original request URL so the user lands back where they
# started after logging in.
location @goauthentik_proxy_signin { location @goauthentik_proxy_signin {
internal; internal;
add_header Set-Cookie $auth_cookie; add_header Set-Cookie $auth_cookie;
+1 -1
View File
@@ -51,7 +51,7 @@ forward-auth gating).
|----------|--------|------------------|--------------| |----------|--------|------------------|--------------|
| `/whep` | `http` | `<PC-LAN-IP>` | `48889` | | `/whep` | `http` | `<PC-LAN-IP>` | `48889` |
| `/hls` | `http` | `<PC-LAN-IP>` | `48888` | | `/hls` | `http` | `<PC-LAN-IP>` | `48888` |
| `/v3` | `http` | `<PC-LAN-IP>` | `49997` | | `/v3` | `http` | `<PC-LAN-IP>` | `19997` |
**SSL tab:** **SSL tab:**
+2 -2
View File
@@ -25,7 +25,7 @@ Prerequisites:
| Frontend HTTP port | `48080` (default) | | Frontend HTTP port | `48080` (default) |
| Firewall rule name | `GameStream-UDP-48189` (must match the rule created by install.ps1) | | Firewall rule name | `GameStream-UDP-48189` (must match the rule created by install.ps1) |
| Public URL | `https://stream.hetherman.cloud` | | Public URL | `https://stream.hetherman.cloud` |
| MediaMTX API URL | `http://127.0.0.1:49997` | | MediaMTX API URL | `http://127.0.0.1:19997` |
4. Check the **Script Log** at the bottom - you should see 4. Check the **Script Log** at the bottom - you should see
`[game_stream] game_stream.py loaded`. `[game_stream] game_stream.py loaded`.
@@ -120,7 +120,7 @@ Should report `False` while not streaming, `True` while streaming.
(another process is already using them). (another process is already using them).
- **Viewers see "Stream offline"** even after you click Start Streaming: - **Viewers see "Stream offline"** even after you click Start Streaming:
- Check that the MediaMTX API returns `ready: true`: - Check that the MediaMTX API returns `ready: true`:
`curl http://localhost:49997/v3/paths/get/game` `curl http://localhost:19997/v3/paths/get/game`
- Check OBS's own streaming indicator - if it's red, OBS is not actually - Check OBS's own streaming indicator - if it's red, OBS is not actually
sending to WHIP. Verify the URL and that the custom service / WHIP sending to WHIP. Verify the URL and that the custom service / WHIP
protocol is selected. protocol is selected.
+1 -1
View File
@@ -10,7 +10,7 @@
</head> </head>
<body> <body>
<main id="stage"> <main id="stage">
<video id="video" autoplay playsinline muted></video> <video id="video" autoplay playsinline></video>
<div id="overlay" class="overlay"> <div id="overlay" class="overlay">
<div class="overlay-card"> <div class="overlay-card">
+15 -8
View File
@@ -10,8 +10,10 @@
*/ */
(function () { (function () {
const PATH_NAME = 'game'; // /status is served by the OBS Python HTTP server, which proxies the
const API_URL = `/v3/paths/get/${PATH_NAME}`; // MediaMTX API internally. This avoids hitting /v3/ through NPM where
// the server-level auth_request directive blocks it.
const API_URL = '/status';
const POLL_INTERVAL_MS = 5000; const POLL_INTERVAL_MS = 5000;
const els = { const els = {
@@ -105,11 +107,11 @@
} }
} }
// ---- Unmute button -------------------------------------------------- // ---- Unmute / play button -------------------------------------------
// //
// Browsers require a user gesture to play audio. The <video> element // Browsers block autoplay with audio until a user gesture occurs.
// starts muted so autoplay works; we surface a button to unmute once // When the video fails to autoplay (or is muted by the browser), we
// playback begins. // surface a "Click to play" button so the user can unblock it manually.
els.unmuteBtn.addEventListener('click', async () => { els.unmuteBtn.addEventListener('click', async () => {
try { try {
@@ -117,12 +119,17 @@
await els.video.play(); await els.video.play();
els.unmuteBtn.hidden = true; els.unmuteBtn.hidden = true;
} catch (err) { } catch (err) {
console.warn('Unmute failed:', err); console.warn('Unmute/play failed:', err);
} }
}); });
function maybeShowUnmute() { function maybeShowUnmute() {
els.unmuteBtn.hidden = !els.video.muted; // Show the button if the video is muted OR paused (autoplay blocked).
const needsGesture = els.video.muted || els.video.paused;
els.unmuteBtn.hidden = !needsGesture;
if (needsGesture) {
els.unmuteBtn.textContent = els.video.muted ? 'Click to unmute' : 'Click to play';
}
} }
// ---- StreamPlayer event wiring -------------------------------------- // ---- StreamPlayer event wiring --------------------------------------
+2 -2
View File
@@ -16,8 +16,8 @@
*/ */
(function () { (function () {
const WHEP_URL = '/whep/game/whep'; const WHEP_URL = '/whep/game-opus/whep';
const HLS_URL = '/hls/game/index.m3u8'; const HLS_URL = '/hls/game-opus/index.m3u8';
const HLS_JS_CDN = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js'; const HLS_JS_CDN = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js';
const WHEP_TIMEOUT_MS = 10000; const WHEP_TIMEOUT_MS = 10000;
const RECONNECT_DELAY_MS = 3000; const RECONNECT_DELAY_MS = 3000;
+59 -17
View File
@@ -50,7 +50,7 @@ CONFIG = {
"http_port": 48080, "http_port": 48080,
"firewall_rule_name": "GameStream-UDP-48189", "firewall_rule_name": "GameStream-UDP-48189",
"public_url": "https://stream.hetherman.cloud", "public_url": "https://stream.hetherman.cloud",
"api_url": "http://127.0.0.1:49997", "api_url": "http://127.0.0.1:19997",
} }
@@ -158,10 +158,15 @@ def start_mediamtx() -> None:
creationflags = 0 creationflags = 0
if os.name == "nt": if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) 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( STATE.mediamtx_proc = subprocess.Popen(
[binary, config], [binary, config],
stdout=subprocess.DEVNULL, stdout=mediamtx_log,
stderr=subprocess.DEVNULL, stderr=mediamtx_log,
creationflags=creationflags, creationflags=creationflags,
) )
log(f"MediaMTX started (pid={STATE.mediamtx_proc.pid})") log(f"MediaMTX started (pid={STATE.mediamtx_proc.pid})")
@@ -197,7 +202,38 @@ def stop_mediamtx() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class _QuietHandler(http.server.SimpleHTTPRequestHandler): class _QuietHandler(http.server.SimpleHTTPRequestHandler):
"""SimpleHTTPRequestHandler that logs to the OBS log instead of stderr.""" """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 def log_message(self, format, *args): # noqa: A002 - match signature
# Keep OBS script log clean; only log errors. # Keep OBS script log clean; only log errors.
@@ -273,7 +309,7 @@ def poll_status() -> None:
if STATE.mediamtx_proc is None: if STATE.mediamtx_proc is None:
return return
url = f"{CONFIG['api_url']}/v3/paths/get/game" url = f"{CONFIG['api_url']}/v3/paths/get/game-opus"
try: try:
with urllib.request.urlopen(url, timeout=2) as resp: with urllib.request.urlopen(url, timeout=2) as resp:
import json import json
@@ -304,27 +340,28 @@ def poll_status() -> None:
def _on_frontend_event(event): def _on_frontend_event(event):
if event == obs.OBS_FRONTEND_EVENT_STREAMING_STARTED: if event == obs.OBS_FRONTEND_EVENT_STREAMING_STARTED:
log("OBS streaming started -> bringing up game-stream-app") # 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() enable_firewall_rule()
start_mediamtx()
start_http_server()
if not STATE.poll_timer_registered: if not STATE.poll_timer_registered:
obs.timer_add(poll_status, 5000) obs.timer_add(poll_status, 5000)
STATE.poll_timer_registered = True STATE.poll_timer_registered = True
log(f"Viewers can watch at: {CONFIG['public_url']}") log(f"OBS streaming started -> viewers can watch at: {CONFIG['public_url']}")
elif event in ( elif event == obs.OBS_FRONTEND_EVENT_STREAMING_STOPPED:
obs.OBS_FRONTEND_EVENT_STREAMING_STOPPED, log("OBS streaming stopped -> disabling firewall rule")
obs.OBS_FRONTEND_EVENT_EXIT, if STATE.poll_timer_registered:
): obs.timer_remove(poll_status)
log("OBS streaming stopped -> tearing down game-stream-app") 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: if STATE.poll_timer_registered:
obs.timer_remove(poll_status) obs.timer_remove(poll_status)
STATE.poll_timer_registered = False STATE.poll_timer_registered = False
stop_mediamtx() stop_mediamtx()
stop_http_server() stop_http_server()
disable_firewall_rule() disable_firewall_rule()
STATE.last_status = "offline"
STATE.last_viewers = 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -409,7 +446,7 @@ def script_defaults(settings):
settings, "public_url", "https://stream.hetherman.cloud", settings, "public_url", "https://stream.hetherman.cloud",
) )
obs.obs_data_set_default_string( obs.obs_data_set_default_string(
settings, "api_url", "http://127.0.0.1:49997", settings, "api_url", "http://127.0.0.1:19997",
) )
@@ -428,6 +465,11 @@ def script_update(settings):
def script_load(settings): def script_load(settings):
script_update(settings) script_update(settings)
obs.obs_frontend_add_event_callback(_on_frontend_event) 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") log("game_stream.py loaded")
+1 -1
View File
@@ -5,7 +5,7 @@
.DESCRIPTION .DESCRIPTION
- Downloads the latest MediaMTX Windows amd64 release from GitHub - Downloads the latest MediaMTX Windows amd64 release from GitHub
into bin/mediamtx.exe. into bin/mediamtx.exe.
- Creates a disabled Windows Firewall rule "GameStream-UDP-8189" that - Creates a disabled Windows Firewall rule "GameStream-UDP-48189" that
the OBS script will toggle on/off with stream lifecycle. the OBS script will toggle on/off with stream lifecycle.
Must be run from an elevated (Administrator) PowerShell prompt. Must be run from an elevated (Administrator) PowerShell prompt.