initial commit
This commit is contained in:
+18
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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…</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>
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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()
|
||||
@@ -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 ""
|
||||
Reference in New Issue
Block a user