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:
+41
-25
@@ -16,30 +16,22 @@ writeQueueSize: 512
|
||||
###############################################################################
|
||||
|
||||
api: yes
|
||||
apiAddress: 127.0.0.1:49997
|
||||
apiAddress: 127.0.0.1:19997
|
||||
|
||||
###############################################################################
|
||||
# WebRTC (WHIP ingest + WHEP playback)
|
||||
###############################################################################
|
||||
|
||||
webrtc: yes
|
||||
# HTTP listener for WHIP/WHEP signaling (SDP exchange).
|
||||
# NPM proxies /whep/* and the OBS WHIP target (localhost) to this.
|
||||
webrtcAddress: :48889
|
||||
webrtcEncryption: no # TLS is handled at NPM; this listener is LAN/localhost only
|
||||
# Dedicated UDP port for SRTP media. NPM Stream forwards public UDP 48189 here.
|
||||
webrtcEncryption: no
|
||||
webrtcLocalUDPAddress: :48189
|
||||
# No TCP fallback - we only want a single UDP path for simplicity.
|
||||
webrtcLocalTCPAddress: ''
|
||||
# Tell browsers to send media to the public hostname.
|
||||
# Replace stream.hetherman.cloud if your public hostname differs.
|
||||
webrtcAdditionalHosts:
|
||||
- stream.hetherman.cloud
|
||||
# Public STUN helps browsers discover their own reflexive candidates when
|
||||
# behind NAT; the server side does not need it but it speeds up ICE.
|
||||
- 192.168.50.254
|
||||
webrtcICEServers2:
|
||||
- url: stun:stun.l.google.com:19302
|
||||
# Disable trickle handshake complications - plain offer/answer is enough.
|
||||
webrtcHandshakeTimeout: 10s
|
||||
webrtcTrackGatherTimeout: 2s
|
||||
|
||||
@@ -56,15 +48,22 @@ hlsSegmentCount: 7
|
||||
hlsSegmentDuration: 200ms
|
||||
hlsPartDuration: 200ms
|
||||
hlsSegmentMaxSize: 50M
|
||||
hlsAllowOrigin: '*'
|
||||
hlsAllowOrigins: ['*']
|
||||
hlsTrustedProxies: []
|
||||
|
||||
###############################################################################
|
||||
# Disabled protocols (reduce attack surface)
|
||||
# Protocols
|
||||
###############################################################################
|
||||
|
||||
rtsp: no
|
||||
rtmp: no
|
||||
# RTSP on localhost only - used internally so FFmpeg can read the game path
|
||||
# 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
|
||||
|
||||
###############################################################################
|
||||
@@ -72,18 +71,35 @@ srt: no
|
||||
###############################################################################
|
||||
|
||||
pathDefaults:
|
||||
# Drop publishers that connect but never send media.
|
||||
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:
|
||||
# The single stream path. OBS publishes here via WHIP
|
||||
# (http://localhost:48889/game/whip), friends watch via WHEP
|
||||
# (https://stream.hetherman.cloud/whep/game/whep).
|
||||
# OBS publishes H264+AAC here via RTMP.
|
||||
# runOnReady spawns FFmpeg which reads via RTSP (as a reader, no publisher
|
||||
# conflict) and re-publishes to game-opus with audio transcoded to Opus.
|
||||
game:
|
||||
source: publisher
|
||||
# Only the local OBS instance is allowed to publish.
|
||||
# External hijack attempts are blocked at this layer, independent of NPM.
|
||||
publishIPs:
|
||||
- 127.0.0.1/32
|
||||
- ::1/128
|
||||
# No reader restrictions - NPM + Authentik gate reads at the edge.
|
||||
runOnReady: >-
|
||||
ffmpeg
|
||||
-i rtsp://127.0.0.1:8554/game
|
||||
-c:v copy
|
||||
-c:a libopus -b:a 128k -ar 48000 -ac 2
|
||||
-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
@@ -1,32 +1,61 @@
|
||||
# Paste this snippet into the "Advanced" tab of the NPM Proxy Host for
|
||||
# stream.hetherman.cloud. It enables Authentik Forward Auth via 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
|
||||
# application)" with external host https://stream.hetherman.cloud and an
|
||||
# Application bound to the `stream-viewers` group.
|
||||
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;
|
||||
error_page 401 = @goauthentik_proxy_signin;
|
||||
|
||||
# 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).
|
||||
# Propagate user identity headers set by the outpost back to the browser.
|
||||
auth_request_set $auth_cookie $upstream_http_set_cookie;
|
||||
add_header Set-Cookie $auth_cookie;
|
||||
auth_request_set $authentik_username $upstream_http_x_authentik_username;
|
||||
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
|
||||
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
|
||||
# auth_request subrequest and the sign-in redirect can complete.
|
||||
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_set_header Host $host;
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
@@ -40,9 +69,7 @@ location /outpost.goauthentik.io {
|
||||
proxy_set_header Content-Length "";
|
||||
}
|
||||
|
||||
# When auth_request returns 401, send the browser to the outpost sign-in page
|
||||
# and preserve the original request URL so the user lands back where they
|
||||
# started after logging in.
|
||||
# When auth_request returns 401, redirect to the outpost sign-in page.
|
||||
location @goauthentik_proxy_signin {
|
||||
internal;
|
||||
add_header Set-Cookie $auth_cookie;
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ forward-auth gating).
|
||||
|----------|--------|------------------|--------------|
|
||||
| `/whep` | `http` | `<PC-LAN-IP>` | `48889` |
|
||||
| `/hls` | `http` | `<PC-LAN-IP>` | `48888` |
|
||||
| `/v3` | `http` | `<PC-LAN-IP>` | `49997` |
|
||||
| `/v3` | `http` | `<PC-LAN-IP>` | `19997` |
|
||||
|
||||
**SSL tab:**
|
||||
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ Prerequisites:
|
||||
| Frontend HTTP port | `48080` (default) |
|
||||
| Firewall rule name | `GameStream-UDP-48189` (must match the rule created by install.ps1) |
|
||||
| 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
|
||||
`[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).
|
||||
- **Viewers see "Stream offline"** even after you click Start Streaming:
|
||||
- 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
|
||||
sending to WHIP. Verify the URL and that the custom service / WHIP
|
||||
protocol is selected.
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<main id="stage">
|
||||
<video id="video" autoplay playsinline muted></video>
|
||||
<video id="video" autoplay playsinline></video>
|
||||
|
||||
<div id="overlay" class="overlay">
|
||||
<div class="overlay-card">
|
||||
|
||||
+15
-8
@@ -10,8 +10,10 @@
|
||||
*/
|
||||
|
||||
(function () {
|
||||
const PATH_NAME = 'game';
|
||||
const API_URL = `/v3/paths/get/${PATH_NAME}`;
|
||||
// /status is served by the OBS Python HTTP server, which proxies the
|
||||
// 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 els = {
|
||||
@@ -105,11 +107,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Unmute button --------------------------------------------------
|
||||
// ---- Unmute / play button -------------------------------------------
|
||||
//
|
||||
// Browsers require a user gesture to play audio. The <video> element
|
||||
// starts muted so autoplay works; we surface a button to unmute once
|
||||
// playback begins.
|
||||
// Browsers block autoplay with audio until a user gesture occurs.
|
||||
// When the video fails to autoplay (or is muted by the browser), we
|
||||
// surface a "Click to play" button so the user can unblock it manually.
|
||||
|
||||
els.unmuteBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
@@ -117,12 +119,17 @@
|
||||
await els.video.play();
|
||||
els.unmuteBtn.hidden = true;
|
||||
} catch (err) {
|
||||
console.warn('Unmute failed:', err);
|
||||
console.warn('Unmute/play failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
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 --------------------------------------
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
*/
|
||||
|
||||
(function () {
|
||||
const WHEP_URL = '/whep/game/whep';
|
||||
const HLS_URL = '/hls/game/index.m3u8';
|
||||
const WHEP_URL = '/whep/game-opus/whep';
|
||||
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 WHEP_TIMEOUT_MS = 10000;
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
|
||||
+59
-17
@@ -50,7 +50,7 @@ CONFIG = {
|
||||
"http_port": 48080,
|
||||
"firewall_rule_name": "GameStream-UDP-48189",
|
||||
"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
|
||||
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=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=mediamtx_log,
|
||||
stderr=mediamtx_log,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
log(f"MediaMTX started (pid={STATE.mediamtx_proc.pid})")
|
||||
@@ -197,7 +202,38 @@ def stop_mediamtx() -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
# Keep OBS script log clean; only log errors.
|
||||
@@ -273,7 +309,7 @@ def poll_status() -> None:
|
||||
if STATE.mediamtx_proc is None:
|
||||
return
|
||||
|
||||
url = f"{CONFIG['api_url']}/v3/paths/get/game"
|
||||
url = f"{CONFIG['api_url']}/v3/paths/get/game-opus"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||
import json
|
||||
@@ -304,27 +340,28 @@ def poll_status() -> None:
|
||||
|
||||
def _on_frontend_event(event):
|
||||
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()
|
||||
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")
|
||||
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()
|
||||
STATE.last_status = "offline"
|
||||
STATE.last_viewers = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -409,7 +446,7 @@ def script_defaults(settings):
|
||||
settings, "public_url", "https://stream.hetherman.cloud",
|
||||
)
|
||||
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):
|
||||
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")
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
.DESCRIPTION
|
||||
- Downloads the latest MediaMTX Windows amd64 release from GitHub
|
||||
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.
|
||||
|
||||
Must be run from an elevated (Administrator) PowerShell prompt.
|
||||
|
||||
Reference in New Issue
Block a user