initial commit

This commit is contained in:
2026-04-05 17:16:51 -04:00
commit 6bc7cf6318
14 changed files with 1829 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# MediaMTX binary and anything else downloaded by install.ps1
bin/*
!bin/.gitkeep
# OBS runtime
obs-script/__pycache__/
*.pyc
# Editor / OS cruft
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# Local overrides
config/*.local.yml
config/*.local.conf
+74
View File
@@ -0,0 +1,74 @@
# game-stream-app
Low-latency browser-based game streaming to a small group of friends, gated by
Authentik authentication.
- **Streamer:** Windows PC with an NVIDIA GPU running OBS Studio.
- **Viewers:** up to ~6 friends, any modern browser, no client install.
- **Auth:** Authentik forward auth at the Nginx Proxy Manager (NPM) edge.
- **Transport:** WebRTC (WHEP) for low latency, with LL-HLS fallback.
- **Latency target:** ~200 ms over WebRTC, ~1-2 s over LL-HLS fallback.
## How it works
```
OBS Studio (NVENC, WHIP out)
-> MediaMTX (localhost) ---> WHEP / HLS / API
-> Frontend HTTP server (localhost:8080)
-> NPM (TLS, Authentik forward auth, reverse proxy)
-> Friend's browser
```
Everything on the gaming PC (MediaMTX, HTTP server, Windows Firewall rule for
the WebRTC UDP port) is spawned and torn down by an OBS Python script -
`obs-script/game_stream.py`. You just click **Start Streaming** in OBS and the
whole pipeline comes up; click **Stop Streaming** and it all goes away.
## Repository layout
| Path | Purpose |
|-------------------------------|-------------------------------------------------------------|
| `config/mediamtx.yml` | MediaMTX configuration (WHIP in, WHEP/HLS out, locked-down) |
| `config/npm-advanced.conf` | Authentik forward-auth snippet for the NPM Advanced tab |
| `obs-script/game_stream.py` | OBS script: lifecycle, HTTP server, firewall toggle |
| `frontend/index.html` | Viewer page |
| `frontend/js/player.js` | WHEP client with HLS fallback |
| `frontend/js/app.js` | Status polling and DOM glue |
| `frontend/css/style.css` | Dark theme |
| `scripts/install.ps1` | Downloads MediaMTX, creates the Windows Firewall rule |
| `docs/authentik-setup.md` | Authentik proxy provider + group configuration |
| `docs/npm-setup.md` | NPM proxy host + stream (UDP) configuration |
| `docs/obs-setup.md` | OBS encoder + WHIP output settings |
## Setup at a glance
1. **Clone** this repo onto the Windows gaming PC.
2. **Install MediaMTX and the firewall rule:** open an elevated PowerShell in
the repo root and run `.\scripts\install.ps1`.
3. **Configure Authentik** - see `docs/authentik-setup.md`.
4. **Configure NPM** - see `docs/npm-setup.md`.
5. **Configure OBS** - see `docs/obs-setup.md`, then add
`obs-script/game_stream.py` via Tools -> Scripts.
6. **Click Start Streaming in OBS.** Friends can now open
`https://stream.hetherman.cloud`, log in with Authentik, and watch.
## Security posture
- TLS terminates at NPM with Let's Encrypt.
- Every request is gated by Authentik forward auth before it reaches the
frontend, WHEP signaling, HLS, or the MediaMTX API.
- MediaMTX only accepts publishers from `127.0.0.1` - nobody on the public
internet can hijack the stream.
- The UDP port used for WebRTC media is opened on the Windows Firewall only
while streaming is active (toggled by the OBS script). Even though NPM and
the router still forward the port, the OS silently drops packets between
streams, so there is no exposed listener.
- WebRTC media is DTLS-encrypted SRTP. An attacker who hits the UDP port
without an Authentik-authenticated WHEP session cannot decrypt or inject
media.
- Removing a friend from the Authentik `stream-viewers` group revokes their
access on the next auth_request subrequest (within seconds).
## License
MIT
View File
+89
View File
@@ -0,0 +1,89 @@
# MediaMTX configuration for game-stream-app
# Spawned as a subprocess by obs-script/game_stream.py when OBS starts streaming.
###############################################################################
# Global
###############################################################################
logLevel: info
logDestinations: [stdout]
readTimeout: 10s
writeTimeout: 10s
writeQueueSize: 512
###############################################################################
# API (used by the OBS script dock to poll viewer count / stream status)
###############################################################################
api: yes
apiAddress: 127.0.0.1:9997
###############################################################################
# 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: :8889
webrtcEncryption: no # TLS is handled at NPM; this listener is LAN/localhost only
# Dedicated UDP port for SRTP media. NPM Stream forwards public UDP 8189 here.
webrtcLocalUDPAddress: :8189
# 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.
webrtcICEServers2:
- url: stun:stun.l.google.com:19302
# Disable trickle handshake complications - plain offer/answer is enough.
webrtcHandshakeTimeout: 10s
webrtcTrackGatherTimeout: 2s
###############################################################################
# HLS (fallback for clients where WebRTC fails)
###############################################################################
hls: yes
hlsAddress: :8888
hlsEncryption: no
hlsAlwaysRemux: no
hlsVariant: lowLatency
hlsSegmentCount: 7
hlsSegmentDuration: 200ms
hlsPartDuration: 200ms
hlsSegmentMaxSize: 50M
hlsAllowOrigin: '*'
hlsTrustedProxies: []
###############################################################################
# Disabled protocols (reduce attack surface)
###############################################################################
rtsp: no
rtmp: no
srt: no
###############################################################################
# Paths
###############################################################################
pathDefaults:
# Drop publishers that connect but never send media.
sourceOnDemand: no
paths:
# The single stream path. OBS publishes here via WHIP
# (http://localhost:8889/game/whip), friends watch via WHEP
# (https://stream.hetherman.cloud/whep/game/whep).
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.
+47
View File
@@ -0,0 +1,47 @@
# 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.
#
# 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.
# Forward every incoming request to the Authentik outpost for validation.
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).
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;
# 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.
proxy_pass https://auth.hetherman.cloud/outpost.goauthentik.io;
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-Ssl on;
add_header Set-Cookie $auth_cookie;
proxy_pass_request_body off;
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.
location @goauthentik_proxy_signin {
internal;
add_header Set-Cookie $auth_cookie;
return 302 /outpost.goauthentik.io/start?rd=$request_uri;
}
+87
View File
@@ -0,0 +1,87 @@
# Authentik setup
Creates a Forward Auth Proxy Provider that NPM will consult before allowing
any request to `https://stream.hetherman.cloud`.
Prerequisites: you already run Authentik at `https://auth.hetherman.cloud` and
have admin access.
## 1. Create a group
1. Authentik admin -> **Directory -> Groups -> Create**
2. Name: `stream-viewers`
3. Save.
4. Add each friend's user account to this group. **Do not** add yourself /
your admin account - your access comes from your existing admin group.
## 2. Create a Proxy Provider
1. **Applications -> Providers -> Create**
2. Select **Proxy Provider** and click Next.
3. Fill in:
- **Name**: `game-stream-forward-auth`
- **Authorization flow**: `default-provider-authorization-implicit-consent`
(skips the "Authorize application" prompt so friends get a one-click login)
- **Type**: **Forward auth (single application)**
- **External host**: `https://stream.hetherman.cloud`
- **Token validity**: default (24 hours) is fine
4. Save.
## 3. Create an Application
1. **Applications -> Applications -> Create**
2. Fill in:
- **Name**: `Game Stream`
- **Slug**: `game-stream`
- **Provider**: `game-stream-forward-auth` (the provider from step 2)
- **Launch URL**: `https://stream.hetherman.cloud`
3. Save.
## 4. Bind the group policy
Restrict who can authenticate to this application:
1. Open the `Game Stream` application you just created.
2. Go to the **Policy / Group / User Bindings** tab -> **Create binding**
3. **Group**: `stream-viewers`
4. Leave the rest as default; Save.
After this step, only members of `stream-viewers` (and Authentik superusers)
can authenticate to the game stream.
## 5. Ensure the outpost serves this application
Authentik runs an "outpost" that exposes `/outpost.goauthentik.io/auth/nginx`
for the nginx `auth_request` forward-auth pattern.
1. **Applications -> Outposts**
2. Edit the default `authentik Embedded Outpost` (or create one if there is
not one already).
3. Under **Applications**, make sure `Game Stream` is checked.
4. Save. The outpost reloads automatically.
Verify the outpost endpoint is reachable:
```
curl -I https://auth.hetherman.cloud/outpost.goauthentik.io/ping
```
A 200 or 204 means the outpost is up.
## 6. Verify end-to-end
After finishing `docs/npm-setup.md`:
1. Open `https://stream.hetherman.cloud` in a private/incognito browser.
2. You should be redirected to Authentik to log in.
3. Log in as a member of `stream-viewers`. You should be redirected back to
the stream page.
4. Log out, clear cookies, try logging in as a non-member. You should be
denied with a "You do not have access" message.
## Revoking access
Remove the friend's user from the `stream-viewers` group. Their Authentik
session remains valid until it expires, but the next `auth_request` forward
auth check (evaluated on every HTTP request NPM sees) will fail the group
policy and they will be locked out within seconds.
+101
View File
@@ -0,0 +1,101 @@
# Nginx Proxy Manager setup
Configures NPM to:
1. Serve `https://stream.hetherman.cloud` with TLS + Authentik forward auth,
reverse-proxying HTTP traffic to the Windows gaming PC.
2. Forward public UDP 8189 (WebRTC media) to the gaming PC via an NPM
**Stream** (L4 UDP proxy).
Replace `<PC-LAN-IP>` with the LAN IP of the Windows gaming PC
(e.g., `192.168.50.10`).
## 1. DNS
Create an A / CNAME record for `stream.hetherman.cloud` pointing to the same
DDNS hostname / public IP your other NPM-hosted services use.
## 2. Router port forwarding
Make sure your router forwards these to NPM (not to the PC directly):
| Proto | External port | Internal target |
|-------|--------------|-------------------|
| TCP | 443 | NPM host, 443 |
| UDP | 8189 | NPM host, 8189 |
(TCP 443 is probably already forwarded for your other services; UDP 8189 is
the new one for this app.)
## 3. NPM Proxy Host (HTTP)
In NPM, **Hosts -> Proxy Hosts -> Add Proxy Host**.
**Details tab:**
| Field | Value |
|------------------|------------------------------------|
| Domain Names | `stream.hetherman.cloud` |
| Scheme | `http` |
| Forward Hostname | `<PC-LAN-IP>` |
| Forward Port | `8080` |
| Cache Assets | off |
| Block Common Exploits | on |
| Websockets Support | **on** (WebRTC signaling works without this, but it costs nothing) |
**Custom locations tab:** add three entries so WHEP, HLS, and the MediaMTX
API are reverse-proxied to the right MediaMTX ports (and inherit the same
forward-auth gating).
| Location | Scheme | Forward Hostname | Forward Port |
|----------|--------|------------------|--------------|
| `/whep` | `http` | `<PC-LAN-IP>` | `8889` |
| `/hls` | `http` | `<PC-LAN-IP>` | `8888` |
| `/v3` | `http` | `<PC-LAN-IP>` | `9997` |
**SSL tab:**
- SSL Certificate: **Request a new SSL Certificate with Let's Encrypt**
- Force SSL: **on**
- HTTP/2 Support: **on**
- HSTS Enabled: optional
**Advanced tab:** paste the entire contents of
[`config/npm-advanced.conf`](../config/npm-advanced.conf). This installs the
Authentik forward-auth subrequest and the sign-in redirect.
Save the proxy host. Wait for the Let's Encrypt certificate to be issued.
## 4. NPM Stream (UDP L4 proxy)
In NPM, **Hosts -> Streams -> Add Stream**.
| Field | Value |
|-------------------|---------------|
| Incoming Port | `8189` |
| Forward Host | `<PC-LAN-IP>` |
| Forward Port | `8189` |
| TCP | **off** |
| UDP | **on** |
Save. NPM (nginx `stream` module) now forwards public UDP 8189 to MediaMTX
on the gaming PC. This is the path WebRTC media takes after ICE negotiation.
## 5. Verify
1. **HTTP + auth:** from an incognito browser on a different network, visit
`https://stream.hetherman.cloud`. You should be redirected to
`auth.hetherman.cloud` to log in. Log in as a `stream-viewers` member -
you should land back at the stream page (video container + "Stream
offline" overlay, assuming you haven't started OBS yet).
2. **Certificate:** the padlock icon should show the Let's Encrypt cert you
requested.
3. **/whep, /hls, /v3:** once you start streaming in OBS, open DevTools on
the stream page and confirm requests to `/whep/game/whep`,
`/hls/game/index.m3u8`, and `/v3/paths/get/game` all return 200 (and not
401/302).
4. **UDP stream:** with OBS streaming, tail the NPM container logs - you
should see entries from the stream module for UDP connections on 8189.
Alternatively, from the NPM host run
`tcpdump -n -i any udp port 8189` and confirm packets flow while a
viewer is connected.
+132
View File
@@ -0,0 +1,132 @@
# OBS setup
Configures OBS Studio on the Windows gaming PC to capture the game, encode
with NVENC, and publish via WHIP to the local MediaMTX instance that the OBS
script spawns.
Prerequisites:
- OBS Studio 30.0 or newer (WHIP output is built in from 30.x onward).
- You already ran `.\scripts\install.ps1` in an elevated PowerShell, so
`bin\mediamtx.exe` exists and the `GameStream-UDP-8189` firewall rule is
registered (in the disabled state).
## 1. Load the OBS script
1. OBS -> **Tools -> Scripts -> +**
2. Select `obs-script/game_stream.py` from this repo.
3. In the properties panel on the right, set:
| Setting | Value |
|-----------------------|---------------------------------------------------------------|
| MediaMTX binary | `<repo>\bin\mediamtx.exe` |
| MediaMTX config | `<repo>\config\mediamtx.yml` |
| Frontend directory | `<repo>\frontend` |
| Frontend HTTP port | `8080` (default) |
| Firewall rule name | `GameStream-UDP-8189` (must match the rule created by install.ps1) |
| Public URL | `https://stream.hetherman.cloud` |
| MediaMTX API URL | `http://127.0.0.1:9997` |
4. Check the **Script Log** at the bottom - you should see
`[game_stream] game_stream.py loaded`.
## 2. OBS output settings
**Settings -> Output**, set **Output Mode** to **Advanced**.
### Streaming tab
| Setting | Value |
|------------------|---------------------------------------------------------|
| Audio Encoder | Opus (or FFmpeg AAC if Opus is unavailable - Opus is preferred for WebRTC) |
| Video Encoder | **NVIDIA NVENC HEVC** or **NVIDIA NVENC H.264** |
Use H.264 for maximum browser compatibility (all browsers). HEVC works in
Safari and recent Chrome but not Firefox - stick with H.264 unless you have a
specific reason.
**Encoder settings (H.264):**
| Setting | Value |
|---------------------|-------------------|
| Rate Control | CBR |
| Bitrate | 8000 Kbps |
| Keyframe Interval | 2 s |
| Preset | P5 (Quality) |
| Tuning | Ultra Low Latency |
| Multipass | Two Passes (Quarter Resolution) |
| Profile | high |
| Look-ahead | off |
| Psycho Visual Tuning | on |
| GPU | 0 |
| Max B-frames | **0** (required for low-latency WebRTC) |
With a 600 Mbps upload and up to 6 viewers at 8 Mbps each, 8000 Kbps leaves
generous headroom. Push to 12000-15000 Kbps if you want higher quality.
### Audio tab
| Setting | Value |
|-----------------|------------|
| Audio Bitrate | 128 Kbps |
| Sample Rate | 48 kHz |
## 3. OBS stream settings
**Settings -> Stream**
| Setting | Value |
|----------|-----------------------------------------------|
| Service | Custom |
| Protocol | **WHIP** |
| Server | `http://localhost:8889/game/whip` |
| Bearer Token | (leave blank) |
Save.
## 4. First stream
1. Click **Start Streaming**.
2. Check the OBS Script Log - you should see:
- `Firewall rule 'GameStream-UDP-8189' ENABLED`
- `MediaMTX started (pid=...)`
- `Frontend HTTP server listening on 0.0.0.0:8080`
- `Viewers can watch at: https://stream.hetherman.cloud`
3. Open `https://stream.hetherman.cloud` from another device, log in with
Authentik, and verify video plays.
## 5. Stopping
Click **Stop Streaming** in OBS. The script will:
- Stop the MediaMTX subprocess
- Stop the frontend HTTP server
- Disable the firewall rule (`GameStream-UDP-8189` -> disabled)
Verify the firewall state from PowerShell:
```powershell
Get-NetFirewallRule -DisplayName "GameStream-UDP-8189" | Select-Object Enabled
```
Should report `False` while not streaming, `True` while streaming.
## Troubleshooting
- **"MediaMTX binary not found"** in the script log: the path in the script
properties panel is wrong. Re-select it with the file picker.
- **OBS cannot connect to WHIP**: MediaMTX did not start. Check the script
log for the actual reason; most commonly a port conflict on 8889 or 8189
(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:9997/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.
- **Viewers connect but playback freezes after a few seconds:** the UDP port
path is broken. Verify the firewall rule is enabled (`Get-NetFirewallRule`),
the router port-forward to NPM for UDP 8189 is correct, and the NPM Stream
entry points at `<PC-LAN-IP>:8189`.
- **Autoplay is blocked / no audio:** browsers start the video muted so
autoplay works. There is a "Click to unmute" button in the status bar.
+203
View File
@@ -0,0 +1,203 @@
/* Game Stream App - dark theme, video fills viewport, minimal chrome. */
:root {
--bg: #0a0a0b;
--fg: #e6e6e6;
--fg-dim: #8a8a8a;
--accent: #4da3ff;
--live: #34c759;
--offline: #8a8a8a;
--error: #ff453a;
--card-bg: rgba(20, 20, 22, 0.92);
--border: rgba(255, 255, 255, 0.08);
--status-bar-height: 36px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
font-size: 14px;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
}
/* --- video stage ------------------------------------------------------- */
#stage {
position: relative;
flex: 1 1 auto;
background: #000;
overflow: hidden;
}
#video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
/* --- offline / loading overlay ---------------------------------------- */
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 10;
transition: opacity 200ms ease;
}
.overlay[hidden] {
display: none;
}
.overlay-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px 44px;
text-align: center;
min-width: 280px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5);
}
.overlay-card h1 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
.overlay-card p {
margin: 0 0 20px 0;
color: var(--fg-dim);
font-size: 13px;
}
.spinner {
width: 28px;
height: 28px;
margin: 0 auto;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 900ms linear infinite;
}
.overlay-card.error .spinner {
display: none;
}
.overlay-card.error h1 {
color: var(--error);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* --- status bar ------------------------------------------------------- */
#status-bar {
flex: 0 0 var(--status-bar-height);
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
background: #111113;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
}
#status-bar b {
color: var(--fg);
font-weight: 600;
}
#status-bar .separator {
color: #333;
}
#status-bar .spacer {
flex: 1 1 auto;
}
.status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
color: #000;
}
.status::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
}
.status.live {
background: var(--live);
color: #003d15;
}
.status.live::before {
background: #003d15;
animation: pulse 1.4s ease-in-out infinite;
}
.status.offline {
background: #2a2a2d;
color: var(--fg-dim);
}
.status.error {
background: var(--error);
color: #3d0000;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
#unmute-btn {
background: var(--accent);
color: #00224d;
border: none;
border-radius: 6px;
padding: 5px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
#unmute-btn:hover {
background: #66b5ff;
}
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<title>Game Stream</title>
<link rel="stylesheet" href="/css/style.css">
<!-- hls.js is only loaded if WebRTC fails and we need the HLS fallback. -->
</head>
<body>
<main id="stage">
<video id="video" autoplay playsinline muted></video>
<div id="overlay" class="overlay">
<div class="overlay-card">
<h1 id="overlay-title">Stream offline</h1>
<p id="overlay-message">Waiting for the streamer to start&hellip;</p>
<div class="spinner" aria-hidden="true"></div>
</div>
</div>
</main>
<footer id="status-bar">
<span id="status-indicator" class="status offline">OFFLINE</span>
<span class="separator">|</span>
<span>Viewers: <b id="viewer-count">0</b></span>
<span class="separator">|</span>
<span>Latency: <b id="latency">--</b></span>
<span class="separator">|</span>
<span>Transport: <b id="transport">--</b></span>
<span class="spacer"></span>
<button id="unmute-btn" type="button" hidden>Click to unmute</button>
</footer>
<script src="/js/player.js"></script>
<script src="/js/app.js"></script>
</body>
</html>
+157
View File
@@ -0,0 +1,157 @@
/*
* app.js - Glue between the DOM and StreamPlayer.
*
* - Polls the MediaMTX API (/v3/paths/get/game) to determine if the stream
* is live, and updates the status bar accordingly.
* - Starts/stops StreamPlayer based on that status.
* - Wires up the "Click to unmute" button (browsers autoplay muted).
* - Authentik forward auth is handled at the NPM layer, so by the time
* this JS runs the user is already authenticated. No auth logic here.
*/
(function () {
const PATH_NAME = 'game';
const API_URL = `/v3/paths/get/${PATH_NAME}`;
const POLL_INTERVAL_MS = 5000;
const els = {
video: document.getElementById('video'),
overlay: document.getElementById('overlay'),
overlayTitle: document.getElementById('overlay-title'),
overlayMessage: document.getElementById('overlay-message'),
overlayCard: document.querySelector('.overlay-card'),
statusIndicator: document.getElementById('status-indicator'),
viewerCount: document.getElementById('viewer-count'),
latency: document.getElementById('latency'),
transport: document.getElementById('transport'),
unmuteBtn: document.getElementById('unmute-btn'),
};
const state = {
pathReady: false,
playerState: 'offline',
};
// ---- overlay helpers -------------------------------------------------
function showOverlay(title, message, isError) {
els.overlayTitle.textContent = title;
els.overlayMessage.textContent = message;
els.overlayCard.classList.toggle('error', !!isError);
els.overlay.hidden = false;
}
function hideOverlay() {
els.overlay.hidden = true;
}
// ---- status bar helpers ---------------------------------------------
function setStatusIndicator(label, cls) {
els.statusIndicator.textContent = label;
els.statusIndicator.className = 'status ' + cls;
}
function setViewerCount(n) {
els.viewerCount.textContent = String(n);
}
function setLatency(ms) {
els.latency.textContent = ms == null ? '--' : `${ms} ms`;
}
function setTransport(label) {
els.transport.textContent = label || '--';
}
// ---- MediaMTX API polling -------------------------------------------
async function pollPathStatus() {
try {
const res = await fetch(API_URL, { credentials: 'include' });
if (res.status === 404) {
applyPathStatus(false, 0);
return;
}
if (!res.ok) {
// 401/403 means session expired - force reload so NPM can redirect to Authentik.
if (res.status === 401 || res.status === 403) {
window.location.reload();
}
return;
}
const data = await res.json();
applyPathStatus(Boolean(data.ready), (data.readers || []).length);
} catch (_) {
applyPathStatus(false, 0);
}
}
function applyPathStatus(ready, viewers) {
setViewerCount(viewers);
if (ready && !state.pathReady) {
state.pathReady = true;
setStatusIndicator('LIVE', 'live');
showOverlay('Connecting to stream…', 'Negotiating WebRTC', false);
window.StreamPlayer.start(els.video);
} else if (!ready && state.pathReady) {
state.pathReady = false;
setStatusIndicator('OFFLINE', 'offline');
setTransport('--');
setLatency(null);
showOverlay('Stream offline', 'Waiting for the streamer to start…', false);
window.StreamPlayer.stop();
}
}
// ---- Unmute 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.
els.unmuteBtn.addEventListener('click', async () => {
try {
els.video.muted = false;
await els.video.play();
els.unmuteBtn.hidden = true;
} catch (err) {
console.warn('Unmute failed:', err);
}
});
function maybeShowUnmute() {
els.unmuteBtn.hidden = !els.video.muted;
}
// ---- StreamPlayer event wiring --------------------------------------
window.StreamPlayer.on('statechange', (s) => {
state.playerState = s;
if (s === 'playing') {
hideOverlay();
maybeShowUnmute();
} else if (s === 'connecting') {
showOverlay('Connecting…', 'Negotiating transport', false);
} else if (s === 'error') {
showOverlay('Playback error', 'Retrying…', true);
} else if (s === 'offline' && state.pathReady) {
showOverlay('Reconnecting…', 'The stream dropped; trying again', false);
}
});
window.StreamPlayer.on('transport', (label) => setTransport(label));
window.StreamPlayer.on('latency', (ms) => setLatency(ms));
window.StreamPlayer.on('error', (err) => {
console.warn('Stream error:', err);
});
// ---- boot -----------------------------------------------------------
setStatusIndicator('OFFLINE', 'offline');
showOverlay('Stream offline', 'Waiting for the streamer to start…', false);
pollPathStatus();
setInterval(pollPathStatus, POLL_INTERVAL_MS);
})();
+323
View File
@@ -0,0 +1,323 @@
/*
* player.js - WHEP client with HLS fallback.
*
* Exposes a global `StreamPlayer` object used by app.js. Keeps a single
* <video> element up to date with the live MediaMTX stream.
*
* Control flow:
* StreamPlayer.start() -> attempt WHEP, fall back to HLS on failure
* StreamPlayer.stop() -> tear down whichever transport is active
*
* WHEP protocol (RFC 9725):
* 1. Create RTCPeerConnection, add recvonly transceivers.
* 2. Create SDP offer, POST to `${WHEP_URL}` as application/sdp.
* 3. Set the returned SDP as the remote description.
* 4. Media flows over UDP via ICE negotiation.
*/
(function () {
const WHEP_URL = '/whep/game/whep';
const HLS_URL = '/hls/game/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;
const listeners = {
statechange: [], // (state) -> void state: 'connecting'|'playing'|'offline'|'error'
transport: [], // (label) -> void label: 'WebRTC'|'HLS'|'--'
latency: [], // (ms|null) -> void
error: [], // (error) -> void
};
const state = {
pc: null, // RTCPeerConnection
hls: null, // Hls.js instance
videoEl: null, // HTMLVideoElement
transport: null, // 'webrtc' | 'hls' | null
running: false, // whether start() is in effect
reconnectTimer: null,
latencyTimer: null,
whepResourceUrl: null, // Location header returned by WHEP for DELETE cleanup
};
function emit(name, payload) {
(listeners[name] || []).forEach((cb) => {
try { cb(payload); } catch (e) { /* swallow */ }
});
}
function on(name, cb) {
if (!listeners[name]) return;
listeners[name].push(cb);
}
// ---------------- WebRTC / WHEP ----------------
async function startWhep() {
emit('statechange', 'connecting');
emit('transport', 'WebRTC');
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
bundlePolicy: 'max-bundle',
});
state.pc = pc;
// Passive viewer - we only receive.
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const remoteStream = new MediaStream();
pc.addEventListener('track', (ev) => {
remoteStream.addTrack(ev.track);
if (state.videoEl && state.videoEl.srcObject !== remoteStream) {
state.videoEl.srcObject = remoteStream;
}
});
pc.addEventListener('connectionstatechange', () => {
const cs = pc.connectionState;
if (cs === 'connected') {
emit('statechange', 'playing');
startLatencyPolling();
} else if (cs === 'failed' || cs === 'disconnected' || cs === 'closed') {
stopLatencyPolling();
if (state.running) {
emit('statechange', 'offline');
scheduleReconnect();
}
}
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Wait for ICE gathering so the offer we POST includes candidates
// (MediaMTX does not require trickle, and this keeps the HTTP flow
// simpler). Most implementations finish gathering in <500ms.
await waitForIceGatheringComplete(pc, 2000);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), WHEP_TIMEOUT_MS);
let response;
try {
response = await fetch(WHEP_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp,
signal: controller.signal,
credentials: 'include',
});
} finally {
clearTimeout(timeoutId);
}
if (!response.ok) {
throw new Error(`WHEP POST failed: HTTP ${response.status}`);
}
const answer = await response.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
state.whepResourceUrl = response.headers.get('Location') || null;
}
function waitForIceGatheringComplete(pc, timeoutMs) {
return new Promise((resolve) => {
if (pc.iceGatheringState === 'complete') return resolve();
const timer = setTimeout(resolve, timeoutMs);
pc.addEventListener('icegatheringstatechange', () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(timer);
resolve();
}
});
});
}
async function stopWhep() {
stopLatencyPolling();
if (state.whepResourceUrl) {
// Best-effort session teardown so MediaMTX frees resources.
try {
await fetch(state.whepResourceUrl, {
method: 'DELETE',
credentials: 'include',
});
} catch (_) { /* ignore */ }
state.whepResourceUrl = null;
}
if (state.pc) {
try { state.pc.close(); } catch (_) {}
state.pc = null;
}
if (state.videoEl) {
state.videoEl.srcObject = null;
}
}
// ---------------- HLS fallback ----------------
function loadHlsJs() {
if (window.Hls) return Promise.resolve(window.Hls);
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = HLS_JS_CDN;
s.onload = () => resolve(window.Hls);
s.onerror = () => reject(new Error('Failed to load hls.js'));
document.head.appendChild(s);
});
}
async function startHls() {
emit('statechange', 'connecting');
emit('transport', 'HLS');
const video = state.videoEl;
// Safari can play HLS natively.
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = HLS_URL;
video.addEventListener('playing', () => emit('statechange', 'playing'), { once: true });
try { await video.play(); } catch (_) { /* autoplay may require mute */ }
return;
}
const Hls = await loadHlsJs();
if (!Hls.isSupported()) {
throw new Error('HLS not supported in this browser');
}
const hls = new Hls({
lowLatencyMode: true,
backBufferLength: 10,
maxBufferLength: 4,
});
state.hls = hls;
hls.loadSource(HLS_URL);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => { /* autoplay may require mute */ });
emit('statechange', 'playing');
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data && data.fatal) {
emit('statechange', 'offline');
if (state.running) scheduleReconnect();
}
});
}
function stopHls() {
if (state.hls) {
try { state.hls.destroy(); } catch (_) {}
state.hls = null;
}
if (state.videoEl) {
state.videoEl.removeAttribute('src');
state.videoEl.load();
}
}
// ---------------- Latency polling (WebRTC only) ----------------
function startLatencyPolling() {
stopLatencyPolling();
state.latencyTimer = setInterval(async () => {
if (!state.pc) return;
try {
const stats = await state.pc.getStats();
let jitterBufferMs = null;
let rttMs = null;
stats.forEach((report) => {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
if (typeof report.jitterBufferDelay === 'number' &&
typeof report.jitterBufferEmittedCount === 'number' &&
report.jitterBufferEmittedCount > 0) {
jitterBufferMs = (report.jitterBufferDelay / report.jitterBufferEmittedCount) * 1000;
}
}
if (report.type === 'candidate-pair' && report.state === 'succeeded' &&
typeof report.currentRoundTripTime === 'number') {
rttMs = report.currentRoundTripTime * 1000;
}
});
let total = null;
if (jitterBufferMs != null || rttMs != null) {
total = (jitterBufferMs || 0) + (rttMs != null ? rttMs / 2 : 0);
}
emit('latency', total != null ? Math.round(total) : null);
} catch (_) {
emit('latency', null);
}
}, 2000);
}
function stopLatencyPolling() {
if (state.latencyTimer) {
clearInterval(state.latencyTimer);
state.latencyTimer = null;
}
emit('latency', null);
}
// ---------------- Reconnect logic ----------------
function scheduleReconnect() {
if (state.reconnectTimer) return;
state.reconnectTimer = setTimeout(() => {
state.reconnectTimer = null;
if (!state.running) return;
restart();
}, RECONNECT_DELAY_MS);
}
async function restart() {
await teardown();
try {
state.transport = 'webrtc';
await startWhep();
} catch (err) {
emit('error', err);
try {
await stopWhep();
state.transport = 'hls';
await startHls();
} catch (err2) {
emit('error', err2);
emit('statechange', 'error');
scheduleReconnect();
}
}
}
async function teardown() {
if (state.transport === 'webrtc') {
await stopWhep();
} else if (state.transport === 'hls') {
stopHls();
}
state.transport = null;
}
// ---------------- Public API ----------------
async function start(videoEl) {
if (state.running) return;
state.running = true;
state.videoEl = videoEl;
await restart();
}
async function stop() {
state.running = false;
if (state.reconnectTimer) {
clearTimeout(state.reconnectTimer);
state.reconnectTimer = null;
}
await teardown();
stopLatencyPolling();
emit('statechange', 'offline');
emit('transport', '--');
}
window.StreamPlayer = { start, stop, on };
})();
+444
View File
@@ -0,0 +1,444 @@
"""
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 (
"<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", 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()
+115
View File
@@ -0,0 +1,115 @@
<#
.SYNOPSIS
One-time installer for game-stream-app on Windows.
.DESCRIPTION
- Downloads the latest MediaMTX Windows amd64 release from GitHub
into bin/mediamtx.exe.
- Creates a disabled Windows Firewall rule "GameStream-UDP-8189" that
the OBS script will toggle on/off with stream lifecycle.
Must be run from an elevated (Administrator) PowerShell prompt.
.EXAMPLE
PS> cd C:\Users\bheth\Documents\game-stream-app
PS> .\scripts\install.ps1
#>
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[string]$FirewallRuleName = "GameStream-UDP-8189",
[int]$UdpPort = 8189
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$binDir = Join-Path $projectRoot 'bin'
function Write-Step { param([string]$msg) Write-Host "==> $msg" -ForegroundColor Cyan }
function Write-Ok { param([string]$msg) Write-Host " $msg" -ForegroundColor Green }
function Write-Warn2 { param([string]$msg) Write-Host " $msg" -ForegroundColor Yellow }
# ---------------------------------------------------------------------------
# 1. Download MediaMTX
# ---------------------------------------------------------------------------
Write-Step "Locating latest MediaMTX release"
$releaseApi = 'https://api.github.com/repos/bluenviron/mediamtx/releases/latest'
$headers = @{ 'User-Agent' = 'game-stream-app-installer' }
$release = Invoke-RestMethod -Uri $releaseApi -Headers $headers
$asset = $release.assets |
Where-Object { $_.name -match 'windows_amd64\.zip$' } |
Select-Object -First 1
if (-not $asset) {
throw "Could not find a windows_amd64 asset in the latest MediaMTX release"
}
Write-Ok "Found $($asset.name) ($($release.tag_name))"
if (-not (Test-Path $binDir)) {
New-Item -ItemType Directory -Path $binDir | Out-Null
}
$zipPath = Join-Path $env:TEMP $asset.name
Write-Step "Downloading MediaMTX"
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $zipPath -UseBasicParsing
Write-Ok "Downloaded to $zipPath"
Write-Step "Extracting to $binDir"
Expand-Archive -Path $zipPath -DestinationPath $binDir -Force
Remove-Item $zipPath -Force
$mediamtxExe = Join-Path $binDir 'mediamtx.exe'
if (-not (Test-Path $mediamtxExe)) {
throw "mediamtx.exe was not found after extraction - check the archive contents"
}
Write-Ok "mediamtx.exe installed at $mediamtxExe"
# ---------------------------------------------------------------------------
# 2. Create (or refresh) the Windows Firewall rule in DISABLED state
# ---------------------------------------------------------------------------
Write-Step "Configuring Windows Firewall rule '$FirewallRuleName'"
$existing = Get-NetFirewallRule -DisplayName $FirewallRuleName -ErrorAction SilentlyContinue
if ($existing) {
Write-Warn2 "Rule already exists - updating"
$existing | Remove-NetFirewallRule
}
New-NetFirewallRule `
-DisplayName $FirewallRuleName `
-Description "game-stream-app: WebRTC media (toggled by OBS script)" `
-Direction Inbound `
-Action Allow `
-Protocol UDP `
-LocalPort $UdpPort `
-Profile Any `
-Enabled False | Out-Null
Write-Ok "Firewall rule created (currently DISABLED)"
# ---------------------------------------------------------------------------
# 3. Next-step instructions
# ---------------------------------------------------------------------------
Write-Host ""
Write-Host "Install complete." -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:"
Write-Host " 1. Open OBS -> Tools -> Scripts -> + and add:"
Write-Host " $(Join-Path $projectRoot 'obs-script\game_stream.py')"
Write-Host " 2. In the script properties panel, set:"
Write-Host " MediaMTX binary : $mediamtxExe"
Write-Host " MediaMTX config : $(Join-Path $projectRoot 'config\mediamtx.yml')"
Write-Host " Frontend dir : $(Join-Path $projectRoot 'frontend')"
Write-Host " 3. Configure OBS Stream output:"
Write-Host " Service : Custom"
Write-Host " Protocol : WHIP"
Write-Host " Server : http://localhost:8889/game/whip"
Write-Host " 4. See docs/ for NPM + Authentik setup on your reverse proxy host."
Write-Host ""