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
|
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 --------------------------------------
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user