Compare commits
108 commits
m1-webrtc-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8557a1c65e | |||
| b3e667c835 | |||
| 4364d9176f | |||
| b6d2a77f8b | |||
| 9aaba9bdf6 | |||
| 278ebaa087 | |||
| 498aaefa0f | |||
| eaa798e77b | |||
| 07db6ebb4e | |||
| 429ff9b595 | |||
| 1648deccf4 | |||
| f1062a4c36 | |||
| 6ec0328b19 | |||
| 841335d14b | |||
| b045b26f17 | |||
| 57542a3d80 | |||
| 10eaaff6b7 | |||
| 5f4ac74080 | |||
| e257deb744 | |||
| 38d75b10b0 | |||
| 8266ca72e6 | |||
| 891f65dff6 | |||
| c4857f5581 | |||
| 228ed4b09b | |||
| 293536563f | |||
| 7490edd770 | |||
| 020a1800ce | |||
| a2e0a8c083 | |||
| 353fa0f3f3 | |||
| 4d94c88d74 | |||
| 4ac63ddfc6 | |||
| 7f545962f6 | |||
| 1be78a8185 | |||
| a22b8c68f0 | |||
| b1057756d2 | |||
| 4bef6563c7 | |||
| 6c9d1864dd | |||
| ca3501f888 | |||
| d72aa8afe1 | |||
| 2a4c8d5f52 | |||
| 01c456cd1a | |||
| 5f9ba6f764 | |||
| 890b09a33c | |||
| 70d0ddb2e3 | |||
| dd639b697f | |||
| 917225c994 | |||
| 9e9c7eb8f1 | |||
| 55b61dd0e5 | |||
| 561a93e044 | |||
| 60f64fe76b | |||
| 28a280b9b3 | |||
| 4beab3423d | |||
| 6b637a35e6 | |||
| 7471507be7 | |||
| e8f39daa75 | |||
| 4b8d9f0e8c | |||
| 1748f9102d | |||
| 47a28bf9d4 | |||
| 1d7cd5b520 | |||
| 15af16ce97 | |||
| 23636e4a76 | |||
| eaf62b7397 | |||
| 70324aad28 | |||
| 2283a32f2a | |||
| 99c568e53e | |||
| 6c3f887faa | |||
| 6449f65468 | |||
| 9a618f0b70 | |||
| 86a5a50dec | |||
| 2d2bd0e5c6 | |||
| 27cc39dab0 | |||
| 949daa26b5 | |||
| 75afcbc0d1 | |||
| 7621f88fea | |||
| 10f3e20a6a | |||
| 26991ec463 | |||
| 45f39a9132 | |||
| 7df7ad2f6e | |||
| fd391b5ca4 | |||
| 8c9ab5db0c | |||
| 6eaf346d06 | |||
| 1be2c3489d | |||
| 73d4049893 | |||
| 671f64ca56 | |||
| b7afd0f08a | |||
| 927ccc6ced | |||
| c8bcf75227 | |||
| 49677fbd3d | |||
| de4b215123 | |||
| 8d60cbd333 | |||
| 07b6b43ab4 | |||
| 4d2f11d836 | |||
| 3abd4d8fd1 | |||
| 4f84c72c85 | |||
| 0417aff3b1 | |||
| f6d36bfa66 | |||
| 2d29dc9c4a | |||
| d96aa70c27 | |||
| b030102611 | |||
| 83eaa28601 | |||
| f6d5b3378a | |||
| 9d38e9ccdb | |||
| 46531bb479 | |||
| 16ae17d2a1 | |||
| 80db028281 | |||
| eaeefee753 | |||
| c38036de94 | |||
| 86bae816c1 |
87 changed files with 11739 additions and 291 deletions
79
.forgejo/workflows/publish.yml
Normal file
79
.forgejo/workflows/publish.yml
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Forgejo Actions — Docker image publish for Dragon Fork.
|
||||||
|
#
|
||||||
|
# Triggers on semver tags (v*.*.*-dragonfork or v*.*.*). Builds a
|
||||||
|
# multi-arch image (linux/amd64 + linux/arm64) and pushes to the
|
||||||
|
# configured registry. The image name and registry are controlled by
|
||||||
|
# repository variables:
|
||||||
|
#
|
||||||
|
# REGISTRY — e.g. ghcr.io or registry.wilddragon.net
|
||||||
|
# IMAGE_NAME — e.g. zgaetano/dragonfork-core (defaults to repo name)
|
||||||
|
#
|
||||||
|
# The push credential must be stored as a repository secret:
|
||||||
|
# REGISTRY_TOKEN — password / token for the registry user
|
||||||
|
# REGISTRY_USER — registry username (defaults to repo owner)
|
||||||
|
#
|
||||||
|
# Quick-start after setting the variables/secrets:
|
||||||
|
# git tag v0.2.0-dragonfork && git push origin v0.2.0-dragonfork
|
||||||
|
|
||||||
|
name: publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
- 'v*.*.*-dragonfork'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
name: Build and push multi-arch image
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU (for arm64 cross-build)
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Derive image metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
REGISTRY="${{ vars.REGISTRY || 'ghcr.io' }}"
|
||||||
|
IMAGE="${{ vars.IMAGE_NAME || github.repository }}"
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
# Normalise: strip leading 'v' for the semver part, keep full tag too
|
||||||
|
SEMVER="${TAG#v}"
|
||||||
|
echo "image=${REGISTRY}/${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "semver=${SEMVER}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY || 'ghcr.io' }}
|
||||||
|
username: ${{ vars.REGISTRY_USER || github.repository_owner }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.tag }}
|
||||||
|
${{ steps.meta.outputs.image }}:latest
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=Dragon Fork Core
|
||||||
|
org.opencontainers.image.description=Datarhei Core with WebRTC egress
|
||||||
|
org.opencontainers.image.version=${{ steps.meta.outputs.semver }}
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
124
.forgejo/workflows/test.yml
Normal file
124
.forgejo/workflows/test.yml
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Forgejo Actions CI for Datarhei — Dragon Fork.
|
||||||
|
#
|
||||||
|
# Mirrors the upstream go-tests.yml shape (GitHub Actions syntax),
|
||||||
|
# but pinned to Go 1.24 to match go.mod and adds the M3 race-detector
|
||||||
|
# pass. The forgejo-runner picks this up automatically.
|
||||||
|
#
|
||||||
|
# Triggered on every push and pull request. Two jobs:
|
||||||
|
# - lint-and-vet: cheap, fast feedback (~30s)
|
||||||
|
# - test: full test suite with -race, ~3 minutes including
|
||||||
|
# the integration tests in app/webrtc that bind UDP
|
||||||
|
# sockets and run a real Pion handshake.
|
||||||
|
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'm[0-9]*-*'
|
||||||
|
- 'fix/**'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-vet:
|
||||||
|
name: vet + build
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.24'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: go build
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: race tests
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: lint-and-vet
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.24'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
# Integration tests need ephemeral UDP ports above 32768; the
|
||||||
|
# default sysctl on ubuntu runners covers this, so no extra
|
||||||
|
# setup is required.
|
||||||
|
|
||||||
|
- name: go test -race -short
|
||||||
|
run: go test -race -short -count=1 ./...
|
||||||
|
env:
|
||||||
|
# The integration tests start Pion peers; tighten the timeout
|
||||||
|
# so a flaky network-bound test never sits the whole job.
|
||||||
|
GORACE: 'halt_on_error=1'
|
||||||
|
|
||||||
|
- name: go test (coverage, no race)
|
||||||
|
# Race detector + coverage in one pass slows things meaningfully;
|
||||||
|
# do them separately. This step's purpose is the coverage.out
|
||||||
|
# artifact, not a second correctness signal.
|
||||||
|
run: go test -coverprofile=coverage.out -covermode=atomic -count=1 ./...
|
||||||
|
|
||||||
|
- name: Upload coverage artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: success() || failure()
|
||||||
|
with:
|
||||||
|
name: coverage-go-${{ github.sha }}
|
||||||
|
path: coverage.out
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# --- WebRTC subsystem-only smoke ---------------------------------
|
||||||
|
# The 5-viewer fanout test catches the largest class of regressions
|
||||||
|
# for the egress path. Promoted to its own job so a failure on the
|
||||||
|
# WebRTC side reads cleanly in the actions log instead of being
|
||||||
|
# buried among ~80 packages of unrelated Core tests.
|
||||||
|
webrtc-smoke:
|
||||||
|
name: WebRTC smoke (5-viewer fanout)
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: lint-and-vet
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.24'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: WebRTC integration tests (race)
|
||||||
|
run: |
|
||||||
|
go test -race -count=1 -v \
|
||||||
|
-run 'TestIntegration_|TestSubsystem_TeardownHookFiresOnProcessStop|TestHandler_' \
|
||||||
|
./app/webrtc/... ./core/webrtc/...
|
||||||
|
|
||||||
|
# --- Latency gate ----------------------------------------------------
|
||||||
|
# Server-hop p95 latency check. Build-tagged so it doesn't run in the
|
||||||
|
# default `go test ./...` invocation; this dedicated job exists to
|
||||||
|
# catch regressions that would otherwise hide behind 'all tests pass'.
|
||||||
|
# Threshold: p95 < 50ms (locally observed: sub-ms; gate is generous
|
||||||
|
# to absorb CI runner noise without false alarms).
|
||||||
|
latency-gate:
|
||||||
|
name: WebRTC latency p95 gate
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
needs: lint-and-vet
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.24'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Server-hop latency p95 < 50ms
|
||||||
|
run: |
|
||||||
|
go test -tags latency -timeout 90s -race -count=1 \
|
||||||
|
-run TestLatencyServerHop \
|
||||||
|
./app/webrtc/... -v
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -15,6 +15,8 @@
|
||||||
!/test/publish.sh
|
!/test/publish.sh
|
||||||
!/test/whep-client/
|
!/test/whep-client/
|
||||||
!/test/whep-client/**
|
!/test/whep-client/**
|
||||||
|
!/test/whep-player.html
|
||||||
|
!/test/TESTING.md
|
||||||
|
|
||||||
*.ts
|
*.ts
|
||||||
*.ts.tmp
|
*.ts.tmp
|
||||||
|
|
@ -25,3 +27,4 @@
|
||||||
*.flv
|
*.flv
|
||||||
|
|
||||||
.VSCodeCounter
|
.VSCodeCounter
|
||||||
|
whep-client
|
||||||
|
|
|
||||||
389
CHANGELOG.md
389
CHANGELOG.md
|
|
@ -1,4 +1,311 @@
|
||||||
# Core
|
# Datarhei — Dragon Fork
|
||||||
|
|
||||||
|
## v0.4.0-dragonfork (2026-05-10)
|
||||||
|
|
||||||
|
WebRTC protocol compliance milestone. Focuses on correctness and operator
|
||||||
|
experience: complete H.264 packetisation coverage, RFC-compliant signalling
|
||||||
|
headers, proper multi-homed NAT support, and comprehensive test coverage
|
||||||
|
for both WHEP and WHIP.
|
||||||
|
|
||||||
|
Resolves issues #18, #19, #20, #21.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **STAP-A IDR detection** (`core/webrtc/keyframecache.go`). The H.264
|
||||||
|
keyframe cache now handles all three RTP packetisation modes for IDR
|
||||||
|
slices: single-NAL (type 5), FU-A start (type 28 with start bit),
|
||||||
|
and STAP-A aggregation packets (type 24, first inner NAL type 5).
|
||||||
|
Without this, an encoder that sends the SPS/PPS/IDR as a single STAP-A
|
||||||
|
aggregate would never trigger a cache reset and late-joining subscribers
|
||||||
|
would not receive a reference frame. Closes #18.
|
||||||
|
- Four new tests in `keyframecache_test.go` exercise STAP-A leading IDR,
|
||||||
|
non-IDR first NAL, and truncated (1-/2-/3-byte) payloads.
|
||||||
|
|
||||||
|
- **WHEP Link headers — RFC 9429 §4.3** (`app/webrtc/handler.go`).
|
||||||
|
The WHEP 201 Subscribe response now emits one `Link: <uri>; rel="ice-server"`
|
||||||
|
header per configured ICE server URI. Browsers can discover STUN/TURN
|
||||||
|
endpoints from the response without a separate signalling round-trip.
|
||||||
|
`addCORS()` updated to expose `Link` in `Access-Control-Expose-Headers`.
|
||||||
|
`ICEServerURIs() []string` added to `Subsystem`. Closes #19.
|
||||||
|
- Tests: `TestSubsystem_ICEServerURIs_ReturnsConfiguredURIs`,
|
||||||
|
`TestAddCORS_ExposesLinkHeader` in `handler_test.go`.
|
||||||
|
|
||||||
|
- **Multi-IP NAT1To1 support** (`core/webrtc/config.go`,
|
||||||
|
`core/webrtc/ice.go`, `app/webrtc/subsystem.go`). The NAT1To1 config now
|
||||||
|
accepts a list of IPs (`NAT1To1IPs []string`) instead of a single string.
|
||||||
|
Pion's `SetNAT1To1IPs` receives the full list so dual-homed servers
|
||||||
|
(e.g., LAN + public) advertise host candidates on all interfaces.
|
||||||
|
`BuildICEConfig` falls back to the legacy `PublicIP` field as a
|
||||||
|
single-element list for backward compatibility. Subsystem merges
|
||||||
|
`PublicIP` + `NAT1To1IPs` with deduplication. Closes #20.
|
||||||
|
- Five new tests in `ice_test.go`: multi-IP list, PublicIP fallback,
|
||||||
|
both-set, neither-set, invalid config rejection.
|
||||||
|
|
||||||
|
- **WHIP Link headers — RFC 9261 §5.2** (`app/webrtc/whip_handler.go`).
|
||||||
|
Symmetric with the WHEP change: the WHIP 201 Publish response now emits
|
||||||
|
`Link` headers for each ICE server, allowing OBS, GStreamer, and
|
||||||
|
browser-based publishers to discover STUN/TURN from the offer response.
|
||||||
|
Closes #21.
|
||||||
|
|
||||||
|
- **WHIP handler test suite** (`app/webrtc/whip_handler_test.go`, new).
|
||||||
|
Nine tests covering Publish/Unpublish/TrickleIngest routes and CORS
|
||||||
|
preflight. Verifies 404 on missing ingest, 400 on bad body, 204 on
|
||||||
|
idempotent DELETE of unknown resource, 404 on PATCH to unknown peer,
|
||||||
|
and Link/Location/ETag present in CORS expose-headers.
|
||||||
|
|
||||||
|
### Upgrade (from v0.3)
|
||||||
|
|
||||||
|
No config changes required. The new `NAT1To1IPs` field in `DataWebRTC`
|
||||||
|
defaults to empty, preserving the existing single-IP behaviour via `PublicIP`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.3.0-dragonfork (2026-05-10)
|
||||||
|
|
||||||
|
WebRTC ingest (WHIP) milestone. Browsers and OBS can now push a
|
||||||
|
WebRTC stream into a channel, and the first-frame experience for WHEP
|
||||||
|
viewers is dramatically improved by the in-memory keyframe cache.
|
||||||
|
|
||||||
|
Resolves issues #15, #16, #17.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **WHIP ingest path** — browsers and OBS Studio can push a WebRTC
|
||||||
|
stream (H.264 + Opus) into any Dragon Fork channel via
|
||||||
|
`POST /api/v3/whip/{id}`. The publisher sends an SDP offer; Core
|
||||||
|
answers, allocates a loopback UDP pair, and injects RTP input legs
|
||||||
|
into the FFmpeg command line — the exact mirror of the WHEP egress
|
||||||
|
path. `DELETE /api/v3/whip/{id}/{resource}` tears down the publisher
|
||||||
|
cleanly. Closes #16.
|
||||||
|
|
||||||
|
- **`ProcessConfigWHIPIngest` API struct** in `http/api/process.go`
|
||||||
|
mapping `whip_ingest.{enabled,video_pt,audio_pt}` between the JSON
|
||||||
|
API and `app.ConfigWHIPIngest`. Without this struct, `WHIPIngest.Enabled`
|
||||||
|
was always false and WHIP could never activate via the API.
|
||||||
|
|
||||||
|
- **WHIP ingest lifecycle hooks** — `onWHIPProcessStart` /
|
||||||
|
`onWHIPProcessStop` in `app/webrtc/whip_lifecycle.go` allocate and
|
||||||
|
teardown the ingest UDP port pair, controlled by the per-process
|
||||||
|
`whip_ingest.enabled` flag. Merged via `MergedHooks()` alongside the
|
||||||
|
existing WHEP egress hooks.
|
||||||
|
|
||||||
|
- **Wild Dragon UI — WHIP toggle control** (`overlay/src/misc/controls/WHIP.js`
|
||||||
|
in the `wilddragon-restreamer-ui` overlay). Mirrors WHEP.js exactly.
|
||||||
|
Renders an Enable checkbox with caption in the channel edit view.
|
||||||
|
|
||||||
|
- **Wild Dragon UI — Edit/index.js wiring** — renders the WHIP control
|
||||||
|
in the Edit view and patches `props.restreamer._upsertProcess` in the
|
||||||
|
`save()` handler to inject `whip_ingest.enabled` into the process
|
||||||
|
config before the SDK PUT reaches Core. The patch is required because
|
||||||
|
the Restreamer SDK's `UpsertIngest` does not forward `webrtc` or
|
||||||
|
`whip_ingest` fields (SDK gap).
|
||||||
|
|
||||||
|
- **In-memory H.264 keyframe cache** in `core/webrtc/keyframecache.go`.
|
||||||
|
Retains the most recent IDR burst (all RTP packets from the first IDR
|
||||||
|
NAL fragment until the next one) per video Source. Bounded at 512
|
||||||
|
packets / 2 MiB. Detects single-NAL IDR (type 5) and FU-A start
|
||||||
|
fragments (type 28, start bit set, inner type 5). Closes #17.
|
||||||
|
|
||||||
|
- **Subscribe pre-fill** — `Source.Subscribe()` snapshots the keyframe
|
||||||
|
cache before registering the new subscriber, then drains the burst
|
||||||
|
into the channel immediately. New WHEP peers receive a complete
|
||||||
|
reference frame on join instead of waiting up to one GOP (≈ 2 s at
|
||||||
|
30 fps / GOP=60).
|
||||||
|
|
||||||
|
- **`Source.EnableKeyFrameCache()`** — opt-in method; called only on
|
||||||
|
video sources in `allocAdjacentPair()`. Audio sources are
|
||||||
|
intentionally uncached (Opus payloads would accumulate without ever
|
||||||
|
triggering a reset).
|
||||||
|
|
||||||
|
- **Test suite for `core/webrtc`** — `keyframecache_test.go` (18
|
||||||
|
functions) and `source_test.go` (5 functions). Covers IDR detection
|
||||||
|
in all packetisation modes, cache reset, burst accumulation, capacity
|
||||||
|
caps, snapshot independence, concurrent read/write under `-race`, and
|
||||||
|
Subscribe pre-fill behaviour. All 34 tests in `core/webrtc` green
|
||||||
|
under `go test -race`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`deploy/truenas/core/seed-data.sh`** — the old no-clobber-only
|
||||||
|
approach kept stale JS bundles alive on the data volume after image
|
||||||
|
rebuilds (`static/` was never refreshed because it already existed).
|
||||||
|
Fixed by splitting into two phases: always-overwrite for `index.html`,
|
||||||
|
`asset-manifest.json`, and `static/`; no-clobber for everything else
|
||||||
|
(channel data, player bundles, operator content). Prevents a class of
|
||||||
|
"new code never runs" deployment bugs.
|
||||||
|
|
||||||
|
### Upgrade (from v0.2)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd deploy/truenas/core
|
||||||
|
git pull
|
||||||
|
docker compose build --no-cache core
|
||||||
|
docker compose up -d core
|
||||||
|
```
|
||||||
|
|
||||||
|
The `seed-data.sh` fix means there is no longer a need to manually
|
||||||
|
`docker exec` a static-bundle copy after rebuilds — it happens
|
||||||
|
automatically on container start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.2 backlog (2026-05-06)
|
||||||
|
|
||||||
|
Completes the open v0.2 issues from the post-GUI-ship backlog.
|
||||||
|
Resolves issues #11, #12, #13, #14.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **WebRTC Prometheus metrics** — eleven metrics in the
|
||||||
|
`dragonfork_webrtc_*` namespace using RED-method principles.
|
||||||
|
Hybrid instrumentation: direct `client_golang` counters/histograms
|
||||||
|
for hot-path WHEP routes and ICE establishment in `app/webrtc/metrics.go`,
|
||||||
|
plus a snapshot collector for gauges in `prometheus/webrtc.go`.
|
||||||
|
Metrics: `whep_requests_total`, `whep_request_duration_seconds`,
|
||||||
|
`ice_establishment_duration_seconds`, `ice_failures_total`,
|
||||||
|
`codec_mismatches_total`, `cap_rejections_total`,
|
||||||
|
`ffmpeg_leg_failures_total`, `active_streams`, `active_peers`,
|
||||||
|
`udp_ports_in_use`. Closes #11.
|
||||||
|
|
||||||
|
- **Grafana observability stack** in `deploy/truenas/core/`:\n Prometheus v2.55 and Grafana OSS 11.3 containers on a `dragonfork-mon`
|
||||||
|
bridge network reaching Core via `host.docker.internal`. Pre-loaded
|
||||||
|
WebRTC Health dashboard (5 rows: WHEP API, ICE, streams/peers, capacity,
|
||||||
|
silent-degradation canary). Four pre-loaded Prometheus alert rules.
|
||||||
|
Deploy upgrade: add `GRAFANA_ADMIN_PASSWORD` to `.env`,
|
||||||
|
`docker compose pull && docker compose up -d`. Closes #11.
|
||||||
|
|
||||||
|
- **Docker image CI publish workflow** at `.forgejo/workflows/publish.yml`.
|
||||||
|
Triggers on semver tags. Builds multi-arch (`linux/amd64` + `linux/arm64`)
|
||||||
|
and pushes to the configured registry (`REGISTRY` repo variable,
|
||||||
|
defaults to `ghcr.io`). Requires `REGISTRY_TOKEN` secret and optional
|
||||||
|
`REGISTRY_USER` / `IMAGE_NAME` variables. Layer cache via GitHub Actions
|
||||||
|
cache. Closes #12.
|
||||||
|
|
||||||
|
- **Upstream rebase policy** at `docs/REBASE.md`. Documents monthly
|
||||||
|
cadence, rebase-not-merge strategy, Dragon Fork divergence boundaries,
|
||||||
|
pre/post-rebase checklist, vendored-dependency procedure, first-rebase
|
||||||
|
runbook, and record-keeping table. First rebase against upstream is
|
||||||
|
pending (to be run locally per the procedure in `docs/REBASE.md`).
|
||||||
|
Closes #13.
|
||||||
|
|
||||||
|
- **WHEP sustained load test** at `test/load/sustained.go`.
|
||||||
|
Headless Go program (`//go:build ignore`, run with `go run`) that drives
|
||||||
|
N concurrent WHEP subscribers against a single stream for a configurable
|
||||||
|
duration. Measures: ICE establishment (p50/p95), jitter (RFC 3550 running
|
||||||
|
average), packet loss estimate (sequence-number gaps), packets received.
|
||||||
|
Outputs a markdown report to `test/load/results/`. Staggered connection
|
||||||
|
setup, trickle-ICE, and graceful DELETE on teardown. Closes #14.
|
||||||
|
|
||||||
|
- **`core/webrtc.Peer.Connected()` channel** — closed on first
|
||||||
|
`PeerConnectionStateConnected` event. Required by the ICE establishment
|
||||||
|
histogram (allows async measurement after the WHEP POST returns).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `deploy/truenas/core/docker-compose.yml`: adds `prom` and `grafana`
|
||||||
|
services + `dragonfork-mon` bridge network + named volumes. `core`
|
||||||
|
service is unchanged (stays on `network_mode: host`).
|
||||||
|
- `app/webrtc/handler.go`: WHEP route handlers now record request duration,
|
||||||
|
status code, codec mismatch, and cap rejection metrics. `tearDownStreamPeers`
|
||||||
|
records FFmpeg leg failures when peers were active at stop time.
|
||||||
|
- `app/webrtc/subsystem.go`: adds `StreamCount()` accessor for the
|
||||||
|
snapshot collector.
|
||||||
|
|
||||||
|
### Upgrade (from v0.2.0-dragonfork)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd deploy/truenas/core
|
||||||
|
git pull
|
||||||
|
# Add new lines to .env:
|
||||||
|
# GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)
|
||||||
|
# GRAFANA_PORT=3000
|
||||||
|
# PROM_PORT=9090
|
||||||
|
docker compose pull # pulls prom + grafana images
|
||||||
|
docker compose up -d # core unchanged, prom + grafana start fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.2.0-dragonfork (2026-05-03)
|
||||||
|
|
||||||
|
The "GUI ship" release. Everything from v0.1 is preserved; this round
|
||||||
|
documents and ships a usable graphical surface for the WebRTC feature
|
||||||
|
that v0.1 only exposed through the API.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Wild Dragon WebRTC admin page** at `/wilddragon-webrtc.html`. Single-file
|
||||||
|
HTML/JS; no build step. Sign in with the API_AUTH_USERNAME / PASSWORD
|
||||||
|
creds, see every process, toggle `webrtc.enabled` per-process with one
|
||||||
|
click, restart on change, copy the WHEP URL, jump straight to the
|
||||||
|
smoke player. Closes the v0.1 GUI gap — the upstream Restreamer UI
|
||||||
|
ships with v0.2 but doesn't know about Core's `webrtc` config block,
|
||||||
|
so toggling WebRTC previously required direct API calls.
|
||||||
|
|
||||||
|
### Documented (was present, just unannounced)
|
||||||
|
|
||||||
|
- **Restreamer UI bundle** in the TrueNAS deploy. The `deploy/truenas/core/`
|
||||||
|
Dockerfile builds the upstream `datarhei/restreamer-ui` v1.14.0 React
|
||||||
|
bundle with the Wild Dragon overlay applied (logo / favicon / header
|
||||||
|
title / welcome card), copies the result into Core's disk filesystem
|
||||||
|
via `seed-data.sh`, and Core serves it at `/`. Was added during M2
|
||||||
|
but not called out in the v0.1 CHANGELOG.
|
||||||
|
- **WHEP smoke player** at `/whep-player.html`. Standalone WebRTC
|
||||||
|
subscriber with ICE/codec/bitrate diagnostics. Was added during M4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.1.0-dragonfork (2026-05-03)
|
||||||
|
|
||||||
|
The first tagged Dragon Fork release. Forked from upstream datarhei
|
||||||
|
Core v16.16.0; everything upstream does is preserved unchanged. New:
|
||||||
|
WebRTC (WHEP) egress, integrated with the existing process supervisor.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **WebRTC subsystem** under `app/webrtc/`, mirroring the shape of
|
||||||
|
upstream's RTMP and SRT servers (Server interface, Echo handlers,
|
||||||
|
process-graph hooks, admin endpoints).
|
||||||
|
- **Per-process opt-in** via `config.webrtc.enabled` on every restream
|
||||||
|
process; resolver auto-injects two RTP output legs and allocates
|
||||||
|
loopback UDP ports.
|
||||||
|
- **`POST /api/v3/whep/{id}`** — WebRTC-HTTP Egress Protocol subscribe.
|
||||||
|
JWT-protected by the existing Core auth.
|
||||||
|
- **`DELETE /api/v3/whep/{id}/{resource}`** — idempotent teardown
|
||||||
|
(returns 204 even on unknown resource per WHEP spec).
|
||||||
|
- **`PATCH /api/v3/whep/{id}/{resource}`** — trickle ICE.
|
||||||
|
- **CORS preflight** on every WHEP route + `Access-Control-Expose-Headers`
|
||||||
|
for `Location` and `ETag` so browser-side WHEP players work
|
||||||
|
cross-origin.
|
||||||
|
- **Configurable stream maps** via `webrtc.video_map` / `webrtc.audio_map`
|
||||||
|
on the per-process config — defaults to `0:v:0` / `0:a:0` for
|
||||||
|
RTMP/SRT publishers, overridable for multi-input pipelines.
|
||||||
|
- **`webrtc.*` global config block** with `CORE_WEBRTC_*` env-var
|
||||||
|
bindings parallel to RTMP and SRT.
|
||||||
|
- **Admin API:** `GET /api/v3/webrtc/streams` + `/streams/{id}/peers`.
|
||||||
|
- **Browser smoke player** at `test/whep-player.html` with ICE / codec
|
||||||
|
/ bitrate diagnostics, JWT field, and `?url=&token=` shareable
|
||||||
|
URLs.
|
||||||
|
- **Server-hop latency p95 gate** in CI (`-tags latency`), enforced at
|
||||||
|
50ms on the runner; locally observed p95 ≈ 240µs.
|
||||||
|
- **TrueNAS deploy bundle** at `deploy/truenas/core/` — host-networked
|
||||||
|
Docker stack with bundled FFmpeg, env-driven config.
|
||||||
|
- **Multi-viewer correctness:** per-stream peer cap, ICE-failure
|
||||||
|
auto-cleanup goroutines, process-stop broadcast tear-down.
|
||||||
|
- **Error matrix:** 406 codec mismatch, 504 ICE timeout, 503 cap
|
||||||
|
reached (separate body for total vs per-stream), 204 DELETE
|
||||||
|
idempotent.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `Config.Clone()` now preserves the `WebRTC` section.
|
||||||
|
- `http/api.ProcessConfig` Marshal/Unmarshal now carry the per-process
|
||||||
|
`webrtc` block.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Core (upstream)
|
||||||
|
|
||||||
|
|
||||||
### Core v16.15.0 > v16.16.0
|
### Core v16.15.0 > v16.16.0
|
||||||
|
|
||||||
|
|
@ -54,83 +361,3 @@
|
||||||
- Fix URL validation if the path contains FFmpeg specific placeholders
|
- Fix URL validation if the path contains FFmpeg specific placeholders
|
||||||
- Fix RTMP DoS attack (thx Johannes Frank)
|
- Fix RTMP DoS attack (thx Johannes Frank)
|
||||||
- Deprecate ENV names that do not correspond to JSON name
|
- Deprecate ENV names that do not correspond to JSON name
|
||||||
|
|
||||||
### Core v16.11.0 > v16.12.0
|
|
||||||
|
|
||||||
- Add S3 storage support
|
|
||||||
- Add support for variables in placeholde parameter
|
|
||||||
- Add support for RTMP token as stream key as last element in path
|
|
||||||
- Add support for soft memory limit with debug.memory_limit_mbytes in config
|
|
||||||
- Add support for partial process config updates
|
|
||||||
- Add support for alternative syntax for auth0 tenants as environment variable
|
|
||||||
- Fix config timestamps created_at and loaded_at
|
|
||||||
- Fix /config/reload return type
|
|
||||||
- Fix modifying DTS in RTMP packets ([restreamer/#487](https://github.com/datarhei/restreamer/issues/487), [restreamer/#367](https://github.com/datarhei/restreamer/issues/367))
|
|
||||||
- Fix default internal SRT latency to 20ms
|
|
||||||
|
|
||||||
### Core v16.10.1 > v16.11.0
|
|
||||||
|
|
||||||
- Add FFmpeg 4.4 to FFmpeg 5.1 migration tool
|
|
||||||
- Add alternative SRT streamid
|
|
||||||
- Mod bump FFmpeg to v5.1.2 (datarhei/core:tag bundles)
|
|
||||||
- Fix crash with custom SSL certificates ([restreamer/#425](https://github.com/datarhei/restreamer/issues/425))
|
|
||||||
- Fix proper version handling for config
|
|
||||||
- Fix widged session data
|
|
||||||
- Fix resetting process stats when process stopped
|
|
||||||
- Fix stale FFmpeg process detection for streams with only audio
|
|
||||||
- Fix wrong return status code ([#6](https://github.com/datarhei/core/issues/6)))
|
|
||||||
- Fix use SRT defaults for key material exchange
|
|
||||||
|
|
||||||
### Core v16.10.0 > v16.10.1
|
|
||||||
|
|
||||||
- Add email address in TLS config for Let's Encrypt
|
|
||||||
- Fix use of Let's Encrypt production CA
|
|
||||||
|
|
||||||
### Core v16.9.1 > v16.10.0
|
|
||||||
|
|
||||||
- Add HLS session middleware to diskfs
|
|
||||||
- Add /v3/metrics (get) endpoint to list all known metrics
|
|
||||||
- Add logging HTTP request and response body sizes
|
|
||||||
- Add process id and reference glob pattern matching
|
|
||||||
- Add cache block list for extensions not to cache
|
|
||||||
- Mod exclude .m3u8 and .mpd files from disk cache by default
|
|
||||||
- Mod replaces x/crypto/acme/autocert with caddyserver/certmagic
|
|
||||||
- Mod exposes ports (Docker desktop)
|
|
||||||
- Fix assigning cleanup rules for diskfs
|
|
||||||
- Fix wrong path for swagger definition
|
|
||||||
- Fix process cleanup on delete, remove empty directories from disk
|
|
||||||
- Fix SRT blocking port on restart (upgrade datarhei/gosrt)
|
|
||||||
- Fix RTMP communication (Blackmagic Web Presenter, thx 235 MEDIA)
|
|
||||||
- Fix RTMP communication (Blackmagic ATEM Mini, [#385](https://github.com/datarhei/restreamer/issues/385))
|
|
||||||
- Fix injecting commit, branch, and build info
|
|
||||||
- Fix API metadata endpoints responses
|
|
||||||
|
|
||||||
#### Core v16.9.0 > v16.9.1^
|
|
||||||
|
|
||||||
- Fix v1 import app
|
|
||||||
- Fix race condition
|
|
||||||
|
|
||||||
#### Core v16.8.0 > v16.9.0
|
|
||||||
|
|
||||||
- Add new placeholders and parameters for placeholder
|
|
||||||
- Allow RTMP server if RTMPS server is enabled. In case you already had RTMPS enabled it will listen on the same port as before. An RTMP server will be started additionally listening on a lower port number. The RTMP app is required to start with a slash.
|
|
||||||
- Add optional escape character to process placeholder
|
|
||||||
- Fix output address validation for tee outputs
|
|
||||||
- Fix updating process config
|
|
||||||
- Add experimental SRT connection stats and logs API
|
|
||||||
- Hide /config/reload endpoint in reade-only mode
|
|
||||||
- Add experimental SRT server (datarhei/gosrt)
|
|
||||||
- Create v16 in go.mod
|
|
||||||
- Fix data races, tests, lint, and update dependencies
|
|
||||||
- Add trailing slash for routed directories (datarhei/restreamer#340)
|
|
||||||
- Allow relative URLs in content in static routes
|
|
||||||
|
|
||||||
#### Core v16.7.2 > v16.8.0
|
|
||||||
|
|
||||||
- Add purge_on_delete function
|
|
||||||
- Mod updated dependencies
|
|
||||||
- Mod updated API docs
|
|
||||||
- Fix disabled session logging
|
|
||||||
- Fix FFmpeg skills reload
|
|
||||||
- Fix ignores processes with invalid references (thx Patron Ramakrishna Chillara)
|
|
||||||
- Fix code scanning alerts
|
|
||||||
|
|
|
||||||
47
CREDITS
Normal file
47
CREDITS
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Credits
|
||||||
|
|
||||||
|
Datarhei — Dragon Fork stands on the shoulders of the open-source
|
||||||
|
projects below. Required-attribution notices and the corresponding
|
||||||
|
licenses live in NOTICE and the per-vendor LICENSE files under
|
||||||
|
vendor/.
|
||||||
|
|
||||||
|
## Direct ancestor
|
||||||
|
|
||||||
|
- **datarhei/core** (Apache-2.0) — the base codebase this fork tracks.
|
||||||
|
https://github.com/datarhei/core
|
||||||
|
|
||||||
|
## Major Go dependencies
|
||||||
|
|
||||||
|
- **github.com/pion/webrtc/v4** (MIT) — the Go WebRTC stack the egress
|
||||||
|
path is built on. https://github.com/pion/webrtc
|
||||||
|
- **github.com/pion/rtp** (MIT) — RTP packet types.
|
||||||
|
- **github.com/pion/dtls/v2** (MIT) — DTLS for SRTP key exchange.
|
||||||
|
- **github.com/pion/ice/v3** (MIT) — ICE candidate gathering.
|
||||||
|
- **github.com/pion/sdp/v3** (MIT) — SDP parsing.
|
||||||
|
- **github.com/labstack/echo/v4** (MIT) — HTTP routing.
|
||||||
|
- **github.com/swaggo/echo-swagger** (MIT) — OpenAPI / Swagger UI
|
||||||
|
middleware.
|
||||||
|
- **github.com/caddyserver/certmagic** (Apache-2.0) — Let's Encrypt
|
||||||
|
TLS automation.
|
||||||
|
- **github.com/datarhei/joy4** (Apache-2.0) — RTMP server primitives
|
||||||
|
(forked from joy4).
|
||||||
|
- **github.com/datarhei/gosrt** (Apache-2.0) — pure-Go SRT.
|
||||||
|
- **go.uber.org/zap** (MIT) — structured logging.
|
||||||
|
|
||||||
|
## Subprocess
|
||||||
|
|
||||||
|
- **FFmpeg** (LGPL-2.1-or-later / GPL-2.0-or-later, build-flag
|
||||||
|
dependent) — used as an out-of-process child by the `restream`
|
||||||
|
subsystem for transcoding and RTP packetisation. Dragon Fork does
|
||||||
|
not link against the FFmpeg libraries.
|
||||||
|
|
||||||
|
## Brand assets
|
||||||
|
|
||||||
|
- **"Wild Dragon" mark** — © Wild Dragon, used as the project mark
|
||||||
|
for Dragon Fork builds.
|
||||||
|
|
||||||
|
## Full list
|
||||||
|
|
||||||
|
The complete dependency tree, including transitive dependencies and
|
||||||
|
their licenses, is enumerated in `vendor/modules.txt` and the
|
||||||
|
per-vendor LICENSE / COPYING files under `vendor/`.
|
||||||
93
NOTES.md
93
NOTES.md
|
|
@ -23,4 +23,95 @@ adds a new section.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- Add M1 verification notes here after Task 12 succeeds. -->
|
## v0.2 (2026-05-06)
|
||||||
|
|
||||||
|
### Restreamer SDK gap — `UpsertIngest` does not forward `webrtc` or `whip_ingest`
|
||||||
|
|
||||||
|
The datarhei Restreamer SDK's `UpsertIngest` builds the FFmpeg process config
|
||||||
|
from `control.hls`, `control.rtmp`, `control.srt`, and `control.process`, but
|
||||||
|
silently discards `control.webrtc` and `control.whip_ingest`. This was confirmed
|
||||||
|
by inspecting the minified SDK bundle inside the running container.
|
||||||
|
|
||||||
|
**Implication:** toggling WebRTC egress or WHIP ingest from the Restreamer UI
|
||||||
|
required a monkey-patch. The Edit view (`overlay/src/views/Edit/index.js`)
|
||||||
|
overrides `props.restreamer._upsertProcess` immediately before the
|
||||||
|
`UpsertIngest` call to inject `whip_ingest.enabled` (and in future,
|
||||||
|
`webrtc.enabled`) into the process config JSON, then restores the original
|
||||||
|
method in a `finally` block. The patch is narrow and reversible.
|
||||||
|
|
||||||
|
**Why not patch the SDK?** The SDK is a vendored minified bundle inside the
|
||||||
|
upstream Restreamer UI. Patching it would require either maintaining a fork of
|
||||||
|
the minifier input (the full SDK source is not in the UI repo) or deobfuscating
|
||||||
|
and re-minifying. The monkey-patch is pragmatic and self-documenting.
|
||||||
|
|
||||||
|
### UI state uses `enable` (no d); Core API uses `enabled` (with d)
|
||||||
|
|
||||||
|
WHEP.js and WHIP.js use `enable` (matching existing convention in the UI
|
||||||
|
controls). The monkey-patch performs the mapping:
|
||||||
|
|
||||||
|
```js
|
||||||
|
config.whip_ingest = { enabled: !!(control.whip_ingest && control.whip_ingest.enable) };
|
||||||
|
```
|
||||||
|
|
||||||
|
If this causes confusion in the future, unify on `enabled` in both places.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.3 (2026-05-10)
|
||||||
|
|
||||||
|
### `ProcessConfigWHIPIngest` was the critical backend gap
|
||||||
|
|
||||||
|
`http/api/process.go` had `ProcessConfigWebRTC` for WHEP but no corresponding
|
||||||
|
struct for WHIP. Without it, the JSON field `whip_ingest` was dropped on
|
||||||
|
unmarshal and `app.Config.WHIPIngest.Enabled` was always `false`. WHIP
|
||||||
|
could never activate via the API regardless of what the UI sent. Adding
|
||||||
|
`ProcessConfigWHIPIngest` and wiring it into `Marshal()`/`Unmarshal()` was
|
||||||
|
the essential fix (commit `4d94c88`).
|
||||||
|
|
||||||
|
### seed-data.sh no-clobber bug
|
||||||
|
|
||||||
|
`seed-data.sh` used `cp -n` (no-clobber) for all files including the built
|
||||||
|
JS bundle. On first deploy the `static/` directory doesn't exist and the copy
|
||||||
|
succeeds. On every subsequent deploy the directory already exists and the copy
|
||||||
|
is skipped — so a rebuilt container silently serves the old bundle. The symptom
|
||||||
|
was confusing: the binary had new code, but the UI didn't reflect it.
|
||||||
|
|
||||||
|
**Fix:** split into two phases. `index.html`, `asset-manifest.json`, and
|
||||||
|
`static/` are always overwritten (`cp -Rfp`). All other content (channel
|
||||||
|
database, player bundles, operator-uploaded media) remains no-clobber to
|
||||||
|
protect live data.
|
||||||
|
|
||||||
|
### Keyframe cache lock ordering
|
||||||
|
|
||||||
|
`keyFrameCache` has its own mutex `c.mu` distinct from `Source.mu`. The two
|
||||||
|
locks are never nested:
|
||||||
|
|
||||||
|
- `readLoop` calls `cache.push(pkt)` (acquires/releases `c.mu`), then
|
||||||
|
acquires `s.mu` for the subscriber fanout — sequential, not nested.
|
||||||
|
- `Source.Subscribe()` takes the snapshot outside `s.mu` (acquires/releases
|
||||||
|
`c.mu`), then acquires `s.mu` to register the subscriber — also sequential.
|
||||||
|
|
||||||
|
This means there is no deadlock risk even though two separate mutexes are
|
||||||
|
involved. The snapshot-before-lock pattern in `Subscribe` was chosen for
|
||||||
|
clarity, not necessity; but it documents the intent explicitly.
|
||||||
|
|
||||||
|
### STAP-A IDR detection is not implemented
|
||||||
|
|
||||||
|
`isH264IDRStart` handles single-NAL (type 5) and FU-A start (type 28, start
|
||||||
|
bit, inner type 5). It does **not** handle STAP-A aggregates (type 24) that
|
||||||
|
happen to lead with an IDR NAL.
|
||||||
|
|
||||||
|
In practice, FFmpeg and GStreamer never emit IDR slices inside STAP-A — IDR
|
||||||
|
frames are large and STAP-A is designed for small NALs (SPS + PPS combos).
|
||||||
|
If a publisher that does use STAP-A for IDR ever appears, the cache will miss
|
||||||
|
the keyframe boundary; the worst outcome is a larger-than-expected burst (the
|
||||||
|
cache grows until the next correctly-detected IDR) rather than a crash or
|
||||||
|
incorrect video. Add STAP-A handling in a future revision if needed.
|
||||||
|
|
||||||
|
### `go test -race ./core/webrtc/...` baseline
|
||||||
|
|
||||||
|
All 34 tests in `core/webrtc` pass under the race detector as of v0.3
|
||||||
|
(commit `228ed4b`). The suite covers config, ICE, registry, peer creation,
|
||||||
|
WHEP handler, keyframe cache, and Source subscribe/pre-fill/close. Total
|
||||||
|
runtime ≈ 16 s (dominated by the two 5-second ICE gathering timeouts in
|
||||||
|
`TestPeerFactory_*`).
|
||||||
|
|
|
||||||
41
NOTICE
Normal file
41
NOTICE
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
Datarhei — Dragon Fork
|
||||||
|
Copyright (c) 2026 Wild Dragon
|
||||||
|
|
||||||
|
This product includes software developed by datarhei.
|
||||||
|
|
||||||
|
datarhei Core
|
||||||
|
Copyright (c) datarhei
|
||||||
|
https://github.com/datarhei/core
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
not use this file except in compliance with the License. A copy of the
|
||||||
|
License is in the LICENSE file at the root of this repository, and is
|
||||||
|
also available at:
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied. See the License for the specific language governing
|
||||||
|
permissions and limitations under the License.
|
||||||
|
|
||||||
|
This fork additionally bundles or depends on:
|
||||||
|
|
||||||
|
Pion WebRTC and related Pion libraries
|
||||||
|
Copyright (c) The Pion authors
|
||||||
|
https://github.com/pion
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Echo HTTP framework
|
||||||
|
Copyright (c) LabStack
|
||||||
|
https://github.com/labstack/echo
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
FFmpeg (used as a subprocess by the restream subsystem; not linked)
|
||||||
|
Copyright (c) The FFmpeg developers
|
||||||
|
https://ffmpeg.org
|
||||||
|
LGPL-2.1-or-later / GPL-2.0-or-later (build-flag dependent)
|
||||||
|
|
||||||
|
A complete list of dependencies and their licenses lives in the
|
||||||
|
CREDITS file at the root of this repository.
|
||||||
251
README.md
251
README.md
|
|
@ -1,92 +1,203 @@
|
||||||
# Core
|
# Datarhei — Dragon Fork
|
||||||
|
|
||||||

|
A fork of [datarhei/core](https://github.com/datarhei/core) that adds a
|
||||||
|
native **WebRTC (WHEP) egress** path. Everything upstream Datarhei
|
||||||
|
already does — RTMP / SRT / RTSP ingest, FFmpeg process orchestration,
|
||||||
|
HLS / DASH outputs, S3 mounts, the HTTP API and Swagger UI — works
|
||||||
|
unchanged. WebRTC sits alongside as another output type, opt-in
|
||||||
|
per process.
|
||||||
|
|
||||||
[](<[https://opensource.org/licenses/MI](https://www.apache.org/licenses/LICENSE-2.0)>)
|
```
|
||||||
[](https://github.com/datarhei/core/actions/workflows/codeql-analysis.yml)
|
publisher (OBS / FFmpeg / SRT) ──▶ datarhei Core ──▶ WebRTC peers
|
||||||
[](https://github.com/datarhei/core/actions/workflows/go-tests.yml)
|
│ │ (1–5 viewers per stream)
|
||||||
[](https://codecov.io/gh/datarhei/core)
|
│ ├──▶ HLS / DASH (existing)
|
||||||
[](https://goreportcard.com/report/github.com/datarhei/core)
|
│ ├──▶ RTMP relay (existing)
|
||||||
[](https://pkg.go.dev/github.com/datarhei/core)
|
└──▶ ingest (RTMP / SRT / …) └──▶ recording (existing)
|
||||||
[](https://docs.datarhei.com/core/guides/beginner)
|
```
|
||||||
|
|
||||||
The datarhei Core is a process management solution for FFmpeg that offers a range of interfaces for media content, including HTTP, RTMP, SRT, and storage options. It is optimized for use in virtual environments such as Docker. It has been implemented in various contexts, from small-scale applications like Restreamer to large-scale, multi-instance frameworks spanning multiple locations, such as dedicated servers, cloud instances, and single-board computers. The datarhei Core stands out from traditional media servers by emphasizing FFmpeg and its capabilities rather than focusing on media conversion.
|
Sub-second glass-to-glass on a LAN over WHEP, no SFU dependencies,
|
||||||
|
single binary, single Docker image.
|
||||||
|
|
||||||
## Objectives of development
|
> **Status:** v0.2 in progress (last work 2026-05-06). Full GUI bundled
|
||||||
|
> (Restreamer UI + Wild Dragon WebRTC admin). Prometheus + Grafana
|
||||||
|
> observability stack shipped. Live deploy running on TrueNAS since
|
||||||
|
> 2026-04-17.
|
||||||
|
|
||||||
The objectives of development are:
|
## What this fork adds
|
||||||
|
|
||||||
- Unhindered use of FFmpeg processes
|
- **`webrtc.*` config block** alongside `rtmp.*` and `srt.*`, with the
|
||||||
- Portability of FFmpeg, including management across development and production environments
|
same `CORE_*` env-var binding pattern.
|
||||||
- Scalability of FFmpeg-based applications through the ability to offload processes to additional instances
|
- **Per-process `webrtc.enabled` toggle** on the existing process
|
||||||
- Streamlining of media product development by focusing on features and design.
|
config. Once true, Core auto-injects two RTP output legs (video +
|
||||||
|
audio), allocates UDP ports, and the WHEP endpoint is live.
|
||||||
|
- **`POST /api/v3/whep/{processID}`** — WebRTC-HTTP Egress Protocol
|
||||||
|
subscribe; SDP offer in, SDP answer out. JWT-protected by the
|
||||||
|
existing Core auth.
|
||||||
|
- **`DELETE /api/v3/whep/{processID}/{resourceID}`** — idempotent
|
||||||
|
teardown.
|
||||||
|
- **`PATCH …/{resourceID}`** — trickle ICE.
|
||||||
|
- **Bundled GUI** — the upstream Restreamer React UI is built into the
|
||||||
|
TrueNAS deploy image with Wild Dragon branding, plus a single-file
|
||||||
|
Wild Dragon WebRTC admin page for one-click `webrtc.enabled` toggling.
|
||||||
|
- **Browser-side smoke player** at `whep-player.html` — zero-dependency
|
||||||
|
WHEP subscriber, ICE/codec/bitrate stats, JWT field, shareable
|
||||||
|
`?url=&token=` URLs.
|
||||||
|
- **Prometheus observability** — eleven `dragonfork_webrtc_*` metrics
|
||||||
|
(RED-method counters/histograms + state gauges). Grafana health
|
||||||
|
dashboard with 5 rows and 4 pre-loaded alert rules.
|
||||||
|
- **Multi-viewer correctness:** per-stream peer cap, ICE-failure
|
||||||
|
auto-cleanup, process-stop broadcast tear-down.
|
||||||
|
- **Error matrix** per the design spec: `406` on codec mismatch,
|
||||||
|
`504` on ICE timeout, `503` on cap, `204` on idempotent DELETE,
|
||||||
|
CORS preflights on every WHEP route.
|
||||||
|
|
||||||
## What issues have been resolved thus far?
|
The existing upstream Datarhei feature set is intact — see "From
|
||||||
|
upstream Datarhei" below.
|
||||||
### Process management
|
|
||||||
|
|
||||||
- Run multiple processes via API
|
|
||||||
- Unrestricted FFmpeg commands in process configuration.
|
|
||||||
- Error detection and recovery (e.g., FFmpeg stalls, dumps)
|
|
||||||
- Referencing for process chaining (pipelines)
|
|
||||||
- Placeholders for storage, RTMP, and SRT usage (automatic credentials management and URL resolution)
|
|
||||||
- Logs (access to current stdout/stderr)
|
|
||||||
- Log history (configurable log history, e.g., for error analysis)
|
|
||||||
- Resource limitation (max. CPU and MEMORY usage per process)
|
|
||||||
- Statistics (like FFmpeg progress per input and output, CPU and MEMORY, state, uptime)
|
|
||||||
- Input verification (like FFprobe)
|
|
||||||
- Metadata (option to store additional information like a title)
|
|
||||||
|
|
||||||
### Media delivery
|
|
||||||
|
|
||||||
- Configurable file systems (in-memory, disk-mount, S3)
|
|
||||||
- HTTP/S, RTMP/S, and SRT services, including Let's Encrypt
|
|
||||||
- Bandwidth and session limiting for HLS/MPEG DASH sessions (protects restreams from congestion)
|
|
||||||
- Viewer session API and logging
|
|
||||||
|
|
||||||
### Misc
|
|
||||||
|
|
||||||
- HTTP REST and GraphQL API
|
|
||||||
- Swagger documentation
|
|
||||||
- Metrics incl. Prometheus support (also detects POSIX and cgroups resources)
|
|
||||||
- Docker images for fast setup of development environments up to the integration of cloud resources
|
|
||||||
|
|
||||||
## Docker images
|
|
||||||
|
|
||||||
- datarhei/core:latest (AMD64, ARM64, ARMv7)
|
|
||||||
- datarhei/core:cuda-latest (Nvidia CUDA 11.7.1, AMD64)
|
|
||||||
- datarhei/core:rpi-latest (Raspberry Pi / OMX/V4L2-M2M, AMD64/ARMv7)
|
|
||||||
- datarhei/core:vaapi-latest (Intel VAAPI, AMD64)
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
1. Run the Docker image
|
### Docker (TrueNAS / any host with Docker + LAN-reachable IP)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --name core -d \
|
git clone https://forge.wilddragon.net/zgaetano/datarhei-dragonfork-core.git
|
||||||
-e CORE_API_AUTH_USERNAME=admin \
|
cd datarhei-dragonfork-core/deploy/truenas/core
|
||||||
-e CORE_API_AUTH_PASSWORD=secret \
|
|
||||||
-p 8080:8080 \
|
cat > .env <<EOF
|
||||||
-v ${HOME}/core/config:/core/config \
|
PUBLIC_IP=10.0.0.25
|
||||||
-v ${HOME}/core/data:/core/data \
|
CORE_HTTP_PORT=8080
|
||||||
datarhei/core:latest
|
API_AUTH_USERNAME=admin
|
||||||
|
API_AUTH_PASSWORD=$(openssl rand -base64 24)
|
||||||
|
API_AUTH_JWT_SECRET=$(openssl rand -base64 48)
|
||||||
|
GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)
|
||||||
|
GRAFANA_PORT=3000
|
||||||
|
PROM_PORT=9090
|
||||||
|
EOF
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Open Swagger
|
Then open in a browser (replace `<host>` with your `PUBLIC_IP`):
|
||||||
http://host-ip:8080/api/swagger/index.html
|
|
||||||
|
|
||||||
3. Log in with Swagger
|
| URL | What it does |
|
||||||
Authorize > Basic authorization > Username: admin, Password: secret
|
| --- | --- |
|
||||||
|
| `http://<host>:8080/` | **Restreamer UI** — manage processes, ingests, outputs |
|
||||||
|
| `http://<host>:8080/wilddragon-webrtc.html` | **Wild Dragon WebRTC admin** — toggle `webrtc.enabled` per process, copy WHEP URL |
|
||||||
|
| `http://<host>:8080/whep-player.html` | **WHEP smoke player** — verify the WebRTC stream renders |
|
||||||
|
| `http://<host>:3000/` | **Grafana** — WebRTC Health dashboard (login with `GRAFANA_ADMIN_PASSWORD`) |
|
||||||
|
| `http://<host>:9090/` | **Prometheus** — raw metrics, alert rules |
|
||||||
|
| `http://<host>:8080/api/swagger/index.html` | **Swagger** — full API docs |
|
||||||
|
|
||||||
|
The Restreamer UI doesn't yet have a WebRTC checkbox in its process
|
||||||
|
editor — use `/wilddragon-webrtc.html` for that. Tracked in issue #15.
|
||||||
|
|
||||||
|
### Pulling a pre-built image (after first tag is published)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Update .env, then:
|
||||||
|
docker compose pull # pulls pre-built multi-arch image
|
||||||
|
docker compose up -d # no --build needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample process JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "live",
|
||||||
|
"input": [
|
||||||
|
{ "address": "{rtmp,name=live.stream}", "options": [] }
|
||||||
|
],
|
||||||
|
"output": [],
|
||||||
|
"webrtc": { "enabled": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. No `webrtc://` URL scheme to learn — the toggle on
|
||||||
|
`config.webrtc.enabled` is the entire surface. The resolver allocates
|
||||||
|
ports, injects `-f rtp udp://…` legs into the FFmpeg command, and the
|
||||||
|
WHEP endpoint at `/api/v3/whep/live` becomes live the moment the
|
||||||
|
process starts.
|
||||||
|
|
||||||
|
For multi-input pipelines (lavfi test sources, multi-camera switches,
|
||||||
|
SDI + file audio), use the `video_map` and `audio_map` fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"webrtc": {
|
||||||
|
"enabled": true,
|
||||||
|
"video_map": "0:v:0",
|
||||||
|
"audio_map": "1:a:0",
|
||||||
|
"force_transcode": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Documentation is available on [docs.datarhei.com/core](https://docs.datarhei.com/core).
|
| Topic | Where |
|
||||||
|
| ----- | ----- |
|
||||||
|
| Design spec | [`docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md`](docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md) |
|
||||||
|
| M1 (PoC) plan | [`docs/design/2026-04-16-datarhei-dragon-fork-m1-webrtc-poc.md`](docs/design/2026-04-16-datarhei-dragon-fork-m1-webrtc-poc.md) |
|
||||||
|
| M2 (Core integration) spec | [`docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md`](docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md) |
|
||||||
|
| Prometheus metrics design | [`docs/design/2026-05-03-datarhei-dragon-fork-webrtc-prometheus-metrics-design.md`](docs/design/2026-05-03-datarhei-dragon-fork-webrtc-prometheus-metrics-design.md) |
|
||||||
|
| Upstream rebase policy | [`docs/REBASE.md`](docs/REBASE.md) |
|
||||||
|
| TrueNAS deploy guide | [`deploy/truenas/core/README.md`](deploy/truenas/core/README.md) |
|
||||||
|
| Testing | [`test/TESTING.md`](test/TESTING.md) |
|
||||||
|
| Changelog (Dragon Fork) | [`CHANGELOG.md`](CHANGELOG.md) |
|
||||||
|
| Upstream Datarhei docs | [docs.datarhei.com/core](https://docs.datarhei.com/core) |
|
||||||
|
|
||||||
- [Quick start](https://docs.datarhei.com/core/guides/beginner)
|
## Building from source
|
||||||
- [Installation](https://docs.datarhei.com/core/installation)
|
|
||||||
- [Configuration](https://docs.datarhei.com/core/configuration)
|
Go 1.24 required (vendored).
|
||||||
- [Coding](https://docs.datarhei.com/core/development/coding)
|
|
||||||
|
```sh
|
||||||
|
make release # cross-compiles linux/amd64 to ./core/core
|
||||||
|
make test # full suite, race detector
|
||||||
|
go test -tags latency -timeout 90s -count=1 \
|
||||||
|
-run TestLatencyServerHop ./app/webrtc/... # latency p95 gate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Load testing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./test/load/sustained.go \
|
||||||
|
-url http://<host>:8080 \
|
||||||
|
-stream <processID> \
|
||||||
|
-peers 5 \
|
||||||
|
-duration 10m \
|
||||||
|
-auth "Bearer <TOKEN>" \
|
||||||
|
-out test/load/results/
|
||||||
|
```
|
||||||
|
|
||||||
|
Reports are written to `test/load/results/`. Observe the Grafana
|
||||||
|
WebRTC Health dashboard during the run.
|
||||||
|
|
||||||
|
## From upstream Datarhei
|
||||||
|
|
||||||
|
This fork preserves everything upstream Datarhei Core does — Dragon
|
||||||
|
Fork is purely additive. If a feature isn't WebRTC-related, the
|
||||||
|
behaviour is unchanged from upstream and the upstream documentation
|
||||||
|
applies as-is.
|
||||||
|
|
||||||
|
| Subsystem | Upstream feature set |
|
||||||
|
| --- | --- |
|
||||||
|
| Process management | API-driven FFmpeg, error detection / recovery, log history, resource limits, statistics, FFprobe input verification, process metadata |
|
||||||
|
| Media delivery | HTTP/S, RTMP/S, SRT services with Let's Encrypt, configurable file systems (in-memory / disk / S3), HLS/DASH session limits, viewer session API |
|
||||||
|
| Misc | HTTP REST + GraphQL, Swagger, Prometheus metrics, multi-arch Docker images |
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
Dragon Fork is built on:
|
||||||
|
|
||||||
|
- **datarhei Core** — Apache 2.0, © datarhei. The base repository this
|
||||||
|
fork tracks. See [`NOTICE`](NOTICE) for the required attribution.
|
||||||
|
- **datarhei Restreamer UI** — Apache 2.0, © datarhei. The React frontend
|
||||||
|
bundled into the TrueNAS deploy image with Wild Dragon overlays.
|
||||||
|
- **Pion WebRTC** — MIT. The Go WebRTC stack the egress path is built
|
||||||
|
on.
|
||||||
|
- **FFmpeg** — LGPL / GPL (build-flag dependent). Used as a subprocess
|
||||||
|
for transcoding and RTP packetisation; Dragon Fork doesn't link
|
||||||
|
against it.
|
||||||
|
|
||||||
|
Full third-party credits in [`CREDITS`](CREDITS).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
datarhei/core is licensed under the Apache License 2.0
|
Apache License 2.0 — same as upstream. See [`LICENSE`](LICENSE).
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/datarhei/core/v16/app"
|
"github.com/datarhei/core/v16/app"
|
||||||
|
appwebrtc "github.com/datarhei/core/v16/app/webrtc"
|
||||||
"github.com/datarhei/core/v16/config"
|
"github.com/datarhei/core/v16/config"
|
||||||
configstore "github.com/datarhei/core/v16/config/store"
|
configstore "github.com/datarhei/core/v16/config/store"
|
||||||
configvars "github.com/datarhei/core/v16/config/vars"
|
configvars "github.com/datarhei/core/v16/config/vars"
|
||||||
|
|
@ -73,6 +74,9 @@ type api struct {
|
||||||
s3fs map[string]fs.Filesystem
|
s3fs map[string]fs.Filesystem
|
||||||
rtmpserver rtmp.Server
|
rtmpserver rtmp.Server
|
||||||
srtserver srt.Server
|
srtserver srt.Server
|
||||||
|
webrtcsub *appwebrtc.Subsystem
|
||||||
|
webrtchandler *appwebrtc.Handler
|
||||||
|
whiphandler *appwebrtc.WHIPHandler
|
||||||
metrics monitor.HistoryMonitor
|
metrics monitor.HistoryMonitor
|
||||||
prom prometheus.Metrics
|
prom prometheus.Metrics
|
||||||
service service.Service
|
service service.Service
|
||||||
|
|
@ -216,6 +220,8 @@ func (a *api) Reload() error {
|
||||||
|
|
||||||
logfields := log.Fields{
|
logfields := log.Fields{
|
||||||
"application": app.Name,
|
"application": app.Name,
|
||||||
|
"variant": app.Variant,
|
||||||
|
"fork": app.Fork,
|
||||||
"version": app.Version.String(),
|
"version": app.Version.String(),
|
||||||
"repository": "https://github.com/datarhei/core",
|
"repository": "https://github.com/datarhei/core",
|
||||||
"license": "Apache License Version 2.0",
|
"license": "Apache License Version 2.0",
|
||||||
|
|
@ -617,6 +623,23 @@ func (a *api) start() error {
|
||||||
|
|
||||||
a.restream = restream
|
a.restream = restream
|
||||||
|
|
||||||
|
// Build the WebRTC egress subsystem if the operator enabled it.
|
||||||
|
// Failure to construct the subsystem (e.g., invalid NAT1To1 IP)
|
||||||
|
// is logged and the subsystem declines to install hooks — Core
|
||||||
|
// starts normally without WebRTC support, consistent with how
|
||||||
|
// disabling the subsystem at runtime is handled.
|
||||||
|
if cfg.WebRTC.Enable {
|
||||||
|
webrtcSub, werr := appwebrtc.New(cfg.WebRTC, a.log.logger.core)
|
||||||
|
if werr != nil {
|
||||||
|
a.log.logger.core.Warn().WithError(werr).Log("WebRTC subsystem disabled: construction failed")
|
||||||
|
} else {
|
||||||
|
a.restream.SetHooks(webrtcSub.MergedHooks())
|
||||||
|
a.webrtcsub = webrtcSub
|
||||||
|
a.webrtchandler = appwebrtc.NewHandler(webrtcSub, 0)
|
||||||
|
a.whiphandler = appwebrtc.NewWHIPHandler(webrtcSub, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var httpjwt jwt.JWT
|
var httpjwt jwt.JWT
|
||||||
|
|
||||||
if cfg.API.Auth.Enable {
|
if cfg.API.Auth.Enable {
|
||||||
|
|
@ -1014,6 +1037,8 @@ func (a *api) start() error {
|
||||||
},
|
},
|
||||||
RTMP: a.rtmpserver,
|
RTMP: a.rtmpserver,
|
||||||
SRT: a.srtserver,
|
SRT: a.srtserver,
|
||||||
|
WebRTC: a.webrtchandler,
|
||||||
|
WHIP: a.whiphandler,
|
||||||
JWT: a.httpjwt,
|
JWT: a.httpjwt,
|
||||||
Config: a.config.store,
|
Config: a.config.store,
|
||||||
Sessions: a.sessions,
|
Sessions: a.sessions,
|
||||||
|
|
@ -1354,6 +1379,21 @@ func (a *api) stop() {
|
||||||
a.srtserver = nil
|
a.srtserver = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tear down the WebRTC subsystem: close any active WHEP peers
|
||||||
|
// first, then release all per-process UDP sockets.
|
||||||
|
if a.webrtchandler != nil {
|
||||||
|
a.webrtchandler.Close()
|
||||||
|
a.webrtchandler = nil
|
||||||
|
}
|
||||||
|
if a.whiphandler != nil {
|
||||||
|
a.whiphandler.Close()
|
||||||
|
a.whiphandler = nil
|
||||||
|
}
|
||||||
|
if a.webrtcsub != nil {
|
||||||
|
a.webrtcsub.Close()
|
||||||
|
a.webrtcsub = nil
|
||||||
|
}
|
||||||
|
|
||||||
// Stop the RTMP server
|
// Stop the RTMP server
|
||||||
if a.rtmpserver != nil {
|
if a.rtmpserver != nil {
|
||||||
a.log.logger.rtmp.Info().Log("Stopping ...")
|
a.log.logger.rtmp.Info().Log("Stopping ...")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,19 @@ import (
|
||||||
// Name of the app
|
// Name of the app
|
||||||
const Name = "datarhei-core"
|
const Name = "datarhei-core"
|
||||||
|
|
||||||
|
// Variant distinguishes a Dragon Fork build from upstream Datarhei
|
||||||
|
// Core in the startup banner and in the /api/v3/about endpoint
|
||||||
|
// payload. Empty would imply an upstream build; we override the
|
||||||
|
// linker default with the fork identity.
|
||||||
|
//
|
||||||
|
// Kept as a var (not const) so a downstream packager can override it
|
||||||
|
// at build time via -ldflags="-X github.com/datarhei/core/v16/app.Variant=…"
|
||||||
|
// without forking the source.
|
||||||
|
var Variant = "dragonfork"
|
||||||
|
|
||||||
|
// Fork carries the human-readable fork name surfaced in logs.
|
||||||
|
var Fork = "Datarhei — Dragon Fork"
|
||||||
|
|
||||||
type versionInfo struct {
|
type versionInfo struct {
|
||||||
Major int
|
Major int
|
||||||
Minor int
|
Minor int
|
||||||
|
|
|
||||||
61
app/webrtc/ffmpeg_args.go
Normal file
61
app/webrtc/ffmpeg_args.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildArgs emits the FFmpeg output-leg args for the WebRTC side of a
|
||||||
|
// process. It produces two separate "outputs" — one for video on
|
||||||
|
// videoPort, one for audio on videoPort+1. Each output ends with its
|
||||||
|
// UDP address so the slice is structured for consumption by
|
||||||
|
// restream.AppendOutput after splitting on the track boundary.
|
||||||
|
//
|
||||||
|
// Copy vs. re-encode: if ForceTranscode is false, we assume the upstream
|
||||||
|
// source is already H.264 + Opus and pass them through (copy). When the
|
||||||
|
// source doesn't match, FFmpeg will fail at runtime and the process will
|
||||||
|
// restart — the user can flip ForceTranscode on to get a baseline-profile
|
||||||
|
// H.264 + Opus re-encode.
|
||||||
|
func BuildArgs(cfg appcfg.ConfigWebRTC, videoPort int) []string {
|
||||||
|
vcopy := []string{"-c:v", "copy"}
|
||||||
|
acopy := []string{"-c:a", "copy"}
|
||||||
|
if cfg.ForceTranscode {
|
||||||
|
vcopy = []string{
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "veryfast",
|
||||||
|
"-profile:v", "baseline",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-tune", "zerolatency",
|
||||||
|
"-g", "60",
|
||||||
|
}
|
||||||
|
acopy = []string{"-c:a", "libopus", "-b:a", "96k"}
|
||||||
|
}
|
||||||
|
|
||||||
|
videoMap := cfg.VideoMap
|
||||||
|
if videoMap == "" {
|
||||||
|
videoMap = "0:v:0"
|
||||||
|
}
|
||||||
|
audioMap := cfg.AudioMap
|
||||||
|
if audioMap == "" {
|
||||||
|
audioMap = "0:a:0"
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"-map", videoMap}
|
||||||
|
args = append(args, vcopy...)
|
||||||
|
args = append(args,
|
||||||
|
"-payload_type", fmt.Sprint(cfg.VideoPT),
|
||||||
|
"-f", "rtp",
|
||||||
|
fmt.Sprintf("udp://127.0.0.1:%d?pkt_size=1316", videoPort),
|
||||||
|
)
|
||||||
|
|
||||||
|
args = append(args, "-map", audioMap)
|
||||||
|
args = append(args, acopy...)
|
||||||
|
args = append(args,
|
||||||
|
"-payload_type", fmt.Sprint(cfg.AudioPT),
|
||||||
|
"-f", "rtp",
|
||||||
|
fmt.Sprintf("udp://127.0.0.1:%d?pkt_size=1316", videoPort+1),
|
||||||
|
)
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
132
app/webrtc/ffmpeg_args_test.go
Normal file
132
app/webrtc/ffmpeg_args_test.go
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildArgs_CopyCodecs(t *testing.T) {
|
||||||
|
cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}
|
||||||
|
got := BuildArgs(cfg, 49200)
|
||||||
|
|
||||||
|
// Must contain -c:v copy and -c:a copy when ForceTranscode is false.
|
||||||
|
if !contains(got, "-c:v", "copy") {
|
||||||
|
t.Fatalf("expected -c:v copy, got %v", got)
|
||||||
|
}
|
||||||
|
if !contains(got, "-c:a", "copy") {
|
||||||
|
t.Fatalf("expected -c:a copy, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two UDP addresses, one per track, with port+1 for audio.
|
||||||
|
if !any(got, "udp://127.0.0.1:49200?") {
|
||||||
|
t.Fatalf("expected video udp on 49200, got %v", got)
|
||||||
|
}
|
||||||
|
if !any(got, "udp://127.0.0.1:49201?") {
|
||||||
|
t.Fatalf("expected audio udp on 49201, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload types must be stringified.
|
||||||
|
if !contains(got, "-payload_type", "102") {
|
||||||
|
t.Fatalf("expected video PT 102, got %v", got)
|
||||||
|
}
|
||||||
|
if !contains(got, "-payload_type", "111") {
|
||||||
|
t.Fatalf("expected audio PT 111, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildArgs_ForceTranscode(t *testing.T) {
|
||||||
|
cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111, ForceTranscode: true}
|
||||||
|
got := BuildArgs(cfg, 49200)
|
||||||
|
|
||||||
|
if !contains(got, "-c:v", "libx264") {
|
||||||
|
t.Fatalf("expected -c:v libx264, got %v", got)
|
||||||
|
}
|
||||||
|
if !contains(got, "-profile:v", "baseline") {
|
||||||
|
t.Fatalf("expected baseline profile, got %v", got)
|
||||||
|
}
|
||||||
|
if !contains(got, "-c:a", "libopus") {
|
||||||
|
t.Fatalf("expected -c:a libopus, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildArgs_TwoTrackBoundary(t *testing.T) {
|
||||||
|
cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}
|
||||||
|
got := BuildArgs(cfg, 49200)
|
||||||
|
|
||||||
|
// The second `-map` marks the start of the audio leg — the split
|
||||||
|
// point restream.AppendOutput callers use.
|
||||||
|
mapCount := 0
|
||||||
|
for _, a := range got {
|
||||||
|
if a == "-map" {
|
||||||
|
mapCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mapCount != 2 {
|
||||||
|
t.Fatalf("expected exactly 2 -map tokens, got %d in %v", mapCount, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains reports whether the two-token sequence appears consecutively.
|
||||||
|
func contains(haystack []string, a, b string) bool {
|
||||||
|
for i := 0; i+1 < len(haystack); i++ {
|
||||||
|
if haystack[i] == a && haystack[i+1] == b {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// any reports whether any element of haystack starts with prefix.
|
||||||
|
func any(haystack []string, prefix string) bool {
|
||||||
|
for _, h := range haystack {
|
||||||
|
if strings.HasPrefix(h, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildArgs_DefaultMaps confirms 0:v:0 / 0:a:0 are emitted when
|
||||||
|
// VideoMap / AudioMap are empty (regression on the fix for issue #2 —
|
||||||
|
// the prior version had these as hardcoded literals; if VideoMap is
|
||||||
|
// ever empty unexpectedly, BuildArgs must still produce a working
|
||||||
|
// command line).
|
||||||
|
func TestBuildArgs_DefaultMaps(t *testing.T) {
|
||||||
|
cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}
|
||||||
|
got := BuildArgs(cfg, 50000)
|
||||||
|
if !contains(got, "-map", "0:v:0") {
|
||||||
|
t.Fatalf("expected default video map 0:v:0, got %v", got)
|
||||||
|
}
|
||||||
|
if !contains(got, "-map", "0:a:0") {
|
||||||
|
t.Fatalf("expected default audio map 0:a:0, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildArgs_CustomMaps drives the issue-#2 fix: when the user
|
||||||
|
// configures a multi-input pipeline (audio on input #1, etc.), the
|
||||||
|
// emitted -map values must follow the user's choice rather than the
|
||||||
|
// "0:v:0"/"0:a:0" assumption.
|
||||||
|
func TestBuildArgs_CustomMaps(t *testing.T) {
|
||||||
|
cfg := appcfg.ConfigWebRTC{
|
||||||
|
Enabled: true,
|
||||||
|
VideoPT: 102,
|
||||||
|
AudioPT: 111,
|
||||||
|
VideoMap: "0:v:1",
|
||||||
|
AudioMap: "1:a:0",
|
||||||
|
}
|
||||||
|
got := BuildArgs(cfg, 50000)
|
||||||
|
if !contains(got, "-map", "0:v:1") {
|
||||||
|
t.Fatalf("expected custom video map 0:v:1, got %v", got)
|
||||||
|
}
|
||||||
|
if !contains(got, "-map", "1:a:0") {
|
||||||
|
t.Fatalf("expected custom audio map 1:a:0, got %v", got)
|
||||||
|
}
|
||||||
|
// The default literals should NOT appear when overridden.
|
||||||
|
for _, opt := range got {
|
||||||
|
if opt == "0:v:0" || opt == "0:a:0" {
|
||||||
|
t.Errorf("expected no default maps in output, found %q in %v", opt, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
422
app/webrtc/handler.go
Normal file
422
app/webrtc/handler.go
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
|
||||||
|
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default per-stream peer cap when the caller passes 0. The total cap
|
||||||
|
// (passed to NewHandler) is enforced separately and takes precedence.
|
||||||
|
const defaultMaxPeersPerStream = 8
|
||||||
|
|
||||||
|
// WebRTCStats is the JSON response for GET /webrtc/stats.
|
||||||
|
type WebRTCStats struct {
|
||||||
|
// ActiveStreams is the number of running FFmpeg processes with a
|
||||||
|
// registered WHEP egress pair (video + audio Sources).
|
||||||
|
ActiveStreams int `json:"active_streams"`
|
||||||
|
|
||||||
|
// ActivePeers is the total count of live WHEP subscriber sessions
|
||||||
|
// (each call to Subscribe that has not yet been torn down).
|
||||||
|
ActivePeers int64 `json:"active_peers"`
|
||||||
|
|
||||||
|
// ActivePublishers is the total count of live WHIP ingest sessions
|
||||||
|
// (each call to WHIPHandler.Publish that has not yet been unpublished).
|
||||||
|
ActivePublishers int64 `json:"active_publishers"`
|
||||||
|
|
||||||
|
// UDPPortsInUse is an approximation of the number of UDP ports
|
||||||
|
// allocated for ICE traffic. When using ephemeral ports (default)
|
||||||
|
// each stream uses two ports (one video, one audio).
|
||||||
|
UDPPortsInUse int `json:"udp_ports_in_use"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler exposes the subsystem's WHEP Echo handlers. Wire them into
|
||||||
|
// the /api/v3 group (or a sibling group) via Handler.Register.
|
||||||
|
type Handler struct {
|
||||||
|
sub *Subsystem
|
||||||
|
whip *WHIPHandler // optional; enables active_publishers in /webrtc/stats
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
peersByStream map[string]map[string]*corewebrtc.Peer // streamID -> resource -> peer
|
||||||
|
peerStream map[string]string // resource -> streamID (reverse index)
|
||||||
|
count int64 // atomic
|
||||||
|
maxCapTotal int64
|
||||||
|
maxCapPerStrm int64
|
||||||
|
|
||||||
|
met *webrtcMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler wraps the subsystem in an Echo-compatible HTTP handler.
|
||||||
|
func NewHandler(s *Subsystem, maxPeers int) *Handler {
|
||||||
|
return NewHandlerWithCaps(s, maxPeers, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandlerWithCaps is NewHandler plus an explicit per-stream cap.
|
||||||
|
func NewHandlerWithCaps(s *Subsystem, maxPeers, maxPeersPerStream int) *Handler {
|
||||||
|
total := int64(maxPeers)
|
||||||
|
if total <= 0 {
|
||||||
|
total = int64(corewebrtc.DefaultConfig().MaxPeersTotal)
|
||||||
|
}
|
||||||
|
perStream := int64(maxPeersPerStream)
|
||||||
|
if perStream <= 0 {
|
||||||
|
perStream = defaultMaxPeersPerStream
|
||||||
|
}
|
||||||
|
h := &Handler{
|
||||||
|
sub: s,
|
||||||
|
peersByStream: make(map[string]map[string]*corewebrtc.Peer),
|
||||||
|
peerStream: make(map[string]string),
|
||||||
|
maxCapTotal: total,
|
||||||
|
maxCapPerStrm: perStream,
|
||||||
|
}
|
||||||
|
if s != nil {
|
||||||
|
s.SetTeardownHook(h.tearDownStreamPeers)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWHIPHandler links the WHIP ingest handler so that /webrtc/stats
|
||||||
|
// can report active_publishers. Pass nil to disable that field (returns 0).
|
||||||
|
func (h *Handler) SetWHIPHandler(wh *WHIPHandler) {
|
||||||
|
h.whip = wh
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register mounts the WHEP routes and the shared stats route on the
|
||||||
|
// provided Echo group.
|
||||||
|
//
|
||||||
|
// Routes registered:
|
||||||
|
//
|
||||||
|
// GET /webrtc/stats
|
||||||
|
// OPTIONS /whep/:id
|
||||||
|
// OPTIONS /whep/:id/:resource
|
||||||
|
// POST /whep/:id
|
||||||
|
// DELETE /whep/:id/:resource
|
||||||
|
// PATCH /whep/:id/:resource
|
||||||
|
func (h *Handler) Register(g *echo.Group) {
|
||||||
|
g.GET("/webrtc/stats", h.StatsHandler)
|
||||||
|
g.OPTIONS("/whep/:id", h.preflight)
|
||||||
|
g.OPTIONS("/whep/:id/:resource", h.preflight)
|
||||||
|
g.POST("/whep/:id", h.Subscribe)
|
||||||
|
g.DELETE("/whep/:id/:resource", h.Unsubscribe)
|
||||||
|
g.PATCH("/whep/:id/:resource", h.Trickle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsHandler handles GET /webrtc/stats. Returns a JSON snapshot of
|
||||||
|
// the current WebRTC subsystem state.
|
||||||
|
//
|
||||||
|
// @Summary WebRTC subsystem stats
|
||||||
|
// @Description Returns a live snapshot: active egress streams, subscriber peer count, ingest publisher count, and approximate UDP port usage.
|
||||||
|
// @Tags v16.16.0
|
||||||
|
// @ID webrtc-3-stats
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} WebRTCStats
|
||||||
|
// @Router /api/v3/webrtc/stats [get]
|
||||||
|
func (h *Handler) StatsHandler(c echo.Context) error {
|
||||||
|
sc := 0
|
||||||
|
if h.sub != nil {
|
||||||
|
sc = h.sub.StreamCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
var publishers int64
|
||||||
|
if h.whip != nil {
|
||||||
|
publishers = h.whip.PublisherCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := WebRTCStats{
|
||||||
|
ActiveStreams: sc,
|
||||||
|
ActivePeers: atomic.LoadInt64(&h.count),
|
||||||
|
ActivePublishers: publishers,
|
||||||
|
UDPPortsInUse: sc * 2,
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe handles POST /whep/:id.
|
||||||
|
func (h *Handler) Subscribe(c echo.Context) error {
|
||||||
|
addCORS(c)
|
||||||
|
t0 := time.Now()
|
||||||
|
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
h.recordRequest("subscribe", "", http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, "missing stream id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if atomic.LoadInt64(&h.count) >= h.maxCapTotal {
|
||||||
|
if h.met != nil {
|
||||||
|
h.met.capRejections.WithLabelValues("", "global").Inc()
|
||||||
|
}
|
||||||
|
h.recordRequest("subscribe", id, http.StatusServiceUnavailable, t0)
|
||||||
|
return c.String(http.StatusServiceUnavailable, corewebrtc.ErrPeerCapReached.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, ok := h.sub.lookup(id)
|
||||||
|
if !ok {
|
||||||
|
h.recordRequest("subscribe", id, http.StatusNotFound, t0)
|
||||||
|
return c.String(http.StatusNotFound, corewebrtc.ErrStreamNotFound.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
if int64(len(h.peersByStream[id])) >= h.maxCapPerStrm {
|
||||||
|
h.mu.Unlock()
|
||||||
|
if h.met != nil {
|
||||||
|
h.met.capRejections.WithLabelValues(id, "stream").Inc()
|
||||||
|
}
|
||||||
|
h.recordRequest("subscribe", id, http.StatusServiceUnavailable, t0)
|
||||||
|
return c.String(http.StatusServiceUnavailable, "webrtc: per-stream peer cap reached")
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(c.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
h.recordRequest("subscribe", id, http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, "read body: "+err.Error())
|
||||||
|
}
|
||||||
|
if len(body) == 0 || !strings.HasPrefix(string(body), "v=") {
|
||||||
|
h.recordRequest("subscribe", id, http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, corewebrtc.ErrInvalidSDP.Error())
|
||||||
|
}
|
||||||
|
if err := requireH264AndOpus(string(body)); err != nil {
|
||||||
|
if h.met != nil {
|
||||||
|
if cme, ok2 := err.(*codecMismatchError); ok2 {
|
||||||
|
for _, kind := range cme.missing {
|
||||||
|
h.met.codecMismatches.WithLabelValues(id, strings.ToLower(kind)).Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.recordRequest("subscribe", id, http.StatusNotAcceptable, t0)
|
||||||
|
return c.String(http.StatusNotAcceptable, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)}
|
||||||
|
peer, err := h.sub.factory.CreatePeerFromSources(c.Request().Context(), stream.video, stream.audio, offer)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case corewebrtc.ErrICETimeout:
|
||||||
|
h.recordRequest("subscribe", id, http.StatusGatewayTimeout, t0)
|
||||||
|
return c.String(http.StatusGatewayTimeout, err.Error())
|
||||||
|
case corewebrtc.ErrCodecMismatch:
|
||||||
|
h.recordRequest("subscribe", id, http.StatusNotAcceptable, t0)
|
||||||
|
return c.String(http.StatusNotAcceptable, err.Error())
|
||||||
|
default:
|
||||||
|
h.recordRequest("subscribe", id, http.StatusInternalServerError, t0)
|
||||||
|
return c.String(http.StatusInternalServerError, "create peer: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rid := peer.ResourceID()
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.peersByStream[id] == nil {
|
||||||
|
h.peersByStream[id] = make(map[string]*corewebrtc.Peer)
|
||||||
|
}
|
||||||
|
h.peersByStream[id][rid] = peer
|
||||||
|
h.peerStream[rid] = id
|
||||||
|
h.mu.Unlock()
|
||||||
|
atomic.AddInt64(&h.count, 1)
|
||||||
|
|
||||||
|
go h.awaitPeerClose(rid, peer)
|
||||||
|
go h.trackICE(id, peer, time.Now())
|
||||||
|
|
||||||
|
h.recordRequest("subscribe", id, http.StatusCreated, t0)
|
||||||
|
|
||||||
|
// RFC 9429 §4.3: emit one Link header per configured ICE server.
|
||||||
|
for _, uri := range h.sub.ICEServerURIs() {
|
||||||
|
c.Response().Header().Add("Link", "<"+uri+`>; rel="ice-server"`)
|
||||||
|
}
|
||||||
|
c.Response().Header().Set("Content-Type", "application/sdp")
|
||||||
|
c.Response().Header().Set("Location", "/whep/"+id+"/"+rid)
|
||||||
|
c.Response().Header().Set("ETag", `"`+rid+`"`)
|
||||||
|
return c.String(http.StatusCreated, peer.Answer().SDP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe handles DELETE /whep/:id/:resource.
|
||||||
|
func (h *Handler) Unsubscribe(c echo.Context) error {
|
||||||
|
addCORS(c)
|
||||||
|
t0 := time.Now()
|
||||||
|
|
||||||
|
resource := c.Param("resource")
|
||||||
|
if resource == "" {
|
||||||
|
h.recordRequest("unsubscribe", "", http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, "missing resource id")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
streamID := h.peerStream[resource]
|
||||||
|
var peer *corewebrtc.Peer
|
||||||
|
if streamID != "" {
|
||||||
|
peer = h.peersByStream[streamID][resource]
|
||||||
|
delete(h.peersByStream[streamID], resource)
|
||||||
|
if len(h.peersByStream[streamID]) == 0 {
|
||||||
|
delete(h.peersByStream, streamID)
|
||||||
|
}
|
||||||
|
delete(h.peerStream, resource)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if peer != nil {
|
||||||
|
_ = peer.Close()
|
||||||
|
}
|
||||||
|
if streamID != "" {
|
||||||
|
atomic.AddInt64(&h.count, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.recordRequest("unsubscribe", streamID, http.StatusNoContent, t0)
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trickle handles PATCH /whep/:id/:resource.
|
||||||
|
func (h *Handler) Trickle(c echo.Context) error {
|
||||||
|
addCORS(c)
|
||||||
|
t0 := time.Now()
|
||||||
|
|
||||||
|
resource := c.Param("resource")
|
||||||
|
if resource == "" {
|
||||||
|
h.recordRequest("trickle", "", http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, "missing resource id")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
streamID := h.peerStream[resource]
|
||||||
|
var peer *corewebrtc.Peer
|
||||||
|
if streamID != "" {
|
||||||
|
peer = h.peersByStream[streamID][resource]
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
if peer == nil {
|
||||||
|
h.recordRequest("trickle", streamID, http.StatusNotFound, t0)
|
||||||
|
return c.NoContent(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(c.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
h.recordRequest("trickle", streamID, http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, "read body: "+err.Error())
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, "a=candidate:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cand := strings.TrimPrefix(line, "a=")
|
||||||
|
_ = peer.AddICECandidate(webrtc.ICECandidateInit{Candidate: cand})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.recordRequest("trickle", streamID, http.StatusNoContent, t0)
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) recordRequest(route, streamID string, code int, t0 time.Time) {
|
||||||
|
if h.met == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
codeStr := fmt.Sprintf("%d", code)
|
||||||
|
h.met.whepRequests.WithLabelValues(route, codeStr, streamID).Inc()
|
||||||
|
h.met.whepRequestDuration.WithLabelValues(route, streamID).Observe(time.Since(t0).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) preflight(c echo.Context) error {
|
||||||
|
addCORS(c)
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close tears down every active peer (e.g., during Core shutdown).
|
||||||
|
func (h *Handler) Close() {
|
||||||
|
h.mu.Lock()
|
||||||
|
peers := make([]*corewebrtc.Peer, 0)
|
||||||
|
for _, m := range h.peersByStream {
|
||||||
|
for _, p := range m {
|
||||||
|
peers = append(peers, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.peersByStream = make(map[string]map[string]*corewebrtc.Peer)
|
||||||
|
h.peerStream = make(map[string]string)
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, p := range peers {
|
||||||
|
if p != nil {
|
||||||
|
_ = p.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
atomic.StoreInt64(&h.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) awaitPeerClose(resource string, peer *corewebrtc.Peer) {
|
||||||
|
<-peer.Done()
|
||||||
|
h.mu.Lock()
|
||||||
|
streamID := h.peerStream[resource]
|
||||||
|
_, present := h.peerStream[resource]
|
||||||
|
if present {
|
||||||
|
delete(h.peerStream, resource)
|
||||||
|
if streamID != "" {
|
||||||
|
delete(h.peersByStream[streamID], resource)
|
||||||
|
if len(h.peersByStream[streamID]) == 0 {
|
||||||
|
delete(h.peersByStream, streamID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
if present {
|
||||||
|
atomic.AddInt64(&h.count, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) tearDownStreamPeers(streamID string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
bucket := h.peersByStream[streamID]
|
||||||
|
hadPeers := len(bucket) > 0
|
||||||
|
peers := make([]*corewebrtc.Peer, 0, len(bucket))
|
||||||
|
for _, p := range bucket {
|
||||||
|
peers = append(peers, p)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, p := range peers {
|
||||||
|
if p != nil {
|
||||||
|
_ = p.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hadPeers && h.met != nil {
|
||||||
|
h.met.ffmpegLegFailures.WithLabelValues(streamID, "video").Inc()
|
||||||
|
h.met.ffmpegLegFailures.WithLabelValues(streamID, "audio").Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addCORS(c echo.Context) {
|
||||||
|
hh := c.Response().Header()
|
||||||
|
hh.Set("Access-Control-Allow-Origin", "*")
|
||||||
|
hh.Set("Access-Control-Allow-Methods", "POST, DELETE, PATCH, OPTIONS")
|
||||||
|
hh.Set("Access-Control-Allow-Headers", "Content-Type, Authorization, If-Match, If-None-Match")
|
||||||
|
hh.Set("Access-Control-Expose-Headers", "Location, ETag, Link")
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireH264AndOpus(sdp string) error {
|
||||||
|
lower := strings.ToLower(sdp)
|
||||||
|
hasH264 := strings.Contains(lower, "h264/90000") || strings.Contains(lower, " h264/")
|
||||||
|
hasOpus := strings.Contains(lower, "opus/48000") || strings.Contains(lower, " opus/")
|
||||||
|
if hasH264 && hasOpus {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
missing := []string{}
|
||||||
|
if !hasH264 {
|
||||||
|
missing = append(missing, "H264")
|
||||||
|
}
|
||||||
|
if !hasOpus {
|
||||||
|
missing = append(missing, "Opus")
|
||||||
|
}
|
||||||
|
return &codecMismatchError{missing: missing}
|
||||||
|
}
|
||||||
|
|
||||||
|
type codecMismatchError struct{ missing []string }
|
||||||
|
|
||||||
|
func (e *codecMismatchError) Error() string {
|
||||||
|
return "webrtc: codec mismatch -- offer is missing: " + strings.Join(e.missing, ", ")
|
||||||
|
}
|
||||||
251
app/webrtc/handler_m3_test.go
Normal file
251
app/webrtc/handler_m3_test.go
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// minimalH264OpusOffer returns an SDP offer that includes both H264
|
||||||
|
// and Opus rtpmap lines — passes requireH264AndOpus but is otherwise
|
||||||
|
// nonsense, so CreatePeerFromSources will fail downstream when this
|
||||||
|
// is wired through. Use it only in tests that don't reach the
|
||||||
|
// PeerConnection path.
|
||||||
|
func minimalH264OpusOffer() string {
|
||||||
|
return "v=0\r\n" +
|
||||||
|
"o=- 0 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\n" +
|
||||||
|
"m=video 9 UDP/TLS/RTP/SAVPF 102\r\n" +
|
||||||
|
"a=rtpmap:102 H264/90000\r\n" +
|
||||||
|
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
|
||||||
|
"a=rtpmap:111 opus/48000/2\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonH264Offer is missing H264 entirely. Triggers requireH264AndOpus.
|
||||||
|
func nonH264Offer() string {
|
||||||
|
return "v=0\r\n" +
|
||||||
|
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\n" +
|
||||||
|
"a=rtpmap:96 VP8/90000\r\n" +
|
||||||
|
"m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n" +
|
||||||
|
"a=rtpmap:111 opus/48000/2\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_Subscribe_406OnCodecMismatch verifies an offer that
|
||||||
|
// doesn't include H264 yields 406, per the design's error matrix.
|
||||||
|
func TestHandler_Subscribe_406OnCodecMismatch(t *testing.T) {
|
||||||
|
sub := newTestSubsystem(t)
|
||||||
|
sub.mu.Lock()
|
||||||
|
sub.streams["s"] = &processStream{id: "s"}
|
||||||
|
sub.mu.Unlock()
|
||||||
|
h := NewHandler(sub, 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whep/s", strings.NewReader(nonH264Offer()))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("s")
|
||||||
|
|
||||||
|
if err := h.Subscribe(c); err != nil {
|
||||||
|
t.Fatalf("Subscribe: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusNotAcceptable {
|
||||||
|
t.Fatalf("expected 406, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "H264") {
|
||||||
|
t.Errorf("body should mention missing codec: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_Subscribe_503OnTotalCap simulates the total cap being
|
||||||
|
// exhausted by another subscriber. We don't actually create real peers
|
||||||
|
// (would need a real PeerConnection); instead we pre-load the atomic
|
||||||
|
// counter so the cap check fires.
|
||||||
|
func TestHandler_Subscribe_503OnTotalCap(t *testing.T) {
|
||||||
|
sub := newTestSubsystem(t)
|
||||||
|
sub.mu.Lock()
|
||||||
|
sub.streams["s"] = &processStream{id: "s"}
|
||||||
|
sub.mu.Unlock()
|
||||||
|
h := NewHandlerWithCaps(sub, 1, 100)
|
||||||
|
atomic.StoreInt64(&h.count, 1) // simulate one in-flight peer
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whep/s", strings.NewReader(minimalH264OpusOffer()))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("s")
|
||||||
|
_ = h.Subscribe(c)
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("expected 503, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), corewebrtc.ErrPeerCapReached.Error()) {
|
||||||
|
t.Errorf("body should mention peer cap: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_Subscribe_503OnPerStreamCap simulates the per-stream cap
|
||||||
|
// being exhausted. Same trick as above but populating the per-stream
|
||||||
|
// index directly.
|
||||||
|
func TestHandler_Subscribe_503OnPerStreamCap(t *testing.T) {
|
||||||
|
sub := newTestSubsystem(t)
|
||||||
|
sub.mu.Lock()
|
||||||
|
sub.streams["s"] = &processStream{id: "s"}
|
||||||
|
sub.mu.Unlock()
|
||||||
|
h := NewHandlerWithCaps(sub, 100, 1)
|
||||||
|
// Drop a placeholder peer into the per-stream bucket so the cap
|
||||||
|
// arithmetic trips on the next subscribe.
|
||||||
|
h.mu.Lock()
|
||||||
|
h.peersByStream["s"] = map[string]*corewebrtc.Peer{"existing": nil}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whep/s", strings.NewReader(minimalH264OpusOffer()))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("s")
|
||||||
|
_ = h.Subscribe(c)
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("expected 503, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "per-stream") {
|
||||||
|
t.Errorf("body should mention per-stream cap: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_Trickle_404WhenUnknown verifies a PATCH for an unknown
|
||||||
|
// resource returns 404 (we still treat the resource as authoritative
|
||||||
|
// here; only DELETE is idempotent per spec).
|
||||||
|
func TestHandler_Trickle_404WhenUnknown(t *testing.T) {
|
||||||
|
h := NewHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/whep/id/unknown", strings.NewReader(""))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id", "resource")
|
||||||
|
c.SetParamValues("id", "unknown")
|
||||||
|
|
||||||
|
if err := h.Trickle(c); err != nil {
|
||||||
|
t.Fatalf("Trickle: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_PreflightCORS verifies OPTIONS returns 204 with the
|
||||||
|
// browser-friendly CORS headers.
|
||||||
|
func TestHandler_PreflightCORS(t *testing.T) {
|
||||||
|
h := NewHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/whep/x", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("x")
|
||||||
|
|
||||||
|
if err := h.preflight(c); err != nil {
|
||||||
|
t.Fatalf("preflight: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
hh := rec.Header()
|
||||||
|
for _, k := range []string{
|
||||||
|
"Access-Control-Allow-Origin",
|
||||||
|
"Access-Control-Allow-Methods",
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Access-Control-Expose-Headers",
|
||||||
|
} {
|
||||||
|
if hh.Get(k) == "" {
|
||||||
|
t.Errorf("missing CORS header %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_RegisterMountsAllRoutes is a sanity check that
|
||||||
|
// Handler.Register installs OPTIONS / POST / DELETE / PATCH on the
|
||||||
|
// expected paths. Echo's Group has no public route enumerator, so we
|
||||||
|
// dispatch synthetic requests and assert the right methods are
|
||||||
|
// reachable.
|
||||||
|
func TestHandler_RegisterMountsAllRoutes(t *testing.T) {
|
||||||
|
h := NewHandler(newTestSubsystem(t), 0)
|
||||||
|
e := echo.New()
|
||||||
|
g := e.Group("")
|
||||||
|
h.Register(g)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
method, path string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{http.MethodOptions, "/whep/foo", http.StatusNoContent},
|
||||||
|
{http.MethodOptions, "/whep/foo/bar", http.StatusNoContent},
|
||||||
|
{http.MethodPost, "/whep/foo", http.StatusNotFound}, // stream missing -> 404
|
||||||
|
{http.MethodDelete, "/whep/foo/bar", http.StatusNoContent},
|
||||||
|
{http.MethodPatch, "/whep/foo/bar", http.StatusNotFound},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(""))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != tc.want {
|
||||||
|
t.Errorf("%s %s: got %d want %d (%s)", tc.method, tc.path, rec.Code, tc.want, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_Close_DrainsPeers seeds a fake peer into the index and
|
||||||
|
// verifies Close clears it without panicking.
|
||||||
|
func TestHandler_Close_DrainsPeers(t *testing.T) {
|
||||||
|
h := NewHandler(newTestSubsystem(t), 0)
|
||||||
|
h.mu.Lock()
|
||||||
|
h.peersByStream["s"] = map[string]*corewebrtc.Peer{"r1": nil}
|
||||||
|
h.peerStream["r1"] = "s"
|
||||||
|
atomic.StoreInt64(&h.count, 1)
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
h.Close()
|
||||||
|
if got := atomic.LoadInt64(&h.count); got != 0 {
|
||||||
|
t.Errorf("count after Close = %d, want 0", got)
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
if len(h.peersByStream) != 0 || len(h.peerStream) != 0 {
|
||||||
|
t.Errorf("indexes not cleared")
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireH264AndOpus covers the SDP scanner's positive +
|
||||||
|
// negative cases.
|
||||||
|
func TestRequireH264AndOpus(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
sdp string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"both", minimalH264OpusOffer(), true},
|
||||||
|
{"missing h264", nonH264Offer(), false},
|
||||||
|
{"missing opus", "m=video 9 UDP/TLS/RTP/SAVPF 102\r\na=rtpmap:102 H264/90000\r\n", false},
|
||||||
|
{"capitalized", "a=rtpmap:111 OPUS/48000\r\na=rtpmap:102 H264/90000", true},
|
||||||
|
{"empty", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
err := requireH264AndOpus(c.sdp)
|
||||||
|
if c.ok && err != nil {
|
||||||
|
t.Errorf("expected ok, got %v", err)
|
||||||
|
}
|
||||||
|
if !c.ok && err == nil {
|
||||||
|
t.Errorf("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/webrtc/handler_stats_test.go
Normal file
127
app/webrtc/handler_stats_test.go
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestStatsHandler_EmptySubsystem verifies that GET /webrtc/stats returns
|
||||||
|
// a well-formed JSON body with all-zero counts when no streams or peers
|
||||||
|
// are active and no WHIP handler is linked.
|
||||||
|
func TestStatsHandler_EmptySubsystem(t *testing.T) {
|
||||||
|
h := NewHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webrtc/stats", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
|
if err := h.StatsHandler(c); err != nil {
|
||||||
|
t.Fatalf("StatsHandler returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats WebRTCStats
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &stats); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v\nbody: %s", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if stats.ActiveStreams != 0 {
|
||||||
|
t.Errorf("ActiveStreams: want 0, got %d", stats.ActiveStreams)
|
||||||
|
}
|
||||||
|
if stats.ActivePeers != 0 {
|
||||||
|
t.Errorf("ActivePeers: want 0, got %d", stats.ActivePeers)
|
||||||
|
}
|
||||||
|
if stats.ActivePublishers != 0 {
|
||||||
|
t.Errorf("ActivePublishers: want 0, got %d", stats.ActivePublishers)
|
||||||
|
}
|
||||||
|
if stats.UDPPortsInUse != 0 {
|
||||||
|
t.Errorf("UDPPortsInUse: want 0, got %d", stats.UDPPortsInUse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatsHandler_WithWHIPHandler verifies that SetWHIPHandler links the
|
||||||
|
// WHIP publisher count into the stats response.
|
||||||
|
func TestStatsHandler_WithWHIPHandler(t *testing.T) {
|
||||||
|
sub := newTestSubsystem(t)
|
||||||
|
h := NewHandler(sub, 0)
|
||||||
|
|
||||||
|
// Link a real WHIPHandler so that StatsHandler calls PublisherCount().
|
||||||
|
wh := NewWHIPHandler(sub, 0)
|
||||||
|
h.SetWHIPHandler(wh)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webrtc/stats", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
|
if err := h.StatsHandler(c); err != nil {
|
||||||
|
t.Fatalf("StatsHandler returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats WebRTCStats
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &stats); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
// With no active publishers the count should be 0 — validates the
|
||||||
|
// link does not panic and that PublisherCount() is being called.
|
||||||
|
if stats.ActivePublishers != 0 {
|
||||||
|
t.Errorf("ActivePublishers: want 0, got %d", stats.ActivePublishers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatsHandler_NilSub verifies that a nil Subsystem (possible during
|
||||||
|
// early wiring) does not panic and returns zeros.
|
||||||
|
func TestStatsHandler_NilSub(t *testing.T) {
|
||||||
|
h := NewHandler(nil, 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webrtc/stats", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
|
if err := h.StatsHandler(c); err != nil {
|
||||||
|
t.Fatalf("StatsHandler returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats WebRTCStats
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &stats); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if stats.ActiveStreams != 0 || stats.UDPPortsInUse != 0 {
|
||||||
|
t.Errorf("expected all zeros with nil sub, got %+v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatsHandler_JSONFieldNames verifies the JSON key names match the
|
||||||
|
// contract defined in the issue so consumer scripts don't break.
|
||||||
|
func TestStatsHandler_JSONFieldNames(t *testing.T) {
|
||||||
|
h := NewHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webrtc/stats", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
|
if err := h.StatsHandler(c); err != nil {
|
||||||
|
t.Fatalf("StatsHandler returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
|
||||||
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"active_streams", "active_peers", "active_publishers", "udp_ports_in_use"} {
|
||||||
|
if _, ok := raw[key]; !ok {
|
||||||
|
t.Errorf("JSON response missing required field %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
179
app/webrtc/handler_test.go
Normal file
179
app/webrtc/handler_test.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
"github.com/datarhei/core/v16/config"
|
||||||
|
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestSubsystem(t *testing.T) *Subsystem {
|
||||||
|
t.Helper()
|
||||||
|
s, err := New(config.DataWebRTC{Enable: true}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_Subscribe_404WhenStreamMissing verifies the WHEP POST
|
||||||
|
// returns 404 when no process has registered a stream for that id.
|
||||||
|
func TestHandler_Subscribe_404WhenStreamMissing(t *testing.T) {
|
||||||
|
h := NewHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whep/ghost", strings.NewReader("v=0\r\n"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("ghost")
|
||||||
|
|
||||||
|
if err := h.Subscribe(c); err != nil {
|
||||||
|
t.Fatalf("Subscribe returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_Subscribe_400OnEmptyBody verifies invalid SDP offers
|
||||||
|
// short-circuit before any peer is created. Requires a registered
|
||||||
|
// stream so lookup doesn't 404 first.
|
||||||
|
func TestHandler_Subscribe_400OnEmptyBody(t *testing.T) {
|
||||||
|
sub := newTestSubsystem(t)
|
||||||
|
// Register a dummy stream so the handler reaches body validation.
|
||||||
|
sub.mu.Lock()
|
||||||
|
sub.streams["probe"] = &processStream{id: "probe"} // video/audio nil is fine here — we never get past body parse
|
||||||
|
sub.mu.Unlock()
|
||||||
|
|
||||||
|
h := NewHandler(sub, 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whep/probe", strings.NewReader(""))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("probe")
|
||||||
|
|
||||||
|
if err := h.Subscribe(c); err != nil {
|
||||||
|
t.Fatalf("Subscribe returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandler_Unsubscribe_204WhenUnknown verifies a DELETE with an
|
||||||
|
// unknown resource id returns 204 (idempotent), per the WHEP spec
|
||||||
|
// and the M2/M3 design's error matrix. Pre-M3 this returned 404; the
|
||||||
|
// updated semantics let clients re-issue DELETE without erroring.
|
||||||
|
func TestHandler_Unsubscribe_204WhenUnknown(t *testing.T) {
|
||||||
|
h := NewHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/whep/id/unknown", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id", "resource")
|
||||||
|
c.SetParamValues("id", "unknown")
|
||||||
|
|
||||||
|
if err := h.Unsubscribe(c); err != nil {
|
||||||
|
t.Fatalf("Unsubscribe returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubsystem_ICEServerURIs_ReturnsConfiguredURIs verifies that
|
||||||
|
// ICEServerURIs() surfaces the URIs from the core config — the same
|
||||||
|
// values Pion uses when building its PeerConnection. A default-config
|
||||||
|
// subsystem must return at least the two bundled STUN servers.
|
||||||
|
func TestSubsystem_ICEServerURIs_ReturnsConfiguredURIs(t *testing.T) {
|
||||||
|
sub := newTestSubsystem(t)
|
||||||
|
uris := sub.ICEServerURIs()
|
||||||
|
|
||||||
|
defaultURIs := corewebrtc.DefaultConfig().ICEServers
|
||||||
|
if len(uris) != len(defaultURIs) {
|
||||||
|
t.Fatalf("expected %d ICE server URIs, got %d", len(defaultURIs), len(uris))
|
||||||
|
}
|
||||||
|
for i, want := range defaultURIs {
|
||||||
|
if uris[i] != want {
|
||||||
|
t.Errorf("ICEServerURIs[%d]: want %q, got %q", i, want, uris[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubsystem_ICEServers_OperatorOverride verifies that when the operator
|
||||||
|
// supplies ICEServers via config (CORE_WEBRTC_ICE_SERVERS), those URIs
|
||||||
|
// completely replace the built-in STUN defaults rather than being appended.
|
||||||
|
// This exercises the override branch added in subsystem.New for issue #23.
|
||||||
|
func TestSubsystem_ICEServers_OperatorOverride(t *testing.T) {
|
||||||
|
custom := []string{
|
||||||
|
"stun:stun.example.com:3478",
|
||||||
|
"turn:user:secret@turn.example.com:3478",
|
||||||
|
}
|
||||||
|
sub, err := New(config.DataWebRTC{
|
||||||
|
Enable: true,
|
||||||
|
ICEServers: custom,
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New with custom ICEServers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uris := sub.ICEServerURIs()
|
||||||
|
|
||||||
|
if len(uris) != len(custom) {
|
||||||
|
t.Fatalf("expected %d URIs (custom), got %d: %v", len(custom), len(uris), uris)
|
||||||
|
}
|
||||||
|
for i, want := range custom {
|
||||||
|
if uris[i] != want {
|
||||||
|
t.Errorf("ICEServerURIs[%d]: want %q, got %q", i, want, uris[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the built-in defaults are NOT present.
|
||||||
|
defaults := corewebrtc.DefaultConfig().ICEServers
|
||||||
|
for _, def := range defaults {
|
||||||
|
for _, got := range uris {
|
||||||
|
if got == def {
|
||||||
|
t.Errorf("built-in default URI %q should have been replaced but was found in override list", def)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddCORS_ExposesLinkHeader verifies that CORS preflight responses
|
||||||
|
// include "Link" in Access-Control-Expose-Headers so browsers can read
|
||||||
|
// the RFC 9429 §4.3 Link headers returned on the 201 Subscribe response.
|
||||||
|
func TestAddCORS_ExposesLinkHeader(t *testing.T) {
|
||||||
|
h := NewHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/whep/any", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("any")
|
||||||
|
|
||||||
|
if err := h.preflight(c); err != nil {
|
||||||
|
t.Fatalf("preflight returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expose := rec.Header().Get("Access-Control-Expose-Headers")
|
||||||
|
if !strings.Contains(expose, "Link") {
|
||||||
|
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Link'", expose)
|
||||||
|
}
|
||||||
|
if !strings.Contains(expose, "Location") {
|
||||||
|
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Location'", expose)
|
||||||
|
}
|
||||||
|
if !strings.Contains(expose, "ETag") {
|
||||||
|
t.Errorf("Access-Control-Expose-Headers %q does not contain 'ETag'", expose)
|
||||||
|
}
|
||||||
|
}
|
||||||
275
app/webrtc/integration_test.go
Normal file
275
app/webrtc/integration_test.go
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
pionwebrtc "github.com/pion/webrtc/v4"
|
||||||
|
|
||||||
|
"github.com/datarhei/core/v16/config"
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegration_SyntheticRTPToWHEP wires the full M2 subsystem end to
|
||||||
|
// end using in-process UDP sockets and a Pion WHEP subscriber:
|
||||||
|
//
|
||||||
|
// 1. Build a Subsystem and Handler (no Core/HTTP server needed).
|
||||||
|
// 2. Fire the OnStart hook directly — this allocates two adjacent
|
||||||
|
// loopback UDP ports and registers a process stream.
|
||||||
|
// 3. Extract the allocated video + audio ports from the returned
|
||||||
|
// ConfigIO legs.
|
||||||
|
// 4. Build a Pion PeerConnection (recvonly video + audio) and POST its
|
||||||
|
// SDP offer through the Echo Handler.
|
||||||
|
// 5. Plumb the returned answer into the PC.
|
||||||
|
// 6. Spray synthetic RTP packets at both UDP ports.
|
||||||
|
// 7. Assert that the PC sees OnTrack for both kinds and at least one
|
||||||
|
// RTP packet arrives on each track inside the timeout budget.
|
||||||
|
//
|
||||||
|
// This is the single highest-leverage integration test for M2 — it
|
||||||
|
// catches the whole stack: port allocation, hook contract, two-track
|
||||||
|
// forwarding, WHEP handshake, and JWT-mounted routing doesn't interfere
|
||||||
|
// with the handler's internal flow.
|
||||||
|
func TestIntegration_SyntheticRTPToWHEP(t *testing.T) {
|
||||||
|
// --- 1. Construct subsystem + handler. ---
|
||||||
|
sub, err := New(config.DataWebRTC{Enable: true}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("subsystem New: %v", err)
|
||||||
|
}
|
||||||
|
defer sub.Close()
|
||||||
|
|
||||||
|
h := NewHandler(sub, 0)
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
|
// --- 2. Fire OnStart directly to populate the stream registry
|
||||||
|
// and allocate ports. We bypass the restream manager by
|
||||||
|
// invoking the hook the subsystem would have registered.
|
||||||
|
processID := "integration-probe"
|
||||||
|
legs, err := sub.onProcessStart(processID, &appcfg.Config{
|
||||||
|
ID: processID,
|
||||||
|
WebRTC: appcfg.ConfigWebRTC{
|
||||||
|
Enabled: true,
|
||||||
|
VideoPT: 102,
|
||||||
|
AudioPT: 111,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("onProcessStart: %v", err)
|
||||||
|
}
|
||||||
|
if len(legs) != 2 {
|
||||||
|
t.Fatalf("expected 2 output legs, got %d", len(legs))
|
||||||
|
}
|
||||||
|
defer sub.onProcessStop(processID)
|
||||||
|
|
||||||
|
// --- 3. Extract UDP ports from leg addresses. ---
|
||||||
|
videoPort, err := portFromLegAddress(legs[0].Address)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("video leg address %q: %v", legs[0].Address, err)
|
||||||
|
}
|
||||||
|
audioPort, err := portFromLegAddress(legs[1].Address)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("audio leg address %q: %v", legs[1].Address, err)
|
||||||
|
}
|
||||||
|
if audioPort != videoPort+1 {
|
||||||
|
t.Fatalf("expected adjacent ports, got video=%d audio=%d", videoPort, audioPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Mount the handler in an Echo server (httptest) so we
|
||||||
|
// exercise the real route registration path. ---
|
||||||
|
e := echo.New()
|
||||||
|
g := e.Group("")
|
||||||
|
h.Register(g)
|
||||||
|
srv := httptest.NewServer(e)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// --- 5. Build the WHEP subscriber PeerConnection. ---
|
||||||
|
me := &pionwebrtc.MediaEngine{}
|
||||||
|
if err := me.RegisterDefaultCodecs(); err != nil {
|
||||||
|
t.Fatalf("register default codecs: %v", err)
|
||||||
|
}
|
||||||
|
api := pionwebrtc.NewAPI(pionwebrtc.WithMediaEngine(me))
|
||||||
|
pc, err := api.NewPeerConnection(pionwebrtc.Configuration{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new PC: %v", err)
|
||||||
|
}
|
||||||
|
defer pc.Close()
|
||||||
|
|
||||||
|
if _, err := pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeVideo,
|
||||||
|
pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||||
|
t.Fatalf("add video transceiver: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeAudio,
|
||||||
|
pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||||
|
t.Fatalf("add audio transceiver: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal when each track has produced its first RTP packet.
|
||||||
|
var videoGot, audioGot atomic.Bool
|
||||||
|
videoCh := make(chan struct{}, 1)
|
||||||
|
audioCh := make(chan struct{}, 1)
|
||||||
|
|
||||||
|
pc.OnTrack(func(tr *pionwebrtc.TrackRemote, _ *pionwebrtc.RTPReceiver) {
|
||||||
|
// Read a single RTP packet and signal the appropriate channel.
|
||||||
|
go func() {
|
||||||
|
if _, _, readErr := tr.ReadRTP(); readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch tr.Kind() {
|
||||||
|
case pionwebrtc.RTPCodecTypeVideo:
|
||||||
|
if videoGot.CompareAndSwap(false, true) {
|
||||||
|
videoCh <- struct{}{}
|
||||||
|
}
|
||||||
|
case pionwebrtc.RTPCodecTypeAudio:
|
||||||
|
if audioGot.CompareAndSwap(false, true) {
|
||||||
|
audioCh <- struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
|
offer, err := pc.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create offer: %v", err)
|
||||||
|
}
|
||||||
|
gatherLocal := pionwebrtc.GatheringCompletePromise(pc)
|
||||||
|
if err := pc.SetLocalDescription(offer); err != nil {
|
||||||
|
t.Fatalf("set local: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-gatherLocal:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatalf("local ICE gathering timeout")
|
||||||
|
}
|
||||||
|
offerSDP := pc.LocalDescription().SDP
|
||||||
|
|
||||||
|
// --- 6. POST the offer to the WHEP endpoint. ---
|
||||||
|
resp, err := http.Post(srv.URL+"/whep/"+processID, "application/sdp",
|
||||||
|
strings.NewReader(offerSDP))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST /whep: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("POST /whep status = %d, want 201", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
answerBuf := make([]byte, 1<<15)
|
||||||
|
n, _ := resp.Body.Read(answerBuf)
|
||||||
|
answerSDP := string(answerBuf[:n])
|
||||||
|
if !strings.Contains(answerSDP, "v=0") {
|
||||||
|
t.Fatalf("answer SDP malformed: %q", answerSDP)
|
||||||
|
}
|
||||||
|
|
||||||
|
loc := resp.Header.Get("Location")
|
||||||
|
if loc == "" || !strings.HasPrefix(loc, "/whep/"+processID+"/") {
|
||||||
|
t.Fatalf("Location header bad: %q", loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pc.SetRemoteDescription(pionwebrtc.SessionDescription{
|
||||||
|
Type: pionwebrtc.SDPTypeAnswer,
|
||||||
|
SDP: answerSDP,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("set remote: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 7. Spray synthetic RTP into both UDP ports. ---
|
||||||
|
videoSender, err := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(videoPort))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial video: %v", err)
|
||||||
|
}
|
||||||
|
defer videoSender.Close()
|
||||||
|
audioSender, err := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(audioPort))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial audio: %v", err)
|
||||||
|
}
|
||||||
|
defer audioSender.Close()
|
||||||
|
|
||||||
|
stopSend := make(chan struct{})
|
||||||
|
defer close(stopSend)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(20 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
var vseq, aseq uint16
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stopSend:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
vseq++
|
||||||
|
aseq++
|
||||||
|
vpkt := synthRTPPacket(102, vseq, uint32(vseq)*3000, 0xcafe0000, []byte("vvvvvvvv"))
|
||||||
|
_, _ = videoSender.Write(vpkt)
|
||||||
|
apkt := synthRTPPacket(111, aseq, uint32(aseq)*960, 0xbeef0000, []byte("aaaaaaaa"))
|
||||||
|
_, _ = audioSender.Write(apkt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// --- 8. Wait for both tracks' first packet. ---
|
||||||
|
waitFor := func(name string, ch chan struct{}) {
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
// success
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatalf("%s: no RTP received via WHEP within 10s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitFor("video", videoCh)
|
||||||
|
waitFor("audio", audioCh)
|
||||||
|
|
||||||
|
// Sanity: the Location path should DELETE cleanly.
|
||||||
|
parsedLoc, err := url.Parse(loc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse Location: %v", err)
|
||||||
|
}
|
||||||
|
deleteReq, _ := http.NewRequest(http.MethodDelete, srv.URL+parsedLoc.Path, nil)
|
||||||
|
delResp, err := http.DefaultClient.Do(deleteReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DELETE /whep/.../resource: %v", err)
|
||||||
|
}
|
||||||
|
_ = delResp.Body.Close()
|
||||||
|
if delResp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("DELETE status = %d, want 204", delResp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// portFromLegAddress pulls the UDP port out of a leg Address like
|
||||||
|
// "udp://127.0.0.1:49200?pkt_size=1316".
|
||||||
|
func portFromLegAddress(addr string) (int, error) {
|
||||||
|
re := regexp.MustCompile(`udp://[^:]+:(\d+)`)
|
||||||
|
m := re.FindStringSubmatch(addr)
|
||||||
|
if len(m) != 2 {
|
||||||
|
return 0, &portParseError{addr: addr}
|
||||||
|
}
|
||||||
|
return strconv.Atoi(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
type portParseError struct{ addr string }
|
||||||
|
|
||||||
|
func (e *portParseError) Error() string { return "cannot parse port from " + e.addr }
|
||||||
|
|
||||||
|
// synthRTPPacket builds a minimal valid RTP packet for injection testing.
|
||||||
|
func synthRTPPacket(pt uint8, seq uint16, ts uint32, ssrc uint32, payload []byte) []byte {
|
||||||
|
p := &rtp.Packet{
|
||||||
|
Header: rtp.Header{
|
||||||
|
Version: 2,
|
||||||
|
PayloadType: pt,
|
||||||
|
SequenceNumber: seq,
|
||||||
|
Timestamp: ts,
|
||||||
|
SSRC: ssrc,
|
||||||
|
Marker: false,
|
||||||
|
},
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
b, _ := p.Marshal()
|
||||||
|
return b
|
||||||
|
}
|
||||||
289
app/webrtc/latency_test.go
Normal file
289
app/webrtc/latency_test.go
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
//go:build latency
|
||||||
|
// +build latency
|
||||||
|
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
// Server-hop latency benchmark. Build-tagged off the default test
|
||||||
|
// suite because it's a load test, not a unit test:
|
||||||
|
//
|
||||||
|
// go test -tags latency -timeout 60s -count=1 ./app/webrtc/... \
|
||||||
|
// -run TestLatencyServerHop -v
|
||||||
|
//
|
||||||
|
// What this measures
|
||||||
|
// -------------------
|
||||||
|
// RTP packet arrival latency end-to-end through the Core WebRTC
|
||||||
|
// egress path:
|
||||||
|
//
|
||||||
|
// publisher (this test) ── UDP ──▶ corewebrtc.Source
|
||||||
|
// │
|
||||||
|
// ▼ subscriber fan-out
|
||||||
|
// Peer ── ICE+SRTP ──▶ Pion subscriber
|
||||||
|
// │
|
||||||
|
// ▼ ReadRTP
|
||||||
|
//
|
||||||
|
// What it does NOT measure (and why)
|
||||||
|
// ----------------------------------
|
||||||
|
// The design (docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md
|
||||||
|
// §7) calls for true glass-to-glass latency: publisher embeds a frame
|
||||||
|
// counter via FFmpeg drawtext, subscriber decodes H.264 and samples a
|
||||||
|
// pixel bounding box, diff is the e2e number. Implementing that in
|
||||||
|
// pure Go would require a cgo H.264 decoder or an FFmpeg-as-sidecar
|
||||||
|
// pipe. Both are heavier than the ~150 LOC this test costs and add a
|
||||||
|
// dependency that doesn't pay off for the dominant CI question
|
||||||
|
// ("did anybody regress the server hop?"). Encode/decode latency
|
||||||
|
// is roughly fixed by the codec stack and isn't something Core code
|
||||||
|
// changes can move.
|
||||||
|
//
|
||||||
|
// We sidestep the decoder by embedding a wall-clock timestamp in the
|
||||||
|
// RTP packet payload (first 8 bytes, big-endian UnixNano). The
|
||||||
|
// subscriber reads it via track.ReadRTP() and diffs against time.Now()
|
||||||
|
// at arrival. This gives us a true server-hop measurement that
|
||||||
|
// exercises:
|
||||||
|
//
|
||||||
|
// - Source.readLoop unmarshalling
|
||||||
|
// - Source.subscribers fan-out
|
||||||
|
// - forwardRTPSplit goroutine
|
||||||
|
// - Pion's TrackLocalStaticRTP.WriteRTP
|
||||||
|
// - DTLS-SRTP encrypt
|
||||||
|
// - ICE socket write
|
||||||
|
// - DTLS-SRTP decrypt at the subscriber
|
||||||
|
// - subscriber TrackRemote.ReadRTP unmarshal
|
||||||
|
//
|
||||||
|
// Threshold
|
||||||
|
// ---------
|
||||||
|
// p95 < 50ms on a quiet Linux host (loopback + Pion). The CI runner
|
||||||
|
// is shared so we set the gate at 200ms — generous, but a regression
|
||||||
|
// that crosses it indicates a genuine slowdown rather than runner
|
||||||
|
// noise.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
pionwebrtc "github.com/pion/webrtc/v4"
|
||||||
|
|
||||||
|
"github.com/datarhei/core/v16/config"
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
latencyPackets = 1000
|
||||||
|
latencyRateHz = 60
|
||||||
|
latencyP95Budget = 50 * time.Millisecond // CI gate; p95 is sub-ms locally
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLatencyServerHop(t *testing.T) {
|
||||||
|
sub, err := New(config.DataWebRTC{Enable: true}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("subsystem New: %v", err)
|
||||||
|
}
|
||||||
|
defer sub.Close()
|
||||||
|
|
||||||
|
h := NewHandler(sub, 0)
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
|
processID := "latency-probe"
|
||||||
|
legs, err := sub.onProcessStart(processID, &appcfg.Config{
|
||||||
|
ID: processID,
|
||||||
|
WebRTC: appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("onProcessStart: %v", err)
|
||||||
|
}
|
||||||
|
defer sub.onProcessStop(processID)
|
||||||
|
|
||||||
|
videoPort, err := portFromLegAddress(legs[0].Address)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("video port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
g := e.Group("")
|
||||||
|
h.Register(g)
|
||||||
|
srv := httptest.NewServer(e)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
pc, samples := buildSubscriber(t, srv.URL, processID)
|
||||||
|
defer pc.Close()
|
||||||
|
|
||||||
|
// Sender: synthetic RTP packets with UnixNano in the first 8 bytes
|
||||||
|
// of payload. We only stream video (latency on audio is identical
|
||||||
|
// in this path).
|
||||||
|
conn, err := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(videoPort))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
tick := time.NewTicker(time.Second / latencyRateHz)
|
||||||
|
defer tick.Stop()
|
||||||
|
var seq uint16
|
||||||
|
for i := 0; i < latencyPackets; i++ {
|
||||||
|
<-tick.C
|
||||||
|
seq++
|
||||||
|
payload := make([]byte, 200)
|
||||||
|
binary.BigEndian.PutUint64(payload, uint64(time.Now().UnixNano()))
|
||||||
|
pkt := &rtp.Packet{
|
||||||
|
Header: rtp.Header{
|
||||||
|
Version: 2,
|
||||||
|
PayloadType: 102,
|
||||||
|
SequenceNumber: seq,
|
||||||
|
Timestamp: uint32(seq) * 3000,
|
||||||
|
SSRC: 0xdeadbeef,
|
||||||
|
},
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
b, _ := pkt.Marshal()
|
||||||
|
_, _ = conn.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the receiver to drain — give it 2× the send window.
|
||||||
|
deadline := time.After(time.Duration(latencyPackets*2) * time.Second / latencyRateHz)
|
||||||
|
for {
|
||||||
|
if int(samples.Load()) >= latencyPackets-50 {
|
||||||
|
break // 5% tolerance for in-flight loss; loopback rarely loses
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
break
|
||||||
|
case <-time.After(10 * time.Millisecond):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
got := samples.Drain()
|
||||||
|
if len(got) < latencyPackets/2 {
|
||||||
|
t.Fatalf("only %d/%d samples received — too lossy to gate", len(got), latencyPackets)
|
||||||
|
}
|
||||||
|
p50, p95, p99 := percentile(got, 50), percentile(got, 95), percentile(got, 99)
|
||||||
|
t.Logf("latency over %d samples: p50=%v p95=%v p99=%v",
|
||||||
|
len(got), p50, p95, p99)
|
||||||
|
|
||||||
|
if p95 > latencyP95Budget {
|
||||||
|
t.Fatalf("p95 latency %v exceeds budget %v (%d samples)",
|
||||||
|
p95, latencyP95Budget, len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// latencySamples is a goroutine-safe append-only sample buffer. The
|
||||||
|
// receiver goroutine appends; the test goroutine reads via Drain
|
||||||
|
// after the run completes.
|
||||||
|
type latencySamples struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
samples []time.Duration
|
||||||
|
count atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *latencySamples) Add(d time.Duration) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.samples = append(s.samples, d)
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.count.Add(1)
|
||||||
|
}
|
||||||
|
func (s *latencySamples) Load() int32 { return s.count.Load() }
|
||||||
|
func (s *latencySamples) Drain() []time.Duration {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]time.Duration, len(s.samples))
|
||||||
|
copy(out, s.samples)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSubscriber spins up a Pion peer, performs the WHEP handshake,
|
||||||
|
// returns a samples buffer that latencyArrival fills as packets land.
|
||||||
|
func buildSubscriber(t *testing.T, srvURL, processID string) (*pionwebrtc.PeerConnection, *latencySamples) {
|
||||||
|
t.Helper()
|
||||||
|
me := &pionwebrtc.MediaEngine{}
|
||||||
|
if err := me.RegisterDefaultCodecs(); err != nil {
|
||||||
|
t.Fatalf("register codecs: %v", err)
|
||||||
|
}
|
||||||
|
api := pionwebrtc.NewAPI(pionwebrtc.WithMediaEngine(me))
|
||||||
|
pc, err := api.NewPeerConnection(pionwebrtc.Configuration{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new PC: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeVideo,
|
||||||
|
pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||||
|
t.Fatalf("add video tx: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeAudio,
|
||||||
|
pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||||
|
t.Fatalf("add audio tx: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
samples := &latencySamples{}
|
||||||
|
pc.OnTrack(func(tr *pionwebrtc.TrackRemote, _ *pionwebrtc.RTPReceiver) {
|
||||||
|
if tr.Kind() != pionwebrtc.RTPCodecTypeVideo {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
p, _, err := tr.ReadRTP()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(p.Payload) < 8 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sentNs := int64(binary.BigEndian.Uint64(p.Payload[:8]))
|
||||||
|
samples.Add(time.Duration(time.Now().UnixNano() - sentNs))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
|
offer, err := pc.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("offer: %v", err)
|
||||||
|
}
|
||||||
|
gather := pionwebrtc.GatheringCompletePromise(pc)
|
||||||
|
if err := pc.SetLocalDescription(offer); err != nil {
|
||||||
|
t.Fatalf("set local: %v", err)
|
||||||
|
}
|
||||||
|
<-gather
|
||||||
|
|
||||||
|
resp, err := http.Post(srvURL+"/whep/"+processID, "application/sdp",
|
||||||
|
strings.NewReader(pc.LocalDescription().SDP))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("POST /whep: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("WHEP status = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 1<<15)
|
||||||
|
n, _ := resp.Body.Read(buf)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err := pc.SetRemoteDescription(pionwebrtc.SessionDescription{
|
||||||
|
Type: pionwebrtc.SDPTypeAnswer,
|
||||||
|
SDP: string(buf[:n]),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("set remote: %v", err)
|
||||||
|
}
|
||||||
|
// Give ICE a moment to settle before the publisher fires.
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
return pc, samples
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentile(samples []time.Duration, p int) time.Duration {
|
||||||
|
if len(samples) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
|
||||||
|
idx := (p * len(samples)) / 100
|
||||||
|
if idx >= len(samples) {
|
||||||
|
idx = len(samples) - 1
|
||||||
|
}
|
||||||
|
return samples[idx]
|
||||||
|
}
|
||||||
|
|
||||||
207
app/webrtc/lifecycle.go
Normal file
207
app/webrtc/lifecycle.go
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default payload types. These match the values the M1 PoC / M2
|
||||||
|
// forwarder expects (H.264 = 102, Opus = 111). Operators can override
|
||||||
|
// per-process via the restream Config.
|
||||||
|
const (
|
||||||
|
defaultVideoPT = 102
|
||||||
|
defaultAudioPT = 111
|
||||||
|
)
|
||||||
|
|
||||||
|
// allocAttempts is the maximum number of times onProcessStart will
|
||||||
|
// retry port allocation to find two adjacent free loopback UDP ports.
|
||||||
|
// The kernel sometimes hands us an odd port for video, making V+1
|
||||||
|
// unavailable — in practice 2-3 retries is plenty.
|
||||||
|
const allocAttempts = 10
|
||||||
|
|
||||||
|
// onProcessStart is registered as the restream ProcessStartHook. It
|
||||||
|
// fires with the restream write lock held, just before FFmpeg Start.
|
||||||
|
//
|
||||||
|
// When the per-process WebRTC config is disabled, it returns (nil, nil)
|
||||||
|
// — FFmpeg starts normally without any extra output legs. When enabled
|
||||||
|
// it:
|
||||||
|
//
|
||||||
|
// 1. Allocates two adjacent loopback UDP ports (video on V, audio on V+1).
|
||||||
|
// 2. Binds Pion Sources on those ports and registers the pair under
|
||||||
|
// the process ID.
|
||||||
|
// 3. Builds the two RTP ConfigIO output legs via BuildArgs and returns
|
||||||
|
// them to the restream manager, which appends them to cfg.Output
|
||||||
|
// and rebuilds the FFmpeg command.
|
||||||
|
//
|
||||||
|
// Any error aborts the process start. On partial allocation failure,
|
||||||
|
// all allocated resources are cleaned up before returning.
|
||||||
|
func (s *Subsystem) onProcessStart(id string, cfg *appcfg.Config) ([]appcfg.ConfigIO, error) {
|
||||||
|
if cfg == nil || !cfg.WebRTC.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize PTs — zero values mean "use defaults".
|
||||||
|
wcfg := cfg.WebRTC
|
||||||
|
if wcfg.VideoPT == 0 {
|
||||||
|
wcfg.VideoPT = defaultVideoPT
|
||||||
|
}
|
||||||
|
if wcfg.AudioPT == 0 {
|
||||||
|
wcfg.AudioPT = defaultAudioPT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuse to re-register — the restream manager should never
|
||||||
|
// double-start a process but defensive check avoids a silent
|
||||||
|
// Source leak if it does.
|
||||||
|
s.mu.Lock()
|
||||||
|
if _, exists := s.streams[id]; exists {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("webrtc: process %q already has an active stream", id)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
videoPort, videoSrc, audioSrc, err := s.allocAdjacentPair(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the UDP readers so they're draining packets the moment
|
||||||
|
// FFmpeg comes online.
|
||||||
|
videoSrc.Start()
|
||||||
|
audioSrc.Start()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.streams[id] = &processStream{id: id, video: videoSrc, audio: audioSrc}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.logger.WithFields(map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"video_port": videoPort,
|
||||||
|
"audio_port": videoPort + 1,
|
||||||
|
"video_pt": wcfg.VideoPT,
|
||||||
|
"audio_pt": wcfg.AudioPT,
|
||||||
|
}).Info().Log("WebRTC egress registered for process")
|
||||||
|
|
||||||
|
args := BuildArgs(wcfg, videoPort)
|
||||||
|
return splitRTPLegs(args), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onProcessStop is registered as the restream ProcessStopHook. It
|
||||||
|
// fires with the restream write lock held, just after FFmpeg has been
|
||||||
|
// stopped. It tears down the per-process Sources (which closes their
|
||||||
|
// sockets and hangs up any subscribed peers).
|
||||||
|
func (s *Subsystem) onProcessStop(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
st, ok := s.streams[id]
|
||||||
|
teardown := s.teardown
|
||||||
|
if ok {
|
||||||
|
delete(s.streams, id)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast first, so any subscribed peers get torn down while
|
||||||
|
// the streamID is still meaningful. The handler's tearDownStreamPeers
|
||||||
|
// drives each Peer.Close() which in turn unsubscribes from the
|
||||||
|
// Sources we're about to shut down — preventing a "subscribers fan
|
||||||
|
// out into a closed channel" race.
|
||||||
|
if teardown != nil {
|
||||||
|
teardown(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.video != nil {
|
||||||
|
_ = st.video.Close()
|
||||||
|
}
|
||||||
|
if st.audio != nil {
|
||||||
|
_ = st.audio.Close()
|
||||||
|
}
|
||||||
|
s.logger.WithField("id", id).Info().Log("WebRTC egress torn down for process")
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocAdjacentPair finds a pair of free loopback UDP ports (V, V+1)
|
||||||
|
// and binds a Source to each. It retries up to allocAttempts times
|
||||||
|
// because the kernel's ephemeral picker may hand us a port whose +1
|
||||||
|
// neighbor is already taken. Caller owns the returned Sources; on
|
||||||
|
// error all partial allocations are cleaned up.
|
||||||
|
func (s *Subsystem) allocAdjacentPair(id string) (int, *corewebrtc.Source, *corewebrtc.Source, error) {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < allocAttempts; attempt++ {
|
||||||
|
port, err := Alloc()
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
videoSrc, err := corewebrtc.NewSourceOn(id, "127.0.0.1", port)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Activate IDR keyframe caching on the video source so that
|
||||||
|
// late-joining WHEP peers receive a reference frame immediately
|
||||||
|
// instead of waiting up to one full keyframe interval.
|
||||||
|
videoSrc.EnableKeyFrameCache()
|
||||||
|
|
||||||
|
audioSrc, err := corewebrtc.NewSourceOn(id+":audio", "127.0.0.1", port+1)
|
||||||
|
if err != nil {
|
||||||
|
_ = videoSrc.Close()
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return port, videoSrc, audioSrc, nil
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("unknown allocation failure")
|
||||||
|
}
|
||||||
|
return 0, nil, nil, fmt.Errorf("webrtc: allocate adjacent UDP port pair after %d attempts: %w", allocAttempts, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitRTPLegs converts the flat BuildArgs output into two ConfigIO
|
||||||
|
// entries — one per RTP output leg. It splits on the second "-map"
|
||||||
|
// token, which marks the audio leg's start (see ffmpeg_args_test.go).
|
||||||
|
// The Address of each ConfigIO is the last argument (the udp:// URL);
|
||||||
|
// everything preceding it forms that output's Options.
|
||||||
|
func splitRTPLegs(args []string) []appcfg.ConfigIO {
|
||||||
|
// Find the two -map indices.
|
||||||
|
mapIdx := []int{}
|
||||||
|
for i, a := range args {
|
||||||
|
if a == "-map" {
|
||||||
|
mapIdx = append(mapIdx, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mapIdx) != 2 {
|
||||||
|
// BuildArgs always emits exactly 2 -maps; a different count
|
||||||
|
// means an upstream bug. Return a single leg covering
|
||||||
|
// everything to avoid silent truncation.
|
||||||
|
return []appcfg.ConfigIO{toLeg(args)}
|
||||||
|
}
|
||||||
|
|
||||||
|
videoTokens := args[mapIdx[0]:mapIdx[1]]
|
||||||
|
audioTokens := args[mapIdx[1]:]
|
||||||
|
|
||||||
|
return []appcfg.ConfigIO{
|
||||||
|
toLeg(videoTokens),
|
||||||
|
toLeg(audioTokens),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toLeg splits a contiguous RTP-output token slice into a ConfigIO:
|
||||||
|
// the trailing token is the udp:// Address; everything before is the
|
||||||
|
// Options slice.
|
||||||
|
func toLeg(tokens []string) appcfg.ConfigIO {
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
return appcfg.ConfigIO{}
|
||||||
|
}
|
||||||
|
addr := tokens[len(tokens)-1]
|
||||||
|
opts := make([]string, len(tokens)-1)
|
||||||
|
copy(opts, tokens[:len(tokens)-1])
|
||||||
|
return appcfg.ConfigIO{
|
||||||
|
ID: "webrtc",
|
||||||
|
Address: addr,
|
||||||
|
Options: opts,
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/webrtc/lifecycle_test.go
Normal file
60
app/webrtc/lifecycle_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSplitRTPLegs_TwoLegs feeds the real BuildArgs output through
|
||||||
|
// the splitter and checks both legs come out with the correct shape.
|
||||||
|
func TestSplitRTPLegs_TwoLegs(t *testing.T) {
|
||||||
|
args := BuildArgs(appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}, 49200)
|
||||||
|
|
||||||
|
legs := splitRTPLegs(args)
|
||||||
|
if len(legs) != 2 {
|
||||||
|
t.Fatalf("expected 2 legs, got %d: %+v", len(legs), legs)
|
||||||
|
}
|
||||||
|
|
||||||
|
video := legs[0]
|
||||||
|
audio := legs[1]
|
||||||
|
|
||||||
|
// Leg 0 is video: address ends with :49200
|
||||||
|
if !strings.HasSuffix(video.Address, ":49200?pkt_size=1316") {
|
||||||
|
t.Fatalf("video Address unexpected: %q", video.Address)
|
||||||
|
}
|
||||||
|
// Leg 1 is audio: address ends with :49201
|
||||||
|
if !strings.HasSuffix(audio.Address, ":49201?pkt_size=1316") {
|
||||||
|
t.Fatalf("audio Address unexpected: %q", audio.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each leg's options start with -map, end with -f rtp.
|
||||||
|
if len(video.Options) < 2 || video.Options[0] != "-map" {
|
||||||
|
t.Fatalf("video leg should start with -map, got %v", video.Options)
|
||||||
|
}
|
||||||
|
if video.Options[len(video.Options)-2] != "-f" || video.Options[len(video.Options)-1] != "rtp" {
|
||||||
|
t.Fatalf("video leg should end with -f rtp, got %v", video.Options)
|
||||||
|
}
|
||||||
|
if len(audio.Options) < 2 || audio.Options[0] != "-map" {
|
||||||
|
t.Fatalf("audio leg should start with -map, got %v", audio.Options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither leg's Options should contain the address itself.
|
||||||
|
for _, opt := range video.Options {
|
||||||
|
if strings.HasPrefix(opt, "udp://") {
|
||||||
|
t.Fatalf("video Options must not contain udp:// address: %v", video.Options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSplitRTPLegs_FallbackOnUnexpectedShape ensures we don't panic
|
||||||
|
// or drop data if BuildArgs ever changes shape — the splitter returns
|
||||||
|
// a single leg wrapping everything.
|
||||||
|
func TestSplitRTPLegs_FallbackOnUnexpectedShape(t *testing.T) {
|
||||||
|
// Single -map: shouldn't happen, but don't panic.
|
||||||
|
legs := splitRTPLegs([]string{"-map", "0:v:0", "udp://1.2.3.4:5000"})
|
||||||
|
if len(legs) != 1 {
|
||||||
|
t.Fatalf("expected single fallback leg, got %d", len(legs))
|
||||||
|
}
|
||||||
|
}
|
||||||
190
app/webrtc/metrics.go
Normal file
190
app/webrtc/metrics.go
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
coreprom "github.com/datarhei/core/v16/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var iceHistBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
|
||||||
|
|
||||||
|
type webrtcMetrics struct {
|
||||||
|
// WHEP egress metrics
|
||||||
|
whepRequests *prometheus.CounterVec
|
||||||
|
whepRequestDuration *prometheus.HistogramVec
|
||||||
|
iceEstablishment *prometheus.HistogramVec
|
||||||
|
iceFailures *prometheus.CounterVec
|
||||||
|
codecMismatches *prometheus.CounterVec
|
||||||
|
capRejections *prometheus.CounterVec
|
||||||
|
ffmpegLegFailures *prometheus.CounterVec
|
||||||
|
|
||||||
|
// WHIP ingest metrics — symmetric with WHEP where applicable.
|
||||||
|
// ICE establishment/failure reuse the WHEP histograms (shared labels).
|
||||||
|
whipRequests *prometheus.CounterVec
|
||||||
|
whipRequestDuration *prometheus.HistogramVec
|
||||||
|
whipCapRejections *prometheus.CounterVec
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRegisterCounter creates a CounterVec and registers it with reg.
|
||||||
|
// Panics on duplicate registration (same semantics as promauto).
|
||||||
|
func mustRegisterCounter(reg prometheus.Registerer, opts prometheus.CounterOpts, labels []string) *prometheus.CounterVec {
|
||||||
|
m := prometheus.NewCounterVec(opts, labels)
|
||||||
|
reg.MustRegister(m)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRegisterHistogram creates a HistogramVec and registers it with reg.
|
||||||
|
func mustRegisterHistogram(reg prometheus.Registerer, opts prometheus.HistogramOpts, labels []string) *prometheus.HistogramVec {
|
||||||
|
m := prometheus.NewHistogramVec(opts, labels)
|
||||||
|
reg.MustRegister(m)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func initMetrics(reg prometheus.Registerer, core string) *webrtcMetrics {
|
||||||
|
cl := prometheus.Labels{"core": core}
|
||||||
|
return &webrtcMetrics{
|
||||||
|
// --- WHEP ---
|
||||||
|
whepRequests: mustRegisterCounter(reg, prometheus.CounterOpts{
|
||||||
|
Name: "dragonfork_webrtc_whep_requests_total",
|
||||||
|
Help: "Count of WHEP HTTP requests by route, HTTP status code, and stream.",
|
||||||
|
ConstLabels: cl,
|
||||||
|
}, []string{"route", "code", "stream_id"}),
|
||||||
|
|
||||||
|
whepRequestDuration: mustRegisterHistogram(reg, prometheus.HistogramOpts{
|
||||||
|
Name: "dragonfork_webrtc_whep_request_duration_seconds",
|
||||||
|
Help: "Server-side WHEP request latency in seconds, by route and stream.",
|
||||||
|
ConstLabels: cl,
|
||||||
|
Buckets: iceHistBuckets,
|
||||||
|
}, []string{"route", "stream_id"}),
|
||||||
|
|
||||||
|
iceEstablishment: mustRegisterHistogram(reg, prometheus.HistogramOpts{
|
||||||
|
Name: "dragonfork_webrtc_ice_establishment_duration_seconds",
|
||||||
|
Help: "Duration from peer creation to first connected or failed ICE state (shared by WHEP and WHIP).",
|
||||||
|
ConstLabels: cl,
|
||||||
|
Buckets: iceHistBuckets,
|
||||||
|
}, []string{"stream_id", "result"}),
|
||||||
|
|
||||||
|
iceFailures: mustRegisterCounter(reg, prometheus.CounterOpts{
|
||||||
|
Name: "dragonfork_webrtc_ice_failures_total",
|
||||||
|
Help: "Count of ICE failures by stream and reason (shared by WHEP and WHIP).",
|
||||||
|
ConstLabels: cl,
|
||||||
|
}, []string{"stream_id", "reason"}),
|
||||||
|
|
||||||
|
codecMismatches: mustRegisterCounter(reg, prometheus.CounterOpts{
|
||||||
|
Name: "dragonfork_webrtc_codec_mismatches_total",
|
||||||
|
Help: "Count of 406 codec-mismatch rejections by stream and codec kind.",
|
||||||
|
ConstLabels: cl,
|
||||||
|
}, []string{"stream_id", "kind"}),
|
||||||
|
|
||||||
|
capRejections: mustRegisterCounter(reg, prometheus.CounterOpts{
|
||||||
|
Name: "dragonfork_webrtc_cap_rejections_total",
|
||||||
|
Help: "Count of 503 WHEP peer-cap rejections by stream and scope (global or stream).",
|
||||||
|
ConstLabels: cl,
|
||||||
|
}, []string{"stream_id", "scope"}),
|
||||||
|
|
||||||
|
ffmpegLegFailures: mustRegisterCounter(reg, prometheus.CounterOpts{
|
||||||
|
Name: "dragonfork_webrtc_ffmpeg_leg_failures_total",
|
||||||
|
Help: "Count of FFmpeg RTP output leg failures (process stopped while peers were active).",
|
||||||
|
ConstLabels: cl,
|
||||||
|
}, []string{"stream_id", "leg"}),
|
||||||
|
|
||||||
|
// --- WHIP ---
|
||||||
|
whipRequests: mustRegisterCounter(reg, prometheus.CounterOpts{
|
||||||
|
Name: "dragonfork_webrtc_whip_requests_total",
|
||||||
|
Help: "Count of WHIP HTTP requests by route, HTTP status code, and stream.",
|
||||||
|
ConstLabels: cl,
|
||||||
|
}, []string{"route", "code", "stream_id"}),
|
||||||
|
|
||||||
|
whipRequestDuration: mustRegisterHistogram(reg, prometheus.HistogramOpts{
|
||||||
|
Name: "dragonfork_webrtc_whip_request_duration_seconds",
|
||||||
|
Help: "Server-side WHIP request latency in seconds, by route and stream.",
|
||||||
|
ConstLabels: cl,
|
||||||
|
Buckets: iceHistBuckets,
|
||||||
|
}, []string{"route", "stream_id"}),
|
||||||
|
|
||||||
|
whipCapRejections: mustRegisterCounter(reg, prometheus.CounterOpts{
|
||||||
|
Name: "dragonfork_webrtc_whip_cap_rejections_total",
|
||||||
|
Help: "Count of 503/409 WHIP publisher-cap or conflict rejections by stream and scope.",
|
||||||
|
ConstLabels: cl,
|
||||||
|
}, []string{"stream_id", "scope"}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitMetrics initialises WebRTC direct-instrumentation metrics on h and
|
||||||
|
// registers the snapshot collector with reg. Call once after construction,
|
||||||
|
// before the handler serves requests. Panics on duplicate registration.
|
||||||
|
func (h *Handler) InitMetrics(reg prometheus.Registerer, core string) {
|
||||||
|
h.met = initMetrics(reg, core)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMetrics attaches a shared *webrtcMetrics to the WHIPHandler so that
|
||||||
|
// WHIP ingest routes emit Prometheus observations. If both the WHEP Handler
|
||||||
|
// and the WHIP Handler are in use, call Handler.InitMetrics once and pass
|
||||||
|
// the result to WHIPHandler.SetMetrics — registering the metrics twice
|
||||||
|
// on the same Registerer panics.
|
||||||
|
func (h *WHIPHandler) SetMetrics(met *webrtcMetrics) {
|
||||||
|
h.met = met
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats implements coreprom.WebRTCStatsSource for the Prometheus snapshot
|
||||||
|
// collector. Returns a consistent snapshot under h.mu.
|
||||||
|
func (h *Handler) Stats() coreprom.WebRTCStats {
|
||||||
|
h.mu.Lock()
|
||||||
|
peers := make(map[string]int, len(h.peersByStream))
|
||||||
|
for id, pm := range h.peersByStream {
|
||||||
|
peers[id] = len(pm)
|
||||||
|
}
|
||||||
|
sc := 0
|
||||||
|
if h.sub != nil {
|
||||||
|
sc = h.sub.StreamCount()
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
return coreprom.WebRTCStats{
|
||||||
|
StreamCount: sc,
|
||||||
|
PeersByStream: peers,
|
||||||
|
UDPPortsInUse: sc * 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublisherCount returns the number of currently active WHIP publishers.
|
||||||
|
// Safe to call from any goroutine (atomic read).
|
||||||
|
func (h *WHIPHandler) PublisherCount() int64 {
|
||||||
|
return h.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackICE waits for the first terminal ICE event and records establishment
|
||||||
|
// duration and failure metrics. t0 should be captured immediately before
|
||||||
|
// CreatePeerFromSources returns. Runs in a goroutine per Subscribe call.
|
||||||
|
func (h *Handler) trackICE(streamID string, peer *corewebrtc.Peer, t0 time.Time) {
|
||||||
|
if h.met == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-peer.Connected():
|
||||||
|
h.met.iceEstablishment.WithLabelValues(streamID, "connected").Observe(time.Since(t0).Seconds())
|
||||||
|
case <-peer.Done():
|
||||||
|
dur := time.Since(t0)
|
||||||
|
h.met.iceEstablishment.WithLabelValues(streamID, "failed").Observe(dur.Seconds())
|
||||||
|
h.met.iceFailures.WithLabelValues(streamID, "reason").Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackICE waits for the first terminal ICE event on a WHIP IngestPeer and
|
||||||
|
// records establishment duration using the same shared histograms as the
|
||||||
|
// WHEP egress ICE tracker. This gives a unified ICE health view across
|
||||||
|
// both publish and subscribe paths.
|
||||||
|
func (h *WHIPHandler) trackICE(streamID string, peer *corewebrtc.IngestPeer, t0 time.Time) {
|
||||||
|
if h.met == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-peer.Connected():
|
||||||
|
h.met.iceEstablishment.WithLabelValues(streamID, "connected").Observe(time.Since(t0).Seconds())
|
||||||
|
case <-peer.Done():
|
||||||
|
dur := time.Since(t0)
|
||||||
|
h.met.iceEstablishment.WithLabelValues(streamID, "failed").Observe(dur.Seconds())
|
||||||
|
h.met.iceFailures.WithLabelValues(streamID, "ingest").Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
149
app/webrtc/metrics_test.go
Normal file
149
app/webrtc/metrics_test.go
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
|
|
||||||
|
"github.com/datarhei/core/v16/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestHandler(t *testing.T) (*Handler, *prometheus.Registry) {
|
||||||
|
t.Helper()
|
||||||
|
s, err := New(config.DataWebRTC{Enable: true}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
h := NewHandler(s, 0)
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
h.InitMetrics(reg, "test")
|
||||||
|
return h, reg
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_Subscribe404BumpsCounter checks that a 404 on unknown stream
|
||||||
|
// increments the request counter with the correct labels.
|
||||||
|
func TestMetrics_Subscribe404BumpsCounter(t *testing.T) {
|
||||||
|
h, reg := newTestHandler(t)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whep/ghost", strings.NewReader("v=0\r\n"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("ghost")
|
||||||
|
_ = h.Subscribe(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := testutil.GatherAndCompare(reg, strings.NewReader(`
|
||||||
|
# HELP dragonfork_webrtc_whep_requests_total Count of WHEP HTTP requests by route, HTTP status code, and stream.
|
||||||
|
# TYPE dragonfork_webrtc_whep_requests_total counter
|
||||||
|
dragonfork_webrtc_whep_requests_total{code="404",core="test",route="subscribe",stream_id="ghost"} 1
|
||||||
|
`), "dragonfork_webrtc_whep_requests_total"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_GlobalCapBumpsCapRejection checks that a global cap 503 fires
|
||||||
|
// the cap_rejections counter with scope=global.
|
||||||
|
func TestMetrics_GlobalCapBumpsCapRejection(t *testing.T) {
|
||||||
|
s, err := New(config.DataWebRTC{Enable: true}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
// maxPeers=1, but inject a stream so we get past lookup
|
||||||
|
s.mu.Lock()
|
||||||
|
s.streams["mystream"] = &processStream{id: "mystream"}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
h := NewHandlerWithCaps(s, 1, 0)
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
h.InitMetrics(reg, "test")
|
||||||
|
// Force count to be at cap
|
||||||
|
h.count = 1
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whep/mystream", strings.NewReader("v=0\r\n"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("mystream")
|
||||||
|
_ = h.Subscribe(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("expected 503, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := testutil.ToFloat64(h.met.capRejections.WithLabelValues("", "global"))
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("cap_rejections{scope=global}: want 1, got %v", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_CodecMismatchBumpsCounter checks that a 406 SDP with no H264
|
||||||
|
// increments codec_mismatches{kind=h264}.
|
||||||
|
func TestMetrics_CodecMismatchBumpsCounter(t *testing.T) {
|
||||||
|
s, err := New(config.DataWebRTC{Enable: true}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.streams["cam"] = &processStream{id: "cam"}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
h := NewHandler(s, 0)
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
h.InitMetrics(reg, "test")
|
||||||
|
|
||||||
|
// SDP with Opus but no H264
|
||||||
|
sdp := "v=0\r\na=rtpmap:111 opus/48000/2\r\n"
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whep/cam", strings.NewReader(sdp))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("cam")
|
||||||
|
_ = h.Subscribe(c)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotAcceptable {
|
||||||
|
t.Fatalf("expected 406, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := testutil.ToFloat64(h.met.codecMismatches.WithLabelValues("cam", "h264"))
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("codec_mismatches{kind=h264}: want 1, got %v", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_Stats returns consistent snapshots at zero and with streams.
|
||||||
|
func TestMetrics_Stats(t *testing.T) {
|
||||||
|
h, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
got := h.Stats()
|
||||||
|
if got.StreamCount != 0 {
|
||||||
|
t.Fatalf("expected 0 streams, got %d", got.StreamCount)
|
||||||
|
}
|
||||||
|
if got.UDPPortsInUse != 0 {
|
||||||
|
t.Fatalf("expected 0 udp ports, got %d", got.UDPPortsInUse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject a stream to verify counts update
|
||||||
|
h.sub.mu.Lock()
|
||||||
|
h.sub.streams["test"] = &processStream{id: "test"}
|
||||||
|
h.sub.mu.Unlock()
|
||||||
|
|
||||||
|
got = h.Stats()
|
||||||
|
if got.StreamCount != 1 {
|
||||||
|
t.Fatalf("expected 1 stream, got %d", got.StreamCount)
|
||||||
|
}
|
||||||
|
if got.UDPPortsInUse != 2 {
|
||||||
|
t.Fatalf("expected 2 udp ports, got %d", got.UDPPortsInUse)
|
||||||
|
}
|
||||||
|
}
|
||||||
257
app/webrtc/multiviewer_test.go
Normal file
257
app/webrtc/multiviewer_test.go
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
pionwebrtc "github.com/pion/webrtc/v4"
|
||||||
|
|
||||||
|
"github.com/datarhei/core/v16/config"
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegration_FiveViewerFanout drives the M3 acceptance criterion
|
||||||
|
// "5 concurrent viewers, all error paths correct, clean teardown" in
|
||||||
|
// the wide direction. Five Pion subscribers attach to a single
|
||||||
|
// process's stream pair and each receives RTP without crosstalk; on
|
||||||
|
// teardown every subscriber's PeerConnection observes its tracks
|
||||||
|
// closing.
|
||||||
|
//
|
||||||
|
// Verifies (in order):
|
||||||
|
// * subsystem.onProcessStart returns adjacent UDP ports
|
||||||
|
// * 5 WHEP POSTs in parallel succeed (per-stream cap default = 8)
|
||||||
|
// * every subscriber's video and audio track receives at least one
|
||||||
|
// RTP packet within the timeout
|
||||||
|
// * onProcessStop tears every subscriber down (PeerConnection
|
||||||
|
// transitions away from connected/connecting)
|
||||||
|
func TestIntegration_FiveViewerFanout(t *testing.T) {
|
||||||
|
const N = 5
|
||||||
|
|
||||||
|
sub, err := New(config.DataWebRTC{Enable: true}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("subsystem New: %v", err)
|
||||||
|
}
|
||||||
|
defer sub.Close()
|
||||||
|
|
||||||
|
h := NewHandler(sub, 0)
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
|
processID := "fanout"
|
||||||
|
legs, err := sub.onProcessStart(processID, &appcfg.Config{
|
||||||
|
ID: processID,
|
||||||
|
WebRTC: appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("onProcessStart: %v", err)
|
||||||
|
}
|
||||||
|
if len(legs) != 2 {
|
||||||
|
t.Fatalf("expected 2 legs, got %d", len(legs))
|
||||||
|
}
|
||||||
|
videoPort, err := portFromLegAddress(legs[0].Address)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("video port: %v", err)
|
||||||
|
}
|
||||||
|
audioPort, err := portFromLegAddress(legs[1].Address)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("audio port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
g := e.Group("")
|
||||||
|
h.Register(g)
|
||||||
|
srv := httptest.NewServer(e)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Each subscriber tracks first-RTP-received signals for V and A.
|
||||||
|
type viewer struct {
|
||||||
|
pc *pionwebrtc.PeerConnection
|
||||||
|
videoCh chan struct{}
|
||||||
|
audioCh chan struct{}
|
||||||
|
}
|
||||||
|
viewers := make([]*viewer, N)
|
||||||
|
api := func() *pionwebrtc.API {
|
||||||
|
me := &pionwebrtc.MediaEngine{}
|
||||||
|
_ = me.RegisterDefaultCodecs()
|
||||||
|
return pionwebrtc.NewAPI(pionwebrtc.WithMediaEngine(me))
|
||||||
|
}()
|
||||||
|
|
||||||
|
subscribe := func(i int) error {
|
||||||
|
pc, err := api.NewPeerConnection(pionwebrtc.Configuration{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v := &viewer{pc: pc, videoCh: make(chan struct{}, 1), audioCh: make(chan struct{}, 1)}
|
||||||
|
viewers[i] = v
|
||||||
|
var vGot, aGot atomic.Bool
|
||||||
|
pc.OnTrack(func(tr *pionwebrtc.TrackRemote, _ *pionwebrtc.RTPReceiver) {
|
||||||
|
go func() {
|
||||||
|
if _, _, rerr := tr.ReadRTP(); rerr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch tr.Kind() {
|
||||||
|
case pionwebrtc.RTPCodecTypeVideo:
|
||||||
|
if vGot.CompareAndSwap(false, true) {
|
||||||
|
v.videoCh <- struct{}{}
|
||||||
|
}
|
||||||
|
case pionwebrtc.RTPCodecTypeAudio:
|
||||||
|
if aGot.CompareAndSwap(false, true) {
|
||||||
|
v.audioCh <- struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
_, _ = pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeVideo,
|
||||||
|
pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly})
|
||||||
|
_, _ = pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeAudio,
|
||||||
|
pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly})
|
||||||
|
offer, err := pc.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gather := pionwebrtc.GatheringCompletePromise(pc)
|
||||||
|
if err := pc.SetLocalDescription(offer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
<-gather
|
||||||
|
resp, err := http.Post(srv.URL+"/whep/"+processID, "application/sdp",
|
||||||
|
strings.NewReader(pc.LocalDescription().SDP))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Errorf("viewer %d: WHEP %d", i, resp.StatusCode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
buf := make([]byte, 1<<15)
|
||||||
|
n, _ := resp.Body.Read(buf)
|
||||||
|
return pc.SetRemoteDescription(pionwebrtc.SessionDescription{
|
||||||
|
Type: pionwebrtc.SDPTypeAnswer,
|
||||||
|
SDP: string(buf[:n]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe all N viewers in parallel.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < N; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := subscribe(i); err != nil {
|
||||||
|
t.Errorf("viewer %d subscribe: %v", i, err)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i := 0; i < N; i++ {
|
||||||
|
if viewers[i] == nil || viewers[i].pc == nil {
|
||||||
|
t.Fatalf("viewer %d not constructed", i)
|
||||||
|
}
|
||||||
|
defer viewers[i].pc.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spray RTP into both ports until every viewer reports first-RTP.
|
||||||
|
videoSender, _ := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(videoPort))
|
||||||
|
audioSender, _ := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(audioPort))
|
||||||
|
defer videoSender.Close()
|
||||||
|
defer audioSender.Close()
|
||||||
|
stop := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(20 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
var seq uint16
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
seq++
|
||||||
|
_, _ = videoSender.Write(synthRTPPacket(102, seq, uint32(seq)*3000, 0xcafe0000, []byte("vvvvvvvv")))
|
||||||
|
_, _ = audioSender.Write(synthRTPPacket(111, seq, uint32(seq)*960, 0xbeef0000, []byte("aaaaaaaa")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer close(stop)
|
||||||
|
|
||||||
|
deadline := time.After(15 * time.Second)
|
||||||
|
for i, v := range viewers {
|
||||||
|
select {
|
||||||
|
case <-v.videoCh:
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("viewer %d: no video RTP within 15s", i)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-v.audioCh:
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("viewer %d: no audio RTP within 15s", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the per-stream peer index has all N entries.
|
||||||
|
h.mu.Lock()
|
||||||
|
got := len(h.peersByStream[processID])
|
||||||
|
h.mu.Unlock()
|
||||||
|
if got != N {
|
||||||
|
t.Errorf("peersByStream[%s] = %d, want %d", processID, got, N)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tear the process down — every viewer's PC should observe state
|
||||||
|
// transitioning away from connected within a short window.
|
||||||
|
sub.onProcessStop(processID)
|
||||||
|
|
||||||
|
// After teardown the peer index for this stream should be empty.
|
||||||
|
// Closing peers is async (driven by Done channel), so poll briefly.
|
||||||
|
deadline2 := time.Now().Add(3 * time.Second)
|
||||||
|
for time.Now().Before(deadline2) {
|
||||||
|
h.mu.Lock()
|
||||||
|
empty := len(h.peersByStream[processID]) == 0
|
||||||
|
h.mu.Unlock()
|
||||||
|
if empty {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
leftover := len(h.peersByStream[processID])
|
||||||
|
h.mu.Unlock()
|
||||||
|
if leftover != 0 {
|
||||||
|
t.Errorf("after onProcessStop, %d peers remain in index", leftover)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubsystem_TeardownHookFiresOnProcessStop is a unit-level check
|
||||||
|
// that the teardown callback the Handler installs actually runs.
|
||||||
|
func TestSubsystem_TeardownHookFiresOnProcessStop(t *testing.T) {
|
||||||
|
sub, err := New(config.DataWebRTC{Enable: true}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
defer sub.Close()
|
||||||
|
|
||||||
|
var fired atomic.Int32
|
||||||
|
sub.SetTeardownHook(func(streamID string) {
|
||||||
|
if streamID == "p1" {
|
||||||
|
fired.Add(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := sub.onProcessStart("p1", &appcfg.Config{
|
||||||
|
ID: "p1",
|
||||||
|
WebRTC: appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("onProcessStart: %v", err)
|
||||||
|
}
|
||||||
|
sub.onProcessStop("p1")
|
||||||
|
if got := fired.Load(); got != 1 {
|
||||||
|
t.Errorf("teardown fired %d times, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/webrtc/portalloc.go
Normal file
31
app/webrtc/portalloc.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Package webrtc is the datarhei Core subsystem that turns WebRTC into
|
||||||
|
// a first-class output alongside RTMP, SRT, and HLS. It owns the WHEP
|
||||||
|
// HTTP handler, wires FFmpeg's RTP output into per-process Pion
|
||||||
|
// Sources, and tracks active peer connections.
|
||||||
|
//
|
||||||
|
// See docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md
|
||||||
|
// for the full design.
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alloc binds :0 on loopback UDPv4, records the port the kernel assigned,
|
||||||
|
// closes the socket, and returns the port number.
|
||||||
|
//
|
||||||
|
// The caller is expected to re-bind that exact port via
|
||||||
|
// core/webrtc.NewSourceOn immediately. There is a microsecond-sized race
|
||||||
|
// window where another process on the host could grab the port; if that
|
||||||
|
// happens, the caller's rebind will fail and the error should be
|
||||||
|
// propagated. In practice this is rare enough that a retry loop would be
|
||||||
|
// unnecessary churn.
|
||||||
|
func Alloc() (int, error) {
|
||||||
|
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("webrtc: portalloc: %w", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
return c.LocalAddr().(*net.UDPAddr).Port, nil
|
||||||
|
}
|
||||||
43
app/webrtc/portalloc_test.go
Normal file
43
app/webrtc/portalloc_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAlloc_ReturnsRebindablePort exercises the alloc/close/rebind
|
||||||
|
// sequence 100 times. If a fast rebind race existed in normal
|
||||||
|
// conditions, this would surface it.
|
||||||
|
func TestAlloc_ReturnsRebindablePort(t *testing.T) {
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
p, err := Alloc()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter %d: Alloc: %v", i, err)
|
||||||
|
}
|
||||||
|
if p == 0 {
|
||||||
|
t.Fatalf("iter %d: expected non-zero port", i)
|
||||||
|
}
|
||||||
|
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: p})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter %d: rebind port %d: %v", i, p, err)
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAlloc_DistinctPorts confirms the OS doesn't hand us the same
|
||||||
|
// ephemeral port twice in quick succession (it shouldn't — the socket
|
||||||
|
// is briefly held in the bound state on close).
|
||||||
|
func TestAlloc_DistinctPorts(t *testing.T) {
|
||||||
|
seen := map[int]bool{}
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
p, err := Alloc()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen[p] {
|
||||||
|
t.Fatalf("duplicate port %d", p)
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
226
app/webrtc/subsystem.go
Normal file
226
app/webrtc/subsystem.go
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/datarhei/core/v16/config"
|
||||||
|
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
"github.com/datarhei/core/v16/log"
|
||||||
|
"github.com/datarhei/core/v16/restream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subsystem is the app-level WebRTC egress manager. It sits alongside
|
||||||
|
// api.API as a sibling — both consume the Restream service, both wire
|
||||||
|
// themselves into the Echo HTTP router. The subsystem is responsible for:
|
||||||
|
//
|
||||||
|
// - Translating the global config.DataWebRTC into the core-level
|
||||||
|
// corewebrtc.Config used by the PeerFactory.
|
||||||
|
// - Installing ProcessHooks on Restreamer so that per-process start
|
||||||
|
// events allocate a pair of UDP ports, create Pion Sources, and
|
||||||
|
// inject RTP output legs into the FFmpeg command line (WHEP egress).
|
||||||
|
// - Optionally installing OnInputStart/OnInputStop hooks for WHIP
|
||||||
|
// ingest: allocates an adjacent UDP pair and injects RTP input legs.
|
||||||
|
// - Serving the WHEP Echo handler (see handler.go) and the WHIP Echo
|
||||||
|
// handler (see whip_handler.go).
|
||||||
|
//
|
||||||
|
// The zero value is not usable; call New.
|
||||||
|
type Subsystem struct {
|
||||||
|
globalCfg config.DataWebRTC
|
||||||
|
coreCfg corewebrtc.Config
|
||||||
|
factory *corewebrtc.PeerFactory
|
||||||
|
logger log.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
streams map[string]*processStream // processID -> WHEP egress stream pair
|
||||||
|
|
||||||
|
// WHIP ingest: active port-pair allocations keyed by processID.
|
||||||
|
whipIngests map[string]*ingestStream
|
||||||
|
|
||||||
|
// teardown is set by the WHEP Handler to be called before egress
|
||||||
|
// Sources close in onProcessStop.
|
||||||
|
teardown func(streamID string)
|
||||||
|
|
||||||
|
// whipTeardown is set by the WHIPHandler to be called before the
|
||||||
|
// ingest port allocation is removed in onWHIPProcessStop.
|
||||||
|
whipTeardown func(streamID string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processStream captures the two Sources (video + audio) backing a
|
||||||
|
// single running process's WHEP egress.
|
||||||
|
type processStream struct {
|
||||||
|
id string
|
||||||
|
video *corewebrtc.Source
|
||||||
|
audio *corewebrtc.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a Subsystem from the global WebRTC config section.
|
||||||
|
// The provided ffmpegUDPMax is advisory for logs only (M2 uses the
|
||||||
|
// OS's ephemeral range via Alloc). Returns an error if the PeerFactory
|
||||||
|
// cannot be built (e.g., bad NAT1To1 IPs).
|
||||||
|
func New(dataCfg config.DataWebRTC, logger log.Logger) (*Subsystem, error) {
|
||||||
|
if logger == nil {
|
||||||
|
logger = log.New("")
|
||||||
|
}
|
||||||
|
|
||||||
|
coreCfg := corewebrtc.DefaultConfig()
|
||||||
|
coreCfg.Enabled = dataCfg.Enable
|
||||||
|
coreCfg.PublicIP = dataCfg.PublicIP
|
||||||
|
|
||||||
|
// Build the NAT1To1IPs list that Pion will use for host candidates.
|
||||||
|
// Strategy: merge PublicIP and NAT1To1IPs, deduplicating.
|
||||||
|
// - If PublicIP is set it comes first.
|
||||||
|
// - Any entries in NAT1To1IPs that differ from PublicIP are appended.
|
||||||
|
// This replaces the old single-IP workaround and allows dual-homed
|
||||||
|
// servers (e.g., a LAN IP + a public IP) to advertise host candidates
|
||||||
|
// on all interfaces simultaneously.
|
||||||
|
nat1to1IPs := make([]string, 0, len(dataCfg.NAT1To1IPs)+1)
|
||||||
|
if dataCfg.PublicIP != "" {
|
||||||
|
nat1to1IPs = append(nat1to1IPs, dataCfg.PublicIP)
|
||||||
|
}
|
||||||
|
for _, ip := range dataCfg.NAT1To1IPs {
|
||||||
|
if ip != dataCfg.PublicIP {
|
||||||
|
nat1to1IPs = append(nat1to1IPs, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coreCfg.NAT1To1IPs = nat1to1IPs
|
||||||
|
|
||||||
|
// If the operator supplied explicit ICE server URIs via config/env,
|
||||||
|
// override the built-in defaults (typically Google's public STUN servers).
|
||||||
|
// An empty list means "keep the built-in defaults".
|
||||||
|
if len(dataCfg.ICEServers) > 0 {
|
||||||
|
coreCfg.ICEServers = make([]string, len(dataCfg.ICEServers))
|
||||||
|
copy(coreCfg.ICEServers, dataCfg.ICEServers)
|
||||||
|
}
|
||||||
|
|
||||||
|
factory, err := corewebrtc.NewPeerFactory(coreCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("webrtc subsystem: build peer factory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Subsystem{
|
||||||
|
globalCfg: dataCfg,
|
||||||
|
coreCfg: coreCfg,
|
||||||
|
factory: factory,
|
||||||
|
logger: logger.WithComponent("WebRTC"),
|
||||||
|
streams: make(map[string]*processStream),
|
||||||
|
whipIngests: make(map[string]*ingestStream),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether the subsystem should register hooks and
|
||||||
|
// serve the WHEP endpoint. Called by the API wiring layer to decide
|
||||||
|
// whether to install anything.
|
||||||
|
func (s *Subsystem) Enabled() bool {
|
||||||
|
return s.globalCfg.Enable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hooks returns the restream.ProcessHooks the subsystem expects to be
|
||||||
|
// installed via restream.Restreamer.SetHooks. Exactly one Subsystem
|
||||||
|
// instance should be installed per Restreamer.
|
||||||
|
//
|
||||||
|
// This returns only the WHEP egress hooks (OnStart/OnStop). Call
|
||||||
|
// WHIPHooks() to get the WHIP ingest hooks, and merge them with
|
||||||
|
// SetHooks if WHIP is also required.
|
||||||
|
func (s *Subsystem) Hooks() restream.ProcessHooks {
|
||||||
|
return restream.ProcessHooks{
|
||||||
|
OnStart: s.onProcessStart,
|
||||||
|
OnStop: s.onProcessStop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHIPHooks returns the restream.ProcessHooks for WHIP ingest
|
||||||
|
// (OnInputStart / OnInputStop). Merge these with the output from
|
||||||
|
// Hooks() before calling restream.Restreamer.SetHooks.
|
||||||
|
func (s *Subsystem) WHIPHooks() restream.ProcessHooks {
|
||||||
|
return restream.ProcessHooks{
|
||||||
|
OnInputStart: s.onWHIPProcessStart,
|
||||||
|
OnInputStop: s.onWHIPProcessStop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergedHooks returns ProcessHooks with both WHEP egress (OnStart/OnStop)
|
||||||
|
// and WHIP ingest (OnInputStart/OnInputStop) wired in. Convenience
|
||||||
|
// helper so callers don't have to merge manually.
|
||||||
|
func (s *Subsystem) MergedHooks() restream.ProcessHooks {
|
||||||
|
return restream.ProcessHooks{
|
||||||
|
OnStart: s.onProcessStart,
|
||||||
|
OnStop: s.onProcessStop,
|
||||||
|
OnInputStart: s.onWHIPProcessStart,
|
||||||
|
OnInputStop: s.onWHIPProcessStop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close tears down every active per-process stream. It is safe to
|
||||||
|
// call during Core shutdown; subsequent WHEP requests will 404.
|
||||||
|
func (s *Subsystem) Close() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for id, st := range s.streams {
|
||||||
|
if st.video != nil {
|
||||||
|
_ = st.video.Close()
|
||||||
|
}
|
||||||
|
if st.audio != nil {
|
||||||
|
_ = st.audio.Close()
|
||||||
|
}
|
||||||
|
delete(s.streams, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTeardownHook registers a callback invoked just before a stream's
|
||||||
|
// Sources are closed in onProcessStop. The callback is expected to
|
||||||
|
// tear down any external resources keyed by streamID — most importantly
|
||||||
|
// the WHEP Handler's per-stream peer index.
|
||||||
|
//
|
||||||
|
// Calling SetTeardownHook again replaces the previous callback; pass
|
||||||
|
// nil to detach. Only one consumer is supported by design.
|
||||||
|
func (s *Subsystem) SetTeardownHook(fn func(streamID string)) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.teardown = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWHIPTeardownHook registers a callback invoked just before a WHIP
|
||||||
|
// ingest allocation is removed in onWHIPProcessStop. The WHIPHandler
|
||||||
|
// uses this to close any active publisher when FFmpeg stops.
|
||||||
|
func (s *Subsystem) SetWHIPTeardownHook(fn func(streamID string)) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.whipTeardown = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamCount returns the number of processes currently registered with
|
||||||
|
// active WebRTC egress. Used by the Prometheus snapshot collector.
|
||||||
|
func (s *Subsystem) StreamCount() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.streams)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ICEServerURIs returns the ICE server URI list from the core config.
|
||||||
|
// Used by the WHEP and WHIP handlers to emit RFC 9429 / RFC 9261 Link
|
||||||
|
// headers so that browsers can discover STUN/TURN servers without a
|
||||||
|
// separate signalling round-trip. If the operator configured explicit
|
||||||
|
// servers via CORE_WEBRTC_ICE_SERVERS those are returned; otherwise
|
||||||
|
// the built-in Pion defaults are returned.
|
||||||
|
func (s *Subsystem) ICEServerURIs() []string {
|
||||||
|
return s.coreCfg.ICEServers
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookup returns the per-process WHEP stream pair for id, or nil, false.
|
||||||
|
// Used by the WHEP handler.
|
||||||
|
func (s *Subsystem) lookup(id string) (*processStream, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st, ok := s.streams[id]
|
||||||
|
return st, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupIngest returns the per-process WHIP ingest port allocation for
|
||||||
|
// id, or nil, false. Used by the WHIPHandler.
|
||||||
|
func (s *Subsystem) lookupIngest(id string) (*ingestStream, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st, ok := s.whipIngests[id]
|
||||||
|
return st, ok
|
||||||
|
}
|
||||||
378
app/webrtc/whip_handler.go
Normal file
378
app/webrtc/whip_handler.go
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
|
||||||
|
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHIPHandler exposes the subsystem's WHIP Echo handlers. Wire them
|
||||||
|
// into the /api/v3 group alongside the WHEP Handler via
|
||||||
|
// WHIPHandler.Register.
|
||||||
|
//
|
||||||
|
// Lifecycle: ingest peers are tracked in a streamID→resourceID→IngestPeer
|
||||||
|
// index. On every Publish a goroutine watches the peer's Done() channel;
|
||||||
|
// when the publisher disconnects or Close() runs the entry is removed
|
||||||
|
// and the counters tick back down — no leaks if OBS rage-quits.
|
||||||
|
type WHIPHandler struct {
|
||||||
|
sub *Subsystem
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
ingestByStream map[string]map[string]*corewebrtc.IngestPeer // streamID -> resource -> peer
|
||||||
|
ingestStream map[string]string // resource -> streamID (reverse index)
|
||||||
|
count int64 // atomic; concurrent publishers
|
||||||
|
maxCapTotal int64
|
||||||
|
|
||||||
|
met *webrtcMetrics // nil until SetMetrics is called
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWHIPHandler wraps the subsystem in an Echo-compatible WHIP handler.
|
||||||
|
// maxPublishers caps concurrent ingest sessions across all streams;
|
||||||
|
// pass 0 to default to 64.
|
||||||
|
//
|
||||||
|
// The constructor registers a teardown hook on the Subsystem so that
|
||||||
|
// when a process stops, any active WHIP publisher is closed automatically
|
||||||
|
// (mirroring the pattern used by the WHEP NewHandler).
|
||||||
|
func NewWHIPHandler(s *Subsystem, maxPublishers int) *WHIPHandler {
|
||||||
|
total := int64(maxPublishers)
|
||||||
|
if total <= 0 {
|
||||||
|
total = 64
|
||||||
|
}
|
||||||
|
h := &WHIPHandler{
|
||||||
|
sub: s,
|
||||||
|
ingestByStream: make(map[string]map[string]*corewebrtc.IngestPeer),
|
||||||
|
ingestStream: make(map[string]string),
|
||||||
|
maxCapTotal: total,
|
||||||
|
}
|
||||||
|
// Wire the WHIP teardown hook so onWHIPProcessStop notifies us
|
||||||
|
// before releasing the port allocation — same pattern as WHEP's
|
||||||
|
// NewHandler → s.SetTeardownHook(h.tearDownStreamPeers).
|
||||||
|
if s != nil {
|
||||||
|
s.SetWHIPTeardownHook(h.tearDownStreamIngests)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register mounts the WHIP routes on the provided Echo group.
|
||||||
|
//
|
||||||
|
// POST /whip/:id — start a publish session (SDP offer → answer)
|
||||||
|
// DELETE /whip/:id/:resource — tear down a publish session
|
||||||
|
// PATCH /whip/:id/:resource — trickle ICE candidates
|
||||||
|
// OPTIONS /whip/* — CORS preflight
|
||||||
|
func (h *WHIPHandler) Register(g *echo.Group) {
|
||||||
|
g.OPTIONS("/whip/:id", h.preflight)
|
||||||
|
g.OPTIONS("/whip/:id/:resource", h.preflight)
|
||||||
|
g.POST("/whip/:id", h.Publish)
|
||||||
|
g.DELETE("/whip/:id/:resource", h.Unpublish)
|
||||||
|
g.PATCH("/whip/:id/:resource", h.TrickleIngest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish handles POST /whip/:id.
|
||||||
|
//
|
||||||
|
// The request body is an SDP offer (Content-Type: application/sdp).
|
||||||
|
// Response is the SDP answer; the Location header identifies the
|
||||||
|
// DELETE/PATCH resource for teardown and trickle ICE.
|
||||||
|
//
|
||||||
|
// The target process must have WHIPIngest.Enabled=true in its config,
|
||||||
|
// and an active ingest port pair must have been allocated by
|
||||||
|
// onWHIPProcessStart.
|
||||||
|
//
|
||||||
|
// @Summary Publish a WebRTC stream via WHIP
|
||||||
|
// @Description Start a WHIP ingest session. Body is the SDP offer (Content-Type: application/sdp). Response is the SDP answer; Location header points at DELETE/PATCH resource.
|
||||||
|
// @Tags v16.16.0
|
||||||
|
// @ID webrtc-3-whip-publish
|
||||||
|
// @Accept application/sdp
|
||||||
|
// @Produce application/sdp
|
||||||
|
// @Param id path string true "Process ID with whip_ingest.enabled=true"
|
||||||
|
// @Success 201 {string} string "SDP answer"
|
||||||
|
// @Failure 400 {string} string "missing stream id, malformed body, or invalid SDP"
|
||||||
|
// @Failure 404 {string} string "no ingest stream registered for this process id"
|
||||||
|
// @Failure 409 {string} string "a publisher is already active on this stream (single-publisher enforcement)"
|
||||||
|
// @Failure 503 {string} string "global publisher cap reached"
|
||||||
|
// @Failure 504 {string} string "ICE gathering timeout"
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Router /api/v3/whip/{id} [post]
|
||||||
|
func (h *WHIPHandler) Publish(c echo.Context) error {
|
||||||
|
addCORS(c)
|
||||||
|
t0 := time.Now()
|
||||||
|
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
h.recordRequest("publish", "", http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, "missing stream id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global cap: cheap atomic check before real work.
|
||||||
|
if atomic.LoadInt64(&h.count) >= h.maxCapTotal {
|
||||||
|
if h.met != nil {
|
||||||
|
h.met.whipCapRejections.WithLabelValues(id, "global").Inc()
|
||||||
|
}
|
||||||
|
h.recordRequest("publish", id, http.StatusServiceUnavailable, t0)
|
||||||
|
return c.String(http.StatusServiceUnavailable, "webrtc: whip: publisher cap reached")
|
||||||
|
}
|
||||||
|
|
||||||
|
ingest, ok := h.sub.lookupIngest(id)
|
||||||
|
if !ok {
|
||||||
|
h.recordRequest("publish", id, http.StatusNotFound, t0)
|
||||||
|
return c.String(http.StatusNotFound, "webrtc: whip: no ingest registered for process")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-publisher enforcement: WHIP is point-to-point —
|
||||||
|
// only one active publisher per stream at a time.
|
||||||
|
h.mu.Lock()
|
||||||
|
if len(h.ingestByStream[id]) > 0 {
|
||||||
|
h.mu.Unlock()
|
||||||
|
if h.met != nil {
|
||||||
|
h.met.whipCapRejections.WithLabelValues(id, "conflict").Inc()
|
||||||
|
}
|
||||||
|
h.recordRequest("publish", id, http.StatusConflict, t0)
|
||||||
|
return c.String(http.StatusConflict, "webrtc: whip: stream already has an active publisher")
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(c.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
h.recordRequest("publish", id, http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, "read body: "+err.Error())
|
||||||
|
}
|
||||||
|
if len(body) == 0 || !strings.HasPrefix(string(body), "v=") {
|
||||||
|
h.recordRequest("publish", id, http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, corewebrtc.ErrInvalidSDP.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)}
|
||||||
|
peer, err := h.sub.factory.CreateIngestPeer(
|
||||||
|
c.Request().Context(),
|
||||||
|
offer,
|
||||||
|
ingest.videoPort,
|
||||||
|
ingest.audioPort,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case corewebrtc.ErrICETimeout:
|
||||||
|
h.recordRequest("publish", id, http.StatusGatewayTimeout, t0)
|
||||||
|
return c.String(http.StatusGatewayTimeout, err.Error())
|
||||||
|
default:
|
||||||
|
h.recordRequest("publish", id, http.StatusInternalServerError, t0)
|
||||||
|
return c.String(http.StatusInternalServerError, "create ingest peer: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rid := peer.ResourceID()
|
||||||
|
h.mu.Lock()
|
||||||
|
if h.ingestByStream[id] == nil {
|
||||||
|
h.ingestByStream[id] = make(map[string]*corewebrtc.IngestPeer)
|
||||||
|
}
|
||||||
|
h.ingestByStream[id][rid] = peer
|
||||||
|
h.ingestStream[rid] = id
|
||||||
|
h.mu.Unlock()
|
||||||
|
atomic.AddInt64(&h.count, 1)
|
||||||
|
|
||||||
|
// Auto-cleanup on disconnect.
|
||||||
|
go h.awaitIngestClose(rid, peer)
|
||||||
|
|
||||||
|
// Track ICE establishment duration using the shared ICE histograms
|
||||||
|
// (same metric family as WHEP egress, disambiguated by result label).
|
||||||
|
go h.trackICE(id, peer, time.Now())
|
||||||
|
|
||||||
|
h.recordRequest("publish", id, http.StatusCreated, t0)
|
||||||
|
|
||||||
|
// RFC 9261 §5.2: emit one Link header per configured ICE server so
|
||||||
|
// that the publisher (OBS, browser, GStreamer, etc.) can discover
|
||||||
|
// STUN/TURN without a separate signalling round-trip — symmetric
|
||||||
|
// with the WHEP Subscribe Link header added in issue #19.
|
||||||
|
for _, uri := range h.sub.ICEServerURIs() {
|
||||||
|
c.Response().Header().Add("Link", "<"+uri+">; rel=\"ice-server\"")
|
||||||
|
}
|
||||||
|
c.Response().Header().Set("Content-Type", "application/sdp")
|
||||||
|
c.Response().Header().Set("Location", "/whip/"+id+"/"+rid)
|
||||||
|
c.Response().Header().Set("ETag", `"`+rid+`"`)
|
||||||
|
return c.String(http.StatusCreated, peer.Answer().SDP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpublish handles DELETE /whip/:id/:resource. Returns 204 even when
|
||||||
|
// the resource is unknown (DELETE is idempotent, per the WHIP spec).
|
||||||
|
//
|
||||||
|
// @Summary Tear down a WHIP publish session
|
||||||
|
// @Tags v16.16.0
|
||||||
|
// @ID webrtc-3-whip-unpublish
|
||||||
|
// @Param id path string true "Process ID"
|
||||||
|
// @Param resource path string true "Resource ID from the Publish Location header"
|
||||||
|
// @Success 204 "no content"
|
||||||
|
// @Failure 400 {string} string "missing resource id"
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Router /api/v3/whip/{id}/{resource} [delete]
|
||||||
|
func (h *WHIPHandler) Unpublish(c echo.Context) error {
|
||||||
|
addCORS(c)
|
||||||
|
t0 := time.Now()
|
||||||
|
|
||||||
|
resource := c.Param("resource")
|
||||||
|
if resource == "" {
|
||||||
|
h.recordRequest("unpublish", "", http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, "missing resource id")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
streamID := h.ingestStream[resource]
|
||||||
|
var peer *corewebrtc.IngestPeer
|
||||||
|
if streamID != "" {
|
||||||
|
peer = h.ingestByStream[streamID][resource]
|
||||||
|
delete(h.ingestByStream[streamID], resource)
|
||||||
|
if len(h.ingestByStream[streamID]) == 0 {
|
||||||
|
delete(h.ingestByStream, streamID)
|
||||||
|
}
|
||||||
|
delete(h.ingestStream, resource)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if peer != nil {
|
||||||
|
_ = peer.Close()
|
||||||
|
}
|
||||||
|
if streamID != "" {
|
||||||
|
atomic.AddInt64(&h.count, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.recordRequest("unpublish", streamID, http.StatusNoContent, t0)
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrickleIngest handles PATCH /whip/:id/:resource — adds ICE candidates
|
||||||
|
// from a trickle-ice-sdpfrag body.
|
||||||
|
//
|
||||||
|
// @Summary Trickle ICE candidates for a WHIP publish session
|
||||||
|
// @Tags v16.16.0
|
||||||
|
// @ID webrtc-3-whip-trickle
|
||||||
|
// @Accept application/trickle-ice-sdpfrag
|
||||||
|
// @Param id path string true "Process ID"
|
||||||
|
// @Param resource path string true "Resource ID from the Publish Location header"
|
||||||
|
// @Success 204 "no content"
|
||||||
|
// @Failure 400 {string} string "missing resource id or unreadable body"
|
||||||
|
// @Failure 404 {string} string "peer not found"
|
||||||
|
// @Security ApiKeyAuth
|
||||||
|
// @Router /api/v3/whip/{id}/{resource} [patch]
|
||||||
|
func (h *WHIPHandler) TrickleIngest(c echo.Context) error {
|
||||||
|
addCORS(c)
|
||||||
|
t0 := time.Now()
|
||||||
|
|
||||||
|
resource := c.Param("resource")
|
||||||
|
if resource == "" {
|
||||||
|
h.recordRequest("trickle", "", http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, "missing resource id")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
streamID := h.ingestStream[resource]
|
||||||
|
var peer *corewebrtc.IngestPeer
|
||||||
|
if streamID != "" {
|
||||||
|
peer = h.ingestByStream[streamID][resource]
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
if peer == nil {
|
||||||
|
h.recordRequest("trickle", streamID, http.StatusNotFound, t0)
|
||||||
|
return c.NoContent(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(c.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
h.recordRequest("trickle", streamID, http.StatusBadRequest, t0)
|
||||||
|
return c.String(http.StatusBadRequest, "read body: "+err.Error())
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, "a=candidate:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cand := strings.TrimPrefix(line, "a=")
|
||||||
|
_ = peer.AddICECandidate(webrtc.ICECandidateInit{Candidate: cand})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.recordRequest("trickle", streamID, http.StatusNoContent, t0)
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close tears down every active ingest peer (e.g., during Core shutdown).
|
||||||
|
func (h *WHIPHandler) Close() {
|
||||||
|
h.mu.Lock()
|
||||||
|
peers := make([]*corewebrtc.IngestPeer, 0)
|
||||||
|
for _, m := range h.ingestByStream {
|
||||||
|
for _, p := range m {
|
||||||
|
peers = append(peers, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.ingestByStream = make(map[string]map[string]*corewebrtc.IngestPeer)
|
||||||
|
h.ingestStream = make(map[string]string)
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, p := range peers {
|
||||||
|
if p != nil {
|
||||||
|
_ = p.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
atomic.StoreInt64(&h.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaitIngestClose blocks on peer.Done() and yanks the index entry
|
||||||
|
// when the publisher disconnects. Idempotent with Unpublish.
|
||||||
|
func (h *WHIPHandler) awaitIngestClose(resource string, peer *corewebrtc.IngestPeer) {
|
||||||
|
<-peer.Done()
|
||||||
|
h.mu.Lock()
|
||||||
|
streamID := h.ingestStream[resource]
|
||||||
|
_, present := h.ingestStream[resource]
|
||||||
|
if present {
|
||||||
|
delete(h.ingestStream, resource)
|
||||||
|
if streamID != "" {
|
||||||
|
delete(h.ingestByStream[streamID], resource)
|
||||||
|
if len(h.ingestByStream[streamID]) == 0 {
|
||||||
|
delete(h.ingestByStream, streamID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
if present {
|
||||||
|
atomic.AddInt64(&h.count, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tearDownStreamIngests is called by the Subsystem's SetWHIPTeardownHook
|
||||||
|
// to close any active publisher when the FFmpeg process stops.
|
||||||
|
func (h *WHIPHandler) tearDownStreamIngests(streamID string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
bucket := h.ingestByStream[streamID]
|
||||||
|
peers := make([]*corewebrtc.IngestPeer, 0, len(bucket))
|
||||||
|
for _, p := range bucket {
|
||||||
|
peers = append(peers, p)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
for _, p := range peers {
|
||||||
|
if p != nil {
|
||||||
|
_ = p.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordRequest logs request metrics to the shared Prometheus metrics
|
||||||
|
// instance. No-ops if SetMetrics has not been called.
|
||||||
|
func (h *WHIPHandler) recordRequest(route, streamID string, code int, t0 time.Time) {
|
||||||
|
if h.met == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
codeStr := fmt.Sprintf("%d", code)
|
||||||
|
h.met.whipRequests.WithLabelValues(route, codeStr, streamID).Inc()
|
||||||
|
h.met.whipRequestDuration.WithLabelValues(route, streamID).Observe(time.Since(t0).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// preflight answers CORS OPTIONS requests.
|
||||||
|
func (h *WHIPHandler) preflight(c echo.Context) error {
|
||||||
|
addCORS(c)
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
278
app/webrtc/whip_handler_test.go
Normal file
278
app/webrtc/whip_handler_test.go
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
corewebrtc "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWHIPHandler_Publish_404WhenNoIngest verifies POST /whip/:id returns
|
||||||
|
// 404 when no process has registered a WHIP ingest for that id.
|
||||||
|
func TestWHIPHandler_Publish_404WhenNoIngest(t *testing.T) {
|
||||||
|
h := NewWHIPHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whip/ghost", strings.NewReader("v=0\r\n"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("ghost")
|
||||||
|
|
||||||
|
if err := h.Publish(c); err != nil {
|
||||||
|
t.Fatalf("Publish returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWHIPHandler_Publish_400OnEmptyBody verifies that an empty SDP body
|
||||||
|
// is rejected before any peer negotiation. A dummy ingest is registered so
|
||||||
|
// the handler reaches body validation.
|
||||||
|
func TestWHIPHandler_Publish_400OnEmptyBody(t *testing.T) {
|
||||||
|
sub := newTestSubsystem(t)
|
||||||
|
sub.mu.Lock()
|
||||||
|
sub.whipIngests["probe"] = &ingestStream{id: "probe", videoPort: 5100, audioPort: 5101}
|
||||||
|
sub.mu.Unlock()
|
||||||
|
|
||||||
|
h := NewWHIPHandler(sub, 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whip/probe", strings.NewReader(""))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("probe")
|
||||||
|
|
||||||
|
if err := h.Publish(c); err != nil {
|
||||||
|
t.Fatalf("Publish returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWHIPHandler_Publish_400OnNonSDP verifies that a body which doesn't
|
||||||
|
// start with "v=" is rejected as an invalid SDP.
|
||||||
|
func TestWHIPHandler_Publish_400OnNonSDP(t *testing.T) {
|
||||||
|
sub := newTestSubsystem(t)
|
||||||
|
sub.mu.Lock()
|
||||||
|
sub.whipIngests["probe"] = &ingestStream{id: "probe", videoPort: 5102, audioPort: 5103}
|
||||||
|
sub.mu.Unlock()
|
||||||
|
|
||||||
|
h := NewWHIPHandler(sub, 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whip/probe",
|
||||||
|
strings.NewReader("not-an-sdp-body"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("probe")
|
||||||
|
|
||||||
|
if err := h.Publish(c); err != nil {
|
||||||
|
t.Fatalf("Publish returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWHIPHandler_Publish_409OnSecondPublisher verifies that attempting to
|
||||||
|
// publish a second time on the same stream while a publisher is already
|
||||||
|
// active returns 409 Conflict, not 201, and does not increment the counter.
|
||||||
|
func TestWHIPHandler_Publish_409OnSecondPublisher(t *testing.T) {
|
||||||
|
sub := newTestSubsystem(t)
|
||||||
|
sub.mu.Lock()
|
||||||
|
sub.whipIngests["probe"] = &ingestStream{id: "probe", videoPort: 5104, audioPort: 5105}
|
||||||
|
sub.mu.Unlock()
|
||||||
|
|
||||||
|
h := NewWHIPHandler(sub, 0)
|
||||||
|
|
||||||
|
// Inject a fake active publisher directly into the handler's index.
|
||||||
|
// We use a nil *IngestPeer because the 409 check only tests map length
|
||||||
|
// and never dereferences the peer pointer.
|
||||||
|
h.mu.Lock()
|
||||||
|
h.ingestByStream["probe"] = map[string]*corewebrtc.IngestPeer{
|
||||||
|
"existing-rid": nil,
|
||||||
|
}
|
||||||
|
h.ingestStream["existing-rid"] = "probe"
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
// Verify initial count is 0 (the fake was injected, not published).
|
||||||
|
if c := h.PublisherCount(); c != 0 {
|
||||||
|
t.Fatalf("expected initial count 0, got %d", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whip/probe",
|
||||||
|
strings.NewReader("v=0\r\nm=video 0 RTP/AVP 96\r\n"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx := e.NewContext(req, rec)
|
||||||
|
ctx.SetParamNames("id")
|
||||||
|
ctx.SetParamValues("probe")
|
||||||
|
|
||||||
|
if err := h.Publish(ctx); err != nil {
|
||||||
|
t.Fatalf("Publish returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
// Count must not have incremented on the rejected request.
|
||||||
|
if c := h.PublisherCount(); c != 0 {
|
||||||
|
t.Errorf("expected count still 0 after 409, got %d", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWHIPHandler_Unpublish_204WhenUnknown verifies DELETE returns 204
|
||||||
|
// even for unknown resource ids — idempotent per the WHIP spec.
|
||||||
|
func TestWHIPHandler_Unpublish_204WhenUnknown(t *testing.T) {
|
||||||
|
h := NewWHIPHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/whip/id/unknown-resource", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id", "resource")
|
||||||
|
c.SetParamValues("id", "unknown-resource")
|
||||||
|
|
||||||
|
if err := h.Unpublish(c); err != nil {
|
||||||
|
t.Fatalf("Unpublish returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWHIPHandler_Unpublish_400OnMissingResource verifies DELETE without
|
||||||
|
// a resource id param returns 400.
|
||||||
|
func TestWHIPHandler_Unpublish_400OnMissingResource(t *testing.T) {
|
||||||
|
h := NewWHIPHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/whip/id/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id", "resource")
|
||||||
|
c.SetParamValues("id", "")
|
||||||
|
|
||||||
|
if err := h.Unpublish(c); err != nil {
|
||||||
|
t.Fatalf("Unpublish returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWHIPHandler_TrickleIngest_404WhenPeerUnknown verifies PATCH returns
|
||||||
|
// 404 when there is no peer registered for the resource id.
|
||||||
|
func TestWHIPHandler_TrickleIngest_404WhenPeerUnknown(t *testing.T) {
|
||||||
|
h := NewWHIPHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/whip/id/ghost",
|
||||||
|
strings.NewReader("a=candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host\r\n"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id", "resource")
|
||||||
|
c.SetParamValues("id", "ghost")
|
||||||
|
|
||||||
|
if err := h.TrickleIngest(c); err != nil {
|
||||||
|
t.Fatalf("TrickleIngest returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWHIPHandler_TrickleIngest_400OnMissingResource verifies PATCH
|
||||||
|
// without a resource id returns 400.
|
||||||
|
func TestWHIPHandler_TrickleIngest_400OnMissingResource(t *testing.T) {
|
||||||
|
h := NewWHIPHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/whip/id/",
|
||||||
|
strings.NewReader("a=candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host\r\n"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id", "resource")
|
||||||
|
c.SetParamValues("id", "")
|
||||||
|
|
||||||
|
if err := h.TrickleIngest(c); err != nil {
|
||||||
|
t.Fatalf("TrickleIngest returned error: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWHIPHandler_Preflight_ExposesLinkHeader verifies that CORS preflight
|
||||||
|
// responses include "Link" in Access-Control-Expose-Headers so browsers
|
||||||
|
// can read the RFC 9261 §5.2 Link headers on the 201 Publish response.
|
||||||
|
func TestWHIPHandler_Preflight_ExposesLinkHeader(t *testing.T) {
|
||||||
|
h := NewWHIPHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/whip/any", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("any")
|
||||||
|
|
||||||
|
if err := h.preflight(c); err != nil {
|
||||||
|
t.Fatalf("preflight returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expose := rec.Header().Get("Access-Control-Expose-Headers")
|
||||||
|
if !strings.Contains(expose, "Link") {
|
||||||
|
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Link'", expose)
|
||||||
|
}
|
||||||
|
if !strings.Contains(expose, "Location") {
|
||||||
|
t.Errorf("Access-Control-Expose-Headers %q does not contain 'Location'", expose)
|
||||||
|
}
|
||||||
|
if !strings.Contains(expose, "ETag") {
|
||||||
|
t.Errorf("Access-Control-Expose-Headers %q does not contain 'ETag'", expose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWHIPHandler_SetMetrics_DoesNotPanic verifies that SetMetrics accepts
|
||||||
|
// a nil argument without panicking (nil-safe guard for wiring code).
|
||||||
|
func TestWHIPHandler_SetMetrics_DoesNotPanic(t *testing.T) {
|
||||||
|
h := NewWHIPHandler(newTestSubsystem(t), 0)
|
||||||
|
// nil metrics is explicitly allowed — recordRequest guards on h.met == nil.
|
||||||
|
h.SetMetrics(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWHIPHandler_PublisherCount_ZeroOnEmpty verifies that a freshly
|
||||||
|
// constructed handler reports 0 active publishers.
|
||||||
|
func TestWHIPHandler_PublisherCount_ZeroOnEmpty(t *testing.T) {
|
||||||
|
h := NewWHIPHandler(newTestSubsystem(t), 0)
|
||||||
|
if n := h.PublisherCount(); n != 0 {
|
||||||
|
t.Errorf("expected 0 publishers on empty handler, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWHIPHandler_Publish_CORSHeadersPresent verifies that every Publish
|
||||||
|
// response (even a 404) carries the CORS headers required for cross-origin
|
||||||
|
// browser-based publishers.
|
||||||
|
func TestWHIPHandler_Publish_CORSHeadersPresent(t *testing.T) {
|
||||||
|
h := NewWHIPHandler(newTestSubsystem(t), 0)
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/whip/ghost", strings.NewReader("v=0\r\n"))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id")
|
||||||
|
c.SetParamValues("ghost")
|
||||||
|
|
||||||
|
_ = h.Publish(c)
|
||||||
|
|
||||||
|
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||||
|
t.Error("expected Access-Control-Allow-Origin: *")
|
||||||
|
}
|
||||||
|
}
|
||||||
184
app/webrtc/whip_lifecycle.go
Normal file
184
app/webrtc/whip_lifecycle.go
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ingestStream captures the two loopback UDP ports that FFmpeg binds
|
||||||
|
// for WHIP ingest — video on videoPort, audio on audioPort.
|
||||||
|
// The WHIPHandler writes received WebRTC RTP to these ports.
|
||||||
|
type ingestStream struct {
|
||||||
|
id string
|
||||||
|
videoPort int
|
||||||
|
audioPort int
|
||||||
|
}
|
||||||
|
|
||||||
|
// onWHIPProcessStart is registered as the restream OnInputStart hook.
|
||||||
|
// It fires just before FFmpeg starts, holding the restream write lock.
|
||||||
|
//
|
||||||
|
// When the per-process WHIPIngest config is disabled it returns (nil, nil)
|
||||||
|
// so FFmpeg starts normally. When enabled it:
|
||||||
|
//
|
||||||
|
// 1. Allocates two adjacent loopback UDP ports (video on V, audio on V+1)
|
||||||
|
// using the same retry strategy as the WHEP egress allocator.
|
||||||
|
// 2. Registers the pair under the process ID in whipIngests so the
|
||||||
|
// WHIPHandler can route incoming WebRTC RTP to them.
|
||||||
|
// 3. Returns two RTP ConfigIO input legs that FFmpeg will open as UDP
|
||||||
|
// listeners. The restream manager prepends them to cfg.Input and
|
||||||
|
// rebuilds the FFmpeg command before Start().
|
||||||
|
func (s *Subsystem) onWHIPProcessStart(id string, cfg *appcfg.Config) ([]appcfg.ConfigIO, error) {
|
||||||
|
if cfg == nil || !cfg.WHIPIngest.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize PTs — zero values mean "use defaults".
|
||||||
|
wcfg := cfg.WHIPIngest
|
||||||
|
if wcfg.VideoPT == 0 {
|
||||||
|
wcfg.VideoPT = defaultVideoPT
|
||||||
|
}
|
||||||
|
if wcfg.AudioPT == 0 {
|
||||||
|
wcfg.AudioPT = defaultAudioPT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuse to re-register.
|
||||||
|
s.mu.Lock()
|
||||||
|
if _, exists := s.whipIngests[id]; exists {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("webrtc: whip: process %q already has an active ingest", id)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Find an adjacent pair (V, V+1). The same retry logic used by
|
||||||
|
// the WHEP egress allocator (allocAdjacentPair) except we only
|
||||||
|
// need port numbers, not Source objects.
|
||||||
|
videoPort, err := allocAdjacentPortPair()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("webrtc: whip: allocate port pair for process %q: %w", id, err)
|
||||||
|
}
|
||||||
|
audioPort := videoPort + 1
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.whipIngests[id] = &ingestStream{
|
||||||
|
id: id,
|
||||||
|
videoPort: videoPort,
|
||||||
|
audioPort: audioPort,
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.logger.WithFields(map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"video_port": videoPort,
|
||||||
|
"audio_port": audioPort,
|
||||||
|
"video_pt": wcfg.VideoPT,
|
||||||
|
"audio_pt": wcfg.AudioPT,
|
||||||
|
}).Info().Log("WebRTC WHIP ingest registered for process")
|
||||||
|
|
||||||
|
return buildWHIPInputLegs(wcfg, videoPort), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onWHIPProcessStop is registered as the restream OnInputStop hook.
|
||||||
|
// It fires just after FFmpeg has been stopped. It removes the port
|
||||||
|
// allocation and signals the WHIPHandler to close any active publisher.
|
||||||
|
func (s *Subsystem) onWHIPProcessStop(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
_, ok := s.whipIngests[id]
|
||||||
|
teardown := s.whipTeardown
|
||||||
|
if ok {
|
||||||
|
delete(s.whipIngests, id)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if teardown != nil {
|
||||||
|
teardown(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.WithField("id", id).Info().Log("WebRTC WHIP ingest torn down for process")
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocAdjacentPortPair finds two consecutive free loopback UDP ports
|
||||||
|
// (V, V+1) and returns V. It retries up to allocAttempts times because
|
||||||
|
// the kernel may hand us a port whose +1 neighbor is already taken.
|
||||||
|
//
|
||||||
|
// The caller owns the returned port numbers; FFmpeg will bind them
|
||||||
|
// immediately on process start via the ConfigIO input legs.
|
||||||
|
func allocAdjacentPortPair() (int, error) {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < allocAttempts; attempt++ {
|
||||||
|
videoPort, err := Alloc()
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
audioPort := videoPort + 1
|
||||||
|
if audioPort > 65535 {
|
||||||
|
lastErr = fmt.Errorf("video port %d would push audio port above 65535", videoPort)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Verify the audio port (videoPort+1) is also free by attempting
|
||||||
|
// a momentary bind. TOCTOU race is accepted; FFmpeg will fail-fast
|
||||||
|
// on the actual bind and the process will restart cleanly.
|
||||||
|
if err := checkPortFree(audioPort); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return videoPort, nil
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("unknown allocation failure")
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("after %d attempts: %w", allocAttempts, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPortFree attempts a momentary UDP bind on the given loopback
|
||||||
|
// port. Returns nil if the port appears available, non-nil otherwise.
|
||||||
|
func checkPortFree(port int) error {
|
||||||
|
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("port %d not free: %w", port, err)
|
||||||
|
}
|
||||||
|
_ = c.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWHIPInputLegs produces the two FFmpeg input ConfigIO legs for
|
||||||
|
// WHIP ingest. FFmpeg opens each as a UDP RTP listener:
|
||||||
|
//
|
||||||
|
// -i udp://127.0.0.1:V?overrun_nonfatal=1&fifo_size=50000000
|
||||||
|
// -i udp://127.0.0.1:A?overrun_nonfatal=1&fifo_size=50000000
|
||||||
|
//
|
||||||
|
// The IngestPeer.forwardTrack goroutine writes received WebRTC RTP to
|
||||||
|
// these ports once the WHIP publisher connects.
|
||||||
|
func buildWHIPInputLegs(cfg appcfg.ConfigWHIPIngest, videoPort int) []appcfg.ConfigIO {
|
||||||
|
audioPort := videoPort + 1
|
||||||
|
return []appcfg.ConfigIO{
|
||||||
|
{
|
||||||
|
ID: "whip:video",
|
||||||
|
Address: fmt.Sprintf("udp://127.0.0.1:%d?overrun_nonfatal=1&fifo_size=50000000", videoPort),
|
||||||
|
Options: []string{
|
||||||
|
"-re",
|
||||||
|
"-protocol_whitelist", "udp,rtp",
|
||||||
|
"-fflags", "+genpts",
|
||||||
|
"-payload_type", fmt.Sprint(cfg.VideoPT),
|
||||||
|
"-codec:v", "copy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "whip:audio",
|
||||||
|
Address: fmt.Sprintf("udp://127.0.0.1:%d?overrun_nonfatal=1&fifo_size=50000000", audioPort),
|
||||||
|
Options: []string{
|
||||||
|
"-re",
|
||||||
|
"-protocol_whitelist", "udp,rtp",
|
||||||
|
"-fflags", "+genpts",
|
||||||
|
"-payload_type", fmt.Sprint(cfg.AudioPT),
|
||||||
|
"-codec:a", "copy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,7 @@ func (d *Config) Clone() *Config {
|
||||||
data.Storage = d.Storage
|
data.Storage = d.Storage
|
||||||
data.RTMP = d.RTMP
|
data.RTMP = d.RTMP
|
||||||
data.SRT = d.SRT
|
data.SRT = d.SRT
|
||||||
|
data.WebRTC = d.WebRTC
|
||||||
data.FFmpeg = d.FFmpeg
|
data.FFmpeg = d.FFmpeg
|
||||||
data.Playout = d.Playout
|
data.Playout = d.Playout
|
||||||
data.Debug = d.Debug
|
data.Debug = d.Debug
|
||||||
|
|
@ -131,6 +132,9 @@ func (d *Config) Clone() *Config {
|
||||||
|
|
||||||
data.SRT.Log.Topics = copy.Slice(d.SRT.Log.Topics)
|
data.SRT.Log.Topics = copy.Slice(d.SRT.Log.Topics)
|
||||||
|
|
||||||
|
data.WebRTC.NAT1To1IPs = copy.Slice(d.WebRTC.NAT1To1IPs)
|
||||||
|
data.WebRTC.ICEServers = copy.Slice(d.WebRTC.ICEServers)
|
||||||
|
|
||||||
data.Router.BlockedPrefixes = copy.Slice(d.Router.BlockedPrefixes)
|
data.Router.BlockedPrefixes = copy.Slice(d.Router.BlockedPrefixes)
|
||||||
data.Router.Routes = copy.StringMap(d.Router.Routes)
|
data.Router.Routes = copy.StringMap(d.Router.Routes)
|
||||||
|
|
||||||
|
|
@ -227,6 +231,13 @@ func (d *Config) init() {
|
||||||
d.vars.Register(value.NewBool(&d.SRT.Log.Enable, false), "srt.log.enable", "CORE_SRT_LOG_ENABLE", nil, "Enable SRT server logging", false, false)
|
d.vars.Register(value.NewBool(&d.SRT.Log.Enable, false), "srt.log.enable", "CORE_SRT_LOG_ENABLE", nil, "Enable SRT server logging", false, false)
|
||||||
d.vars.Register(value.NewStringList(&d.SRT.Log.Topics, []string{}, ","), "srt.log.topics", "CORE_SRT_LOG_TOPICS", nil, "List of topics to log", false, false)
|
d.vars.Register(value.NewStringList(&d.SRT.Log.Topics, []string{}, ","), "srt.log.topics", "CORE_SRT_LOG_TOPICS", nil, "List of topics to log", false, false)
|
||||||
|
|
||||||
|
// WebRTC (Dragon Fork M2)
|
||||||
|
d.vars.Register(value.NewBool(&d.WebRTC.Enable, false), "webrtc.enable", "CORE_WEBRTC_ENABLE", nil, "Enable WebRTC egress subsystem", false, false)
|
||||||
|
d.vars.Register(value.NewString(&d.WebRTC.PublicIP, ""), "webrtc.public_ip", "CORE_WEBRTC_PUBLIC_IP", nil, "ICE NAT1To1 host candidate IP (LAN or public)", false, false)
|
||||||
|
d.vars.Register(value.NewStringList(&d.WebRTC.NAT1To1IPs, []string{}, " "), "webrtc.nat_1_to_1_ips", "CORE_WEBRTC_NAT_1_TO_1_IPS", nil, "Advanced: multiple NAT1To1 IPs", false, false)
|
||||||
|
d.vars.Register(value.NewInt(&d.WebRTC.UDPMuxPort, 0), "webrtc.udp_mux_port", "CORE_WEBRTC_UDP_MUX_PORT", nil, "Single UDP port for all ICE traffic (0 = ephemeral)", false, false)
|
||||||
|
d.vars.Register(value.NewStringList(&d.WebRTC.ICEServers, []string{}, ","), "webrtc.ice_servers", "CORE_WEBRTC_ICE_SERVERS", nil, "Comma-separated STUN/TURN URIs overriding built-in defaults (e.g. stun:stun.example.com:3478)", false, false)
|
||||||
|
|
||||||
// FFmpeg
|
// FFmpeg
|
||||||
d.vars.Register(value.NewExec(&d.FFmpeg.Binary, "ffmpeg", d.fs), "ffmpeg.binary", "CORE_FFMPEG_BINARY", nil, "Path to ffmpeg binary", true, false)
|
d.vars.Register(value.NewExec(&d.FFmpeg.Binary, "ffmpeg", d.fs), "ffmpeg.binary", "CORE_FFMPEG_BINARY", nil, "Path to ffmpeg binary", true, false)
|
||||||
d.vars.Register(value.NewInt64(&d.FFmpeg.MaxProcesses, 0), "ffmpeg.max_processes", "CORE_FFMPEG_MAXPROCESSES", nil, "Max. allowed simultaneously running ffmpeg instances, 0 for unlimited", false, false)
|
d.vars.Register(value.NewInt64(&d.FFmpeg.MaxProcesses, 0), "ffmpeg.max_processes", "CORE_FFMPEG_MAXPROCESSES", nil, "Max. allowed simultaneously running ffmpeg instances, 0 for unlimited", false, false)
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,33 @@ func TestConfigCopy(t *testing.T) {
|
||||||
require.Equal(t, []string{"foo.com"}, config2.Host.Name)
|
require.Equal(t, []string{"foo.com"}, config2.Host.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConfigCopyWebRTC is a regression test for Clone() silently dropping the
|
||||||
|
// WebRTC Data section. The first live M2 deploy surfaced this: env vars bound
|
||||||
|
// correctly onto the original Config, but Core handed the clone to app/api, so
|
||||||
|
// cfg.WebRTC.Enable was always the zero value and the subsystem was skipped.
|
||||||
|
func TestConfigCopyWebRTC(t *testing.T) {
|
||||||
|
fs, _ := fs.NewMemFilesystem(fs.MemConfig{})
|
||||||
|
config1 := New(fs)
|
||||||
|
|
||||||
|
config1.WebRTC.Enable = true
|
||||||
|
config1.WebRTC.PublicIP = "10.0.0.25"
|
||||||
|
config1.WebRTC.NAT1To1IPs = []string{"10.0.0.25", "203.0.113.10"}
|
||||||
|
config1.WebRTC.UDPMuxPort = 45000
|
||||||
|
|
||||||
|
config2 := config1.Clone()
|
||||||
|
|
||||||
|
require.Equal(t, true, config2.WebRTC.Enable)
|
||||||
|
require.Equal(t, "10.0.0.25", config2.WebRTC.PublicIP)
|
||||||
|
require.Equal(t, []string{"10.0.0.25", "203.0.113.10"}, config2.WebRTC.NAT1To1IPs)
|
||||||
|
require.Equal(t, 45000, config2.WebRTC.UDPMuxPort)
|
||||||
|
|
||||||
|
// NAT1To1IPs is a slice — mutating the clone must not affect the
|
||||||
|
// source, which is what every other section guarantees via
|
||||||
|
// copy.Slice. Same contract for WebRTC.
|
||||||
|
config2.WebRTC.NAT1To1IPs[0] = "mutated"
|
||||||
|
require.Equal(t, "10.0.0.25", config1.WebRTC.NAT1To1IPs[0])
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateDefault(t *testing.T) {
|
func TestValidateDefault(t *testing.T) {
|
||||||
fs, err := fs.NewMemFilesystem(fs.MemConfig{})
|
fs, err := fs.NewMemFilesystem(fs.MemConfig{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ type Data struct {
|
||||||
Topics []string `json:"topics"`
|
Topics []string `json:"topics"`
|
||||||
} `json:"log"`
|
} `json:"log"`
|
||||||
} `json:"srt"`
|
} `json:"srt"`
|
||||||
|
WebRTC DataWebRTC `json:"webrtc"`
|
||||||
FFmpeg struct {
|
FFmpeg struct {
|
||||||
Binary string `json:"binary"`
|
Binary string `json:"binary"`
|
||||||
MaxProcesses int64 `json:"max_processes" format:"int64"`
|
MaxProcesses int64 `json:"max_processes" format:"int64"`
|
||||||
|
|
@ -334,3 +335,17 @@ func DowngradeV3toV2(d *Data) (*v2.Data, error) {
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DataWebRTC is the global WebRTC egress configuration. Promoted to a
|
||||||
|
// named type so the app/webrtc subsystem can accept it by value.
|
||||||
|
type DataWebRTC struct {
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
PublicIP string `json:"public_ip"`
|
||||||
|
NAT1To1IPs []string `json:"nat_1_to_1_ips"`
|
||||||
|
UDPMuxPort int `json:"udp_mux_port" format:"int"`
|
||||||
|
// ICEServers is an optional operator-supplied list of STUN/TURN URIs
|
||||||
|
// (e.g. "stun:stun.example.com:3478", "turn:user:pass@turn.example.com").
|
||||||
|
// When non-empty it overrides the built-in default STUN servers used by
|
||||||
|
// the WebRTC subsystem. Exposed via CORE_WEBRTC_ICE_SERVERS (comma-separated).
|
||||||
|
ICEServers []string `json:"ice_servers"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,18 @@ type Config struct {
|
||||||
|
|
||||||
// PublicIP is the server's externally-reachable IP, advertised in ICE
|
// PublicIP is the server's externally-reachable IP, advertised in ICE
|
||||||
// candidates via NAT1To1. Empty means rely on STUN discovery.
|
// candidates via NAT1To1. Empty means rely on STUN discovery.
|
||||||
|
// Deprecated in favour of NAT1To1IPs for multi-homed servers; when both
|
||||||
|
// are set, PublicIP is treated as the first entry in NAT1To1IPs.
|
||||||
PublicIP string
|
PublicIP string
|
||||||
|
|
||||||
|
// NAT1To1IPs is the list of NAT1To1 IPs for ICE host candidates.
|
||||||
|
// When non-empty, Pion advertises a host candidate for each IP so that
|
||||||
|
// peers can reach this server through NAT on any of the listed addresses.
|
||||||
|
// Takes precedence over PublicIP when set; PublicIP is treated as a member
|
||||||
|
// of this list when both are configured. Typical use: dual-homed servers
|
||||||
|
// with both a LAN IP and a public IP.
|
||||||
|
NAT1To1IPs []string
|
||||||
|
|
||||||
// UDPPortRange bounds the local UDP ports allocated for FFmpeg→Pion RTP.
|
// UDPPortRange bounds the local UDP ports allocated for FFmpeg→Pion RTP.
|
||||||
UDPPortRange PortRange
|
UDPPortRange PortRange
|
||||||
|
|
||||||
|
|
@ -35,6 +45,7 @@ func DefaultConfig() Config {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
WHEPListen: ":8787",
|
WHEPListen: ":8787",
|
||||||
PublicIP: "",
|
PublicIP: "",
|
||||||
|
NAT1To1IPs: nil,
|
||||||
UDPPortRange: PortRange{Low: 10000, High: 10100},
|
UDPPortRange: PortRange{Low: 10000, High: 10100},
|
||||||
ICEServers: []string{"stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"},
|
ICEServers: []string{"stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"},
|
||||||
MaxPeersTotal: 32,
|
MaxPeersTotal: 32,
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// forwardRTP reads packets from sub and writes them to the correct track
|
// forwardRTP reads packets from sub and writes them to the correct track
|
||||||
// based on payload type (H.264 → video, Opus → audio). Payload-type
|
// based on payload type (H.264 → video, Opus → audio). Used by the M1
|
||||||
// inspection is the simplest M1 approach; M2 will switch to per-track
|
// single-source PoC where FFmpeg emits both video and audio RTP to the
|
||||||
// source channels once the process resolver manages separate video/audio
|
// same UDP port.
|
||||||
// UDP ports.
|
|
||||||
func forwardRTP(done <-chan struct{}, sub <-chan *rtp.Packet,
|
func forwardRTP(done <-chan struct{}, sub <-chan *rtp.Packet,
|
||||||
video, audio *webrtc.TrackLocalStaticRTP) {
|
video, audio *webrtc.TrackLocalStaticRTP) {
|
||||||
for {
|
for {
|
||||||
|
|
@ -35,3 +34,29 @@ func forwardRTP(done <-chan struct{}, sub <-chan *rtp.Packet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forwardRTPSplit is the M2 variant: it reads from two independent
|
||||||
|
// per-track channels (one video, one audio) and writes each to its
|
||||||
|
// own Pion track. This is the mode used when the restream manager
|
||||||
|
// emits two FFmpeg RTP legs on separate UDP ports. Either channel
|
||||||
|
// closing or done firing terminates the loop.
|
||||||
|
func forwardRTPSplit(done <-chan struct{},
|
||||||
|
videoSub <-chan *rtp.Packet, audioSub <-chan *rtp.Packet,
|
||||||
|
video, audio *webrtc.TrackLocalStaticRTP) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case pkt, ok := <-videoSub:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = video.WriteRTP(pkt)
|
||||||
|
case pkt, ok := <-audioSub:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = audio.WriteRTP(pkt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,14 @@ import (
|
||||||
// PeerConnection needs: a webrtc.Configuration (with ICE servers) and a
|
// PeerConnection needs: a webrtc.Configuration (with ICE servers) and a
|
||||||
// SettingEngine (with NAT1To1 and port range tuning).
|
// SettingEngine (with NAT1To1 and port range tuning).
|
||||||
//
|
//
|
||||||
|
// NAT1To1 IP resolution order:
|
||||||
|
// 1. NAT1To1IPs — the full list is passed directly to Pion when non-empty.
|
||||||
|
// 2. PublicIP — promoted to a single-element NAT1To1IPs list for backward
|
||||||
|
// compatibility with configs that only set PublicIP.
|
||||||
|
// 3. Neither set — STUN-only mode; no host candidates are injected.
|
||||||
|
//
|
||||||
// The returned *SettingEngine may be nil if no engine-level tuning is
|
// The returned *SettingEngine may be nil if no engine-level tuning is
|
||||||
// required (i.e. PublicIP unset and UDPPortRange at defaults). Callers
|
// required (i.e. no NAT1To1 IPs and UDPPortRange at defaults). Callers
|
||||||
// should only pass it to webrtc.NewAPI when non-nil.
|
// should only pass it to webrtc.NewAPI when non-nil.
|
||||||
func BuildICEConfig(c Config) (webrtc.Configuration, *webrtc.SettingEngine, error) {
|
func BuildICEConfig(c Config) (webrtc.Configuration, *webrtc.SettingEngine, error) {
|
||||||
if err := c.Validate(); err != nil {
|
if err := c.Validate(); err != nil {
|
||||||
|
|
@ -25,11 +31,20 @@ func BuildICEConfig(c Config) (webrtc.Configuration, *webrtc.SettingEngine, erro
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the effective NAT1To1 IP list.
|
||||||
|
// Prefer the explicit NAT1To1IPs slice; fall back to PublicIP as a
|
||||||
|
// single-element list so that legacy configs (PublicIP only) continue
|
||||||
|
// to work without operator changes.
|
||||||
|
nat1to1 := c.NAT1To1IPs
|
||||||
|
if len(nat1to1) == 0 && c.PublicIP != "" {
|
||||||
|
nat1to1 = []string{c.PublicIP}
|
||||||
|
}
|
||||||
|
|
||||||
var se *webrtc.SettingEngine
|
var se *webrtc.SettingEngine
|
||||||
if c.PublicIP != "" || c.UDPPortRange.Low > 0 {
|
if len(nat1to1) > 0 || c.UDPPortRange.Low > 0 {
|
||||||
engine := webrtc.SettingEngine{}
|
engine := webrtc.SettingEngine{}
|
||||||
if c.PublicIP != "" {
|
if len(nat1to1) > 0 {
|
||||||
engine.SetNAT1To1IPs([]string{c.PublicIP}, webrtc.ICECandidateTypeHost)
|
engine.SetNAT1To1IPs(nat1to1, webrtc.ICECandidateTypeHost)
|
||||||
}
|
}
|
||||||
// Constrain the ephemeral UDP range Pion allocates for ICE candidates.
|
// Constrain the ephemeral UDP range Pion allocates for ICE candidates.
|
||||||
// Note: this is a separate concern from our FFmpeg→Source UDP ports;
|
// Note: this is a separate concern from our FFmpeg→Source UDP ports;
|
||||||
|
|
|
||||||
|
|
@ -48,3 +48,76 @@ func TestBuildICEConfig_InvalidConfig(t *testing.T) {
|
||||||
t.Error("BuildICEConfig should reject invalid config")
|
t.Error("BuildICEConfig should reject invalid config")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuildICEConfig_NAT1To1IPs_Multi verifies that a list of multiple
|
||||||
|
// NAT1To1 IPs is accepted and a SettingEngine is returned, allowing
|
||||||
|
// dual-homed servers to advertise host candidates on all interfaces.
|
||||||
|
func TestBuildICEConfig_NAT1To1IPs_Multi(t *testing.T) {
|
||||||
|
c := DefaultConfig()
|
||||||
|
c.NAT1To1IPs = []string{"10.0.0.1", "203.0.113.10"}
|
||||||
|
_, se, err := BuildICEConfig(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildICEConfig with multiple NAT1To1IPs: %v", err)
|
||||||
|
}
|
||||||
|
if se == nil {
|
||||||
|
t.Fatal("SettingEngine should not be nil when NAT1To1IPs is set")
|
||||||
|
}
|
||||||
|
// Smoke-test: Pion should accept the engine without panicking.
|
||||||
|
api := webrtc.NewAPI(webrtc.WithSettingEngine(*se))
|
||||||
|
if api == nil {
|
||||||
|
t.Fatal("NewAPI returned nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildICEConfig_NAT1To1IPs_FallsBackToPublicIP verifies that when
|
||||||
|
// NAT1To1IPs is empty but PublicIP is set, the single IP is promoted to
|
||||||
|
// the NAT1To1 list (backward-compat path).
|
||||||
|
func TestBuildICEConfig_NAT1To1IPs_FallsBackToPublicIP(t *testing.T) {
|
||||||
|
c := DefaultConfig()
|
||||||
|
c.PublicIP = "198.51.100.1"
|
||||||
|
c.NAT1To1IPs = nil
|
||||||
|
_, se, err := BuildICEConfig(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildICEConfig (PublicIP fallback): %v", err)
|
||||||
|
}
|
||||||
|
if se == nil {
|
||||||
|
t.Fatal("SettingEngine should be set when PublicIP is used as fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildICEConfig_NAT1To1IPs_TakesPrecedenceOverPublicIP verifies that
|
||||||
|
// when both NAT1To1IPs and PublicIP are set, a SettingEngine is still
|
||||||
|
// returned (the subsystem merges them; BuildICEConfig sees only NAT1To1IPs).
|
||||||
|
func TestBuildICEConfig_NAT1To1IPs_TakesPrecedenceOverPublicIP(t *testing.T) {
|
||||||
|
c := DefaultConfig()
|
||||||
|
c.PublicIP = "198.51.100.1"
|
||||||
|
c.NAT1To1IPs = []string{"10.0.0.1", "198.51.100.1"}
|
||||||
|
_, se, err := BuildICEConfig(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildICEConfig (NAT1To1IPs + PublicIP): %v", err)
|
||||||
|
}
|
||||||
|
if se == nil {
|
||||||
|
t.Fatal("SettingEngine should not be nil when NAT1To1IPs is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildICEConfig_NAT1To1IPs_NeitherSet verifies that with no IP hints
|
||||||
|
// the function still succeeds (STUN-only mode). A SettingEngine is still
|
||||||
|
// returned because the default UDPPortRange is non-zero.
|
||||||
|
func TestBuildICEConfig_NAT1To1IPs_NeitherSet(t *testing.T) {
|
||||||
|
c := DefaultConfig()
|
||||||
|
c.PublicIP = ""
|
||||||
|
c.NAT1To1IPs = nil
|
||||||
|
_, se, err := BuildICEConfig(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildICEConfig (STUN-only): %v", err)
|
||||||
|
}
|
||||||
|
// UDPPortRange.Low > 0 in DefaultConfig, so se is non-nil; verify it
|
||||||
|
// builds without error.
|
||||||
|
if se != nil {
|
||||||
|
api := webrtc.NewAPI(webrtc.WithSettingEngine(*se))
|
||||||
|
if api == nil {
|
||||||
|
t.Fatal("NewAPI returned nil in STUN-only mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
102
core/webrtc/keyframecache.go
Normal file
102
core/webrtc/keyframecache.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyFrameCache retains the most recent H.264 keyframe burst so that
|
||||||
|
// new WHEP subscribers can receive it immediately on Subscribe(),
|
||||||
|
// cutting first-frame latency from up to one IDR interval (typically
|
||||||
|
// 2 s at a 0.5 Hz keyframe rate) to nearly zero.
|
||||||
|
//
|
||||||
|
// A "burst" spans all RTP packets from the first fragment of an IDR NAL
|
||||||
|
// until (but not including) the next IDR NAL. The cache is bounded by
|
||||||
|
// maxPackets and maxBytes to cap per-stream memory usage.
|
||||||
|
//
|
||||||
|
// Thread safety: all public methods are safe for concurrent use.
|
||||||
|
// push() is intended to be called only from the single-goroutine
|
||||||
|
// readLoop — the lock it holds is small and brief.
|
||||||
|
type keyFrameCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
packets []*rtp.Packet
|
||||||
|
byteLen int
|
||||||
|
maxPackets int
|
||||||
|
maxBytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newKeyFrameCache returns a cache bounded to 512 packets / 2 MiB.
|
||||||
|
// At typical H.264 streaming bitrates (1–4 Mbps), an IDR frame plus a
|
||||||
|
// handful of subsequent P-frames fits comfortably within these limits.
|
||||||
|
func newKeyFrameCache() *keyFrameCache {
|
||||||
|
return &keyFrameCache{
|
||||||
|
packets: make([]*rtp.Packet, 0, 64),
|
||||||
|
maxPackets: 512,
|
||||||
|
maxBytes: 2 << 20, // 2 MiB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isH264IDRStart returns true if pkt begins an H.264 IDR (keyframe)
|
||||||
|
// NAL. It recognises three RFC 6184 packetisation modes:
|
||||||
|
//
|
||||||
|
// - Single NAL unit (type 5): the entire payload is one IDR slice.
|
||||||
|
// - FU-A fragment (type 28): the FU header byte has the start bit set
|
||||||
|
// (0x80) and the inner NAL type is 5.
|
||||||
|
// - STAP-A aggregate (type 24): the first NAL in the aggregate is an
|
||||||
|
// IDR slice. STAP-A format: byte 0 = NAL header (type 24), bytes
|
||||||
|
// 1–2 = first NAL size (big-endian uint16), byte 3 = first NAL
|
||||||
|
// header. Minimum valid payload: 4 bytes.
|
||||||
|
func isH264IDRStart(pkt *rtp.Packet) bool {
|
||||||
|
p := pkt.Payload
|
||||||
|
if len(p) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
nalType := p[0] & 0x1F
|
||||||
|
switch nalType {
|
||||||
|
case 5: // Single NAL unit, IDR slice
|
||||||
|
return true
|
||||||
|
case 24: // STAP-A — bytes 1–2 are the first NAL's size; byte 3 is its header
|
||||||
|
return len(p) >= 4 && p[3]&0x1F == 5
|
||||||
|
case 28: // FU-A — byte 1 is the FU header: bit 7 = start, bits 4–0 = inner type
|
||||||
|
return len(p) >= 2 && p[1]&0x80 != 0 && p[1]&0x1F == 5
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// push appends pkt to the cache. If pkt is the start of an H.264 IDR
|
||||||
|
// NAL the existing burst is cleared first so the cache always holds
|
||||||
|
// exactly one complete keyframe burst. Packets beyond the capacity
|
||||||
|
// limits are silently dropped.
|
||||||
|
//
|
||||||
|
// push is called exclusively from readLoop (a single goroutine); the
|
||||||
|
// isH264IDRStart check outside the lock is therefore safe.
|
||||||
|
func (c *keyFrameCache) push(pkt *rtp.Packet) {
|
||||||
|
isIDR := isH264IDRStart(pkt)
|
||||||
|
payloadLen := len(pkt.Payload)
|
||||||
|
c.mu.Lock()
|
||||||
|
if isIDR {
|
||||||
|
c.packets = c.packets[:0]
|
||||||
|
c.byteLen = 0
|
||||||
|
}
|
||||||
|
if len(c.packets) < c.maxPackets && c.byteLen+payloadLen <= c.maxBytes {
|
||||||
|
c.packets = append(c.packets, pkt)
|
||||||
|
c.byteLen += payloadLen
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot returns a shallow copy of the current burst. The returned
|
||||||
|
// slice is safe to iterate without holding any lock; the *rtp.Packet
|
||||||
|
// values are never mutated after being placed in the cache.
|
||||||
|
// Returns nil when the cache is empty.
|
||||||
|
func (c *keyFrameCache) snapshot() []*rtp.Packet {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if len(c.packets) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
snap := make([]*rtp.Packet, len(c.packets))
|
||||||
|
copy(snap, c.packets)
|
||||||
|
return snap
|
||||||
|
}
|
||||||
311
core/webrtc/keyframecache_test.go
Normal file
311
core/webrtc/keyframecache_test.go
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pion/rtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makePacket returns a minimal *rtp.Packet with the given payload bytes.
|
||||||
|
func makePacket(payload []byte) *rtp.Packet {
|
||||||
|
return &rtp.Packet{Payload: payload}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- isH264IDRStart ---
|
||||||
|
|
||||||
|
func TestIsH264IDRStart_Empty(t *testing.T) {
|
||||||
|
if isH264IDRStart(makePacket(nil)) {
|
||||||
|
t.Error("empty payload should not be IDR")
|
||||||
|
}
|
||||||
|
if isH264IDRStart(makePacket([]byte{})) {
|
||||||
|
t.Error("zero-length payload should not be IDR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsH264IDRStart_SingleNAL_IDR(t *testing.T) {
|
||||||
|
// NAL type 5 = IDR slice. Forbidden zero bit + NRI can be anything.
|
||||||
|
p := makePacket([]byte{0x65, 0xb8, 0x00}) // 0x65 = 0110_0101 → type=5
|
||||||
|
if !isH264IDRStart(p) {
|
||||||
|
t.Error("single NAL type 5 should be detected as IDR start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsH264IDRStart_SingleNAL_NonIDR(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
payload []byte
|
||||||
|
}{
|
||||||
|
{"SPS (type 7)", []byte{0x67, 0x42, 0x00}},
|
||||||
|
{"PPS (type 8)", []byte{0x68, 0xce, 0x38}},
|
||||||
|
{"P-frame (type 1)", []byte{0x41, 0x9a}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
nalType := tt.payload[0] & 0x1F
|
||||||
|
if isH264IDRStart(makePacket(tt.payload)) {
|
||||||
|
t.Errorf("NAL type %d should not be IDR start", nalType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsH264IDRStart_FUA_Start_IDR(t *testing.T) {
|
||||||
|
// FU-A: header byte NAL type = 28 (0x1C), FU header start bit set (0x80), inner type = 5
|
||||||
|
p := makePacket([]byte{0x7c, 0x85, 0x00, 0x00}) // 0x7c = type 28; 0x85 = start|IDR
|
||||||
|
if !isH264IDRStart(p) {
|
||||||
|
t.Error("FU-A with start bit + inner type 5 should be IDR start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsH264IDRStart_FUA_Start_NonIDR(t *testing.T) {
|
||||||
|
// FU-A start, but inner NAL type = 1 (P-frame fragment)
|
||||||
|
p := makePacket([]byte{0x7c, 0x81}) // start bit set, inner type = 1
|
||||||
|
if isH264IDRStart(p) {
|
||||||
|
t.Error("FU-A P-frame start should not be IDR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsH264IDRStart_FUA_Continuation(t *testing.T) {
|
||||||
|
// FU-A continuation: start bit NOT set, even if inner type byte = 5
|
||||||
|
p := makePacket([]byte{0x7c, 0x05}) // 0x05 & 0x80 == 0 — no start bit
|
||||||
|
if isH264IDRStart(p) {
|
||||||
|
t.Error("FU-A continuation should not be IDR start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsH264IDRStart_FUA_TruncatedPayload(t *testing.T) {
|
||||||
|
// FU-A with only 1 byte — no FU header byte present
|
||||||
|
p := makePacket([]byte{0x7c})
|
||||||
|
if isH264IDRStart(p) {
|
||||||
|
t.Error("truncated FU-A (1 byte) should not panic or return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsH264IDRStart_STAPA_LeadingIDR(t *testing.T) {
|
||||||
|
// STAP-A (type 24): byte 0 = NAL header (0x78 = type 24),
|
||||||
|
// bytes 1-2 = first NAL size (big-endian), byte 3 = first NAL header.
|
||||||
|
// First NAL type = 5 (IDR) → should be detected.
|
||||||
|
p := makePacket([]byte{
|
||||||
|
0x78, // STAP-A header: NRI=3, type=24
|
||||||
|
0x00, 0x03, // first NAL size = 3 bytes
|
||||||
|
0x65, 0x88, 0x84, // first NAL: type 5 (IDR slice)
|
||||||
|
0x00, 0x02, // second NAL size = 2 bytes (SPS, doesn't matter)
|
||||||
|
0x67, 0x42, // second NAL: SPS
|
||||||
|
})
|
||||||
|
if !isH264IDRStart(p) {
|
||||||
|
t.Error("STAP-A with leading IDR NAL (type 5) should be detected as IDR start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsH264IDRStart_STAPA_LeadingNonIDR(t *testing.T) {
|
||||||
|
// STAP-A where the first NAL is SPS (type 7), not IDR.
|
||||||
|
// Common pattern: encoders bundle SPS+PPS+IDR in separate STAP-A,
|
||||||
|
// then IDR in a single NAL or FU-A. This STAP-A should not trigger reset.
|
||||||
|
p := makePacket([]byte{
|
||||||
|
0x78, // STAP-A header
|
||||||
|
0x00, 0x03, // first NAL size = 3
|
||||||
|
0x67, 0x42, 0x00, // first NAL: SPS (type 7)
|
||||||
|
})
|
||||||
|
if isH264IDRStart(p) {
|
||||||
|
t.Error("STAP-A with leading SPS (type 7) should not be IDR start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsH264IDRStart_STAPA_Truncated(t *testing.T) {
|
||||||
|
// STAP-A with fewer than 4 bytes — cannot safely read first NAL header.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
payload []byte
|
||||||
|
}{
|
||||||
|
{"1 byte (header only)", []byte{0x78}},
|
||||||
|
{"2 bytes (header + 1 size byte)", []byte{0x78, 0x00}},
|
||||||
|
{"3 bytes (header + size, no NAL)", []byte{0x78, 0x00, 0x01}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if isH264IDRStart(makePacket(tt.payload)) {
|
||||||
|
t.Errorf("truncated STAP-A (%d bytes) should not panic or return true", len(tt.payload))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsH264IDRStart_Opus(t *testing.T) {
|
||||||
|
// Opus RTP payload starts with a TOC byte — definitely not H.264
|
||||||
|
p := makePacket([]byte{0xf8, 0xff, 0xfe})
|
||||||
|
if isH264IDRStart(p) {
|
||||||
|
t.Error("Opus payload should not be detected as IDR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- keyFrameCache push / snapshot ---
|
||||||
|
|
||||||
|
func TestKeyFrameCache_EmptySnapshot(t *testing.T) {
|
||||||
|
c := newKeyFrameCache()
|
||||||
|
if snap := c.snapshot(); snap != nil {
|
||||||
|
t.Errorf("expected nil snapshot from empty cache, got %d packets", len(snap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyFrameCache_IDRResetsPreviousBurst(t *testing.T) {
|
||||||
|
c := newKeyFrameCache()
|
||||||
|
|
||||||
|
// Push some non-IDR packets first.
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
c.push(makePacket([]byte{0x41, byte(i)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now push an IDR start — cache should reset to just this packet.
|
||||||
|
idrPkt := makePacket([]byte{0x65, 0x88, 0x84})
|
||||||
|
c.push(idrPkt)
|
||||||
|
|
||||||
|
snap := c.snapshot()
|
||||||
|
if len(snap) != 1 {
|
||||||
|
t.Errorf("expected exactly 1 packet after IDR reset, got %d", len(snap))
|
||||||
|
}
|
||||||
|
if snap[0] != idrPkt {
|
||||||
|
t.Error("snapshot should contain the IDR packet itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyFrameCache_BurstAccumulation(t *testing.T) {
|
||||||
|
c := newKeyFrameCache()
|
||||||
|
|
||||||
|
idr := makePacket([]byte{0x65, 0x88, 0x84})
|
||||||
|
c.push(idr)
|
||||||
|
|
||||||
|
// Push 9 more packets (P-frames)
|
||||||
|
for i := 0; i < 9; i++ {
|
||||||
|
c.push(makePacket([]byte{0x41, byte(i)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := c.snapshot()
|
||||||
|
if len(snap) != 10 {
|
||||||
|
t.Errorf("expected 10 packets in burst, got %d", len(snap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyFrameCache_SecondIDRResetsAgain(t *testing.T) {
|
||||||
|
c := newKeyFrameCache()
|
||||||
|
|
||||||
|
c.push(makePacket([]byte{0x65, 0x01})) // first IDR
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
c.push(makePacket([]byte{0x41, byte(i)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
second := makePacket([]byte{0x65, 0x02}) // second IDR — resets burst
|
||||||
|
c.push(second)
|
||||||
|
|
||||||
|
snap := c.snapshot()
|
||||||
|
if len(snap) != 1 {
|
||||||
|
t.Errorf("second IDR should reset burst to 1 packet, got %d", len(snap))
|
||||||
|
}
|
||||||
|
if snap[0] != second {
|
||||||
|
t.Error("snapshot should contain the second IDR packet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyFrameCache_STAPA_IDR_ResetsCache(t *testing.T) {
|
||||||
|
// Verify that a STAP-A with a leading IDR NAL correctly resets the burst,
|
||||||
|
// just like a single-NAL IDR packet does.
|
||||||
|
c := newKeyFrameCache()
|
||||||
|
|
||||||
|
// Pre-load some P-frames.
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
c.push(makePacket([]byte{0x41, byte(i)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
stapA := makePacket([]byte{
|
||||||
|
0x78, // STAP-A header
|
||||||
|
0x00, 0x03, // first NAL size = 3
|
||||||
|
0x65, 0x88, 0x84, // first NAL: IDR (type 5)
|
||||||
|
})
|
||||||
|
c.push(stapA)
|
||||||
|
|
||||||
|
snap := c.snapshot()
|
||||||
|
if len(snap) != 1 {
|
||||||
|
t.Errorf("STAP-A IDR should reset burst to 1 packet, got %d", len(snap))
|
||||||
|
}
|
||||||
|
if snap[0] != stapA {
|
||||||
|
t.Error("snapshot should contain the STAP-A IDR packet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyFrameCache_MaxPacketsCap(t *testing.T) {
|
||||||
|
c := newKeyFrameCache()
|
||||||
|
c.maxPackets = 5
|
||||||
|
c.maxBytes = 1 << 20 // generous byte cap
|
||||||
|
|
||||||
|
idr := makePacket([]byte{0x65, 0x88})
|
||||||
|
c.push(idr)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
c.push(makePacket([]byte{0x41, byte(i)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
snap := c.snapshot()
|
||||||
|
if len(snap) != 5 {
|
||||||
|
t.Errorf("cache should stop at maxPackets=5, got %d", len(snap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyFrameCache_MaxBytesCap(t *testing.T) {
|
||||||
|
c := newKeyFrameCache()
|
||||||
|
c.maxPackets = 512
|
||||||
|
c.maxBytes = 10 // tiny — only 2 packets of 4 bytes each fit (8 ≤ 10 < 12)
|
||||||
|
|
||||||
|
idr := makePacket([]byte{0x65, 0x88, 0x84, 0x21}) // 4 bytes payload
|
||||||
|
c.push(idr)
|
||||||
|
c.push(makePacket([]byte{0x41, 0x9a, 0xab, 0xcd})) // 4 bytes → total 8 ≤ 10 ✓
|
||||||
|
c.push(makePacket([]byte{0x41, 0x01, 0x02, 0x03})) // 4 bytes → would be 12 > 10 ✗
|
||||||
|
|
||||||
|
snap := c.snapshot()
|
||||||
|
if len(snap) != 2 {
|
||||||
|
t.Errorf("expected 2 packets within byte cap, got %d", len(snap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyFrameCache_SnapshotIsACopy(t *testing.T) {
|
||||||
|
c := newKeyFrameCache()
|
||||||
|
c.push(makePacket([]byte{0x65, 0x88}))
|
||||||
|
c.push(makePacket([]byte{0x41, 0x01}))
|
||||||
|
|
||||||
|
snap1 := c.snapshot()
|
||||||
|
|
||||||
|
// Push another IDR — clears the internal slice.
|
||||||
|
c.push(makePacket([]byte{0x65, 0x99}))
|
||||||
|
|
||||||
|
// snap1 should still hold the original 2 packets.
|
||||||
|
if len(snap1) != 2 {
|
||||||
|
t.Errorf("snapshot should be independent of later cache mutations, got %d", len(snap1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyFrameCache_ConcurrentSnapshotAndPush(t *testing.T) {
|
||||||
|
c := newKeyFrameCache()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
// Single writer: 1000 pushes alternating IDR / P-frame.
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
if i%50 == 0 {
|
||||||
|
c.push(makePacket([]byte{0x65, byte(i)}))
|
||||||
|
} else {
|
||||||
|
c.push(makePacket([]byte{0x41, byte(i)}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Multiple readers: concurrent snapshots — must not data-race or panic.
|
||||||
|
for r := 0; r < 4; r++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 250; i++ {
|
||||||
|
_ = c.snapshot()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
@ -44,15 +44,29 @@ func NewPeerFactory(c Config) (*PeerFactory, error) {
|
||||||
return &PeerFactory{api: api, rtcConfig: rtcConfig}, nil
|
return &PeerFactory{api: api, rtcConfig: rtcConfig}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peer wraps a Pion PeerConnection bound to a Source's subscription.
|
// Peer wraps a Pion PeerConnection bound to either a single Source
|
||||||
|
// subscription (M1, payload-type split forwarding) or to a pair of
|
||||||
|
// video+audio Source subscriptions (M2, per-track forwarding).
|
||||||
type Peer struct {
|
type Peer struct {
|
||||||
resourceID string
|
resourceID string
|
||||||
pc *webrtc.PeerConnection
|
pc *webrtc.PeerConnection
|
||||||
answer webrtc.SessionDescription
|
answer webrtc.SessionDescription
|
||||||
|
|
||||||
|
// M1 single-source mode: source+sub are set, videoSource/audioSource are nil.
|
||||||
source *Source
|
source *Source
|
||||||
sub chan *rtp.Packet
|
sub chan *rtp.Packet
|
||||||
|
|
||||||
|
// M2 two-source mode: videoSource/audioSource and their subs are set,
|
||||||
|
// source/sub are nil.
|
||||||
|
videoSource *Source
|
||||||
|
audioSource *Source
|
||||||
|
videoSub chan *rtp.Packet
|
||||||
|
audioSub chan *rtp.Packet
|
||||||
|
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
once sync.Once
|
once sync.Once
|
||||||
|
connected chan struct{}
|
||||||
|
connOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePeer builds a PeerConnection, sets the remote offer, generates an
|
// CreatePeer builds a PeerConnection, sets the remote offer, generates an
|
||||||
|
|
@ -119,9 +133,13 @@ func (f *PeerFactory) CreatePeer(ctx context.Context, src *Source, offer webrtc.
|
||||||
source: src,
|
source: src,
|
||||||
sub: sub,
|
sub: sub,
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
connected: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) {
|
pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) {
|
||||||
|
if st == webrtc.PeerConnectionStateConnected {
|
||||||
|
p.connOnce.Do(func() { close(p.connected) })
|
||||||
|
}
|
||||||
if st == webrtc.PeerConnectionStateFailed ||
|
if st == webrtc.PeerConnectionStateFailed ||
|
||||||
st == webrtc.PeerConnectionStateDisconnected ||
|
st == webrtc.PeerConnectionStateDisconnected ||
|
||||||
st == webrtc.PeerConnectionStateClosed {
|
st == webrtc.PeerConnectionStateClosed {
|
||||||
|
|
@ -140,18 +158,134 @@ func (p *Peer) Answer() webrtc.SessionDescription { return p.answer }
|
||||||
// ResourceID returns the stable resource id used in the WHEP Location header.
|
// ResourceID returns the stable resource id used in the WHEP Location header.
|
||||||
func (p *Peer) ResourceID() string { return p.resourceID }
|
func (p *Peer) ResourceID() string { return p.resourceID }
|
||||||
|
|
||||||
// Close tears down the peer connection and unsubscribes from the source.
|
// Done returns a channel that is closed when the Peer has been torn down
|
||||||
// Safe to call multiple times.
|
// (either explicitly via Close, or because Pion observed an ICE
|
||||||
|
// failure / disconnection). Consumers can range over it to drive
|
||||||
|
// index cleanup without polling.
|
||||||
|
func (p *Peer) Done() <-chan struct{} { return p.done }
|
||||||
|
|
||||||
|
// Connected returns a channel that is closed the first time Pion reports
|
||||||
|
// PeerConnectionStateConnected. Callers that need to measure ICE
|
||||||
|
// establishment duration select on Connected() vs Done() from the moment
|
||||||
|
// the peer is created.
|
||||||
|
func (p *Peer) Connected() <-chan struct{} { return p.connected }
|
||||||
|
|
||||||
|
// Close tears down the peer connection and unsubscribes from each
|
||||||
|
// source. Safe to call multiple times.
|
||||||
func (p *Peer) Close() error {
|
func (p *Peer) Close() error {
|
||||||
var err error
|
var err error
|
||||||
p.once.Do(func() {
|
p.once.Do(func() {
|
||||||
close(p.done)
|
close(p.done)
|
||||||
|
if p.source != nil && p.sub != nil {
|
||||||
p.source.Unsubscribe(p.sub)
|
p.source.Unsubscribe(p.sub)
|
||||||
|
}
|
||||||
|
if p.videoSource != nil && p.videoSub != nil {
|
||||||
|
p.videoSource.Unsubscribe(p.videoSub)
|
||||||
|
}
|
||||||
|
if p.audioSource != nil && p.audioSub != nil {
|
||||||
|
p.audioSource.Unsubscribe(p.audioSub)
|
||||||
|
}
|
||||||
err = p.pc.Close()
|
err = p.pc.Close()
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreatePeerFromSources is the M2 entry point: it builds a
|
||||||
|
// PeerConnection with video+audio tracks and subscribes each to its
|
||||||
|
// own dedicated Source. Used when the restream manager emits two
|
||||||
|
// FFmpeg RTP legs on separate UDP ports — there is no payload-type
|
||||||
|
// sniffing required, each Source feeds its matching track directly.
|
||||||
|
func (f *PeerFactory) CreatePeerFromSources(ctx context.Context,
|
||||||
|
videoSrc, audioSrc *Source, offer webrtc.SessionDescription) (*Peer, error) {
|
||||||
|
pc, err := f.api.NewPeerConnection(f.rtcConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("webrtc: new peer connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
videoTrack, err := webrtc.NewTrackLocalStaticRTP(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
|
||||||
|
"video", "dragonfork")
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: new video track: %w", err)
|
||||||
|
}
|
||||||
|
audioTrack, err := webrtc.NewTrackLocalStaticRTP(
|
||||||
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
|
||||||
|
"audio", "dragonfork")
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: new audio track: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := pc.AddTrack(videoTrack); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: add video track: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := pc.AddTrack(audioTrack); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: add audio track: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pc.SetRemoteDescription(offer); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: set remote: %w", err)
|
||||||
|
}
|
||||||
|
answer, err := pc.CreateAnswer(nil)
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: create answer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gatherComplete := webrtc.GatheringCompletePromise(pc)
|
||||||
|
if err := pc.SetLocalDescription(answer); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: set local: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-gatherComplete:
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, ErrICETimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
videoSub := videoSrc.Subscribe(64)
|
||||||
|
audioSub := audioSrc.Subscribe(64)
|
||||||
|
|
||||||
|
p := &Peer{
|
||||||
|
resourceID: newResourceID(),
|
||||||
|
pc: pc,
|
||||||
|
answer: *pc.LocalDescription(),
|
||||||
|
videoSource: videoSrc,
|
||||||
|
audioSource: audioSrc,
|
||||||
|
videoSub: videoSub,
|
||||||
|
audioSub: audioSub,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
connected: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) {
|
||||||
|
if st == webrtc.PeerConnectionStateConnected {
|
||||||
|
p.connOnce.Do(func() { close(p.connected) })
|
||||||
|
}
|
||||||
|
if st == webrtc.PeerConnectionStateFailed ||
|
||||||
|
st == webrtc.PeerConnectionStateDisconnected ||
|
||||||
|
st == webrtc.PeerConnectionStateClosed {
|
||||||
|
_ = p.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
go forwardRTPSplit(p.done, videoSub, audioSub, videoTrack, audioTrack)
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddICECandidate forwards a trickle-ICE candidate to the underlying
|
||||||
|
// PeerConnection. Returns the underlying error if the candidate is
|
||||||
|
// malformed or the connection has already been closed.
|
||||||
|
func (p *Peer) AddICECandidate(c webrtc.ICECandidateInit) error {
|
||||||
|
return p.pc.AddICECandidate(c)
|
||||||
|
}
|
||||||
|
|
||||||
func newResourceID() string {
|
func newResourceID() string {
|
||||||
b := make([]byte, 8)
|
b := make([]byte, 8)
|
||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ type Source struct {
|
||||||
started bool
|
started bool
|
||||||
closed bool
|
closed bool
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
|
||||||
|
// cache is non-nil only for video sources that have had
|
||||||
|
// EnableKeyFrameCache() called. It holds the most recent H.264 IDR
|
||||||
|
// burst so new subscribers can receive a keyframe immediately.
|
||||||
|
cache *keyFrameCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSource binds a UDP socket on 127.0.0.1:port. Pass port=0 to let the OS
|
// NewSource binds a UDP socket on 127.0.0.1:port. Pass port=0 to let the OS
|
||||||
|
|
@ -61,13 +66,58 @@ func (s *Source) LocalAddr() *net.UDPAddr {
|
||||||
return s.conn.LocalAddr().(*net.UDPAddr)
|
return s.conn.LocalAddr().(*net.UDPAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnableKeyFrameCache activates H.264 IDR keyframe burst caching for
|
||||||
|
// this source. Once enabled, new calls to Subscribe() will pre-fill the
|
||||||
|
// returned channel with the most recent IDR burst before registering it
|
||||||
|
// in the live fanout, cutting first-frame latency for late-joining peers
|
||||||
|
// from up to one keyframe interval to nearly zero.
|
||||||
|
//
|
||||||
|
// Call this on video sources only; calling it on audio sources is
|
||||||
|
// harmless but wastes memory accumulating non-IDR packets that will
|
||||||
|
// never trigger a cache reset.
|
||||||
|
//
|
||||||
|
// Must be called before Start(). Subsequent calls are no-ops.
|
||||||
|
func (s *Source) EnableKeyFrameCache() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.cache == nil {
|
||||||
|
s.cache = newKeyFrameCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe returns a new buffered channel that receives every RTP packet
|
// Subscribe returns a new buffered channel that receives every RTP packet
|
||||||
// read from the UDP socket. bufDepth is the channel buffer size; when full,
|
// read from the UDP socket. bufDepth is the channel buffer size; when full,
|
||||||
// packets are dropped (preventing a slow subscriber from back-pressuring
|
// packets are dropped (preventing a slow subscriber from back-pressuring
|
||||||
// the reader).
|
// the reader).
|
||||||
|
//
|
||||||
|
// If a keyframe cache is active (EnableKeyFrameCache was called), the
|
||||||
|
// channel is pre-filled with the most recent IDR burst before being
|
||||||
|
// registered in the live fanout, so the subscriber receives a complete
|
||||||
|
// reference frame immediately rather than waiting for the next keyframe.
|
||||||
func (s *Source) Subscribe(bufDepth int) chan *rtp.Packet {
|
func (s *Source) Subscribe(bufDepth int) chan *rtp.Packet {
|
||||||
ch := make(chan *rtp.Packet, bufDepth)
|
ch := make(chan *rtp.Packet, bufDepth)
|
||||||
|
|
||||||
|
// Snapshot outside s.mu to avoid any cross-lock ordering issue:
|
||||||
|
// readLoop acquires cache.mu (in push) then s.mu (in fanout), so
|
||||||
|
// we must not hold s.mu while calling snapshot (which acquires
|
||||||
|
// cache.mu). s.cache itself is immutable after EnableKeyFrameCache.
|
||||||
|
var burst []*rtp.Packet
|
||||||
|
if s.cache != nil {
|
||||||
|
burst = s.cache.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
// Pre-fill with the IDR burst. Use a labeled break so that a full
|
||||||
|
// channel (bufDepth smaller than burst length) stops pre-filling
|
||||||
|
// gracefully — the subscriber will catch the next live keyframe.
|
||||||
|
prefill:
|
||||||
|
for _, pkt := range burst {
|
||||||
|
select {
|
||||||
|
case ch <- pkt:
|
||||||
|
default:
|
||||||
|
break prefill
|
||||||
|
}
|
||||||
|
}
|
||||||
s.subscribers[ch] = struct{}{}
|
s.subscribers[ch] = struct{}{}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return ch
|
return ch
|
||||||
|
|
@ -118,6 +168,15 @@ func (s *Source) readLoop() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the keyframe cache (video sources only; push is a
|
||||||
|
// no-op on audio sources because isH264IDRStart returns false
|
||||||
|
// for Opus payload types). Called before the fanout so that a
|
||||||
|
// subscriber joining concurrently gets a snapshot that includes
|
||||||
|
// this packet if it is an IDR start.
|
||||||
|
if s.cache != nil {
|
||||||
|
s.cache.push(pkt)
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
for ch := range s.subscribers {
|
for ch := range s.subscribers {
|
||||||
select {
|
select {
|
||||||
|
|
|
||||||
|
|
@ -1,129 +1,150 @@
|
||||||
package webrtc
|
package webrtc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pion/rtp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSource_ID(t *testing.T) {
|
// TestSourceSubscribe_PreFillFromCache verifies that a subscriber joining
|
||||||
s, err := NewSource("streamA", 0) // 0 = ephemeral port
|
// after an IDR packet has been pushed immediately receives the cached burst
|
||||||
|
// before any live packets arrive.
|
||||||
|
func TestSourceSubscribe_PreFillFromCache(t *testing.T) {
|
||||||
|
src, err := NewSource("test-prefill", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSource: %v", err)
|
t.Fatalf("NewSource: %v", err)
|
||||||
}
|
}
|
||||||
defer s.Close()
|
defer src.Close()
|
||||||
|
|
||||||
if s.ID() != "streamA" {
|
src.EnableKeyFrameCache()
|
||||||
t.Errorf("ID() = %q, want streamA", s.ID())
|
src.Start()
|
||||||
|
|
||||||
|
// Push directly into the cache — no need to go through UDP.
|
||||||
|
idrPkt := makePacket([]byte{0x65, 0x88, 0x84})
|
||||||
|
src.cache.push(idrPkt)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
src.cache.push(makePacket([]byte{0x41, byte(i)}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscribe with a buffer big enough to hold the burst.
|
||||||
|
ch := src.Subscribe(64)
|
||||||
|
|
||||||
|
// Channel should already contain 4 packets — no live UDP required.
|
||||||
|
if len(ch) != 4 {
|
||||||
|
t.Errorf("expected 4 pre-filled packets, got %d", len(ch))
|
||||||
|
}
|
||||||
|
|
||||||
|
// First packet must be the IDR.
|
||||||
|
first := <-ch
|
||||||
|
if first.Payload[0]&0x1F != 5 {
|
||||||
|
t.Errorf("first pre-fill packet should be IDR (type 5), got type %d", first.Payload[0]&0x1F)
|
||||||
|
}
|
||||||
|
|
||||||
|
src.Unsubscribe(ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSource_ReceiveAndFanout(t *testing.T) {
|
// TestSourceSubscribe_NoCacheByDefault verifies that without
|
||||||
s, err := NewSource("streamA", 0)
|
// EnableKeyFrameCache the channel starts empty.
|
||||||
|
func TestSourceSubscribe_NoCacheByDefault(t *testing.T) {
|
||||||
|
src, err := NewSource("test-nocache", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSource: %v", err)
|
t.Fatalf("NewSource: %v", err)
|
||||||
}
|
}
|
||||||
defer s.Close()
|
defer src.Close()
|
||||||
|
|
||||||
// Subscribe before sending.
|
src.Start()
|
||||||
sub := s.Subscribe(16) // buffer depth 16
|
ch := src.Subscribe(64)
|
||||||
defer s.Unsubscribe(sub)
|
|
||||||
|
|
||||||
s.Start()
|
if len(ch) != 0 {
|
||||||
|
t.Errorf("expected empty channel without cache, got %d packets", len(ch))
|
||||||
// Build and send a minimal RTP packet to the source's UDP port.
|
|
||||||
pkt := &rtp.Packet{
|
|
||||||
Header: rtp.Header{
|
|
||||||
Version: 2,
|
|
||||||
PayloadType: 96,
|
|
||||||
SequenceNumber: 1,
|
|
||||||
Timestamp: 1000,
|
|
||||||
SSRC: 0xDEADBEEF,
|
|
||||||
},
|
|
||||||
Payload: []byte{0x01, 0x02, 0x03, 0x04},
|
|
||||||
}
|
|
||||||
raw, err := pkt.Marshal()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pkt.Marshal: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := net.Dial("udp", s.LocalAddr().String())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("net.Dial: %v", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
if _, err := conn.Write(raw); err != nil {
|
|
||||||
t.Fatalf("conn.Write: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case got := <-sub:
|
|
||||||
if got.SSRC != 0xDEADBEEF {
|
|
||||||
t.Errorf("received SSRC = %x, want DEADBEEF", got.SSRC)
|
|
||||||
}
|
|
||||||
if got.SequenceNumber != 1 {
|
|
||||||
t.Errorf("received SeqNum = %d, want 1", got.SequenceNumber)
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("timed out waiting for RTP packet on subscriber channel")
|
|
||||||
}
|
}
|
||||||
|
src.Unsubscribe(ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSource_MultipleSubscribers(t *testing.T) {
|
// TestSourceSubscribe_PreFillStopsOnFullChannel verifies that pre-fill does
|
||||||
s, err := NewSource("streamA", 0)
|
// not block when bufDepth is smaller than the burst length.
|
||||||
|
func TestSourceSubscribe_PreFillStopsOnFullChannel(t *testing.T) {
|
||||||
|
src, err := NewSource("test-smallbuf", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSource: %v", err)
|
t.Fatalf("NewSource: %v", err)
|
||||||
}
|
}
|
||||||
defer s.Close()
|
defer src.Close()
|
||||||
|
|
||||||
subs := []chan *rtp.Packet{
|
src.EnableKeyFrameCache()
|
||||||
s.Subscribe(8),
|
src.Start()
|
||||||
s.Subscribe(8),
|
|
||||||
s.Subscribe(8),
|
// Push 10 packets into the cache.
|
||||||
}
|
src.cache.push(makePacket([]byte{0x65, 0x88})) // IDR
|
||||||
for _, sub := range subs {
|
for i := 0; i < 9; i++ {
|
||||||
defer s.Unsubscribe(sub)
|
src.cache.push(makePacket([]byte{0x41, byte(i)}))
|
||||||
}
|
}
|
||||||
|
|
||||||
s.Start()
|
// Subscribe with bufDepth=3 — only 3 should land.
|
||||||
|
ch := src.Subscribe(3)
|
||||||
|
|
||||||
raw, _ := (&rtp.Packet{
|
if len(ch) != 3 {
|
||||||
Header: rtp.Header{Version: 2, PayloadType: 96, SequenceNumber: 42, SSRC: 1},
|
t.Errorf("expected exactly 3 pre-filled packets (bufDepth cap), got %d", len(ch))
|
||||||
Payload: []byte{0xAA},
|
}
|
||||||
}).Marshal()
|
src.Unsubscribe(ch)
|
||||||
conn, _ := net.Dial("udp", s.LocalAddr().String())
|
}
|
||||||
defer conn.Close()
|
|
||||||
_, _ = conn.Write(raw)
|
|
||||||
|
|
||||||
for i, sub := range subs {
|
// TestSourceClose_UnsubscribesAll verifies that Close closes every subscriber
|
||||||
|
// channel so goroutines ranging over them terminate cleanly.
|
||||||
|
func TestSourceClose_UnsubscribesAll(t *testing.T) {
|
||||||
|
src, err := NewSource("test-close", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSource: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src.Start()
|
||||||
|
ch1 := src.Subscribe(8)
|
||||||
|
ch2 := src.Subscribe(8)
|
||||||
|
|
||||||
|
if err := src.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{}, 2)
|
||||||
|
go func() {
|
||||||
|
for range ch1 {
|
||||||
|
}
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
for range ch2 {
|
||||||
|
}
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
|
timeout := time.After(500 * time.Millisecond)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
select {
|
select {
|
||||||
case got := <-sub:
|
case <-done:
|
||||||
if got.SequenceNumber != 42 {
|
case <-timeout:
|
||||||
t.Errorf("sub %d got seq %d, want 42", i, got.SequenceNumber)
|
t.Error("subscriber channel not closed within 500ms of src.Close()")
|
||||||
}
|
return
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Errorf("sub %d timed out", i)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSource_UnsubscribeStopsDelivery(t *testing.T) {
|
// TestEnableKeyFrameCache_Idempotent verifies that calling EnableKeyFrameCache
|
||||||
s, _ := NewSource("streamA", 0)
|
// twice does not replace or reset an existing cache.
|
||||||
defer s.Close()
|
func TestEnableKeyFrameCache_Idempotent(t *testing.T) {
|
||||||
sub := s.Subscribe(8)
|
src, err := NewSource("test-idempotent", 0)
|
||||||
s.Start()
|
if err != nil {
|
||||||
s.Unsubscribe(sub)
|
t.Fatalf("NewSource: %v", err)
|
||||||
|
|
||||||
// After Unsubscribe, the channel should be closed.
|
|
||||||
select {
|
|
||||||
case _, ok := <-sub:
|
|
||||||
if ok {
|
|
||||||
t.Error("expected channel closed after Unsubscribe, got value")
|
|
||||||
}
|
}
|
||||||
case <-time.After(500 * time.Millisecond):
|
defer src.Close()
|
||||||
t.Error("timed out waiting for channel close")
|
|
||||||
|
src.EnableKeyFrameCache()
|
||||||
|
firstCache := src.cache
|
||||||
|
src.cache.push(makePacket([]byte{0x65, 0x01}))
|
||||||
|
|
||||||
|
src.EnableKeyFrameCache() // second call — must be a no-op
|
||||||
|
|
||||||
|
if src.cache != firstCache {
|
||||||
|
t.Error("EnableKeyFrameCache should not replace an existing cache")
|
||||||
|
}
|
||||||
|
if len(src.cache.snapshot()) != 1 {
|
||||||
|
t.Error("second EnableKeyFrameCache call should not clear the cache contents")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
195
core/webrtc/whip.go
Normal file
195
core/webrtc/whip.go
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IngestPeer receives a WebRTC publish stream (WHIP protocol) and
|
||||||
|
// forwards the received RTP tracks to loopback UDP ports for FFmpeg
|
||||||
|
// consumption. It is the symmetric inverse of the egress Peer:
|
||||||
|
//
|
||||||
|
// Publisher (browser / OBS) -> WebRTC -> IngestPeer -> UDP -> FFmpeg input
|
||||||
|
//
|
||||||
|
// FFmpeg must already be bound on videoPort/audioPort (i.e., the process
|
||||||
|
// has started with those ports as its RTP input legs) before the first
|
||||||
|
// RTP packets arrive — the loopback UDP writes are fire-and-forget and
|
||||||
|
// harmless if FFmpeg hasn't opened the socket yet.
|
||||||
|
type IngestPeer struct {
|
||||||
|
resourceID string
|
||||||
|
pc *webrtc.PeerConnection
|
||||||
|
answer webrtc.SessionDescription
|
||||||
|
|
||||||
|
// Destination UDP addresses — FFmpeg's bound RTP input sockets.
|
||||||
|
videoAddr *net.UDPAddr
|
||||||
|
audioAddr *net.UDPAddr
|
||||||
|
|
||||||
|
// Shared sender socket used for all forwarded packets.
|
||||||
|
udpConn *net.UDPConn
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
connected chan struct{}
|
||||||
|
connOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIngestPeer builds a recvonly PeerConnection, sets the remote
|
||||||
|
// offer (from the WHIP publisher), creates and gathers the answer, then
|
||||||
|
// wires OnTrack to forward received video and audio RTP to videoPort and
|
||||||
|
// audioPort on localhost respectively.
|
||||||
|
//
|
||||||
|
// videoPort and audioPort must be loopback UDP ports that FFmpeg (or any
|
||||||
|
// other RTP consumer) is already listening on. The caller owns the returned
|
||||||
|
// peer and must call Close() when done.
|
||||||
|
func (f *PeerFactory) CreateIngestPeer(ctx context.Context,
|
||||||
|
offer webrtc.SessionDescription,
|
||||||
|
videoPort, audioPort int) (*IngestPeer, error) {
|
||||||
|
|
||||||
|
pc, err := f.api.NewPeerConnection(f.rtcConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("webrtc: whip: new peer connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add recvonly transceivers so the SDP negotiation offers to
|
||||||
|
// receive both video and audio from the publisher.
|
||||||
|
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo,
|
||||||
|
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: whip: add video transceiver: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio,
|
||||||
|
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: whip: add audio transceiver: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pc.SetRemoteDescription(offer); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: whip: set remote: %w", err)
|
||||||
|
}
|
||||||
|
answer, err := pc.CreateAnswer(nil)
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: whip: create answer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gatherComplete := webrtc.GatheringCompletePromise(pc)
|
||||||
|
if err := pc.SetLocalDescription(answer); err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: whip: set local: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-gatherComplete:
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, ErrICETimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared UDP sender socket for forwarding RTP to FFmpeg.
|
||||||
|
udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||||
|
if err != nil {
|
||||||
|
_ = pc.Close()
|
||||||
|
return nil, fmt.Errorf("webrtc: whip: bind sender socket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &IngestPeer{
|
||||||
|
resourceID: newResourceID(),
|
||||||
|
pc: pc,
|
||||||
|
answer: *pc.LocalDescription(),
|
||||||
|
videoAddr: &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: videoPort},
|
||||||
|
audioAddr: &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: audioPort},
|
||||||
|
udpConn: udpConn,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
connected: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
pc.OnConnectionStateChange(func(st webrtc.PeerConnectionState) {
|
||||||
|
if st == webrtc.PeerConnectionStateConnected {
|
||||||
|
p.connOnce.Do(func() { close(p.connected) })
|
||||||
|
}
|
||||||
|
if st == webrtc.PeerConnectionStateFailed ||
|
||||||
|
st == webrtc.PeerConnectionStateDisconnected ||
|
||||||
|
st == webrtc.PeerConnectionStateClosed {
|
||||||
|
_ = p.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wire each incoming track to its UDP destination. OnTrack fires
|
||||||
|
// once per negotiated media section; we expect at most one video
|
||||||
|
// and one audio track.
|
||||||
|
pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||||
|
var dst *net.UDPAddr
|
||||||
|
switch track.Kind() {
|
||||||
|
case webrtc.RTPCodecTypeVideo:
|
||||||
|
dst = p.videoAddr
|
||||||
|
case webrtc.RTPCodecTypeAudio:
|
||||||
|
dst = p.audioAddr
|
||||||
|
default:
|
||||||
|
// Unknown media kind — ignore.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go p.forwardTrack(track, dst)
|
||||||
|
})
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardTrack reads raw RTP packets from the remote track and writes
|
||||||
|
// them verbatim to dst via the shared UDP sender socket. Exits when
|
||||||
|
// p.done is closed or the track read errors (e.g., peer connection
|
||||||
|
// closed by the remote).
|
||||||
|
func (p *IngestPeer) forwardTrack(track *webrtc.TrackRemote, dst *net.UDPAddr) {
|
||||||
|
buf := make([]byte, 1500) // MTU-sized; same as Source.readLoop
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
n, _, err := track.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
// Track closed or peer gone — exit cleanly.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// WriteToUDP is non-blocking from the caller's perspective:
|
||||||
|
// if FFmpeg hasn't bound the port yet the OS will ICMP-reject
|
||||||
|
// and we'll get a net.Error. We ignore write errors to avoid
|
||||||
|
// thrashing on transient startup races.
|
||||||
|
_, _ = p.udpConn.WriteToUDP(buf[:n], dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer returns the locally-created SDP answer. Valid after CreateIngestPeer.
|
||||||
|
func (p *IngestPeer) Answer() webrtc.SessionDescription { return p.answer }
|
||||||
|
|
||||||
|
// ResourceID returns the stable resource id used in the WHIP Location header.
|
||||||
|
func (p *IngestPeer) ResourceID() string { return p.resourceID }
|
||||||
|
|
||||||
|
// Done returns a channel closed when the peer has been torn down.
|
||||||
|
func (p *IngestPeer) Done() <-chan struct{} { return p.done }
|
||||||
|
|
||||||
|
// Connected returns a channel closed when ICE first reaches Connected state.
|
||||||
|
func (p *IngestPeer) Connected() <-chan struct{} { return p.connected }
|
||||||
|
|
||||||
|
// AddICECandidate forwards a trickle-ICE candidate to the underlying
|
||||||
|
// PeerConnection.
|
||||||
|
func (p *IngestPeer) AddICECandidate(c webrtc.ICECandidateInit) error {
|
||||||
|
return p.pc.AddICECandidate(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close tears down the peer connection and stops all track forwarders.
|
||||||
|
// Safe to call multiple times.
|
||||||
|
func (p *IngestPeer) Close() error {
|
||||||
|
var err error
|
||||||
|
p.once.Do(func() {
|
||||||
|
close(p.done)
|
||||||
|
_ = p.udpConn.Close()
|
||||||
|
err = p.pc.Close()
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
110
deploy/truenas/core/Dockerfile
Normal file
110
deploy/truenas/core/Dockerfile
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Dragon Fork datarhei Core image (M2 + WebRTC egress).
|
||||||
|
#
|
||||||
|
# Builds the real root Core binary — the one that replaces the M1 PoC
|
||||||
|
# in production. FFmpeg is baked in so restream processes can run the
|
||||||
|
# RTP output legs emitted by the WebRTC subsystem.
|
||||||
|
#
|
||||||
|
# Two-stage:
|
||||||
|
# 1. builder: compile a static Go binary (CGO off — no dynamic libs)
|
||||||
|
# 2. ui-builder: clone wilddragon-restreamer-ui, apply its overlay on
|
||||||
|
# top of upstream datarhei/restreamer-ui v1.14.0, yarn build.
|
||||||
|
# See https://forge.wilddragon.net/zgaetano/wilddragon-restreamer-ui
|
||||||
|
# 3. runtime: alpine with ffmpeg for the subprocess path
|
||||||
|
#
|
||||||
|
# Usage via compose:
|
||||||
|
# docker compose -f deploy/truenas/core/docker-compose.yml up -d --build
|
||||||
|
#
|
||||||
|
# The compose file drives configuration via CORE_* env vars — see
|
||||||
|
# README.md in this directory.
|
||||||
|
|
||||||
|
# ---- builder ----
|
||||||
|
# go.mod requires go 1.24; pinning the image keeps Docker's toolchain
|
||||||
|
# download off the hot path and makes the build reproducible.
|
||||||
|
FROM golang:1.24-alpine3.20 AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
|
||||||
|
RUN make release && make import && make ffmigrate
|
||||||
|
|
||||||
|
# ---- ui-builder ----
|
||||||
|
# Clones wilddragon-restreamer-ui (the Wild Dragon UI fork) which
|
||||||
|
# provides apply-overlay.sh and all overlay files (branding, WebRTC
|
||||||
|
# WHEP controls). Then clones upstream restreamer-ui at the pinned
|
||||||
|
# tag, applies the overlay, and runs yarn build.
|
||||||
|
#
|
||||||
|
# WD_UI_REF: branch or tag in wilddragon-restreamer-ui to build from.
|
||||||
|
# RESTREAMER_UI_REF: upstream datarhei/restreamer-ui tag to base on.
|
||||||
|
FROM node:21-alpine3.20 AS ui-builder
|
||||||
|
ARG WD_UI_REF=main
|
||||||
|
ARG RESTREAMER_UI_REF=v1.14.0
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Wild Dragon UI fork: overlay files + apply-overlay.sh
|
||||||
|
RUN git clone --depth=1 --branch ${WD_UI_REF} \
|
||||||
|
https://forge.wilddragon.net/zgaetano/wilddragon-restreamer-ui.git /wd-ui
|
||||||
|
|
||||||
|
# Upstream React SPA at the pinned version
|
||||||
|
RUN git clone --depth=1 --branch ${RESTREAMER_UI_REF} \
|
||||||
|
https://github.com/datarhei/restreamer-ui.git /ui
|
||||||
|
|
||||||
|
WORKDIR /ui
|
||||||
|
RUN yarn install --frozen-lockfile --network-timeout 600000
|
||||||
|
|
||||||
|
# Apply Wild Dragon branding + WebRTC controls.
|
||||||
|
# chmod is required because git may clone the script without +x.
|
||||||
|
RUN chmod +x /wd-ui/apply-overlay.sh && \
|
||||||
|
OVERLAY=/wd-ui/overlay UI=/ui /wd-ui/apply-overlay.sh
|
||||||
|
|
||||||
|
RUN PUBLIC_URL="./" GENERATE_SOURCEMAP=false yarn build
|
||||||
|
|
||||||
|
# ---- runtime ----
|
||||||
|
# Alpine with ffmpeg (Core shells out to it for every restream process).
|
||||||
|
# Scratch isn't an option here because the process manager needs ffmpeg
|
||||||
|
# on PATH.
|
||||||
|
FROM alpine:3.20 AS runtime
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg tini ca-certificates
|
||||||
|
|
||||||
|
# make release's `-o core` lands the binary inside the core/ Go
|
||||||
|
# package directory (Go cannot overwrite a directory with a file, so
|
||||||
|
# it places the output file _inside_ it). The `import` and `ffmigrate`
|
||||||
|
# Makefile targets cd into app/<name> and write the binary back up to
|
||||||
|
# the repo root with a relative path, so those end up at /src/import
|
||||||
|
# and /src/ffmigrate.
|
||||||
|
COPY --from=builder /src/core/core /core/bin/core
|
||||||
|
COPY --from=builder /src/import /core/bin/import
|
||||||
|
COPY --from=builder /src/ffmigrate /core/bin/ffmigrate
|
||||||
|
COPY --from=builder /src/mime.types /core/mime.types
|
||||||
|
COPY --from=builder /src/run.sh /core/bin/run.sh
|
||||||
|
|
||||||
|
# Static content for /core/data, seeded on first boot by seed-data.sh.
|
||||||
|
# Stacking order:
|
||||||
|
# 1. Restreamer UI bundle (the React SPA — gives us index.html)
|
||||||
|
# 2. Dragon Fork extras (whep-player.html, etc.) — won't overwrite
|
||||||
|
# the UI's index.html (seed-data is no-clobber).
|
||||||
|
#
|
||||||
|
# The result: GET / serves the Wild Dragon dashboard, and
|
||||||
|
# /whep-player.html serves the standalone WHEP smoke player.
|
||||||
|
COPY --from=ui-builder /ui/build/ /core/static/
|
||||||
|
COPY --from=builder /src/deploy/truenas/core/static/ /core/static/
|
||||||
|
COPY --from=builder /src/deploy/truenas/core/seed-data.sh /core/bin/seed-data.sh
|
||||||
|
|
||||||
|
RUN chmod +x /core/bin/seed-data.sh && mkdir -p /core/config /core/data
|
||||||
|
|
||||||
|
ENV CORE_CONFIGFILE=/core/config/config.json
|
||||||
|
ENV CORE_STORAGE_DISK_DIR=/core/data
|
||||||
|
ENV CORE_DB_DIR=/core/config
|
||||||
|
|
||||||
|
VOLUME ["/core/data", "/core/config"]
|
||||||
|
EXPOSE 8080/tcp
|
||||||
|
|
||||||
|
# Seed /core/data on first boot, then exec the upstream run.sh which
|
||||||
|
# handles imports, ffmpeg migrations, and the core binary. tini reaps
|
||||||
|
# child PIDs and forwards signals.
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--", "/bin/sh", "-c", "/core/bin/seed-data.sh && exec /core/bin/run.sh"]
|
||||||
|
WORKDIR /core
|
||||||
144
deploy/truenas/core/README.md
Normal file
144
deploy/truenas/core/README.md
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
# TrueNAS deploy — datarhei Core (M2, WebRTC-in-Core)
|
||||||
|
|
||||||
|
Host-networked Docker stack that runs the real root Core binary with
|
||||||
|
the M2 WebRTC egress subsystem wired in. This replaces the M1
|
||||||
|
`webrtc-poc` stack — WebRTC is now a first-class output alongside
|
||||||
|
RTMP/SRT/HLS.
|
||||||
|
|
||||||
|
## What changed from M1
|
||||||
|
|
||||||
|
| M1 (webrtc-poc) | M2 (this stack) |
|
||||||
|
| -------------------------------------- | -------------------------------------------- |
|
||||||
|
| Standalone `cmd/webrtc-poc` binary | Full Core with restream, HTTP API, storage |
|
||||||
|
| One hard-coded stream id | Every restream process can opt into WebRTC |
|
||||||
|
| Single UDP ingest, PT-split forwarding | Two UDP ports per process, per-track |
|
||||||
|
| Plain `/whep/:id` on a side port | `/api/v3/whep/:id` on the JWT-protected API |
|
||||||
|
| No auth | JWT (same creds as the rest of Core) |
|
||||||
|
|
||||||
|
## Prereqs
|
||||||
|
|
||||||
|
- Docker on the TrueNAS host (TrueNAS SCALE includes it)
|
||||||
|
- LAN or public IP that clients can reach (set in `.env` as `PUBLIC_IP`)
|
||||||
|
- Admin credentials for Core's API
|
||||||
|
- FFmpeg is bundled in the image — no host install required
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo mkdir -p /mnt/NVME/Docker/dragonfork-core
|
||||||
|
cd /mnt/NVME/Docker/dragonfork-core
|
||||||
|
|
||||||
|
# Pull the repo (or sync deploy files) onto the host. The compose
|
||||||
|
# build `context:` points at the repo root.
|
||||||
|
git clone https://forgejo.wilddragon.net/zgaetano/datarhei-dragonfork-core.git
|
||||||
|
cd datarhei-dragonfork-core/deploy/truenas/core
|
||||||
|
|
||||||
|
cat > .env <<EOF
|
||||||
|
PUBLIC_IP=10.0.0.25
|
||||||
|
CORE_HTTP_PORT=8080
|
||||||
|
API_AUTH_USERNAME=admin
|
||||||
|
API_AUTH_PASSWORD=$(openssl rand -base64 24)
|
||||||
|
API_AUTH_JWT_SECRET=$(openssl rand -base64 48)
|
||||||
|
LOG_LEVEL=info
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mkdir -p config data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see Core come up logging all configured listeners, including
|
||||||
|
a line from the WebRTC component confirming the subsystem is enabled.
|
||||||
|
|
||||||
|
The first build takes ~5 minutes — it compiles Core from source AND
|
||||||
|
builds the React UI bundle. Subsequent rebuilds are faster (Docker
|
||||||
|
layer cache).
|
||||||
|
|
||||||
|
## GUI surfaces
|
||||||
|
|
||||||
|
Once the stack is up, three browser-reachable UIs ship out of the box:
|
||||||
|
|
||||||
|
| URL | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `http://<host>:8080/` | The full Restreamer UI (rebranded "Wild Dragon"). Manage processes, configure ingests, set up RTMP/SRT/HLS outputs, view logs. The standard Datarhei admin experience. |
|
||||||
|
| `http://<host>:8080/wilddragon-webrtc.html` | Wild Dragon WebRTC admin. Sign in, pick a process, click "Enable WebRTC". The page restarts the process so the new RTP output legs go live, then surfaces the WHEP URL with a one-click jump to the smoke player. **The fastest path from "I want WebRTC on this stream" to "the smoke player is rendering it."** |
|
||||||
|
| `http://<host>:8080/whep-player.html` | Standalone WHEP subscriber (the smoke player). ICE / codec / bitrate diagnostics, JWT input, shareable URLs. Use to verify WebRTC actually works after enabling it. |
|
||||||
|
| `http://<host>:8080/api/swagger/index.html` | Swagger API docs. Same auth. Hit the WHEP endpoints directly when scripting. |
|
||||||
|
|
||||||
|
The Restreamer UI doesn't (yet) have a WebRTC checkbox in its process editor —
|
||||||
|
that's why the standalone admin page exists. A proper UI fork that adds
|
||||||
|
WebRTC controls inline is tracked in issue #15.
|
||||||
|
|
||||||
|
## End-to-end smoke test
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Open http://<host>:8080/ (Restreamer UI). Sign in with admin / <API_AUTH_PASSWORD>.
|
||||||
|
2. Create a new "Source" with type RTMP. Note the RTMP push URL it shows.
|
||||||
|
3. Push your test source to that URL (OBS, ffmpeg, etc.). Confirm it
|
||||||
|
shows "running" in the UI.
|
||||||
|
4. Open http://<host>:8080/wilddragon-webrtc.html. Sign in with the same creds.
|
||||||
|
5. Click "Enable WebRTC" on the process you just created.
|
||||||
|
6. Click the "open ↗" link next to the WHEP URL to load the smoke player.
|
||||||
|
7. Click "Subscribe" in the smoke player. Within ~1s you should see your
|
||||||
|
RTMP source rendering as WebRTC.
|
||||||
|
```
|
||||||
|
|
||||||
|
If step 7 hangs, the most common cause is `PUBLIC_IP` in `.env` not
|
||||||
|
matching what the browser can actually reach (host firewall, wrong
|
||||||
|
LAN IP, etc.). Check the WHEP smoke player's log panel — it'll
|
||||||
|
surface the ICE state transitions.
|
||||||
|
|
||||||
|
## Smoke-test via API directly
|
||||||
|
|
||||||
|
```
|
||||||
|
# Issue a JWT against the admin creds from .env:
|
||||||
|
TOKEN=$(curl -s -X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"<from .env>"}' \
|
||||||
|
http://10.0.0.25:8080/api/login | jq -r '.access_token')
|
||||||
|
|
||||||
|
# Probe the WHEP endpoint — should 404 for an unknown id.
|
||||||
|
curl -i -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-X POST http://10.0.0.25:8080/api/v3/whep/nope
|
||||||
|
# → HTTP/1.1 404 Not Found
|
||||||
|
|
||||||
|
# Create a process with WebRTC enabled, send RTMP to its input, then
|
||||||
|
# subscribe the Pion whep-client to /api/v3/whep/<process-id>.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cutting over from the M1 PoC
|
||||||
|
|
||||||
|
The M1 `webrtc-poc` stack is independent; it binds its own ports. You
|
||||||
|
can run both side-by-side during the cutover:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Stop the M1 stack when you're ready to retire it:
|
||||||
|
cd /mnt/NVME/Docker/dragonfork-webrtc-poc
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Teardown
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- The WHEP endpoint is mounted under `/api/v3`, which is JWT-protected.
|
||||||
|
That's the M2 posture — WHEP clients (browsers) need a token. M3
|
||||||
|
adds per-process signed-URL tokens so embeds don't require admin
|
||||||
|
credentials.
|
||||||
|
- The binary runs as root inside the container; if you need an unpriv
|
||||||
|
user, mount volumes owned by a fixed UID and add a `user:` directive.
|
||||||
|
This matches how the upstream datarhei/core image ships.
|
||||||
|
- Put Caddy or nginx in front for TLS. The media itself is
|
||||||
|
DTLS-SRTP-encrypted regardless.
|
||||||
|
- The Wild Dragon WebRTC admin page (`/wilddragon-webrtc.html`) talks to
|
||||||
|
the same JWT-protected API. The token is held in `localStorage` and
|
||||||
|
cleared when you click "Sign out". If you've configured Core's API
|
||||||
|
to require auth — which you should — this page is gated by it.
|
||||||
99
deploy/truenas/core/docker-compose.yml
Normal file
99
deploy/truenas/core/docker-compose.yml
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Dragon Fork datarhei Core — v0.2 deployment with WebRTC egress and observability.
|
||||||
|
#
|
||||||
|
# This replaces the M2 stack. Adds Prometheus and Grafana containers so the
|
||||||
|
# operator can answer "is WebRTC healthy right now?" from a single dashboard
|
||||||
|
# without tailing logs or hitting the API.
|
||||||
|
#
|
||||||
|
# Host networking is required for WebRTC ICE (see deploy/truenas/docker-compose.yml).
|
||||||
|
# Prometheus and Grafana sit on a bridge network (dragonfork-mon) and reach
|
||||||
|
# Core via host.docker.internal:CORE_HTTP_PORT.
|
||||||
|
#
|
||||||
|
# Copy this file to /mnt/NVME/Docker/dragonfork-core/ alongside a .env:
|
||||||
|
#
|
||||||
|
# PUBLIC_IP=10.0.0.25
|
||||||
|
# API_AUTH_USERNAME=admin
|
||||||
|
# API_AUTH_PASSWORD=change-me-please
|
||||||
|
# API_AUTH_JWT_SECRET=<32+ random bytes, base64>
|
||||||
|
# GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)
|
||||||
|
#
|
||||||
|
# Then:
|
||||||
|
# docker compose up -d --build
|
||||||
|
# docker compose logs -f
|
||||||
|
|
||||||
|
services:
|
||||||
|
core:
|
||||||
|
build:
|
||||||
|
context: ../../.. # repo root (where go.mod lives)
|
||||||
|
dockerfile: deploy/truenas/core/Dockerfile
|
||||||
|
container_name: dragonfork-core
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
# --- API ---
|
||||||
|
CORE_ADDRESS: ":${CORE_HTTP_PORT:-8080}"
|
||||||
|
CORE_API_AUTH_ENABLE: "true"
|
||||||
|
CORE_API_AUTH_USERNAME: "${API_AUTH_USERNAME:?set in .env}"
|
||||||
|
CORE_API_AUTH_PASSWORD: "${API_AUTH_PASSWORD:?set in .env}"
|
||||||
|
CORE_API_AUTH_JWT_SECRET: "${API_AUTH_JWT_SECRET:?set in .env}"
|
||||||
|
|
||||||
|
# --- WebRTC egress ---
|
||||||
|
CORE_WEBRTC_ENABLE: "true"
|
||||||
|
CORE_WEBRTC_PUBLIC_IP: "${PUBLIC_IP:?set in .env}"
|
||||||
|
|
||||||
|
# --- Port overrides ---
|
||||||
|
CORE_RTMP_ADDRESS: "${CORE_RTMP_ADDRESS:-:1935}"
|
||||||
|
CORE_RTMP_ADDRESS_TLS: "${CORE_RTMP_ADDRESS_TLS:-:1936}"
|
||||||
|
CORE_SRT_ADDRESS: "${CORE_SRT_ADDRESS:-:6000}"
|
||||||
|
CORE_TLS_ADDRESS: "${CORE_TLS_ADDRESS:-:8181}"
|
||||||
|
|
||||||
|
# --- Logging ---
|
||||||
|
CORE_LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./config:/core/config
|
||||||
|
- ./data:/core/data
|
||||||
|
|
||||||
|
prom:
|
||||||
|
image: prom/prometheus:v2.55.0
|
||||||
|
container_name: dragonfork-prom
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [dragonfork-mon]
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- ./prom/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- ./prom/rules:/etc/prometheus/rules:ro
|
||||||
|
- prom-data:/prometheus
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.retention.time=${PROM_RETENTION:-15d}
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
|
- --web.console.libraries=/usr/share/prometheus/console_libraries
|
||||||
|
- --web.console.templates=/usr/share/prometheus/consoles
|
||||||
|
ports:
|
||||||
|
- "${PROM_PORT:-9090}:9090"
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana-oss:11.3.0
|
||||||
|
container_name: dragonfork-grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [dragonfork-mon]
|
||||||
|
depends_on: [prom]
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD:?set in .env}"
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
||||||
|
volumes:
|
||||||
|
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
ports:
|
||||||
|
- "${GRAFANA_PORT:-3000}:3000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dragonfork-mon:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
prom-data:
|
||||||
|
grafana-data:
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
{
|
||||||
|
"__inputs": [],
|
||||||
|
"__requires": [
|
||||||
|
{"type": "grafana", "id": "grafana", "name": "Grafana", "version": "11.3.0"},
|
||||||
|
{"type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0"}
|
||||||
|
],
|
||||||
|
"annotations": {"list": []},
|
||||||
|
"description": "Dragon Fork WebRTC egress health: WHEP API, ICE establishment, active streams/peers, capacity, and silent-degradation canary.",
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 0},
|
||||||
|
"id": 1,
|
||||||
|
"title": "WHEP API Health",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {"color": {"mode": "thresholds"}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 0.1}]}},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 1},
|
||||||
|
"id": 2,
|
||||||
|
"options": {"colorMode": "background", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"]}},
|
||||||
|
"targets": [{"expr": "sum(rate(dragonfork_webrtc_whep_requests_total{code=~\"4..|5..\"}[5m]))", "legendFormat": "error rate/s"}],
|
||||||
|
"title": "WHEP Error Rate",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "reqps"}, "overrides": []},
|
||||||
|
"gridPos": {"h": 8, "w": 9, "x": 6, "y": 1},
|
||||||
|
"id": 3,
|
||||||
|
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
|
||||||
|
"targets": [
|
||||||
|
{"expr": "sum by (route) (rate(dragonfork_webrtc_whep_requests_total{code=~\"2..\"}[5m]))", "legendFormat": "{{route}} 2xx"},
|
||||||
|
{"expr": "sum by (route, code) (rate(dragonfork_webrtc_whep_requests_total{code=~\"4..|5..\"}[5m]))", "legendFormat": "{{route}} {{code}}"}
|
||||||
|
],
|
||||||
|
"title": "WHEP Request Rate by Route",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "s"}, "overrides": []},
|
||||||
|
"gridPos": {"h": 8, "w": 9, "x": 15, "y": 1},
|
||||||
|
"id": 4,
|
||||||
|
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
|
||||||
|
"targets": [
|
||||||
|
{"expr": "histogram_quantile(0.95, sum by (le, route) (rate(dragonfork_webrtc_whep_request_duration_seconds_bucket[5m])))", "legendFormat": "p95 {{route}}"},
|
||||||
|
{"expr": "histogram_quantile(0.50, sum by (le, route) (rate(dragonfork_webrtc_whep_request_duration_seconds_bucket[5m])))", "legendFormat": "p50 {{route}}"}
|
||||||
|
],
|
||||||
|
"title": "WHEP Request Duration (p50/p95)",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 9},
|
||||||
|
"id": 10,
|
||||||
|
"title": "ICE Establishment",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "s"}, "overrides": []},
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 10},
|
||||||
|
"id": 11,
|
||||||
|
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
|
||||||
|
"targets": [
|
||||||
|
{"expr": "histogram_quantile(0.95, sum by (le, stream_id, result) (rate(dragonfork_webrtc_ice_establishment_duration_seconds_bucket[10m])))", "legendFormat": "p95 {{stream_id}} {{result}}"},
|
||||||
|
{"expr": "histogram_quantile(0.50, sum by (le, stream_id, result) (rate(dragonfork_webrtc_ice_establishment_duration_seconds_bucket[10m])))", "legendFormat": "p50 {{stream_id}} {{result}}"}
|
||||||
|
],
|
||||||
|
"title": "ICE Establishment Duration (p50/p95)",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "cps"}, "overrides": []},
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 10},
|
||||||
|
"id": 12,
|
||||||
|
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
|
||||||
|
"targets": [
|
||||||
|
{"expr": "sum by (stream_id, reason) (rate(dragonfork_webrtc_ice_failures_total[5m]))", "legendFormat": "{{stream_id}} {{reason}}"}
|
||||||
|
],
|
||||||
|
"title": "ICE Failure Rate",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 18},
|
||||||
|
"id": 20,
|
||||||
|
"title": "Active Streams & Peers",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}}, "overrides": []},
|
||||||
|
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 19},
|
||||||
|
"id": 21,
|
||||||
|
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"]}},
|
||||||
|
"targets": [{"expr": "dragonfork_webrtc_active_streams", "legendFormat": "streams"}],
|
||||||
|
"title": "Active Streams",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
|
||||||
|
"gridPos": {"h": 8, "w": 20, "x": 4, "y": 19},
|
||||||
|
"id": 22,
|
||||||
|
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
|
||||||
|
"targets": [
|
||||||
|
{"expr": "dragonfork_webrtc_active_peers", "legendFormat": "{{stream_id}}"}
|
||||||
|
],
|
||||||
|
"title": "Active Peers per Stream",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 27},
|
||||||
|
"id": 30,
|
||||||
|
"title": "Capacity & Rejections",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "thresholds"},
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "yellow", "value": 4}, {"color": "red", "value": 8}]}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 28},
|
||||||
|
"id": 31,
|
||||||
|
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"]}},
|
||||||
|
"targets": [{"expr": "dragonfork_webrtc_udp_ports_in_use", "legendFormat": "in use"}],
|
||||||
|
"title": "UDP Ports In Use",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "cps"}, "overrides": []},
|
||||||
|
"gridPos": {"h": 8, "w": 20, "x": 4, "y": 28},
|
||||||
|
"id": 32,
|
||||||
|
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
|
||||||
|
"targets": [
|
||||||
|
{"expr": "sum by (stream_id, scope) (rate(dragonfork_webrtc_cap_rejections_total[5m]))", "legendFormat": "{{stream_id}} {{scope}}"}
|
||||||
|
],
|
||||||
|
"title": "Cap Rejection Rate (503s)",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 36},
|
||||||
|
"id": 40,
|
||||||
|
"title": "Silent Degradation Canary",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 37},
|
||||||
|
"id": 41,
|
||||||
|
"options": {"legend": {"displayMode": "list", "placement": "bottom"}, "tooltip": {"mode": "multi"}},
|
||||||
|
"targets": [
|
||||||
|
{"expr": "increase(dragonfork_webrtc_ffmpeg_leg_failures_total[5m])", "legendFormat": "{{stream_id}} {{leg}}"}
|
||||||
|
],
|
||||||
|
"title": "FFmpeg RTP Leg Failures (5m window)",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"fieldConfig": {"defaults": {"color": {"mode": "thresholds"}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 1}]}}, "overrides": []},
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 37},
|
||||||
|
"id": 42,
|
||||||
|
"options": {"colorMode": "background", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["sum"]}},
|
||||||
|
"targets": [
|
||||||
|
{"expr": "sum by (stream_id, kind) (increase(dragonfork_webrtc_codec_mismatches_total[1h]))", "legendFormat": "{{stream_id}} {{kind}}"}
|
||||||
|
],
|
||||||
|
"title": "Codec Mismatches (1h)",
|
||||||
|
"type": "stat"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"tags": ["dragonfork", "webrtc"],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"current": {},
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": false,
|
||||||
|
"label": "Datasource",
|
||||||
|
"name": "datasource",
|
||||||
|
"options": [],
|
||||||
|
"query": "prometheus",
|
||||||
|
"refresh": 1,
|
||||||
|
"type": "datasource"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": {"from": "now-1h", "to": "now"},
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "Dragon Fork — WebRTC Health",
|
||||||
|
"uid": "dragonfork-webrtc-health",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
apiVersion: 1
|
||||||
|
providers:
|
||||||
|
- name: dragonfork
|
||||||
|
orgId: 1
|
||||||
|
folder: "Dragon Fork"
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 30
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
apiVersion: 1
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prom:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
19
deploy/truenas/core/prom/prometheus.yml
Normal file
19
deploy/truenas/core/prom/prometheus.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
scrape_timeout: 10s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
external_labels:
|
||||||
|
core: dragonfork-truenas
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/rules/*.yml
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: dragonfork-core
|
||||||
|
static_configs:
|
||||||
|
- targets: ["host.docker.internal:${CORE_HTTP_PORT:-8080}"]
|
||||||
|
metrics_path: /metrics
|
||||||
|
# If API auth is enabled on /metrics, uncomment and add creds:
|
||||||
|
# basic_auth:
|
||||||
|
# username: <user>
|
||||||
|
# password: <pass>
|
||||||
45
deploy/truenas/core/prom/rules/webrtc-alerts.yml
Normal file
45
deploy/truenas/core/prom/rules/webrtc-alerts.yml
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
groups:
|
||||||
|
- name: dragonfork-webrtc
|
||||||
|
rules:
|
||||||
|
- alert: WebRTCWHEPErrorRateHigh
|
||||||
|
expr: |
|
||||||
|
sum by (stream_id) (
|
||||||
|
rate(dragonfork_webrtc_whep_requests_total{code=~"4..|5.."}[5m])
|
||||||
|
) > 0.5
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "WHEP error rate high on stream {{ $labels.stream_id }}"
|
||||||
|
description: "Sustained 4xx/5xx rate >0.5/sec for 5m."
|
||||||
|
|
||||||
|
- alert: WebRTCICEEstablishmentSlow
|
||||||
|
expr: |
|
||||||
|
histogram_quantile(0.95,
|
||||||
|
sum by (le, stream_id) (
|
||||||
|
rate(dragonfork_webrtc_ice_establishment_duration_seconds_bucket[10m])
|
||||||
|
)
|
||||||
|
) > 3
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "ICE establishment p95 >3s on {{ $labels.stream_id }}"
|
||||||
|
|
||||||
|
- alert: WebRTCICEFailureRateHigh
|
||||||
|
expr: |
|
||||||
|
sum by (stream_id) (rate(dragonfork_webrtc_ice_failures_total[5m])) > 0.2
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "ICE failures sustained on {{ $labels.stream_id }}"
|
||||||
|
|
||||||
|
- alert: WebRTCFFmpegLegFailure
|
||||||
|
expr: |
|
||||||
|
increase(dragonfork_webrtc_ffmpeg_leg_failures_total[5m]) > 0
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "FFmpeg RTP leg failed on {{ $labels.stream_id }} ({{ $labels.leg }})"
|
||||||
|
description: "Process stopped while peers were active. Check FFmpeg logs."
|
||||||
55
deploy/truenas/core/seed-data.sh
Executable file
55
deploy/truenas/core/seed-data.sh
Executable file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# seed-data.sh — boot-time seed of /core/data with Dragon Fork
|
||||||
|
# landing page artifacts (index.html, whep-player.html, compiled UI bundle).
|
||||||
|
#
|
||||||
|
# Runs from the entrypoint before bin/core.
|
||||||
|
#
|
||||||
|
# Strategy:
|
||||||
|
# - Build artifacts (index.html, asset-manifest.json, static/) are ALWAYS
|
||||||
|
# overwritten so a redeployed container picks up the new UI bundle even
|
||||||
|
# when the data volume already exists. The React bundle hash changes every
|
||||||
|
# build, so leaving the old bundle in place would serve a stale UI.
|
||||||
|
# - Everything else (channels/, _player/, _playersite/, custom HTML files)
|
||||||
|
# is no-clobber so operator-edited content is never lost.
|
||||||
|
#
|
||||||
|
# Source dir: /core/static (baked by the Dockerfile)
|
||||||
|
# Target dir: /core/data (operator-mounted; what Core serves at /)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SRC=/core/static
|
||||||
|
DST="${CORE_STORAGE_DISK_DIR:-/core/data}"
|
||||||
|
|
||||||
|
if [ ! -d "$SRC" ]; then
|
||||||
|
# No static dir baked — nothing to seed. Fall through silently.
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$DST" ]; then
|
||||||
|
mkdir -p "$DST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Always-overwrite entries: build artifacts whose content changes every deploy.
|
||||||
|
for always in index.html asset-manifest.json static; do
|
||||||
|
src="$SRC/$always"
|
||||||
|
dst="$DST/$always"
|
||||||
|
[ -e "$src" ] || continue
|
||||||
|
if [ -d "$src" ]; then
|
||||||
|
cp -Rfp "$src" "$dst"
|
||||||
|
else
|
||||||
|
cp -fp "$src" "$dst"
|
||||||
|
fi
|
||||||
|
echo "seed-data: refreshed $always -> $dst"
|
||||||
|
done
|
||||||
|
|
||||||
|
# No-clobber entries: everything else (operator content, player bundles, etc.)
|
||||||
|
for f in "$SRC"/* "$SRC"/.[!.]*; do
|
||||||
|
[ -e "$f" ] || continue
|
||||||
|
name=$(basename "$f")
|
||||||
|
# Skip entries already handled above.
|
||||||
|
case "$name" in index.html|asset-manifest.json|static) continue ;; esac
|
||||||
|
if [ ! -e "$DST/$name" ]; then
|
||||||
|
cp -Rp "$f" "$DST/$name"
|
||||||
|
echo "seed-data: copied $name -> $DST/$name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
354
deploy/truenas/core/static/whep-player.html
Normal file
354
deploy/truenas/core/static/whep-player.html
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Dragon Fork — WHEP Player</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--fg: #e7e7ea;
|
||||||
|
--bg: #0d0e12;
|
||||||
|
--accent: #ff6633;
|
||||||
|
--muted: #8b8e98;
|
||||||
|
--good: #5dd29c;
|
||||||
|
--warn: #ffb45e;
|
||||||
|
--bad: #ff6470;
|
||||||
|
--panel: #1a1c22;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #232530;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
header h1 .accent { color: var(--accent); }
|
||||||
|
header .subtitle { color: var(--muted); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
main {
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
input[type=text] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: #0d0e12;
|
||||||
|
border: 1px solid #2a2c36;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
input[type=text]:focus { border-color: var(--accent); outline: none; }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
button.secondary { background: #2a2c36; color: var(--fg); }
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 10px;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.stats .label { color: var(--muted); }
|
||||||
|
.stats .value { font-variant-numeric: tabular-nums; }
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: #2a2c36;
|
||||||
|
}
|
||||||
|
.pill.good { background: rgba(93,210,156,0.18); color: var(--good); }
|
||||||
|
.pill.warn { background: rgba(255,180,94,0.18); color: var(--warn); }
|
||||||
|
.pill.bad { background: rgba(255,100,112,0.20); color: var(--bad); }
|
||||||
|
|
||||||
|
.log {
|
||||||
|
margin-top: 1rem;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #0d0e12;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.log .ts { color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Dragon Fork <span class="accent">WHEP</span></h1>
|
||||||
|
<span class="subtitle">manual smoke test for the WebRTC egress path</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="panel">
|
||||||
|
<label for="whep-url">WHEP endpoint</label>
|
||||||
|
<input id="whep-url" type="text" placeholder="http://10.0.0.25:8090/api/v3/whep/myStream"
|
||||||
|
value="">
|
||||||
|
<label for="bearer">JWT bearer token</label>
|
||||||
|
<input id="bearer" type="text" placeholder="eyJhbGciOi…">
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="btn-play">Subscribe</button>
|
||||||
|
<button id="btn-stop" class="secondary" disabled>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<span class="label">ICE</span> <span id="stat-ice" class="value pill">idle</span>
|
||||||
|
<span class="label">Connection</span> <span id="stat-conn" class="value pill">idle</span>
|
||||||
|
<span class="label">Resource</span> <span id="stat-res" class="value">—</span>
|
||||||
|
<span class="label">Video codec</span> <span id="stat-vcodec" class="value">—</span>
|
||||||
|
<span class="label">Audio codec</span> <span id="stat-acodec" class="value">—</span>
|
||||||
|
<span class="label">Inbound bitrate</span><span id="stat-bitrate" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="log" class="log" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" style="padding:0;background:#000;">
|
||||||
|
<video id="video" controls autoplay playsinline muted></video>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- tiny state -------------------------------------------------
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const log = (line, level='info') => {
|
||||||
|
const ts = new Date().toLocaleTimeString();
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = `<span class="ts">${ts}</span> <span class="lvl-${level}">${line}</span>`;
|
||||||
|
$('log').prepend(div);
|
||||||
|
};
|
||||||
|
const setPill = (el, text, klass) => { el.textContent = text; el.className = 'value pill ' + klass; };
|
||||||
|
|
||||||
|
let pc = null;
|
||||||
|
let resourceURL = null; // absolute or path; whichever the server returned
|
||||||
|
let bitrateTimer = null;
|
||||||
|
|
||||||
|
// --- subscribe / disconnect -------------------------------------
|
||||||
|
$('btn-play').addEventListener('click', subscribe);
|
||||||
|
$('btn-stop').addEventListener('click', disconnect);
|
||||||
|
|
||||||
|
// Pre-populate WHEP endpoint from query string for shareable URLs
|
||||||
|
// (e.g. file:///.../whep-player.html?url=http://.../whep/foo&token=…).
|
||||||
|
(function bootstrap() {
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
if (q.get('url')) $('whep-url').value = q.get('url');
|
||||||
|
if (q.get('token')) $('bearer').value = q.get('token');
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function subscribe() {
|
||||||
|
if (pc) { log('already connected; disconnect first', 'warn'); return; }
|
||||||
|
const url = $('whep-url').value.trim();
|
||||||
|
const token = $('bearer').value.trim();
|
||||||
|
if (!url) { log('WHEP URL is required', 'bad'); return; }
|
||||||
|
|
||||||
|
$('btn-play').disabled = true;
|
||||||
|
$('btn-stop').disabled = false;
|
||||||
|
setPill($('stat-ice'), 'gathering', 'warn');
|
||||||
|
setPill($('stat-conn'), 'connecting', 'warn');
|
||||||
|
|
||||||
|
pc = new RTCPeerConnection({
|
||||||
|
// No ICE servers: production deploy advertises NAT1To1 host
|
||||||
|
// candidates, which work over the LAN. Add stun:/turn: here
|
||||||
|
// if you're testing across NAT.
|
||||||
|
iceServers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.ontrack = (evt) => {
|
||||||
|
log(`ontrack: kind=${evt.track.kind}`, 'info');
|
||||||
|
// Both tracks share the same MediaStream; attach once.
|
||||||
|
if ($('video').srcObject !== evt.streams[0]) {
|
||||||
|
$('video').srcObject = evt.streams[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
const s = pc.iceConnectionState;
|
||||||
|
let klass = 'warn';
|
||||||
|
if (s === 'connected' || s === 'completed') klass = 'good';
|
||||||
|
else if (s === 'failed' || s === 'disconnected' || s === 'closed') klass = 'bad';
|
||||||
|
setPill($('stat-ice'), s, klass);
|
||||||
|
log(`ICE state: ${s}`);
|
||||||
|
};
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
const s = pc.connectionState;
|
||||||
|
let klass = 'warn';
|
||||||
|
if (s === 'connected') klass = 'good';
|
||||||
|
else if (s === 'failed' || s === 'disconnected' || s === 'closed') klass = 'bad';
|
||||||
|
setPill($('stat-conn'), s, klass);
|
||||||
|
log(`PC state: ${s}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
// Wait for ICE gathering to complete so the offer is non-trickle.
|
||||||
|
await new Promise((res) => {
|
||||||
|
if (pc.iceGatheringState === 'complete') return res();
|
||||||
|
pc.addEventListener('icegatheringstatechange', () => {
|
||||||
|
if (pc.iceGatheringState === 'complete') res();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = { 'Content-Type': 'application/sdp' };
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: pc.localDescription.sdp,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.text();
|
||||||
|
throw new Error(`WHEP POST ${resp.status}: ${body || resp.statusText}`);
|
||||||
|
}
|
||||||
|
// Per WHEP spec: server returns SDP answer; Location is the resource.
|
||||||
|
const loc = resp.headers.get('Location');
|
||||||
|
if (loc) {
|
||||||
|
// Resolve relative Location against the WHEP URL.
|
||||||
|
try { resourceURL = new URL(loc, url).toString(); }
|
||||||
|
catch { resourceURL = loc; }
|
||||||
|
$('stat-res').textContent = resourceURL;
|
||||||
|
}
|
||||||
|
const answer = await resp.text();
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
||||||
|
log(`subscribed (${resp.status})`, 'good');
|
||||||
|
|
||||||
|
// Pull codec info out of the SDP for a quick UI hint.
|
||||||
|
const codec = (kind, sdp) => {
|
||||||
|
const m = new RegExp(`m=${kind}[^\r\n]*[\r\n](?:[abc][^\r\n]*[\r\n]){0,30}?a=rtpmap:\\d+ ([^/\r\n]+)`).exec(sdp);
|
||||||
|
return m ? m[1] : '?';
|
||||||
|
};
|
||||||
|
$('stat-vcodec').textContent = codec('video', answer);
|
||||||
|
$('stat-acodec').textContent = codec('audio', answer);
|
||||||
|
|
||||||
|
bitrateTimer = setInterval(updateBitrate, 1000);
|
||||||
|
} catch (err) {
|
||||||
|
log(`error: ${err.message}`, 'bad');
|
||||||
|
await disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnect() {
|
||||||
|
if (bitrateTimer) { clearInterval(bitrateTimer); bitrateTimer = null; }
|
||||||
|
$('btn-play').disabled = false;
|
||||||
|
$('btn-stop').disabled = true;
|
||||||
|
|
||||||
|
// WHEP: best-effort DELETE on the resource URL the server gave us.
|
||||||
|
if (resourceURL) {
|
||||||
|
try {
|
||||||
|
const headers = {};
|
||||||
|
const token = $('bearer').value.trim();
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
const r = await fetch(resourceURL, { method: 'DELETE', headers });
|
||||||
|
log(`DELETE ${r.status}`, r.ok ? 'good' : 'warn');
|
||||||
|
} catch (e) {
|
||||||
|
log(`DELETE failed: ${e.message}`, 'warn');
|
||||||
|
}
|
||||||
|
resourceURL = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pc) { pc.close(); pc = null; }
|
||||||
|
$('video').srcObject = null;
|
||||||
|
setPill($('stat-ice'), 'idle', '');
|
||||||
|
setPill($('stat-conn'), 'idle', '');
|
||||||
|
$('stat-res').textContent = '—';
|
||||||
|
$('stat-vcodec').textContent = '—';
|
||||||
|
$('stat-acodec').textContent = '—';
|
||||||
|
$('stat-bitrate').textContent = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bitrate sampling -------------------------------------------
|
||||||
|
let lastBytes = null;
|
||||||
|
let lastTs = null;
|
||||||
|
async function updateBitrate() {
|
||||||
|
if (!pc || pc.connectionState !== 'connected') return;
|
||||||
|
const stats = await pc.getStats();
|
||||||
|
let bytes = 0;
|
||||||
|
stats.forEach((r) => {
|
||||||
|
if (r.type === 'inbound-rtp' && !r.isRemote) bytes += r.bytesReceived || 0;
|
||||||
|
});
|
||||||
|
const now = performance.now();
|
||||||
|
if (lastBytes !== null) {
|
||||||
|
const kbps = ((bytes - lastBytes) * 8) / ((now - lastTs) || 1);
|
||||||
|
$('stat-bitrate').textContent = kbps.toFixed(0) + ' kbps';
|
||||||
|
}
|
||||||
|
lastBytes = bytes;
|
||||||
|
lastTs = now;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
711
deploy/truenas/core/static/wilddragon-webrtc.html
Normal file
711
deploy/truenas/core/static/wilddragon-webrtc.html
Normal file
|
|
@ -0,0 +1,711 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Wild Dragon — WebRTC Admin</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #09090d;
|
||||||
|
--panel: #111118;
|
||||||
|
--panel2: #16161f;
|
||||||
|
--border: #1f1f2e;
|
||||||
|
--border2: #2a2a3d;
|
||||||
|
--fg: #e8e8f0;
|
||||||
|
--fg2: #9898b0;
|
||||||
|
--accent: #ff5c28;
|
||||||
|
--accent2: #ff7a4a;
|
||||||
|
--good: #4ecb8d;
|
||||||
|
--bad: #ff4d60;
|
||||||
|
--warn: #f5a623;
|
||||||
|
--mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Rajdhani', system-ui, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* dot grid */
|
||||||
|
background-image: radial-gradient(circle, #1e1e30 1px, transparent 1px);
|
||||||
|
background-size: 22px 22px;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── HEADER ─────────────────────────────────────────────────── */
|
||||||
|
header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: rgba(9,9,13,0.88);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WD monogram — 28×28 */
|
||||||
|
.header-monogram {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
background: var(--border2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.header-title span { color: var(--accent); }
|
||||||
|
|
||||||
|
.header-spacer { flex: 1; }
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
color: var(--fg2);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-left: 20px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
nav a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── MAIN ───────────────────────────────────────────────────── */
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px 24px;
|
||||||
|
max-width: 960px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── LOGIN PANEL ────────────────────────────────────────────── */
|
||||||
|
#login-panel {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 48px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wordmark {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FORM ELEMENTS ──────────────────────────────────────────── */
|
||||||
|
.field {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg2);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(255,92,40,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── BUTTONS ────────────────────────────────────────────────── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.btn:active { transform: scale(0.97); }
|
||||||
|
.btn:disabled { opacity: 0.35; cursor: not-allowed; transform: none; }
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover:not(:disabled) { background: var(--accent2); }
|
||||||
|
.btn-secondary { background: var(--panel2); color: var(--fg); border: 1px solid var(--border2); }
|
||||||
|
.btn-secondary:hover:not(:disabled) { border-color: var(--fg2); }
|
||||||
|
.btn-danger { background: rgba(255,77,96,0.12); color: var(--bad); border: 1px solid rgba(255,77,96,0.25); }
|
||||||
|
.btn-danger:hover:not(:disabled) { background: rgba(255,77,96,0.22); }
|
||||||
|
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ── BADGES ─────────────────────────────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.badge-good { background: rgba(78,203,141,0.12); color: var(--good); }
|
||||||
|
.badge-bad { background: rgba(255,77,96,0.12); color: var(--bad); }
|
||||||
|
.badge-warn { background: rgba(245,166,35,0.12); color: var(--warn); }
|
||||||
|
.badge-neutral { background: var(--panel2); color: var(--fg2); }
|
||||||
|
.badge-accent { background: rgba(255,92,40,0.12); color: var(--accent); }
|
||||||
|
|
||||||
|
/* animated pulse dot */
|
||||||
|
.pulse-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pulse-dot.good { background: var(--good); box-shadow: 0 0 0 0 rgba(78,203,141,0.4); animation: pulse-green 2s infinite; }
|
||||||
|
.pulse-dot.bad { background: var(--bad); box-shadow: 0 0 0 0 rgba(255,77,96,0.4); animation: pulse-red 2s infinite; }
|
||||||
|
.pulse-dot.warn { background: var(--warn); box-shadow: 0 0 0 0 rgba(245,166,35,0.4); animation: pulse-warn 2s infinite; }
|
||||||
|
|
||||||
|
@keyframes pulse-green {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(78,203,141,0.5); }
|
||||||
|
70% { box-shadow: 0 0 0 6px rgba(78,203,141,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(78,203,141,0); }
|
||||||
|
}
|
||||||
|
@keyframes pulse-red {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255,77,96,0.5); }
|
||||||
|
70% { box-shadow: 0 0 0 6px rgba(255,77,96,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(255,77,96,0); }
|
||||||
|
}
|
||||||
|
@keyframes pulse-warn {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(245,166,35,0.5); }
|
||||||
|
70% { box-shadow: 0 0 0 6px rgba(245,166,35,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(245,166,35,0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PROCESS CARDS ──────────────────────────────────────────── */
|
||||||
|
.process-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
|
.process-card {
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--border2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 12px 16px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.process-card.state-running { border-left-color: var(--good); }
|
||||||
|
.process-card.state-failed { border-left-color: var(--bad); }
|
||||||
|
.process-card.state-connecting { border-left-color: var(--warn); }
|
||||||
|
.process-card.state-finished { border-left-color: var(--fg2); }
|
||||||
|
|
||||||
|
.process-id {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-ref {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg2);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whep-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255,92,40,0.06);
|
||||||
|
border: 1px solid rgba(255,92,40,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.whep-row .whep-label {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.whep-row .whep-url-text { flex: 1; }
|
||||||
|
.whep-row .whep-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── LOG ────────────────────────────────────────────────────── */
|
||||||
|
.log {
|
||||||
|
margin-top: 16px;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.log .ts { color: var(--fg2); }
|
||||||
|
.log .l-bad { color: var(--bad); }
|
||||||
|
.log .l-good { color: var(--good); }
|
||||||
|
.log .l-warn { color: var(--warn); }
|
||||||
|
|
||||||
|
/* ── EMPTY STATE ─────────────────────────────────────────────── */
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--fg2);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ROW UTIL ────────────────────────────────────────────────── */
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── HEADER ──────────────────────────────────────────────────── -->
|
||||||
|
<header>
|
||||||
|
<a class="header-logo" href="/">
|
||||||
|
<!-- WD monogram 28×28 -->
|
||||||
|
<svg class="header-monogram" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<rect width="28" height="28" rx="6" fill="#1a1a24"/>
|
||||||
|
<!-- flame chevron -->
|
||||||
|
<path d="M14 20 L8 10 L11.5 13 L14 8 L16.5 13 L20 10 Z" fill="#ff5c28" opacity="0.9"/>
|
||||||
|
<!-- WD text mark -->
|
||||||
|
<text x="3.5" y="26" font-family="'Rajdhani',sans-serif" font-size="8.5" font-weight="700" fill="#e8e8f0" letter-spacing="0.5">WD</text>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="header-divider"></div>
|
||||||
|
<span class="header-title">WebRTC <span>Admin</span></span>
|
||||||
|
<div class="header-spacer"></div>
|
||||||
|
<nav>
|
||||||
|
<a href="/">Restreamer</a>
|
||||||
|
<a href="/whep-player.html">WHEP Player</a>
|
||||||
|
<a href="/api/swagger/index.html">API</a>
|
||||||
|
<a href="#" id="logout-link" style="display:none">Sign out</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── MAIN ────────────────────────────────────────────────────── -->
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<!-- LOGIN PANEL -->
|
||||||
|
<section id="login-panel">
|
||||||
|
|
||||||
|
<!-- Wild Dragon wordmark SVG -->
|
||||||
|
<svg class="login-wordmark" width="220" height="48" viewBox="0 0 220 48" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Wild Dragon">
|
||||||
|
<!-- rounded dark rect icon -->
|
||||||
|
<rect x="0" y="4" width="40" height="40" rx="8" fill="#1a1a24"/>
|
||||||
|
<!-- flame gradient defs -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="flameG" x1="20" y1="38" x2="20" y2="12" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#ff5c28"/>
|
||||||
|
<stop offset="100%" stop-color="#ffaa44"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- flame/chevron -->
|
||||||
|
<path d="M20 36 L11 20 L16 24 L20 14 L24 24 L29 20 Z" fill="url(#flameG)"/>
|
||||||
|
<!-- W mark -->
|
||||||
|
<text x="5" y="44" font-family="'Rajdhani',sans-serif" font-size="13" font-weight="700" fill="#c8c8e0" letter-spacing="1">WD</text>
|
||||||
|
<!-- WILD text -->
|
||||||
|
<text x="50" y="26" font-family="'Rajdhani',sans-serif" font-size="20" font-weight="300" letter-spacing="4" fill="#e8e8f0">WILD</text>
|
||||||
|
<!-- DRAGON text -->
|
||||||
|
<text x="50" y="44" font-family="'Rajdhani',sans-serif" font-size="20" font-weight="700" letter-spacing="4" fill="#ff5c28">DRAGON</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">Sign in</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:14px; color:var(--fg2); margin-bottom:18px;">
|
||||||
|
Use your Core API credentials (<code style="font-family:var(--mono);font-size:12px;color:var(--fg)">API_AUTH_USERNAME</code> / <code style="font-family:var(--mono);font-size:12px;color:var(--fg)">API_AUTH_PASSWORD</code>).
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="login-user">Username</label>
|
||||||
|
<input id="login-user" type="text" autocomplete="username" placeholder="admin">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="login-pass">Password</label>
|
||||||
|
<input id="login-pass" type="password" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:18px;">
|
||||||
|
<button class="btn btn-primary" id="btn-login">Sign in</button>
|
||||||
|
</div>
|
||||||
|
<div id="login-log" class="log" aria-live="polite" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ADMIN PANEL -->
|
||||||
|
<section id="admin-panel" style="display:none">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">Processes</span>
|
||||||
|
<div class="row" style="gap:6px;">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-refresh">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="process-list" class="process-list">
|
||||||
|
<div class="empty">Loading…</div>
|
||||||
|
</div>
|
||||||
|
<div id="admin-log" class="log" aria-live="polite" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── tiny state ────────────────────────────────────────────────────
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const TOKEN_KEY = 'dragonfork-admin-token';
|
||||||
|
let authToken = null;
|
||||||
|
|
||||||
|
function log(panel, line, level = 'info') {
|
||||||
|
panel.style.display = '';
|
||||||
|
const ts = new Date().toLocaleTimeString();
|
||||||
|
const cls = level === 'bad' ? 'l-bad' : level === 'good' ? 'l-good' : level === 'warn' ? 'l-warn' : '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = `<span class="ts">${ts}</span> <span class="${cls}">${escapeHTML(line)}</span>`;
|
||||||
|
panel.prepend(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
})[c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── auth ──────────────────────────────────────────────────────────
|
||||||
|
function setAuth(token) {
|
||||||
|
authToken = token;
|
||||||
|
if (token) {
|
||||||
|
try { localStorage.setItem(TOKEN_KEY, token); } catch (e) {}
|
||||||
|
$('login-panel').style.display = 'none';
|
||||||
|
$('admin-panel').style.display = '';
|
||||||
|
$('logout-link').style.display = '';
|
||||||
|
refreshProcesses();
|
||||||
|
} else {
|
||||||
|
try { localStorage.removeItem(TOKEN_KEY); } catch (e) {}
|
||||||
|
$('login-panel').style.display = '';
|
||||||
|
$('admin-panel').style.display = 'none';
|
||||||
|
$('logout-link').style.display = 'none';
|
||||||
|
$('process-list').innerHTML = '<div class="empty">Not signed in.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const user = $('login-user').value.trim();
|
||||||
|
const pass = $('login-pass').value;
|
||||||
|
if (!user || !pass) {
|
||||||
|
log($('login-log'), 'username and password required', 'bad');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('btn-login').disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password: pass }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.text();
|
||||||
|
throw new Error(`HTTP ${r.status}: ${body || r.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
const token = data.access_token || data.accessToken || data.token;
|
||||||
|
if (!token) throw new Error('login response missing access_token');
|
||||||
|
log($('login-log'), 'authenticated', 'good');
|
||||||
|
$('login-pass').value = '';
|
||||||
|
setAuth(token);
|
||||||
|
} catch (err) {
|
||||||
|
log($('login-log'), 'login failed: ' + err.message, 'bad');
|
||||||
|
} finally {
|
||||||
|
$('btn-login').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('btn-login').addEventListener('click', login);
|
||||||
|
$('login-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
|
||||||
|
$('login-user').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
|
||||||
|
$('logout-link').addEventListener('click', (e) => { e.preventDefault(); setAuth(null); });
|
||||||
|
|
||||||
|
// Restore cached session (will bounce back to login on 401)
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (cached) setAuth(cached);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// ── API helpers ───────────────────────────────────────────────────
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const headers = { 'Authorization': 'Bearer ' + authToken };
|
||||||
|
const init = { method, headers };
|
||||||
|
if (body !== undefined) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
init.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const r = await fetch(path, init);
|
||||||
|
if (r.status === 401) {
|
||||||
|
log($('admin-log'), 'session expired, please sign in again', 'warn');
|
||||||
|
setAuth(null);
|
||||||
|
throw new Error('unauthorized');
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
const text = await r.text();
|
||||||
|
throw new Error(`${method} ${path} → ${r.status}: ${text || r.statusText}`);
|
||||||
|
}
|
||||||
|
if (r.status === 204) return null;
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── process list ──────────────────────────────────────────────────
|
||||||
|
$('btn-refresh').addEventListener('click', refreshProcesses);
|
||||||
|
|
||||||
|
async function refreshProcesses() {
|
||||||
|
if (!authToken) return;
|
||||||
|
$('process-list').innerHTML = '<div class="empty">Loading…</div>';
|
||||||
|
try {
|
||||||
|
const procs = await api('GET', '/api/v3/process?filter=config,state');
|
||||||
|
renderProcesses(procs);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message === 'unauthorized') return;
|
||||||
|
log($('admin-log'), 'list processes: ' + err.message, 'bad');
|
||||||
|
$('process-list').innerHTML = '<div class="empty">Failed to load processes.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateClass(state) {
|
||||||
|
if (state === 'running') return 'state-running';
|
||||||
|
if (state === 'failed' || state === 'killed') return 'state-failed';
|
||||||
|
if (state === 'finishing') return 'state-finished';
|
||||||
|
return 'state-connecting'; // starting, reconnecting, idle, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateBadgeClass(state) {
|
||||||
|
if (state === 'running') return 'badge-good';
|
||||||
|
if (state === 'failed' || state === 'killed') return 'badge-bad';
|
||||||
|
if (state === 'finishing') return 'badge-neutral';
|
||||||
|
return 'badge-warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateDotClass(state) {
|
||||||
|
if (state === 'running') return 'good';
|
||||||
|
if (state === 'failed' || state === 'killed') return 'bad';
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProcesses(procs) {
|
||||||
|
const list = $('process-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!Array.isArray(procs) || procs.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty">No processes configured. Create one in the Restreamer UI.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
procs.sort((a, b) => (a.id || '').localeCompare(b.id || ''));
|
||||||
|
for (const p of procs) {
|
||||||
|
list.appendChild(renderProcess(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProcess(proc) {
|
||||||
|
const cfg = proc.config || {};
|
||||||
|
const webrtcEnabled = !!(cfg.webrtc && cfg.webrtc.enabled);
|
||||||
|
const state = (proc.state && proc.state.exec) || (proc.state && proc.state.state) || 'unknown';
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'process-card ' + stateClass(state);
|
||||||
|
|
||||||
|
// ── left ──
|
||||||
|
const left = document.createElement('div');
|
||||||
|
|
||||||
|
// id
|
||||||
|
const idEl = document.createElement('div');
|
||||||
|
idEl.className = 'process-id';
|
||||||
|
idEl.textContent = proc.id || '(no id)';
|
||||||
|
left.appendChild(idEl);
|
||||||
|
|
||||||
|
// meta row: state badge + webrtc badge + ref
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'process-meta';
|
||||||
|
|
||||||
|
const dotCls = stateDotClass(state);
|
||||||
|
const badgeCls = stateBadgeClass(state);
|
||||||
|
const stateBadge = document.createElement('span');
|
||||||
|
stateBadge.className = 'badge ' + badgeCls;
|
||||||
|
stateBadge.innerHTML = `<span class="pulse-dot ${dotCls}"></span>${escapeHTML(state)}`;
|
||||||
|
meta.appendChild(stateBadge);
|
||||||
|
|
||||||
|
const webrtcBadge = document.createElement('span');
|
||||||
|
webrtcBadge.className = 'badge ' + (webrtcEnabled ? 'badge-accent' : 'badge-neutral');
|
||||||
|
webrtcBadge.textContent = 'WebRTC ' + (webrtcEnabled ? 'on' : 'off');
|
||||||
|
meta.appendChild(webrtcBadge);
|
||||||
|
|
||||||
|
if (cfg.reference) {
|
||||||
|
const ref = document.createElement('span');
|
||||||
|
ref.className = 'process-ref';
|
||||||
|
ref.textContent = cfg.reference;
|
||||||
|
meta.appendChild(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
left.appendChild(meta);
|
||||||
|
card.appendChild(left);
|
||||||
|
|
||||||
|
// ── right: actions ──
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'process-actions';
|
||||||
|
|
||||||
|
const toggleBtn = document.createElement('button');
|
||||||
|
toggleBtn.className = 'btn btn-sm ' + (webrtcEnabled ? 'btn-danger' : 'btn-secondary');
|
||||||
|
toggleBtn.textContent = webrtcEnabled ? 'Disable WebRTC' : 'Enable WebRTC';
|
||||||
|
toggleBtn.addEventListener('click', () => toggleWebRTC(proc.id, !webrtcEnabled, toggleBtn));
|
||||||
|
actions.appendChild(toggleBtn);
|
||||||
|
card.appendChild(actions);
|
||||||
|
|
||||||
|
// ── WHEP URL row ──
|
||||||
|
if (webrtcEnabled) {
|
||||||
|
const whepURL = location.origin + '/api/v3/whep/' + encodeURIComponent(proc.id);
|
||||||
|
const playerURL = '/whep-player.html?url=' + encodeURIComponent(whepURL);
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'whep-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="whep-label">WHEP</span>
|
||||||
|
<span class="whep-url-text">${escapeHTML(whepURL)}</span>
|
||||||
|
<span class="whep-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" data-copy="${escapeHTML(whepURL)}">Copy</button>
|
||||||
|
<a class="btn btn-secondary btn-sm" href="${escapeHTML(playerURL)}" target="_blank" rel="noopener" style="text-decoration:none">Open ↗</a>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
row.querySelector('[data-copy]').addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigator.clipboard?.writeText(whepURL).then(
|
||||||
|
() => log($('admin-log'), 'WHEP URL copied', 'good'),
|
||||||
|
() => log($('admin-log'), 'clipboard write failed', 'warn'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
card.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── toggle webrtc.enabled ─────────────────────────────────────────
|
||||||
|
async function toggleWebRTC(id, enabled, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = enabled ? 'Enabling…' : 'Disabling…';
|
||||||
|
try {
|
||||||
|
const cfg = await api('GET', '/api/v3/process/' + encodeURIComponent(id) + '/config');
|
||||||
|
cfg.webrtc = cfg.webrtc || {};
|
||||||
|
cfg.webrtc.enabled = enabled;
|
||||||
|
await api('PUT', '/api/v3/process/' + encodeURIComponent(id), cfg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('PUT', '/api/v3/process/' + encodeURIComponent(id) + '/command', { command: 'restart' });
|
||||||
|
log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id} (restarted)`, 'good');
|
||||||
|
} catch (cmdErr) {
|
||||||
|
log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id}, restart skipped: ${cmdErr.message}`, 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshProcesses();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message === 'unauthorized') return;
|
||||||
|
log($('admin-log'), `toggle ${id}: ${err.message}`, 'bad');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = enabled ? 'Enable WebRTC' : 'Disable WebRTC';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
deploy/truenas/core/ui-overlay/apply-overlay.sh
Executable file
70
deploy/truenas/core/ui-overlay/apply-overlay.sh
Executable file
|
|
@ -0,0 +1,70 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# apply-overlay.sh — Wild Dragon reskin patches applied to a freshly
|
||||||
|
# cloned datarhei/restreamer-ui tree. Two phases:
|
||||||
|
#
|
||||||
|
# 1. File overlay: rsync the contents of $OVERLAY/{public,src} on top
|
||||||
|
# of the upstream working tree. Whole-file replacements only —
|
||||||
|
# simple and idempotent.
|
||||||
|
#
|
||||||
|
# 2. Targeted in-place sed for one-line UI strings that aren't worth
|
||||||
|
# a whole-file overlay (the header title, a few welcome strings).
|
||||||
|
# Each pattern is anchored to a unique surrounding context so a
|
||||||
|
# future upstream rename doesn't silently rewrite the wrong line.
|
||||||
|
#
|
||||||
|
# Caller: the Dockerfile's ui-builder stage. Expects:
|
||||||
|
# $OVERLAY = /overlay (the COPY destination)
|
||||||
|
# $UI = /ui (the cloned upstream source root)
|
||||||
|
#
|
||||||
|
# Idempotent on a single source tree (rerunning is a no-op).
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
OVERLAY="${OVERLAY:-/overlay}"
|
||||||
|
UI="${UI:-/ui}"
|
||||||
|
|
||||||
|
echo "wilddragon-overlay: layering $OVERLAY -> $UI"
|
||||||
|
|
||||||
|
# Phase 1 — file copies. -L follows any future symlinks, -p preserves
|
||||||
|
# perms, -R recursive. We deliberately avoid --delete: the upstream
|
||||||
|
# tree must stay intact except for the files we override.
|
||||||
|
for sub in public src; do
|
||||||
|
if [ -d "$OVERLAY/$sub" ]; then
|
||||||
|
cp -RLp "$OVERLAY/$sub/." "$UI/$sub/"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Phase 2 — targeted seds. Each replacement is wrapped in a check so
|
||||||
|
# the script fails loudly if upstream changed the line we're patching
|
||||||
|
# (rather than silently no-op'ing and shipping un-rebranded UI).
|
||||||
|
patch_line() {
|
||||||
|
file="$1"; needle="$2"; replacement="$3"
|
||||||
|
if ! grep -qF "$needle" "$file"; then
|
||||||
|
echo "wilddragon-overlay: ERROR — pattern not found in $file:"
|
||||||
|
echo " $needle"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Use awk for safe literal substitution (sed's regex would mishandle
|
||||||
|
# special chars in the replacement).
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
awk -v n="$needle" -v r="$replacement" '
|
||||||
|
index($0, n) { sub(n, r); }
|
||||||
|
{ print }
|
||||||
|
' "$file" > "$tmp"
|
||||||
|
mv "$tmp" "$file"
|
||||||
|
echo "wilddragon-overlay: patched $(basename "$file") — $needle -> $replacement"
|
||||||
|
}
|
||||||
|
|
||||||
|
patch_line "$UI/src/Header.js" \
|
||||||
|
'<Typography className="headerTitle">Restreamer</Typography>' \
|
||||||
|
'<Typography className="headerTitle">Wild Dragon</Typography>'
|
||||||
|
|
||||||
|
# Welcome view top-of-page card.
|
||||||
|
patch_line "$UI/src/views/Welcome.js" \
|
||||||
|
'title="Welcome to Restreamer v2"' \
|
||||||
|
'title="Welcome to Wild Dragon"'
|
||||||
|
|
||||||
|
patch_line "$UI/src/views/Settings.js" \
|
||||||
|
'title="Welcome to Restreamer v2"' \
|
||||||
|
'title="Welcome to Wild Dragon"'
|
||||||
|
|
||||||
|
echo "wilddragon-overlay: done."
|
||||||
BIN
deploy/truenas/core/ui-overlay/public/favicon.ico
Normal file
BIN
deploy/truenas/core/ui-overlay/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
17
deploy/truenas/core/ui-overlay/public/index.html
Normal file
17
deploy/truenas/core/ui-overlay/public/index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="favicon.ico" />
|
||||||
|
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
|
||||||
|
<meta name="theme-color" content="#0d0e12" />
|
||||||
|
<meta name="description" content="Wild Dragon — low-latency live video streaming dashboard" />
|
||||||
|
<link rel="apple-touch-icon" href="logo192.png" />
|
||||||
|
<link rel="manifest" href="manifest.json" />
|
||||||
|
<title>Wild Dragon</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
deploy/truenas/core/ui-overlay/public/logo192.png
Normal file
1
deploy/truenas/core/ui-overlay/public/logo192.png
Normal file
File diff suppressed because one or more lines are too long
BIN
deploy/truenas/core/ui-overlay/public/logo512.png
Normal file
BIN
deploy/truenas/core/ui-overlay/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
13
deploy/truenas/core/ui-overlay/public/manifest.json
Normal file
13
deploy/truenas/core/ui-overlay/public/manifest.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"short_name": "Wild Dragon",
|
||||||
|
"name": "Wild Dragon — Live Streaming",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "favicon.ico", "sizes": "64x64 32x32 16x16", "type": "image/x-icon" },
|
||||||
|
{ "src": "logo192.png", "type": "image/png", "sizes": "192x192" },
|
||||||
|
{ "src": "logo512.png", "type": "image/png", "sizes": "512x512" }
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#0d0e12",
|
||||||
|
"background_color": "#0d0e12"
|
||||||
|
}
|
||||||
24
deploy/truenas/core/ui-overlay/src/misc/Logo/images/logo.svg
Normal file
24
deploy/truenas/core/ui-overlay/src/misc/Logo/images/logo.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 70" width="320" height="70">
|
||||||
|
<!-- Wild Dragon wordmark: small ember+chevron icon followed by the text. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ember-w" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ff8855"/>
|
||||||
|
<stop offset="1" stop-color="#cc3300"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- icon -->
|
||||||
|
<g transform="translate(0,4)">
|
||||||
|
<rect x="2" y="2" width="58" height="58" rx="10" fill="#1a1c22"/>
|
||||||
|
<path d="M14 48 Q22 30 31 38 Q40 30 48 48 Q40 53 31 47 Q22 53 14 48 Z"
|
||||||
|
fill="url(#ember-w)" opacity="0.7"/>
|
||||||
|
<text x="31" y="40" text-anchor="middle"
|
||||||
|
font-family="'DejaVu Sans','Helvetica',sans-serif"
|
||||||
|
font-size="26" font-weight="700" fill="#ff6633">WD</text>
|
||||||
|
</g>
|
||||||
|
<!-- wordmark -->
|
||||||
|
<text x="76" y="48"
|
||||||
|
font-family="'Dosis','Roboto','Helvetica',sans-serif"
|
||||||
|
font-size="36" font-weight="300" letter-spacing="2" fill="#e7e7ea">
|
||||||
|
WILD <tspan fill="#ff6633" font-weight="500">DRAGON</tspan>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1 KiB |
|
|
@ -0,0 +1,19 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
||||||
|
<!-- Wild Dragon mark: dark rounded panel with stylised flame chevron + 'WD' monogram. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="ember" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ff6633"/>
|
||||||
|
<stop offset="1" stop-color="#cc3300"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="6" y="6" width="188" height="188" rx="32" fill="#0d0e12"/>
|
||||||
|
<!-- Flame chevron underneath the monogram -->
|
||||||
|
<path d="M40 150 Q60 110 100 130 Q140 110 160 150 Q140 165 100 152 Q60 165 40 150 Z"
|
||||||
|
fill="url(#ember)" opacity="0.55"/>
|
||||||
|
<!-- 'W' -->
|
||||||
|
<path d="M50 60 L62 130 L78 90 L94 130 L106 60"
|
||||||
|
stroke="#ff6633" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||||
|
<!-- 'D' -->
|
||||||
|
<path d="M118 60 L118 130 L138 130 Q165 130 165 95 Q165 60 138 60 L118 60 Z"
|
||||||
|
stroke="#ff6633" stroke-width="10" stroke-linejoin="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 975 B |
24
deploy/truenas/core/ui-overlay/src/misc/Logo/index.js
Normal file
24
deploy/truenas/core/ui-overlay/src/misc/Logo/index.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import makeStyles from '@mui/styles/makeStyles';
|
||||||
|
|
||||||
|
import company_logo from './images/logo.svg';
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
Logo: {
|
||||||
|
height: 27,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function Logo(props) {
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
let link = 'https://forge.wilddragon.net/zgaetano/datarhei-dragonfork-core';
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-useless-escape
|
||||||
|
return (
|
||||||
|
<a href={link} className={classes.Logo} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src={company_logo} alt="Wild Dragon logo" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
deploy/truenas/core/ui-overlay/src/misc/Logo/rsLogo.js
Normal file
24
deploy/truenas/core/ui-overlay/src/misc/Logo/rsLogo.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import makeStyles from '@mui/styles/makeStyles';
|
||||||
|
|
||||||
|
import company_logo from './images/rs-logo.svg';
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
Logo: {
|
||||||
|
height: 95,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function Logo(props) {
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
let link = 'https://forge.wilddragon.net/zgaetano/datarhei-dragonfork-core';
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-useless-escape
|
||||||
|
return (
|
||||||
|
<a href={link} className={classes.Logo} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src={company_logo} alt="Wild Dragon mark" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
docs/REBASE.md
Normal file
165
docs/REBASE.md
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
# Dragon Fork — Upstream Rebase Policy
|
||||||
|
|
||||||
|
Tracks the relationship between `forge.wilddragon.net/zgaetano/datarhei-dragonfork-core`
|
||||||
|
(this fork) and upstream `github.com/datarhei/core`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
| Point | Value |
|
||||||
|
|---|---|
|
||||||
|
| Fork date | 2026-04-17 |
|
||||||
|
| Fork commit | `0de97f4` ("Add linux/arm/v8 build") |
|
||||||
|
| Upstream module path | `github.com/datarhei/core/v16` (kept — see `NOTES.md`) |
|
||||||
|
| First rebase target | upstream `main` (post-v16.16.0) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cadence
|
||||||
|
|
||||||
|
Rebase against upstream `main` **monthly**, timed to follow an upstream
|
||||||
|
minor release (`v16.X.0`) appearing on the upstream default branch.
|
||||||
|
|
||||||
|
Emergency rebases (CVE in a transitive dependency, critical upstream fix)
|
||||||
|
are performed ad-hoc following the same procedure, just sooner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategy: Rebase, Not Merge
|
||||||
|
|
||||||
|
Use `git rebase upstream/main` rather than `git merge upstream/main`.
|
||||||
|
|
||||||
|
- Keeps a linear history; `git log --oneline` stays readable.
|
||||||
|
- Dragon Fork commits remain visually distinct from upstream commits.
|
||||||
|
- Conflicts surface one commit at a time rather than in a single merge blob.
|
||||||
|
|
||||||
|
Merge commits are only used for feature branches merging into `main` via PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Divergence Boundaries
|
||||||
|
|
||||||
|
### Files exclusively owned by Dragon Fork (expect zero conflicts)
|
||||||
|
|
||||||
|
These paths did not exist upstream at fork time and are not being upstreamed:
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|---|---|
|
||||||
|
| `app/webrtc/` | WebRTC app subsystem |
|
||||||
|
| `core/webrtc/` | Pion peer factory, Source, ICE helpers |
|
||||||
|
| `deploy/truenas/` | TrueNAS deployment bundle |
|
||||||
|
| `docs/design/` | Dragon Fork design documents |
|
||||||
|
| `docs/REBASE.md` | This file |
|
||||||
|
| `.forgejo/` | Forgejo CI workflows |
|
||||||
|
| `test/load/` | WHEP load-test harness |
|
||||||
|
|
||||||
|
### Files modified from upstream (higher conflict risk)
|
||||||
|
|
||||||
|
| Path | Our change | Conflict strategy |
|
||||||
|
|---|---|---|
|
||||||
|
| `go.mod` / `go.sum` | Added Pion + Prometheus deps | Accept upstream base; re-add our `require` blocks; run `go mod tidy` |
|
||||||
|
| `http/server.go` | WebRTC handler registration | Search for `WebRTC` in diff; reapply our three-line wiring |
|
||||||
|
| `restream/` | `ProcessHooks` interface + `SetHooks` | Check if upstream changed hook shape; adapt our callbacks |
|
||||||
|
| `config/` | `DataWebRTC` config block | Keep our field additions; adopt upstream structural changes |
|
||||||
|
| `README.md` | Dragon Fork branding | Keep our content; cherry-pick upstream security notices |
|
||||||
|
| `CHANGELOG.md` | Dragon Fork version entries | Keep our entries at top; adopt upstream format changes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Rebase Checklist
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] git fetch upstream # confirm new upstream commits exist
|
||||||
|
[ ] CI is green on current main
|
||||||
|
[ ] go mod vendor is clean:
|
||||||
|
go mod vendor && git diff --quiet vendor/ # commit if dirty
|
||||||
|
[ ] Tag current tip:
|
||||||
|
git tag pre-rebase-v<upstream-ver> # e.g. pre-rebase-v16.17.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First Rebase Procedure
|
||||||
|
|
||||||
|
Run these commands locally (or on any machine with the repo checked out):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Add upstream remote (idempotent)
|
||||||
|
git remote add upstream https://github.com/datarhei/core.git 2>/dev/null || true
|
||||||
|
|
||||||
|
# 2. Fetch
|
||||||
|
git fetch upstream
|
||||||
|
|
||||||
|
# 3. Tag current tip
|
||||||
|
UPSTREAM_VER=$(git describe --tags upstream/main --abbrev=0 2>/dev/null || echo manual)
|
||||||
|
git tag pre-rebase-${UPSTREAM_VER}
|
||||||
|
|
||||||
|
# 4. Rebase
|
||||||
|
git rebase upstream/main
|
||||||
|
# On conflict:
|
||||||
|
# git diff — see what conflicted
|
||||||
|
# <edit the file>
|
||||||
|
# git add <file>
|
||||||
|
# git rebase --continue
|
||||||
|
|
||||||
|
# 5. Update vendored dependencies
|
||||||
|
go mod tidy
|
||||||
|
go mod vendor
|
||||||
|
git add vendor/ go.mod go.sum
|
||||||
|
git commit -m "chore: update vendor after upstream rebase to ${UPSTREAM_VER}"
|
||||||
|
|
||||||
|
# 6. Run verification gate (see below)
|
||||||
|
|
||||||
|
# 7. Push
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Rebase Verification Gate
|
||||||
|
|
||||||
|
All of the following must pass before pushing the rebased `main`:
|
||||||
|
|
||||||
|
| Check | Command |
|
||||||
|
|---|---|
|
||||||
|
| Build | `go build ./...` |
|
||||||
|
| Vet | `go vet ./...` |
|
||||||
|
| Unit + race | `go test -race -short -count=1 ./...` |
|
||||||
|
| WebRTC smoke | `go test -race -count=1 -v -run 'TestIntegration_\|TestSubsystem_\|TestHandler_' ./app/webrtc/... ./core/webrtc/...` |
|
||||||
|
| Latency gate | `go test -tags latency -timeout 90s -race -count=1 -run TestLatencyServerHop ./app/webrtc/... -v` |
|
||||||
|
| TrueNAS smoke | Deploy to staging, subscribe one WHEP peer, verify video renders |
|
||||||
|
|
||||||
|
Forgejo CI covers the first four automatically on push. The latency gate and
|
||||||
|
TrueNAS smoke are manual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## go.mod / Vendored Dependencies
|
||||||
|
|
||||||
|
After rebasing and running `go mod tidy`:
|
||||||
|
|
||||||
|
1. Confirm Pion packages (`github.com/pion/*`) remain in `vendor/` at our
|
||||||
|
required versions.
|
||||||
|
2. If upstream bumped a shared dep (e.g. `labstack/echo`), review that dep's
|
||||||
|
changelog before accepting the version bump.
|
||||||
|
3. Commit `vendor/`, `go.mod`, and `go.sum` together:
|
||||||
|
`chore: update vendor after upstream rebase to v<X>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI Automation (Future)
|
||||||
|
|
||||||
|
Automated monthly rebase via a Forgejo-Actions scheduled workflow is a v0.3
|
||||||
|
consideration. Blocked on: runner having a git identity for push, and a
|
||||||
|
strategy for surfacing conflict PRs when automation fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Record-Keeping
|
||||||
|
|
||||||
|
After each successful rebase, append a row to this table:
|
||||||
|
|
||||||
|
| Date | Upstream version | Pre-rebase tag | Conflicts | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| (first rebase pending) | v16.X.0 | pre-rebase-v16.X.0 | — | run locally per procedure above |
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
# M2 — WebRTC into datarhei Core proper
|
||||||
|
|
||||||
|
**Status:** Design approved, implementation pending
|
||||||
|
**Date:** 2026-04-17
|
||||||
|
**Author:** Zac (zgaetano@wilddragon.net), Dragon Fork
|
||||||
|
**Depends on:** M1 (`2026-04-16-datarhei-dragon-fork-m1-webrtc-poc.md`)
|
||||||
|
**Branch:** `m2-webrtc-core-integration`
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
M1 produced a standalone `cmd/webrtc-poc` binary that proved the Pion-based
|
||||||
|
WHEP egress path end-to-end on TrueNAS. M2 promotes that work into the
|
||||||
|
datarhei Core binary so WebRTC becomes a first-class output alongside
|
||||||
|
RTMP, SRT, and HLS, surfaced in the core-ui dashboard.
|
||||||
|
|
||||||
|
After M2 a user can:
|
||||||
|
|
||||||
|
1. Create or edit a process in core-ui.
|
||||||
|
2. Toggle a "WebRTC" switch on that process's config.
|
||||||
|
3. Save → Core restarts the process with an extra RTP output leg.
|
||||||
|
4. Open the process's "Live (WebRTC)" tab and watch the feed in the
|
||||||
|
browser with sub-second latency, authenticated by the user's JWT.
|
||||||
|
|
||||||
|
Out of scope for M2 (explicit):
|
||||||
|
- Public / unauthenticated embeds (handled in M3 via signed URLs).
|
||||||
|
- A separate "broadcast center" dashboard page (per-process tab is enough).
|
||||||
|
- Lazy / on-demand Source binding — eager binding only.
|
||||||
|
- WHIP ingest — that's M4.
|
||||||
|
|
||||||
|
## 2. High-level architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────┐
|
||||||
|
│ datarhei Core │
|
||||||
|
│ │
|
||||||
|
FFmpeg (per │ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
process, │ │ restream │─────▶│ app/webrtc │ │
|
||||||
|
spawned by │──▶│ │◀─────│ (NEW) │ │
|
||||||
|
restream) ───┐ │ │ - lifecycle │hooks │ │ │
|
||||||
|
│ │ │ - AppendOut │ │ - registry │ │
|
||||||
|
│ │ │ - config │ │ - sources │ │
|
||||||
|
│ │ │ (now incl. │ │ - PeerFactory│ │
|
||||||
|
│ │ │ WebRTC) │ │ - WHEP mux │ │
|
||||||
|
│ │ └──────────────┘ └──────┬───────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
udp:// │ │ ┌──────────────┐ │ │
|
||||||
|
127.0.0.1: └─▶│ │ core/webrtc │◀────uses────┘ │
|
||||||
|
<auto>rtp │ │ (from M1, │ │
|
||||||
|
│ │ unchanged) │ ┌────────────────┐ │
|
||||||
|
│ └──────────────┘ │ http/server │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ mounts │ │
|
||||||
|
│ │ /api/v3/process│ │
|
||||||
|
│ │ /:id/whep │ │
|
||||||
|
│ └────────┬───────┘ │
|
||||||
|
└────────────────────────────────┼───────────┘
|
||||||
|
│
|
||||||
|
(DTLS-SRTP over ICE) │
|
||||||
|
▼
|
||||||
|
Browser (core-ui
|
||||||
|
player tab, RTCPeer)
|
||||||
|
```
|
||||||
|
|
||||||
|
Three boxes matter:
|
||||||
|
|
||||||
|
- **existing `restream`** — grows two tiny hooks.
|
||||||
|
- **existing `core/webrtc`** (from M1) — unchanged.
|
||||||
|
- **new `app/webrtc`** — the glue subsystem.
|
||||||
|
|
||||||
|
## 3. Key decisions (settled during brainstorming)
|
||||||
|
|
||||||
|
| # | Decision | Choice |
|
||||||
|
|---|----------|--------|
|
||||||
|
| 1 | Scope | Backend + full UI with embedded player |
|
||||||
|
| 2 | Stream addressing | `/whep/{processID}` — per-process |
|
||||||
|
| 3 | HTTP listener | Under Core's `/api/v3` group (inherits JWT) |
|
||||||
|
| 4 | Viewer auth | JWT only in M2 — public embeds are M3 |
|
||||||
|
| 5 | FFmpeg wiring | Auto-inject UDP RTP output; re-encode when needed |
|
||||||
|
| 6 | Enable state | Field on `restream.Config.WebRTC` |
|
||||||
|
| 7 | UI surface | New "Live (WebRTC)" tab on process detail view |
|
||||||
|
| 8 | Lifecycle | Eager — Source bound when process starts |
|
||||||
|
| 9 | Code placement | New `app/webrtc` sibling subsystem (not inside restream) |
|
||||||
|
|
||||||
|
## 4. Components
|
||||||
|
|
||||||
|
### 4.1 Config — `config/data.go` + `restream/app/process.go`
|
||||||
|
|
||||||
|
Per-process:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// restream/app/process.go — new sibling of ConfigIO on Config
|
||||||
|
type ConfigWebRTC struct {
|
||||||
|
Enabled bool // master switch for this process
|
||||||
|
VideoPT uint8 // default 102 (H.264)
|
||||||
|
AudioPT uint8 // default 111 (Opus)
|
||||||
|
ForceTranscode bool // default false — true => always re-encode
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Global (Core config, one block):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// config/data.go
|
||||||
|
type DataWebRTC struct {
|
||||||
|
Enable bool // master feature flag; default false for safety
|
||||||
|
PublicIP string // NAT1To1 / ICE host candidate rewrite (e.g. LAN IP)
|
||||||
|
NAT1To1IPs []string // advanced: multiple public IPs
|
||||||
|
UDPMuxPort int // optional: single UDP port for all ICE traffic
|
||||||
|
// (0 = ephemeral per peer, default)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Registered through the existing `vars.Register` mechanism in `config/config.go`.
|
||||||
|
|
||||||
|
### 4.2 New package — `app/webrtc/`
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `subsystem.go` | `type WebRTC struct` with `Start()` / `Stop()`; owns the `core/webrtc.Registry` and a single `core/webrtc.PeerFactory`. Implements the same shape as other Core subsystems. |
|
||||||
|
| `lifecycle.go` | `OnProcessStart(id, cfg)` / `OnProcessStop(id)` callbacks registered with restream. Allocates a UDP port, calls `restream.AppendOutput`, binds a `core/webrtc.Source`, registers it. |
|
||||||
|
| `portalloc.go` | `Alloc() (int, error)` — binds `:0` on loopback, reads the port, closes the listener, returns the number. Race window is microseconds; `NewSourceOn` re-binds immediately. If the rebind fails (rare: another process grabbed the port in the gap), `OnStart` returns the error, restream aborts the start, operator retries. Tested with 100× tight-loop. |
|
||||||
|
| `ffmpeg_args.go` | `BuildArgs(cfg ConfigWebRTC, port int) []string` — emits the `-map`, `-c:v`, `-c:a`, `-f rtp`, `udp://127.0.0.1:PORT?pkt_size=1316` fragments. Branches on `ForceTranscode`. |
|
||||||
|
| `handler.go` | HTTP handler for WHEP — wraps the M1 `core/webrtc.NewWHEPHandler`, but looks up the Source by `processID` path param. Adds `DELETE /api/v3/process/:id/whep/:peerid`. |
|
||||||
|
|
||||||
|
### 4.3 Two additions to `restream`
|
||||||
|
|
||||||
|
1. **Lifecycle callback pair.** Added as fields on the restream manager:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ProcessHook func(id string, cfg *app.Config) error
|
||||||
|
type ProcessHooks struct {
|
||||||
|
OnStart ProcessHook // fires after args are assembled, before exec
|
||||||
|
OnStop ProcessHook // fires after wait() returns
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Single consumer is fine — no event bus yet. `app/webrtc` registers itself at subsystem start.
|
||||||
|
|
||||||
|
2. **`AppendOutput(id string, extra []string) error`** — mutates the *pending*
|
||||||
|
FFmpeg args for a process that has fired `OnStart` but has not yet exec'd.
|
||||||
|
Inside `OnStart`, the subsystem calls `AppendOutput` to add the
|
||||||
|
`-f rtp udp://…` fragment; restream then exec's with the augmented
|
||||||
|
args. Outside the `OnStart` window `AppendOutput` returns an error —
|
||||||
|
Core does not mutate running FFmpeg processes.
|
||||||
|
|
||||||
|
These two additions are useful beyond WebRTC (stats consumers, future
|
||||||
|
sidecar modules), so the surface cost is justified.
|
||||||
|
|
||||||
|
### 4.4 One route in `http/server.go`
|
||||||
|
|
||||||
|
Inside the existing `/api/v3` group (inherits JWT auth):
|
||||||
|
|
||||||
|
```go
|
||||||
|
api.POST("/process/:id/whep", webrtcHandler.Subscribe)
|
||||||
|
api.DELETE("/process/:id/whep/:peerid", webrtcHandler.Unsubscribe)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 UI — `core-ui/src/views/Edit/LiveTab.jsx` (new)
|
||||||
|
|
||||||
|
- Shown only when `process.config.webrtc.enabled === true`.
|
||||||
|
- `<video autoplay muted playsinline />` driven by a small `useWHEP()` hook
|
||||||
|
that does:
|
||||||
|
1. `new RTCPeerConnection({ iceServers: [] })`
|
||||||
|
2. `pc.addTransceiver('video', { direction: 'recvonly' })`
|
||||||
|
3. `pc.addTransceiver('audio', { direction: 'recvonly' })`
|
||||||
|
4. `await pc.setLocalDescription(await pc.createOffer())`
|
||||||
|
5. POST offer SDP to `/api/v3/process/{id}/whep` with the JWT.
|
||||||
|
6. `pc.setRemoteDescription(answer)`.
|
||||||
|
7. `pc.ontrack` → attach stream to the `<video>`.
|
||||||
|
- "Copy WHEP URL" button.
|
||||||
|
- Status line derived from `pc.connectionState` + `pc.getStats()` (codec, bitrate).
|
||||||
|
- No external WebRTC dependency — browser-native `RTCPeerConnection`.
|
||||||
|
|
||||||
|
## 5. Data flow
|
||||||
|
|
||||||
|
### 5.1 Enabling WebRTC (write)
|
||||||
|
|
||||||
|
```
|
||||||
|
core-ui ──PUT /api/v3/process/{id} { ..., config: { webrtc: { enabled: true }}}──▶ http
|
||||||
|
http ──restream.UpdateProcess(id, cfg)──▶ restream
|
||||||
|
restream ──persist → stop old → about to exec new──▶ OnProcessStart(id, cfg)
|
||||||
|
app/webrtc ─port P = Alloc()
|
||||||
|
app/webrtc ─restream.AppendOutput(id, BuildArgs(cfg.WebRTC, P))
|
||||||
|
app/webrtc ─NewSourceOn(id, "127.0.0.1", P).Start() → registry[id] = src
|
||||||
|
restream ─exec ffmpeg with augmented args
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordering guarantee: Source is bound *before* FFmpeg execs. No race window.
|
||||||
|
|
||||||
|
### 5.2 WHEP subscribe (read)
|
||||||
|
|
||||||
|
```
|
||||||
|
browser ──POST /api/v3/process/{id}/whep (SDP offer, JWT)──▶ http
|
||||||
|
http (JWT ok) ──handler.Subscribe──▶ app/webrtc
|
||||||
|
app/webrtc ─src = registry[id] (404 if absent)
|
||||||
|
app/webrtc ─peer, answer = factory.NewPeer(src, offer)
|
||||||
|
app/webrtc ─go forwarder: src.Subscribe(ch) → peer.WriteRTP
|
||||||
|
http ──201 Created, Location: .../whep/{peerid}, body=answer──▶ browser
|
||||||
|
browser ──ICE, DTLS-SRTP──▶ peer ──▶ <video>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Process stop (teardown)
|
||||||
|
|
||||||
|
```
|
||||||
|
restream ─kill ffmpeg, wait()──▶ OnProcessStop(id)
|
||||||
|
app/webrtc ─for each peer in peers[id]: peer.Close()
|
||||||
|
app/webrtc ─src = registry.Remove(id); src.Close()
|
||||||
|
app/webrtc ─delete peers[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Disabling WebRTC on a running process
|
||||||
|
|
||||||
|
Same as 5.1 in reverse: new cfg has `webrtc.enabled = false`. Restream
|
||||||
|
persists → stops (fires `OnProcessStop` → 5.3 runs) → starts without RTP leg.
|
||||||
|
|
||||||
|
### 5.5 Core restart
|
||||||
|
|
||||||
|
Restream enumerates stored configs at boot and starts each process.
|
||||||
|
`OnProcessStart` fires inside that loop for every `webrtc.enabled = true`
|
||||||
|
process. WebRTC state rebuilds from the persisted config — no separate
|
||||||
|
bootstrap path.
|
||||||
|
|
||||||
|
## 6. Error handling
|
||||||
|
|
||||||
|
| Failure | Surface |
|
||||||
|
|---------|---------|
|
||||||
|
| Port alloc fails | `OnProcessStart` returns error → restream aborts start, logs `webrtc: port alloc failed`. Process shows failed in UI. |
|
||||||
|
| FFmpeg wiring fails (bad codec + !ForceTranscode) | Source binds; RTP counter stays zero. Log after N seconds of silence; expose `RTPPacketsReceived` to UI. |
|
||||||
|
| WHEP POST for unknown id | `404 stream not found` (same as M1). |
|
||||||
|
| Peer DELETE unknown peerid | `204 No Content` (idempotent). |
|
||||||
|
| JWT missing / invalid | `401` — inherited from `/api` group. No code in handler. |
|
||||||
|
| ICE fails on client | Browser `iceconnectionstatechange = failed` → UI retry button. Server no-op. |
|
||||||
|
| Subsystem Start fails at boot (bad `PublicIP`, etc.) | Subsystem logs the error and declines to start; the hooks are never registered; restream runs all processes without the RTP leg. Core does **not** exit — WebRTC is non-critical. |
|
||||||
|
| Subscriber backpressure | Already handled in `core/webrtc.Source` — full channel drops. No change. |
|
||||||
|
|
||||||
|
**Design rule:** a WebRTC subsystem failure must not prevent a process's
|
||||||
|
RTMP/SRT/HLS outputs from running. Hooks wrap their own errors and log;
|
||||||
|
restream does not abort a start because of a WebRTC problem *unless* the
|
||||||
|
`AppendOutput` itself fails (wrong args shape — a programming bug, not a
|
||||||
|
runtime condition).
|
||||||
|
|
||||||
|
## 7. Testing strategy
|
||||||
|
|
||||||
|
### 7.1 Unit (fast, in-package, no network)
|
||||||
|
|
||||||
|
- `app/webrtc/ffmpeg_args_test.go` — table-driven: video-only, audio-only,
|
||||||
|
both, transcode on/off. Asserts exact arg slice.
|
||||||
|
- `app/webrtc/portalloc_test.go` — `Alloc()` returns a port that a
|
||||||
|
subsequent `ListenUDP` can bind; run 100× to catch races.
|
||||||
|
- `app/webrtc/lifecycle_test.go` — fake restream calls `OnProcessStart` /
|
||||||
|
`OnProcessStop`; asserts registry state transitions and Source is closed
|
||||||
|
exactly once.
|
||||||
|
|
||||||
|
### 7.2 Integration (in-process, real HTTP, no FFmpeg)
|
||||||
|
|
||||||
|
- `app/api/api_webrtc_whep_test.go` — boot a Core with a fake process that
|
||||||
|
has `webrtc.enabled=true`; inject synthetic RTP on the allocated port;
|
||||||
|
POST a WHEP offer using the M1 `test/whep-client.Subscribe` helper (now
|
||||||
|
imported as a library); assert both tracks receive a packet within 2s.
|
||||||
|
- `app/api/api_webrtc_auth_test.go` — POST without JWT → 401; POST for
|
||||||
|
unknown id → 404; DELETE unknown peerid → 204.
|
||||||
|
- `app/api/config_persist_test.go` — create process with `webrtc.enabled`,
|
||||||
|
simulate Core restart, assert Source is re-bound and WHEP still works.
|
||||||
|
|
||||||
|
### 7.3 End-to-end (manual, TrueNAS)
|
||||||
|
|
||||||
|
- Replace the M1 `test/publish.sh` workflow with a real Core process
|
||||||
|
configured via core-ui (`testsrc2` as input), flip WebRTC on, open the
|
||||||
|
Live tab, verify the test pattern plays.
|
||||||
|
- Use `chrome://webrtc-internals` to confirm ICE completes and SRTP is
|
||||||
|
flowing.
|
||||||
|
|
||||||
|
No new test dependencies. `test/whep-client` graduates from binary to
|
||||||
|
importable helper package.
|
||||||
|
|
||||||
|
## 8. Acceptance criteria
|
||||||
|
|
||||||
|
M2 is done when, on a fresh TrueNAS deploy of the Core binary:
|
||||||
|
|
||||||
|
1. `POST /api/v3/config` with a `webrtc.enable=true` global block succeeds.
|
||||||
|
2. Creating a process with `config.webrtc.enabled=true` via core-ui
|
||||||
|
persists and starts.
|
||||||
|
3. `POST /api/v3/process/{id}/whep` with a valid JWT returns `201` with an
|
||||||
|
SDP answer, and the connection reaches `iceconnectionstate=connected`.
|
||||||
|
4. The core-ui "Live (WebRTC)" tab plays video within 3 seconds of opening.
|
||||||
|
5. Disabling WebRTC in the UI stops the stream and subsequent WHEP POSTs
|
||||||
|
return `404`.
|
||||||
|
6. Restarting the Core binary keeps the stream working without manual
|
||||||
|
reconfiguration.
|
||||||
|
7. All unit and integration tests pass with `-race`.
|
||||||
|
|
||||||
|
## 9. Rollback
|
||||||
|
|
||||||
|
Each layer has a rollback lever:
|
||||||
|
|
||||||
|
- **Operator:** set global `webrtc.enable = false` in Core config → subsystem
|
||||||
|
declines to start (no hooks registered); processes run without the RTP
|
||||||
|
leg; existing RTMP/SRT/HLS unaffected. Core continues to serve normally.
|
||||||
|
- **Per-process:** toggle `config.webrtc.enabled = false` in the process
|
||||||
|
config → restream restarts the process without the leg.
|
||||||
|
- **Code:** the `app/webrtc` subsystem is a single import in `main.go`.
|
||||||
|
Removing that import and the two restream hook wires restores pre-M2
|
||||||
|
behavior. `core/webrtc` stays in the tree as inert code.
|
||||||
|
|
||||||
|
## 10. Milestones inside M2
|
||||||
|
|
||||||
|
Not the full plan — that lives in a separate plan doc after this spec is
|
||||||
|
approved. This is a sanity breakdown:
|
||||||
|
|
||||||
|
1. **Config wiring** — add `DataWebRTC` and `ConfigWebRTC`; tests for
|
||||||
|
marshal/unmarshal and defaults.
|
||||||
|
2. **Restream hooks** — add `ProcessHooks` and `AppendOutput`; unit tests
|
||||||
|
using the existing restream test harness.
|
||||||
|
3. **`app/webrtc` package** — subsystem, lifecycle, portalloc, ffmpeg_args,
|
||||||
|
handler; unit tests per the testing strategy.
|
||||||
|
4. **Core main.go wiring** — instantiate subsystem, register hooks, mount
|
||||||
|
HTTP route.
|
||||||
|
5. **Integration tests** — in-process WHEP end-to-end, auth, persistence.
|
||||||
|
6. **core-ui LiveTab** — new React tab + WHEP hook.
|
||||||
|
7. **TrueNAS smoke test** — rebuild Core image, redeploy, verify live.
|
||||||
|
|
||||||
|
Each milestone ends with a commit. The feature branch is
|
||||||
|
`m2-webrtc-core-integration` (created from `m1-webrtc-poc`).
|
||||||
|
|
@ -0,0 +1,666 @@
|
||||||
|
# Datarhei - Dragon Fork: WebRTC Prometheus Metrics
|
||||||
|
|
||||||
|
**Status:** Draft for review
|
||||||
|
**Author:** Zac (Wild Dragon)
|
||||||
|
**Date:** 2026-05-03
|
||||||
|
**Predecessors:**
|
||||||
|
- [`2026-04-16-datarhei-dragon-fork-webrtc-design.md`](2026-04-16-datarhei-dragon-fork-webrtc-design.md)
|
||||||
|
- [`2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md`](2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md)
|
||||||
|
- v0.1.0-dragonfork released 2026-05-03
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add Prometheus instrumentation to Dragon Fork's WebRTC subsystem and ship a
|
||||||
|
collection-and-dashboard stack in the existing TrueNAS deploy bundle. Closes
|
||||||
|
the v0.1 observability gap: the WHEP egress has been running in production
|
||||||
|
since 2026-04-17 with zero per-subsystem signal.
|
||||||
|
|
||||||
|
The deliverable is a RED-method dashboard ("rate, errors, duration") that
|
||||||
|
answers a single operator question — _is the WebRTC stack healthy right now?_
|
||||||
|
Eleven new metrics in the `dragonfork_webrtc_*` namespace, two new containers
|
||||||
|
(Prometheus + Grafana) in `deploy/truenas/core/`, four pre-loaded alert rules,
|
||||||
|
one pre-provisioned dashboard.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Operator can answer "is WebRTC healthy right now?" from a single Grafana
|
||||||
|
dashboard, without tailing logs or hitting the API.
|
||||||
|
- Per-stream drill-down available when the dashboard goes red — labels carry
|
||||||
|
`stream_id` everywhere it's meaningful, never `peer_id`.
|
||||||
|
- Deploy is one-command on a fresh TrueNAS box (`docker compose up -d`),
|
||||||
|
matching the existing v0.1 deploy ergonomics.
|
||||||
|
- Backwards-compatible: zero changes to upstream's `/metrics` payload. New
|
||||||
|
metrics are purely additive.
|
||||||
|
- Bucket choices and label sets are tuned for the realistic latency ranges
|
||||||
|
observed in v0.1 (server-hop p95 ≈ 240µs, ICE establishment seconds-scale).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- **Alertmanager bundling.** Alert rules are loaded into Prometheus but not
|
||||||
|
routed. Paging configuration is too opinionated to ship a default; separate
|
||||||
|
spec if/when paging is wanted.
|
||||||
|
- **Per-peer metric labels.** Peer-level forensics (individual session
|
||||||
|
lifetimes, per-resource teardown reasons) is out of scope. `peer_id` is
|
||||||
|
unbounded under churn and risks cardinality bloat.
|
||||||
|
- **Federated multi-Core scrape.** Single-deploy scrape config only. The
|
||||||
|
`core` label is set statically to `dragonfork-truenas`.
|
||||||
|
- **Latency p95 CI gate via Prometheus.** Server-hop latency stays a Go
|
||||||
|
test gate (`-tags latency`); not a Prometheus histogram.
|
||||||
|
- **Server-hop microsecond histogram.** The 240µs server-hop is well below
|
||||||
|
HTTP request scales and would need its own bucket set; it's already
|
||||||
|
covered by the latency CI test, no need to duplicate in Prom.
|
||||||
|
- **Custom monitor/metric bus integration.** Upstream pulls from
|
||||||
|
`monitor/metric.Reader`. We diverge — see Module Layout for rationale.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
v0.1 surface area:
|
||||||
|
|
||||||
|
- WHEP HTTP routes: `POST /api/v3/whep/{id}`, `DELETE /api/v3/whep/{id}/{r}`,
|
||||||
|
`PATCH /api/v3/whep/{id}/{r}`, plus admin `GET /api/v3/webrtc/streams`
|
||||||
|
and `GET /api/v3/webrtc/streams/{id}/peers`.
|
||||||
|
- Error matrix in v0.1: `406` codec mismatch, `503` cap reached (split into
|
||||||
|
global vs per-stream in response body), `504` ICE timeout, `204` DELETE
|
||||||
|
idempotent, `404` unknown stream.
|
||||||
|
- Pion-mediated peer connection lifecycle in `app/webrtc/lifecycle.go` —
|
||||||
|
ICE state transitions are the natural hook for ICE timing/failure metrics.
|
||||||
|
- FFmpeg RTP output legs supervised by the existing process supervisor;
|
||||||
|
silent leg failure is a known "quietly degrading" risk worth instrumenting.
|
||||||
|
|
||||||
|
Existing Prometheus integration (upstream):
|
||||||
|
|
||||||
|
- `prometheus/prometheus.go` exposes a `Metrics` interface with `Register`
|
||||||
|
and an `HTTPHandler()`. Single shared `prometheus.Registry`.
|
||||||
|
- `prometheus/restream.go` is the reference collector — pulls from
|
||||||
|
`monitor/metric.Reader` via `metric.Pattern` queries, emits via
|
||||||
|
`prometheus.MustNewConstMetric`. All upstream collectors carry a `core`
|
||||||
|
label as the first dimension.
|
||||||
|
- `/metrics` endpoint already exposed by Core; auth handled at the same
|
||||||
|
layer as the rest of the API.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
**Hybrid instrumentation, in two surfaces:**
|
||||||
|
|
||||||
|
1. **Direct `prometheus/client_golang` instrumentation** in `app/webrtc/`
|
||||||
|
for hot-path counters and histograms (request rate, request duration,
|
||||||
|
ICE establishment duration, error counters by reason). Histograms can't
|
||||||
|
be reconstructed from a scrape-time snapshot, so this is non-negotiable
|
||||||
|
for RED-method.
|
||||||
|
|
||||||
|
2. **Snapshot-style collector** in `prometheus/webrtc.go` for slow-changing
|
||||||
|
gauges (active streams, active peers per stream, UDP port pool usage).
|
||||||
|
Calls a new `Stats()` method on the WebRTC subsystem at scrape time.
|
||||||
|
|
||||||
|
Both surfaces register against the same `prometheus.Registerer` exposed by
|
||||||
|
`prometheus.Metrics`. No new HTTP endpoint, no new auth path. Both take a
|
||||||
|
`core` first-label dimension to match upstream collector convention.
|
||||||
|
|
||||||
|
### Why not pure snapshot?
|
||||||
|
|
||||||
|
Upstream's `prometheus/restream.go` pulls from a `monitor/metric` bus that
|
||||||
|
the FFmpeg supervision layer writes into. We could mirror that for WebRTC
|
||||||
|
— have `app/webrtc/lifecycle.go` and `handler.go` push events onto the bus,
|
||||||
|
have `prometheus/webrtc.go` pull them. Two reasons not to:
|
||||||
|
|
||||||
|
- **Histograms don't fit the pattern.** The bus stores point-in-time values
|
||||||
|
(gauges and counters), not distributions. RED-method needs duration p50
|
||||||
|
and p95; you'd end up maintaining an in-process sliding-window quantile
|
||||||
|
estimator inside the WebRTC subsystem, which is more code than just using
|
||||||
|
`client_golang.Histogram` directly.
|
||||||
|
- **The bus is FFmpeg-shaped.** `metric.Pattern` queries are designed for
|
||||||
|
process-state metrics (process IDs, FFmpeg states). Bolting WebRTC
|
||||||
|
semantics on requires defining new patterns the bus consumers all need
|
||||||
|
to know about, for a payload only the WebRTC collector cares about.
|
||||||
|
|
||||||
|
The hybrid keeps each metric type on the cleanest path. The cost is two
|
||||||
|
patterns in the codebase instead of one — accepted, with a comment in
|
||||||
|
`prometheus/webrtc.go` pointing at this rationale so the next contributor
|
||||||
|
doesn't try to "fix" the divergence.
|
||||||
|
|
||||||
|
### Why not pure direct?
|
||||||
|
|
||||||
|
Pure `client_golang` everywhere would mean the gauges (active streams,
|
||||||
|
active peers, UDP ports) sit in `app/webrtc/` alongside histograms. Workable,
|
||||||
|
but loses the "one collector file per subsystem in `prometheus/`" pattern
|
||||||
|
that anyone reading the repo's existing structure would expect. Snapshot
|
||||||
|
gauges are cheap to implement via the existing pattern, so we keep them
|
||||||
|
where a casual reader would look.
|
||||||
|
|
||||||
|
## Module Layout
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
```
|
||||||
|
app/webrtc/metrics.go (~150 LOC)
|
||||||
|
app/webrtc/metrics_test.go (~200 LOC)
|
||||||
|
prometheus/webrtc.go (~120 LOC)
|
||||||
|
prometheus/webrtc_test.go (~150 LOC)
|
||||||
|
deploy/truenas/core/prom/prometheus.yml
|
||||||
|
deploy/truenas/core/prom/rules/webrtc-alerts.yml
|
||||||
|
deploy/truenas/core/grafana/provisioning/datasources/prometheus.yml
|
||||||
|
deploy/truenas/core/grafana/provisioning/dashboards/webrtc.yml
|
||||||
|
deploy/truenas/core/grafana/dashboards/dragonfork-webrtc-health.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
```
|
||||||
|
app/webrtc/handler.go — add metric middleware around WHEP routes
|
||||||
|
app/webrtc/lifecycle.go — record ICE timing in OnConnectionStateChange
|
||||||
|
app/webrtc/subsystem.go — add Stats() method, instrument process hooks
|
||||||
|
deploy/truenas/core/docker-compose.yml — add prom + grafana services
|
||||||
|
deploy/truenas/core/README.md — document new env vars + ports
|
||||||
|
README.md — quick-start mentions Grafana URL
|
||||||
|
CHANGELOG.md — v0.2.0-dragonfork section
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/webrtc/metrics.go` — direct instrumentation
|
||||||
|
|
||||||
|
`promauto`-registered into the shared registry, exposed as package-level
|
||||||
|
vars so `handler.go` and `lifecycle.go` can increment without dependency
|
||||||
|
injection. Single `Init(reg prometheus.Registerer, core string)` called
|
||||||
|
from `subsystem.New` after the registry is available.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Sketch — exact wire format finalized at implementation.
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var histBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
|
||||||
|
|
||||||
|
type metrics struct {
|
||||||
|
whepRequests *prometheus.CounterVec // route, code, stream_id
|
||||||
|
whepRequestDuration *prometheus.HistogramVec // route, stream_id
|
||||||
|
iceEstablishment *prometheus.HistogramVec // stream_id, result
|
||||||
|
iceFailures *prometheus.CounterVec // stream_id, reason
|
||||||
|
codecMismatches *prometheus.CounterVec // stream_id, kind
|
||||||
|
capRejections *prometheus.CounterVec // stream_id, scope
|
||||||
|
ffmpegLegFailures *prometheus.CounterVec // stream_id, leg
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMetrics(reg prometheus.Registerer, core string) *metrics {
|
||||||
|
factory := promauto.With(reg)
|
||||||
|
return &metrics{
|
||||||
|
whepRequests: factory.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "dragonfork_webrtc_whep_requests_total",
|
||||||
|
Help: "Count of WHEP requests by route, status code, and stream.",
|
||||||
|
ConstLabels: prometheus.Labels{"core": core},
|
||||||
|
}, []string{"route", "code", "stream_id"}),
|
||||||
|
// ... etc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `core` label is a `ConstLabels` (set once at construction) rather than a
|
||||||
|
per-request dimension — matches the upstream collector pattern and avoids
|
||||||
|
threading it through every call site.
|
||||||
|
|
||||||
|
### `prometheus/webrtc.go` — snapshot collector
|
||||||
|
|
||||||
|
Standard `prometheus.Collector` interface (Describe / Collect). Keeps a
|
||||||
|
reference to a `WebRTCStatsSource` interface, which the WebRTC subsystem
|
||||||
|
implements via its `Stats()` method. Avoids importing `app/webrtc` from
|
||||||
|
`prometheus/` — the dependency arrow points the right way.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Sketch.
|
||||||
|
type WebRTCStatsSource interface {
|
||||||
|
Stats() WebRTCStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebRTCStats struct {
|
||||||
|
StreamCount int
|
||||||
|
PeersByStream map[string]int
|
||||||
|
UDPPortsInUse int
|
||||||
|
UDPPortsAvailable int
|
||||||
|
}
|
||||||
|
|
||||||
|
type webrtcCollector struct {
|
||||||
|
core string
|
||||||
|
source WebRTCStatsSource
|
||||||
|
|
||||||
|
activeStreamsDesc *prometheus.Desc
|
||||||
|
activePeersDesc *prometheus.Desc
|
||||||
|
udpPortsInUseDesc *prometheus.Desc
|
||||||
|
udpPortsAvailableDesc *prometheus.Desc
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebRTCCollector(core string, source WebRTCStatsSource) prometheus.Collector { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `WebRTCStats` type lives in `prometheus/webrtc.go` (not in `app/webrtc/`)
|
||||||
|
so the dependency stays one-directional. The subsystem implements the
|
||||||
|
interface by satisfying the shape, not by importing from `prometheus/`.
|
||||||
|
|
||||||
|
### `app/webrtc/subsystem.go` — `Stats()` method
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Subsystem) Stats() prometheus.WebRTCStats {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
peers := make(map[string]int, len(s.streams))
|
||||||
|
for id, st := range s.streams {
|
||||||
|
peers[id] = len(st.peers) // assume peers tracked per-stream
|
||||||
|
}
|
||||||
|
return prometheus.WebRTCStats{
|
||||||
|
StreamCount: len(s.streams),
|
||||||
|
PeersByStream: peers,
|
||||||
|
UDPPortsInUse: s.portAlloc.InUse(),
|
||||||
|
UDPPortsAvailable: s.portAlloc.Available(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing subsystem tracks streams in `s.streams` under `s.mu`. Peer
|
||||||
|
count per stream needs the per-stream peer index that already exists in
|
||||||
|
`handler.go` — the `Stats()` method consults it via the existing teardown
|
||||||
|
hook plumbing or a small new accessor on `Handler`. Pick whichever surface
|
||||||
|
introduces the smaller blast radius at implementation time.
|
||||||
|
|
||||||
|
## Metric Inventory
|
||||||
|
|
||||||
|
Eleven metrics. Eight new label dimensions across them. ~50 active series
|
||||||
|
at typical 1-5 stream scale.
|
||||||
|
|
||||||
|
### Direct instrumentation (`app/webrtc/metrics.go`)
|
||||||
|
|
||||||
|
| Name | Type | Labels | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `dragonfork_webrtc_whep_requests_total` | Counter | core, route, code, stream_id | Count of WHEP requests by route+status code. |
|
||||||
|
| `dragonfork_webrtc_whep_request_duration_seconds` | Histogram | core, route, stream_id | Server-side WHEP request duration. Buckets: `[0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]`. |
|
||||||
|
| `dragonfork_webrtc_ice_establishment_duration_seconds` | Histogram | core, stream_id, result | Time from `SetLocalDescription` to first `connected` or `failed` ICE state. Same buckets. |
|
||||||
|
| `dragonfork_webrtc_ice_failures_total` | Counter | core, stream_id, reason | ICE failure count. `reason` ∈ {timeout, disconnected, failed}. |
|
||||||
|
| `dragonfork_webrtc_codec_mismatches_total` | Counter | core, stream_id, kind | 406 rejections by kind. `kind` ∈ {video, audio}. |
|
||||||
|
| `dragonfork_webrtc_cap_rejections_total` | Counter | core, stream_id, scope | 503 rejections. `scope` ∈ {global, stream}. |
|
||||||
|
| `dragonfork_webrtc_ffmpeg_leg_failures_total` | Counter | core, stream_id, leg | RTP output leg failures. `leg` ∈ {video, audio}. |
|
||||||
|
|
||||||
|
### Snapshot collector (`prometheus/webrtc.go`)
|
||||||
|
|
||||||
|
| Name | Type | Labels | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `dragonfork_webrtc_active_streams` | Gauge | core | Streams currently registered (processes with `webrtc.enabled=true` running). |
|
||||||
|
| `dragonfork_webrtc_active_peers` | Gauge | core, stream_id | Currently subscribed WHEP peers per stream. |
|
||||||
|
| `dragonfork_webrtc_udp_ports_in_use` | Gauge | core | UDP ports currently allocated from the pool. |
|
||||||
|
| `dragonfork_webrtc_udp_ports_available` | Gauge | core | Pool size minus in-use (explicit for alert friendliness). |
|
||||||
|
|
||||||
|
### Label rationale
|
||||||
|
|
||||||
|
- `whep_request_duration_seconds` deliberately omits `code` — separating
|
||||||
|
distributions per outcome makes p95 noisy, and per-route per-stream p95
|
||||||
|
is what an operator actually looks at. Errors get visibility through the
|
||||||
|
request-counter ratio.
|
||||||
|
- `ice_establishment_duration_seconds` includes both `connected` and
|
||||||
|
`failed` results in the same histogram via the `result` label —
|
||||||
|
intentionally — so the dashboard can compare success latency to
|
||||||
|
failure-tail latency on the same axis.
|
||||||
|
- `cap_rejections_total` keeps the `scope` label because v0.1's response
|
||||||
|
body already splits global vs per-stream rejections; metrics mirror that
|
||||||
|
distinction so the dashboard shows whether to raise `max_peers_total`
|
||||||
|
or just one stream's per-stream cap.
|
||||||
|
- `ffmpeg_leg_failures_total` is the "quietly degrading" canary — a silent
|
||||||
|
RTP-output-leg failure (port bind, encoder crash) is exactly what the
|
||||||
|
"is it healthy?" framing is meant to catch.
|
||||||
|
|
||||||
|
### Cardinality budget
|
||||||
|
|
||||||
|
At typical scale (5 streams, 3 routes, ~6 status codes seen in practice):
|
||||||
|
|
||||||
|
- `whep_requests_total`: 5 × 3 × 6 = 90 series (worst case)
|
||||||
|
- `whep_request_duration_seconds`: 5 × 3 × (8 buckets + sum + count) = 150 series
|
||||||
|
- `ice_establishment_duration_seconds`: 5 × 2 × 10 = 100 series
|
||||||
|
- All others: 5–15 series each
|
||||||
|
- **Total: <500 active series at 5-stream sustained load**
|
||||||
|
|
||||||
|
Well within Prometheus's comfort zone. At 15s scrape interval × 15-day
|
||||||
|
retention, on-disk storage ~80MB.
|
||||||
|
|
||||||
|
### Specifically excluded metrics
|
||||||
|
|
||||||
|
- **Per-peer session metrics.** Listed under non-goals.
|
||||||
|
- **Bytes-out / bandwidth.** Pion exposes RTP write bytes via stats; would
|
||||||
|
be useful but pulls peer-level state. Defer to a future v0.3 spec
|
||||||
|
("WebRTC bandwidth observability") if needed.
|
||||||
|
- **Server-hop latency (FFmpeg → peer).** Microsecond scale, already
|
||||||
|
covered by `-tags latency` test gate, would need its own bucket set.
|
||||||
|
|
||||||
|
## Deploy Bundle
|
||||||
|
|
||||||
|
### `deploy/truenas/core/docker-compose.yml` additions
|
||||||
|
|
||||||
|
Two new services on a new bridge network `dragonfork-mon`. Core continues
|
||||||
|
on `network_mode: host` unchanged. The new containers reach Core via
|
||||||
|
`host.docker.internal:${CORE_HTTP_PORT}` (Linux Docker resolves this when
|
||||||
|
`extra_hosts: ["host.docker.internal:host-gateway"]` is set on the service).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
core:
|
||||||
|
# ... existing definition unchanged
|
||||||
|
|
||||||
|
prom:
|
||||||
|
image: prom/prometheus:v2.55.0
|
||||||
|
container_name: dragonfork-prom
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [dragonfork-mon]
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- ./prom/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- ./prom/rules:/etc/prometheus/rules:ro
|
||||||
|
- ./prom-data:/prometheus
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.retention.time=15d
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
|
- --web.console.libraries=/usr/share/prometheus/console_libraries
|
||||||
|
- --web.console.templates=/usr/share/prometheus/consoles
|
||||||
|
ports:
|
||||||
|
- "${PROM_PORT:-9090}:9090"
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana-oss:11.3.0
|
||||||
|
container_name: dragonfork-grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
networks: [dragonfork-mon]
|
||||||
|
depends_on: [prom]
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD:?set in .env}"
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "false"
|
||||||
|
volumes:
|
||||||
|
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
- ./grafana-data:/var/lib/grafana
|
||||||
|
ports:
|
||||||
|
- "${GRAFANA_PORT:-3000}:3000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dragonfork-mon:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
### `prom/prometheus.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
scrape_timeout: 10s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
external_labels:
|
||||||
|
core: dragonfork-truenas
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/rules/*.yml
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: dragonfork-core
|
||||||
|
static_configs:
|
||||||
|
- targets: ["host.docker.internal:8080"]
|
||||||
|
metrics_path: /metrics
|
||||||
|
# If API auth is enabled on /metrics, uncomment and provide creds via
|
||||||
|
# env-substituted file. v0.1 leaves /metrics public by default.
|
||||||
|
# basic_auth:
|
||||||
|
# username_file: /run/secrets/prom_basic_user
|
||||||
|
# password_file: /run/secrets/prom_basic_pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### `prom/rules/webrtc-alerts.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
groups:
|
||||||
|
- name: dragonfork-webrtc
|
||||||
|
rules:
|
||||||
|
- alert: WebRTCWHEPErrorRateHigh
|
||||||
|
expr: |
|
||||||
|
sum by (stream_id) (
|
||||||
|
rate(dragonfork_webrtc_whep_requests_total{code=~"4..|5.."}[5m])
|
||||||
|
) > 0.5
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "WHEP error rate high on stream {{ $labels.stream_id }}"
|
||||||
|
description: "Sustained 4xx/5xx rate >0.5/sec for 5m."
|
||||||
|
|
||||||
|
- alert: WebRTCICEEstablishmentSlow
|
||||||
|
expr: |
|
||||||
|
histogram_quantile(0.95,
|
||||||
|
sum by (le, stream_id) (
|
||||||
|
rate(dragonfork_webrtc_ice_establishment_duration_seconds_bucket[10m])
|
||||||
|
)
|
||||||
|
) > 3
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "ICE establishment p95 >3s on {{ $labels.stream_id }}"
|
||||||
|
|
||||||
|
- alert: WebRTCICEFailureRateHigh
|
||||||
|
expr: |
|
||||||
|
sum by (stream_id) (rate(dragonfork_webrtc_ice_failures_total[5m])) > 0.2
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "ICE failures sustained on {{ $labels.stream_id }}"
|
||||||
|
|
||||||
|
- alert: WebRTCFFmpegLegFailure
|
||||||
|
expr: |
|
||||||
|
increase(dragonfork_webrtc_ffmpeg_leg_failures_total[5m]) > 0
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "FFmpeg RTP leg failed on {{ $labels.stream_id }} ({{ $labels.leg }})"
|
||||||
|
description: "Silent degradation of RTP output. Check FFmpeg logs."
|
||||||
|
```
|
||||||
|
|
||||||
|
Alerts evaluate but route nowhere. Alertmanager bundling deferred — see
|
||||||
|
non-goals.
|
||||||
|
|
||||||
|
### Grafana provisioning
|
||||||
|
|
||||||
|
Datasource provisioning at `grafana/provisioning/datasources/prometheus.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: 1
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prom:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard provisioning at `grafana/provisioning/dashboards/webrtc.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: 1
|
||||||
|
providers:
|
||||||
|
- name: dragonfork
|
||||||
|
orgId: 1
|
||||||
|
folder: "Dragon Fork"
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 30
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dashboard JSON: `dragonfork-webrtc-health.json`
|
||||||
|
|
||||||
|
Single dashboard, five rows aligned to the questions from the metric
|
||||||
|
inventory:
|
||||||
|
|
||||||
|
1. **WHEP API health** — request rate by route (stat panel), error rate
|
||||||
|
stacked by code (timeseries), p95 request duration by route (timeseries).
|
||||||
|
2. **ICE establishment** — success/failure rate (gauge), p50/p95
|
||||||
|
establishment duration (timeseries with a 3s threshold line for the
|
||||||
|
alert), failure breakdown by reason (table).
|
||||||
|
3. **What's flowing** — `active_streams` (stat), `active_peers` per stream
|
||||||
|
(timeseries), top 5 streams by peer count (table).
|
||||||
|
4. **Capacity headroom** — `udp_ports_available` (gauge with red-zone <10),
|
||||||
|
cap rejection rate by scope (timeseries).
|
||||||
|
5. **Silent degradation** — FFmpeg leg failure timeline (timeseries with
|
||||||
|
annotations), codec mismatch counter (stat).
|
||||||
|
|
||||||
|
Built in Grafana 11.3, exported as JSON, committed to the repo. Refresh
|
||||||
|
default 30s.
|
||||||
|
|
||||||
|
### `.env` template additions
|
||||||
|
|
||||||
|
Append to `deploy/truenas/core/README.md`'s example `.env`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# --- Observability (added in v0.2) ---
|
||||||
|
GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)
|
||||||
|
GRAFANA_PORT=3000
|
||||||
|
PROM_PORT=9090
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit tests — `prometheus/webrtc_test.go`
|
||||||
|
|
||||||
|
Mock `WebRTCStatsSource`. Drive the collector through three states (no
|
||||||
|
streams, one stream with N peers, multiple streams). Use
|
||||||
|
`testutil.CollectAndCompare` to assert exact metric/label/value output
|
||||||
|
against a golden plaintext fixture.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Golden fixture (excerpt):
|
||||||
|
// # HELP dragonfork_webrtc_active_streams ...
|
||||||
|
// # TYPE dragonfork_webrtc_active_streams gauge
|
||||||
|
// dragonfork_webrtc_active_streams{core="test"} 2
|
||||||
|
// # HELP dragonfork_webrtc_active_peers ...
|
||||||
|
// # TYPE dragonfork_webrtc_active_peers gauge
|
||||||
|
// dragonfork_webrtc_active_peers{core="test",stream_id="live"} 3
|
||||||
|
// dragonfork_webrtc_active_peers{core="test",stream_id="cam"} 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unit tests — `app/webrtc/metrics_test.go`
|
||||||
|
|
||||||
|
Reuse `handler_test.go` setup (fake registry, in-process Echo router).
|
||||||
|
Hit each WHEP route, assert the corresponding counter and histogram have
|
||||||
|
the expected increment via `testutil.ToFloat64`. Drive forced error paths
|
||||||
|
(unknown stream → 404, codec-less SDP → 406, cap exceeded → 503, ICE
|
||||||
|
timeout → 504) and assert the right error-bucket counters bumped.
|
||||||
|
|
||||||
|
### Integration verification — `test/TESTING.md`
|
||||||
|
|
||||||
|
New section "Verifying Prometheus metrics":
|
||||||
|
|
||||||
|
```
|
||||||
|
1. docker compose up -d
|
||||||
|
2. curl -s http://<host>:8080/metrics | grep dragonfork_webrtc_
|
||||||
|
- expect: 11 metric families present, all with `core="dragonfork-truenas"`
|
||||||
|
3. Open http://<host>:3000 (Grafana), log in with GRAFANA_ADMIN_PASSWORD
|
||||||
|
4. Navigate to Dashboards → Dragon Fork → WebRTC Health
|
||||||
|
- expect: all 5 rows render, no "no data" panels except where stream traffic is absent
|
||||||
|
5. Trigger one of each error in test/whep-player.html (intentional codec
|
||||||
|
mismatch via SDP edit, kill the publisher mid-stream, etc.)
|
||||||
|
6. Watch the Grafana panels and verify counters tick within 15s.
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
Existing test runner picks up the new `_test.go` files. No new CI gates
|
||||||
|
beyond standard build+test — observability isn't a contract; the unit
|
||||||
|
tests verify shape only. Grafana dashboard JSON is *not* validated in CI
|
||||||
|
(no good lightweight validator); manual verification only.
|
||||||
|
|
||||||
|
### Load test alignment
|
||||||
|
|
||||||
|
The deferred 5-peer × 10-min load test (separate spec) will use this
|
||||||
|
dashboard as its primary observation surface. Recording rules for the
|
||||||
|
load test's specific aggregations can be added in that spec without
|
||||||
|
touching this one.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
The TrueNAS v0.1.0-dragonfork deploy upgrades via:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd deploy/truenas/core
|
||||||
|
git pull # latest main with this change
|
||||||
|
# Add new lines to .env (see template above)
|
||||||
|
docker compose pull # grabs prom + grafana images
|
||||||
|
docker compose up -d # core unchanged, prom + grafana new
|
||||||
|
```
|
||||||
|
|
||||||
|
Core continues on host networking. The new containers connect via
|
||||||
|
`host.docker.internal:host-gateway`, no firewall changes required for
|
||||||
|
intra-host traffic. External Grafana access is on `${GRAFANA_PORT}`.
|
||||||
|
|
||||||
|
### Backwards compatibility
|
||||||
|
|
||||||
|
- No upstream metric names or labels modified. New metrics are purely
|
||||||
|
additive in `dragonfork_webrtc_*` namespace.
|
||||||
|
- No API changes. `/metrics` payload grows but stays well-formed
|
||||||
|
Prometheus exposition.
|
||||||
|
- Existing config, env vars, and process JSON formats unchanged.
|
||||||
|
|
||||||
|
### Forward compatibility
|
||||||
|
|
||||||
|
- The `core` label being a `ConstLabels` value (not a per-event dimension)
|
||||||
|
means future federated multi-Core scrapes will distinguish series cleanly
|
||||||
|
by setting `core="dragonfork-truenas-east"` etc. in each deploy's config
|
||||||
|
loader. Spec'd here, implemented when needed.
|
||||||
|
- New metrics in this spec follow the `dragonfork_<subsystem>_<noun>` naming
|
||||||
|
pattern. Future Dragon-Fork-specific metrics (WHIP, keyframe cache,
|
||||||
|
bandwidth) should adopt the same convention.
|
||||||
|
|
||||||
|
### Known gaps post-rollout
|
||||||
|
|
||||||
|
- No paging. Alerts evaluate, no Alertmanager. If `WebRTCFFmpegLegFailure`
|
||||||
|
fires at 3am, no notification — operator notices at next dashboard check.
|
||||||
|
Acceptable for v0.2 single-operator deploy. Track as a v0.3 spec.
|
||||||
|
- Grafana dashboard JSON is hand-edited via Grafana UI then re-exported.
|
||||||
|
No JSON-as-code library used. If dashboard maintenance gets painful,
|
||||||
|
Grafonnet/Grafana-as-code is a v0.3+ refactor.
|
||||||
|
- `/metrics` itself is unauthenticated by default in v0.1 (matches
|
||||||
|
upstream). If Core's deploy bundle is exposed to untrusted networks,
|
||||||
|
the operator should already be using auth on Core's HTTP listener. Not
|
||||||
|
this spec's problem to solve, but worth a one-line note in
|
||||||
|
`deploy/truenas/core/README.md`.
|
||||||
|
|
||||||
|
## Open Decisions
|
||||||
|
|
||||||
|
1. **Should the `Stats()` method live on `Subsystem` or on `Handler`?**
|
||||||
|
The peer count is in `Handler`'s per-stream peer index; stream count
|
||||||
|
is in `Subsystem`'s registry; UDP port pool is in `portalloc`. Easiest
|
||||||
|
shape: `Subsystem.Stats()` is the public surface and internally
|
||||||
|
gathers from `Handler` (via the existing teardown-hook plumbing) and
|
||||||
|
`portalloc`. Decide at implementation time based on which surface
|
||||||
|
exposes the cleanest seams.
|
||||||
|
|
||||||
|
2. **Should histograms also include a `core` label, given it's already a
|
||||||
|
`ConstLabels`?** Yes — `ConstLabels` is automatically present on every
|
||||||
|
sample, no per-call overhead, and federations need it.
|
||||||
|
|
||||||
|
3. **Should Prometheus retention be configurable via `.env`?** Defaulting
|
||||||
|
to 15d covers the realistic window for "what happened last week?"
|
||||||
|
queries. Adding `PROM_RETENTION_DAYS=15d` to `.env` is a one-line
|
||||||
|
change. Including it as optional, defaulting to 15d.
|
||||||
|
|
||||||
|
4. **Import-alias collision.** The local package is `package prometheus`
|
||||||
|
(at `github.com/datarhei/core/v16/prometheus`) and `client_golang` is
|
||||||
|
also `package prometheus`. Files in `app/webrtc/` that need both must
|
||||||
|
alias one — convention is `coreprom "github.com/datarhei/core/v16/prometheus"`.
|
||||||
|
Implementation note only; doesn't change the design.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Prometheus client_golang](https://pkg.go.dev/github.com/prometheus/client_golang/prometheus)
|
||||||
|
- [Prometheus instrumentation best practices](https://prometheus.io/docs/practices/instrumentation/)
|
||||||
|
- [Histogram bucket design](https://prometheus.io/docs/practices/histograms/)
|
||||||
|
- [Grafana provisioning docs](https://grafana.com/docs/grafana/latest/administration/provisioning/)
|
||||||
|
- v0.1 design: `docs/design/2026-04-16-datarhei-dragon-fork-webrtc-design.md`
|
||||||
|
- M2 integration: `docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md`
|
||||||
224
docs/docs.go
224
docs/docs.go
|
|
@ -1,4 +1,4 @@
|
||||||
// Code generated by swaggo/swag. DO NOT EDIT
|
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||||
package docs
|
package docs
|
||||||
|
|
||||||
import "github.com/swaggo/swag"
|
import "github.com/swaggo/swag"
|
||||||
|
|
@ -1903,6 +1903,165 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v3/whep/{id}": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Subscribe to a process's WebRTC egress stream. Body is the SDP offer (Content-Type: application/sdp). Response is the SDP answer; the Location header points at the DELETE/PATCH resource for teardown and trickle ICE.",
|
||||||
|
"consumes": [
|
||||||
|
"application/sdp"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/sdp"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"v16.16.0"
|
||||||
|
],
|
||||||
|
"summary": "Subscribe to a WebRTC stream via WHEP",
|
||||||
|
"operationId": "webrtc-3-whep-subscribe",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID with config.webrtc.enabled=true",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "SDP answer",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing stream id, malformed body, or invalid SDP",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "no stream registered for this process id",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"406": {
|
||||||
|
"description": "offer SDP missing required H264 / Opus rtpmap",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "peer cap reached (per-stream or total)",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"504": {
|
||||||
|
"description": "ICE gathering timeout",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v3/whep/{id}/{resource}": {
|
||||||
|
"delete": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Idempotent peer teardown by resource id (returned in the Location header by Subscribe). Returns 204 even when the resource is unknown, per the WHEP spec.",
|
||||||
|
"tags": [
|
||||||
|
"v16.16.0"
|
||||||
|
],
|
||||||
|
"summary": "Tear down a WHEP subscription",
|
||||||
|
"operationId": "webrtc-3-whep-unsubscribe",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Resource ID from the Subscribe Location header",
|
||||||
|
"name": "resource",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "no content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing resource id",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Add ICE candidates to an existing WebRTC peer. Body is application/trickle-ice-sdpfrag.",
|
||||||
|
"consumes": [
|
||||||
|
"application/trickle-ice-sdpfrag"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"v16.16.0"
|
||||||
|
],
|
||||||
|
"summary": "Trickle ICE candidates for a WHEP subscription",
|
||||||
|
"operationId": "webrtc-3-whep-trickle",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Resource ID from the Subscribe Location header",
|
||||||
|
"name": "resource",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "no content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing resource id or unreadable body",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "peer not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v3/widget/process/{id}": {
|
"/api/v3/widget/process/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Fetch minimal statistics about a process, which is not protected by any auth.",
|
"description": "Fetch minimal statistics about a process, which is not protected by any auth.",
|
||||||
|
|
@ -2082,6 +2241,10 @@ const docTemplate = `{
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"fork": {
|
||||||
|
"description": "Fork is the human-readable fork name (e.g. \"Datarhei — Dragon Fork\").",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -2091,6 +2254,10 @@ const docTemplate = `{
|
||||||
"uptime_seconds": {
|
"uptime_seconds": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"variant": {
|
||||||
|
"description": "Variant identifies the build flavor — empty (or \"core\") for an\nupstream Datarhei build, \"dragonfork\" for the Dragon Fork.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"$ref": "#/definitions/api.Version"
|
"$ref": "#/definitions/api.Version"
|
||||||
}
|
}
|
||||||
|
|
@ -2629,6 +2796,9 @@ const docTemplate = `{
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/config.DataWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -3109,6 +3279,9 @@ const docTemplate = `{
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
""
|
""
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/api.ProcessConfigWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -3176,6 +3349,29 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"api.ProcessConfigWebRTC": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"audio_map": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"audio_pt": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"force_transcode": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"video_map": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"video_pt": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"api.ProcessReport": {
|
"api.ProcessReport": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -4441,6 +4637,9 @@ const docTemplate = `{
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/config.DataWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -4709,6 +4908,27 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.DataWebRTC": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"nat_1_to_1_ips": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"public_ip": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"udp_mux_port": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"github_com_datarhei_core_v16_http_api.Config": {
|
"github_com_datarhei_core_v16_http_api.Config": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -4831,6 +5051,8 @@ var SwaggerInfo = &swag.Spec{
|
||||||
Description: "Expose REST API for the datarhei Core",
|
Description: "Expose REST API for the datarhei Core",
|
||||||
InfoInstanceName: "swagger",
|
InfoInstanceName: "swagger",
|
||||||
SwaggerTemplate: docTemplate,
|
SwaggerTemplate: docTemplate,
|
||||||
|
LeftDelim: "{{",
|
||||||
|
RightDelim: "}}",
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
||||||
839
docs/superpowers/plans/2026-04-17-m2-webrtc-core-integration.md
Normal file
839
docs/superpowers/plans/2026-04-17-m2-webrtc-core-integration.md
Normal file
|
|
@ -0,0 +1,839 @@
|
||||||
|
# M2 — WebRTC into datarhei Core proper — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Wire the M1 `core/webrtc` package into the datarhei Core binary as a first-class output, served via WHEP under `/api/v3/process/{id}/whep`, with an eagerly bound `Source` per WebRTC-enabled process.
|
||||||
|
|
||||||
|
**Architecture:** New `app/webrtc` sibling subsystem that hooks into restream's process lifecycle. Two small additions to restream (`ProcessHooks` callbacks + `AppendOutput` method). Reuses the untouched M1 `core/webrtc` package. UI lives in a separate core-ui repo and is deferred to a sibling plan.
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.24, Pion WebRTC v4 (via `core/webrtc` from M1), Echo v4 HTTP router, existing datarhei Core subsystem pattern.
|
||||||
|
|
||||||
|
**Spec:** `docs/design/2026-04-17-datarhei-dragon-fork-m2-webrtc-core-integration.md`
|
||||||
|
|
||||||
|
**Branch:** `m2-webrtc-core-integration` (already created from `m1-webrtc-poc`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
- `app/webrtc/portalloc.go` + `portalloc_test.go` — ephemeral UDP port allocation
|
||||||
|
- `app/webrtc/ffmpeg_args.go` + `ffmpeg_args_test.go` — builds `-f rtp …` output fragments
|
||||||
|
- `app/webrtc/lifecycle.go` + `lifecycle_test.go` — `OnStart`/`OnStop` hook bodies
|
||||||
|
- `app/webrtc/subsystem.go` + `subsystem_test.go` — `WebRTC` struct; `Start`/`Stop`
|
||||||
|
- `app/webrtc/handler.go` + `handler_test.go` — WHEP HTTP handler
|
||||||
|
- `core/webrtc/registry.go` already exists — no changes.
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
- `restream/app/process.go` — add `ConfigWebRTC` type and `WebRTC` field on `Config`. Update `Clone()` and `CreateCommand()`.
|
||||||
|
- `restream/restream.go` — add `ProcessHooks` and `AppendOutput`.
|
||||||
|
- `config/data.go` — add `WebRTC` block on `Data` struct.
|
||||||
|
- `config/config.go` — `vars.Register` entries for WebRTC fields.
|
||||||
|
- `app/api/api.go` — instantiate the WebRTC subsystem alongside restream.
|
||||||
|
- `http/server.go` — mount `/whep` routes under existing `/api/v3` group.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 — `ConfigWebRTC` on restream's `Config`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `restream/app/process.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1.1 — Add `ConfigWebRTC` type + field**
|
||||||
|
|
||||||
|
Append after `ConfigIO` definition (~line 34), add field to `Config`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ConfigWebRTC struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
VideoPT uint8 `json:"video_pt"`
|
||||||
|
AudioPT uint8 `json:"audio_pt"`
|
||||||
|
ForceTranscode bool `json:"force_transcode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w ConfigWebRTC) Clone() ConfigWebRTC { return w }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `Config` struct:
|
||||||
|
```go
|
||||||
|
WebRTC ConfigWebRTC `json:"webrtc"`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1.2 — Update `Config.Clone()` to carry WebRTC**
|
||||||
|
|
||||||
|
```go
|
||||||
|
clone.WebRTC = config.WebRTC.Clone()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1.3 — Verify build**
|
||||||
|
|
||||||
|
Run: `go build ./restream/...`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 1.4 — Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add restream/app/process.go
|
||||||
|
git commit -m "feat(restream): add ConfigWebRTC per-process field"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 — `DataWebRTC` on global config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/data.go`
|
||||||
|
- Modify: `config/config.go`
|
||||||
|
|
||||||
|
- [ ] **Step 2.1 — Add `WebRTC` block to `Data`**
|
||||||
|
|
||||||
|
In `config/data.go`, following the pattern of `SRT`/`FFmpeg` blocks, add near the similar service blocks:
|
||||||
|
|
||||||
|
```go
|
||||||
|
WebRTC struct {
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
PublicIP string `json:"public_ip"`
|
||||||
|
NAT1To1IPs []string `json:"nat_1_to_1_ips"`
|
||||||
|
UDPMuxPort int `json:"udp_mux_port"`
|
||||||
|
} `json:"webrtc"`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2.2 — Register vars**
|
||||||
|
|
||||||
|
In `config/config.go`, at the end of the `vars.Register` block, add:
|
||||||
|
|
||||||
|
```go
|
||||||
|
d.vars.Register(value.NewBool(&d.WebRTC.Enable, false), "webrtc.enable", "CORE_WEBRTC_ENABLE", nil, "Enable WebRTC egress subsystem", false, false)
|
||||||
|
d.vars.Register(value.NewString(&d.WebRTC.PublicIP, ""), "webrtc.public_ip", "CORE_WEBRTC_PUBLIC_IP", nil, "ICE NAT1To1 host candidate IP", false, false)
|
||||||
|
d.vars.Register(value.NewStringList(&d.WebRTC.NAT1To1IPs, []string{}, " "), "webrtc.nat_1_to_1_ips", "CORE_WEBRTC_NAT_1_TO_1_IPS", nil, "Advanced: multiple NAT1To1 IPs", false, false)
|
||||||
|
d.vars.Register(value.NewInt(&d.WebRTC.UDPMuxPort, 0), "webrtc.udp_mux_port", "CORE_WEBRTC_UDP_MUX_PORT", nil, "Single UDP port for all ICE traffic (0 = ephemeral)", false, false)
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the project uses a different `vars.Register` signature, match the neighbors.)
|
||||||
|
|
||||||
|
- [ ] **Step 2.3 — Verify build and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./config/...
|
||||||
|
git add config/data.go config/config.go
|
||||||
|
git commit -m "feat(config): add webrtc global config block"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 — `ProcessHooks` + `AppendOutput` on restream
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `restream/restream.go`
|
||||||
|
|
||||||
|
- [ ] **Step 3.1 — Add `ProcessHook`, `ProcessHooks` types and field on restream struct**
|
||||||
|
|
||||||
|
Near the top (after imports, in the types region):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ProcessHook is called at well-defined points in a process's lifecycle.
|
||||||
|
// Return a non-nil error to abort the start (OnStart only; OnStop errors
|
||||||
|
// are logged and otherwise ignored).
|
||||||
|
type ProcessHook func(id string, cfg *app.Config) error
|
||||||
|
|
||||||
|
// ProcessHooks carries optional lifecycle callbacks for restream to invoke.
|
||||||
|
// A nil hook is a no-op.
|
||||||
|
type ProcessHooks struct {
|
||||||
|
OnStart ProcessHook // fires after args are assembled, before exec
|
||||||
|
OnStop ProcessHook // fires after wait() returns
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a field to the `restream` struct:
|
||||||
|
```go
|
||||||
|
hooks ProcessHooks
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a `SetHooks` method:
|
||||||
|
```go
|
||||||
|
func (r *restream) SetHooks(h ProcessHooks) {
|
||||||
|
r.lock.Lock()
|
||||||
|
defer r.lock.Unlock()
|
||||||
|
r.hooks = h
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3.2 — Wire OnStart / OnStop into the task lifecycle**
|
||||||
|
|
||||||
|
Find the `startProcess` / `ffmpeg.Start()` call site (~line 1065 per survey). Before the `Start()` call, insert:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if r.hooks.OnStart != nil {
|
||||||
|
if err := r.hooks.OnStart(task.id, task.config); err != nil {
|
||||||
|
r.logger.WithField("id", task.id).WithError(err).Error().Log("OnStart hook aborted process start")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Find `stopProcess` / `ffmpeg.Stop()` (~line 1094). After the stop completes, add:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if r.hooks.OnStop != nil {
|
||||||
|
if err := r.hooks.OnStop(task.id, task.config); err != nil {
|
||||||
|
r.logger.WithField("id", task.id).WithError(err).Warn().Log("OnStop hook returned error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3.3 — `AppendOutput`**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// AppendOutput appends extra FFmpeg args to a process's pending command.
|
||||||
|
// Only valid during OnStart (between hook fire and exec). Returns an
|
||||||
|
// error otherwise.
|
||||||
|
func (r *restream) AppendOutput(id string, extra []string) error {
|
||||||
|
r.lock.Lock()
|
||||||
|
defer r.lock.Unlock()
|
||||||
|
t, ok := r.tasks[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("restream: no such process %q", id)
|
||||||
|
}
|
||||||
|
if t.config == nil {
|
||||||
|
return fmt.Errorf("restream: process %q has no config", id)
|
||||||
|
}
|
||||||
|
// Append to the free-form Options slice on a synthetic ConfigIO so
|
||||||
|
// CreateCommand picks it up. We model this as an extra Output with
|
||||||
|
// empty Address — address is carried inside extra itself.
|
||||||
|
t.config.Output = append(t.config.Output, app.ConfigIO{
|
||||||
|
ID: "webrtc",
|
||||||
|
Options: extra,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: callers build `extra` so that the last element is the UDP address; the appended `ConfigIO` has empty `Address` so `CreateCommand` won't double-append. Instead, fix `CreateCommand` to support this — or (cleaner) pass the address as the last entry of `Options` and set the inserted `ConfigIO.Address` to that last entry, dropping it from `Options`. Concretely:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *restream) AppendOutput(id string, extra []string) error {
|
||||||
|
r.lock.Lock()
|
||||||
|
defer r.lock.Unlock()
|
||||||
|
t, ok := r.tasks[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("restream: no such process %q", id)
|
||||||
|
}
|
||||||
|
if t.config == nil || len(extra) == 0 {
|
||||||
|
return fmt.Errorf("restream: append-output invalid args")
|
||||||
|
}
|
||||||
|
opts, addr := extra[:len(extra)-1], extra[len(extra)-1]
|
||||||
|
t.config.Output = append(t.config.Output, app.ConfigIO{
|
||||||
|
ID: "webrtc",
|
||||||
|
Address: addr,
|
||||||
|
Options: append([]string{}, opts...),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3.4 — Verify build and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./restream/...
|
||||||
|
git add restream/restream.go
|
||||||
|
git commit -m "feat(restream): add ProcessHooks and AppendOutput"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 — `app/webrtc/portalloc.go` (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/webrtc/portalloc.go`
|
||||||
|
- Create: `app/webrtc/portalloc_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 4.1 — Write failing test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlloc_ReturnsPortBindable(t *testing.T) {
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
p, err := Alloc()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Alloc: %v", err)
|
||||||
|
}
|
||||||
|
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127,0,0,1), Port: p})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("iter %d: rebind %d: %v", i, p, err)
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlloc_Nonzero(t *testing.T) {
|
||||||
|
p, err := Alloc()
|
||||||
|
if err != nil { t.Fatal(err) }
|
||||||
|
if p == 0 { t.Fatal("expected non-zero port") }
|
||||||
|
fmt.Sprintf("%d", p)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4.2 — Run test (should fail to compile)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./app/webrtc/ -run TestAlloc -race
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4.3 — Implement**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alloc binds :0 on loopback UDPv4, records the assigned port, closes the
|
||||||
|
// socket, and returns the port. Callers must re-bind immediately; if the
|
||||||
|
// port is taken in the gap (rare), the rebind will fail and the caller
|
||||||
|
// should propagate that error.
|
||||||
|
func Alloc() (int, error) {
|
||||||
|
c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127,0,0,1), Port: 0})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("webrtc portalloc: %w", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
return c.LocalAddr().(*net.UDPAddr).Port, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4.4 — Run, commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./app/webrtc/ -race
|
||||||
|
git add app/webrtc/portalloc.go app/webrtc/portalloc_test.go
|
||||||
|
git commit -m "feat(app/webrtc): ephemeral loopback UDP port allocator"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 — `app/webrtc/ffmpeg_args.go` (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/webrtc/ffmpeg_args.go`
|
||||||
|
- Create: `app/webrtc/ffmpeg_args_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 5.1 — Write failing test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildArgs_CopyCodecs(t *testing.T) {
|
||||||
|
cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}
|
||||||
|
got := BuildArgs(cfg, 49200)
|
||||||
|
want := []string{
|
||||||
|
"-map", "0:v:0", "-c:v", "copy", "-payload_type", "102", "-f", "rtp",
|
||||||
|
"udp://127.0.0.1:49200?pkt_size=1316",
|
||||||
|
"-map", "0:a:0", "-c:a", "copy", "-payload_type", "111", "-f", "rtp",
|
||||||
|
"udp://127.0.0.1:49201?pkt_size=1316",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("BuildArgs mismatch\ngot: %v\nwant: %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildArgs_ForceTranscode(t *testing.T) {
|
||||||
|
cfg := appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111, ForceTranscode: true}
|
||||||
|
got := BuildArgs(cfg, 49200)
|
||||||
|
// video leg should include -c:v libx264 / profile=baseline
|
||||||
|
if !containsSeq(got, []string{"-c:v", "libx264"}) {
|
||||||
|
t.Fatalf("expected -c:v libx264, got %v", got)
|
||||||
|
}
|
||||||
|
if !containsSeq(got, []string{"-c:a", "libopus"}) {
|
||||||
|
t.Fatalf("expected -c:a libopus, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSeq(haystack, needle []string) bool {
|
||||||
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||||
|
match := true
|
||||||
|
for j := range needle {
|
||||||
|
if haystack[i+j] != needle[j] { match = false; break }
|
||||||
|
}
|
||||||
|
if match { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5.2 — Implement**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildArgs returns the FFmpeg output-leg args for a WebRTC-enabled
|
||||||
|
// process. The caller passes a video RTP port; audio uses port+1.
|
||||||
|
// The returned slice is designed for restream.AppendOutput — the final
|
||||||
|
// element is the UDP address, the rest are options.
|
||||||
|
//
|
||||||
|
// We emit two separate outputs (one per track) so that -payload_type
|
||||||
|
// applies correctly to each. This produces *two* calls' worth of args
|
||||||
|
// but AppendOutput currently handles one output at a time. Callers
|
||||||
|
// should split on the boundary (the second `-map` token).
|
||||||
|
func BuildArgs(cfg appcfg.ConfigWebRTC, videoPort int) []string {
|
||||||
|
vcopy := []string{"-c:v", "copy"}
|
||||||
|
acopy := []string{"-c:a", "copy"}
|
||||||
|
if cfg.ForceTranscode {
|
||||||
|
vcopy = []string{
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "veryfast",
|
||||||
|
"-profile:v", "baseline",
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-tune", "zerolatency",
|
||||||
|
"-g", "60",
|
||||||
|
}
|
||||||
|
acopy = []string{"-c:a", "libopus", "-b:a", "96k"}
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"-map", "0:v:0"}
|
||||||
|
args = append(args, vcopy...)
|
||||||
|
args = append(args, "-payload_type", fmt.Sprint(cfg.VideoPT), "-f", "rtp",
|
||||||
|
fmt.Sprintf("udp://127.0.0.1:%d?pkt_size=1316", videoPort))
|
||||||
|
|
||||||
|
args = append(args, "-map", "0:a:0")
|
||||||
|
args = append(args, acopy...)
|
||||||
|
args = append(args, "-payload_type", fmt.Sprint(cfg.AudioPT), "-f", "rtp",
|
||||||
|
fmt.Sprintf("udp://127.0.0.1:%d?pkt_size=1316", videoPort+1))
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5.3 — Run, commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./app/webrtc/ -race
|
||||||
|
git add app/webrtc/ffmpeg_args.go app/webrtc/ffmpeg_args_test.go
|
||||||
|
git commit -m "feat(app/webrtc): FFmpeg RTP output arg builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6 — `app/webrtc/subsystem.go` + `lifecycle.go` (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/webrtc/subsystem.go`, `subsystem_test.go`
|
||||||
|
- Create: `app/webrtc/lifecycle.go`, `lifecycle_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 6.1 — Subsystem skeleton with dependency interface**
|
||||||
|
|
||||||
|
Because restream is a large package, define the dependency as an interface the subsystem needs:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// app/webrtc/subsystem.go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
core "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Restreamer interface {
|
||||||
|
SetHooks(ProcessHooks)
|
||||||
|
AppendOutput(id string, extra []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessHook func(id string, cfg *appcfg.Config) error
|
||||||
|
|
||||||
|
type ProcessHooks struct {
|
||||||
|
OnStart ProcessHook
|
||||||
|
OnStop ProcessHook
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
PublicIP string
|
||||||
|
NAT1To1IPs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subsystem struct {
|
||||||
|
cfg Config
|
||||||
|
restream Restreamer
|
||||||
|
registry *core.Registry
|
||||||
|
factory *core.PeerFactory
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
peers map[string]map[string]*core.Peer // processID -> peerID -> peer
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config, r Restreamer) (*Subsystem, error) {
|
||||||
|
ccfg := core.DefaultConfig()
|
||||||
|
ccfg.PublicIP = cfg.PublicIP
|
||||||
|
ccfg.NAT1To1IPs = cfg.NAT1To1IPs
|
||||||
|
f, err := core.NewPeerFactory(ccfg)
|
||||||
|
if err != nil { return nil, err }
|
||||||
|
return &Subsystem{
|
||||||
|
cfg: cfg,
|
||||||
|
restream: r,
|
||||||
|
registry: core.NewRegistry(),
|
||||||
|
factory: f,
|
||||||
|
peers: make(map[string]map[string]*core.Peer),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) Start() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.started { s.mu.Unlock(); return nil }
|
||||||
|
s.started = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.restream.SetHooks(ProcessHooks{
|
||||||
|
OnStart: s.onProcessStart,
|
||||||
|
OnStop: s.onProcessStop,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) Stop() error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.started = false
|
||||||
|
s.restream.SetHooks(ProcessHooks{}) // clear
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** There's a type mismatch: `restream.ProcessHooks` is in package `restream`, this subsystem has its own `webrtc.ProcessHooks`. In the wiring task we either (a) import `restream.ProcessHooks` in the subsystem, or (b) define an adapter. Cleanest: the subsystem imports `restream` and uses `restream.ProcessHooks`. Let me rewrite using the real type — replace the local `ProcessHook`/`ProcessHooks` with `restream.ProcessHooks`. Do that in the actual implementation; the plan keeps the outline for readability.
|
||||||
|
|
||||||
|
- [ ] **Step 6.2 — Lifecycle (onProcessStart / onProcessStop)**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// app/webrtc/lifecycle.go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
core "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Subsystem) onProcessStart(id string, cfg *appcfg.Config) error {
|
||||||
|
if cfg == nil || !cfg.WebRTC.Enabled { return nil }
|
||||||
|
|
||||||
|
port, err := Alloc()
|
||||||
|
if err != nil { return fmt.Errorf("webrtc: alloc port: %w", err) }
|
||||||
|
|
||||||
|
args := BuildArgs(cfg.WebRTC, port)
|
||||||
|
if err := s.restream.AppendOutput(id, args); err != nil {
|
||||||
|
return fmt.Errorf("webrtc: append output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := core.NewSourceOn(id, "127.0.0.1", port)
|
||||||
|
if err != nil { return fmt.Errorf("webrtc: bind source: %w", err) }
|
||||||
|
src.Start()
|
||||||
|
if err := s.registry.Register(id, src); err != nil {
|
||||||
|
src.Close()
|
||||||
|
return fmt.Errorf("webrtc: register source: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) onProcessStop(id string, _ *appcfg.Config) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
peers := s.peers[id]
|
||||||
|
delete(s.peers, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, p := range peers { _ = p.Close() }
|
||||||
|
if src, ok := s.registry.Get(id); ok {
|
||||||
|
s.registry.Remove(id)
|
||||||
|
_ = src.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6.3 — Lifecycle test**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// lifecycle_test.go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRestream struct {
|
||||||
|
appended map[string][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRestream) SetHooks(ProcessHooks) {}
|
||||||
|
func (f *fakeRestream) AppendOutput(id string, extra []string) error {
|
||||||
|
if f.appended == nil { f.appended = map[string][]string{} }
|
||||||
|
f.appended[id] = extra
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLifecycle_DisabledIsNoop(t *testing.T) {
|
||||||
|
f := &fakeRestream{}
|
||||||
|
s, err := New(Config{}, f)
|
||||||
|
if err != nil { t.Fatal(err) }
|
||||||
|
cfg := &appcfg.Config{ID: "p1", WebRTC: appcfg.ConfigWebRTC{Enabled: false}}
|
||||||
|
if err := s.onProcessStart("p1", cfg); err != nil { t.Fatal(err) }
|
||||||
|
if _, ok := f.appended["p1"]; ok { t.Fatal("expected no append for disabled") }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLifecycle_EnabledAppendsAndRegisters(t *testing.T) {
|
||||||
|
f := &fakeRestream{}
|
||||||
|
s, err := New(Config{}, f)
|
||||||
|
if err != nil { t.Fatal(err) }
|
||||||
|
cfg := &appcfg.Config{ID: "p2", WebRTC: appcfg.ConfigWebRTC{Enabled: true, VideoPT: 102, AudioPT: 111}}
|
||||||
|
if err := s.onProcessStart("p2", cfg); err != nil { t.Fatal(err) }
|
||||||
|
if len(f.appended["p2"]) == 0 { t.Fatal("expected append") }
|
||||||
|
if _, ok := s.registry.Get("p2"); !ok { t.Fatal("expected registered source") }
|
||||||
|
// teardown
|
||||||
|
if err := s.onProcessStop("p2", cfg); err != nil { t.Fatal(err) }
|
||||||
|
if _, ok := s.registry.Get("p2"); ok { t.Fatal("expected removed") }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6.4 — Run, commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./app/webrtc/ -race
|
||||||
|
git add app/webrtc/subsystem.go app/webrtc/subsystem_test.go app/webrtc/lifecycle.go app/webrtc/lifecycle_test.go
|
||||||
|
git commit -m "feat(app/webrtc): subsystem skeleton + process lifecycle hooks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7 — `app/webrtc/handler.go` (WHEP HTTP)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/webrtc/handler.go`, `handler_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 7.1 — Handler: delegate to M1's WHEP handler with process-ID lookup**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// handler.go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
core "github.com/datarhei/core/v16/core/webrtc"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscribe handles POST /api/v3/process/:id/whep — look up the Source
|
||||||
|
// for the given process, run a WHEP offer/answer cycle, and forward
|
||||||
|
// RTP to the new peer.
|
||||||
|
func (s *Subsystem) Subscribe(c echo.Context) error {
|
||||||
|
id := c.Param("id")
|
||||||
|
src, ok := s.registry.Get(id)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, "stream not found")
|
||||||
|
}
|
||||||
|
// Delegate to the M1 WHEP handler — but we already have the source
|
||||||
|
// so we call the lower-level path.
|
||||||
|
offer, err := readBody(c)
|
||||||
|
if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) }
|
||||||
|
|
||||||
|
peer, answer, err := s.factory.NewPeerFromOffer(src, offer)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
peerID := peer.ID()
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.peers[id] == nil { s.peers[id] = map[string]*core.Peer{} }
|
||||||
|
s.peers[id][peerID] = peer
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
c.Response().Header().Set("Location",
|
||||||
|
"/api/v3/process/"+id+"/whep/"+peerID)
|
||||||
|
return c.Blob(http.StatusCreated, "application/sdp", []byte(answer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe handles DELETE /api/v3/process/:id/whep/:peerid.
|
||||||
|
func (s *Subsystem) Unsubscribe(c echo.Context) error {
|
||||||
|
id, peerID := c.Param("id"), c.Param("peerid")
|
||||||
|
s.mu.Lock()
|
||||||
|
peer := s.peers[id][peerID]
|
||||||
|
delete(s.peers[id], peerID)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if peer != nil { _ = peer.Close() }
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBody(c echo.Context) (string, error) {
|
||||||
|
buf := make([]byte, 0, 8192)
|
||||||
|
for {
|
||||||
|
tmp := make([]byte, 4096)
|
||||||
|
n, err := c.Request().Body.Read(tmp)
|
||||||
|
if n > 0 { buf = append(buf, tmp[:n]...) }
|
||||||
|
if err != nil { break }
|
||||||
|
}
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** If `core/webrtc.PeerFactory` doesn't expose `NewPeerFromOffer`, swap in whatever API M1 provided (`factory.NewPeer(...)` taking source+offer). If the M1 handler is higher-level, wrap it instead of reimplementing.
|
||||||
|
|
||||||
|
- [ ] **Step 7.2 — Handler test: 404 on unknown id**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package webrtc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubscribe_404OnUnknown(t *testing.T) {
|
||||||
|
f := &fakeRestream{}
|
||||||
|
s, _ := New(Config{}, f)
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(""))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id"); c.SetParamValues("missing")
|
||||||
|
err := s.Subscribe(c)
|
||||||
|
if he, ok := err.(*echo.HTTPError); !ok || he.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsubscribe_IdempotentNoContent(t *testing.T) {
|
||||||
|
f := &fakeRestream{}
|
||||||
|
s, _ := New(Config{}, f)
|
||||||
|
e := echo.New()
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
c.SetParamNames("id", "peerid"); c.SetParamValues("p", "nope")
|
||||||
|
if err := s.Unsubscribe(c); err != nil { t.Fatal(err) }
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7.3 — Run, commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./app/webrtc/ -race
|
||||||
|
git add app/webrtc/handler.go app/webrtc/handler_test.go
|
||||||
|
git commit -m "feat(app/webrtc): WHEP HTTP handler"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8 — Wire subsystem into app/api/api.go + http/server.go
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/api/api.go`
|
||||||
|
- Modify: `http/server.go`
|
||||||
|
|
||||||
|
- [ ] **Step 8.1 — Instantiate subsystem in api.New**
|
||||||
|
|
||||||
|
In `app/api/api.go`, after `restream := restream.New(...)`, when `cfg.WebRTC.Enable` is true, create the subsystem:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if cfg.WebRTC.Enable {
|
||||||
|
webrtcSub, err := webrtcapp.New(webrtcapp.Config{
|
||||||
|
PublicIP: cfg.WebRTC.PublicIP,
|
||||||
|
NAT1To1IPs: cfg.WebRTC.NAT1To1IPs,
|
||||||
|
}, restream)
|
||||||
|
if err != nil {
|
||||||
|
a.log.logger.core.Warn().WithError(err).Log("webrtc subsystem disabled")
|
||||||
|
} else {
|
||||||
|
_ = webrtcSub.Start()
|
||||||
|
a.webrtc = webrtcSub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store on the api struct: `webrtc *webrtcapp.Subsystem`.
|
||||||
|
|
||||||
|
- [ ] **Step 8.2 — Mount HTTP routes**
|
||||||
|
|
||||||
|
In `http/server.go` near line 568 (where `v3.POST("/process", ...)` lives):
|
||||||
|
|
||||||
|
```go
|
||||||
|
if s.webrtc != nil {
|
||||||
|
v3.POST("/process/:id/whep", s.webrtc.Subscribe)
|
||||||
|
v3.DELETE("/process/:id/whep/:peerid", s.webrtc.Unsubscribe)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Plumb `s.webrtc` from api → http/server constructor.
|
||||||
|
|
||||||
|
- [ ] **Step 8.3 — Verify build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8.4 — Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/api/api.go http/server.go
|
||||||
|
git commit -m "feat(core): wire webrtc subsystem + WHEP routes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9 — Integration smoke test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/webrtc/integration_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 9.1 — Synthetic RTP → WHEP end-to-end**
|
||||||
|
|
||||||
|
Import M1's `test/whep-client` as a library. Boot a Subsystem, inject synthetic RTP on the allocated port (mimic Task 6's lifecycle), POST a WHEP offer, assert both tracks arrive. See M1's `test/whep-client/main_test.go` for reference.
|
||||||
|
|
||||||
|
- [ ] **Step 9.2 — Run with -race and commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10 — TrueNAS redeploy
|
||||||
|
|
||||||
|
- [ ] **Step 10.1 — Rebuild Core image (Dockerfile currently targets `cmd/webrtc-poc`; add a second target or switch to the root `./` build for Core proper).**
|
||||||
|
- [ ] **Step 10.2 — Redeploy via docker compose on TrueNAS; verify WHEP endpoint returns 404 before any process exists, 201 after enabling WebRTC on a process.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope for this plan
|
||||||
|
|
||||||
|
- `core-ui/src/views/Edit/LiveTab.jsx` — core-ui is a separate repo and requires its own plan. Track as M2.5 once core-ui is cloned into the workspace.
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- Task 7 depends on `core/webrtc.PeerFactory.NewPeerFromOffer` signature from M1; if it's named differently, adjust the call site (don't rewrite the handler).
|
||||||
|
- Task 3 Step 3.3 assumes `restream.tasks` is a map keyed by id with a `*task` value that carries `config`. Confirm by reading around line 90 before implementing; the exact struct name may differ.
|
||||||
|
- Task 2 `vars.NewStringList` / `vars.NewInt` signatures need confirming against the real `config/vars/value` package.
|
||||||
|
|
@ -1896,6 +1896,165 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v3/whep/{id}": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Subscribe to a process's WebRTC egress stream. Body is the SDP offer (Content-Type: application/sdp). Response is the SDP answer; the Location header points at the DELETE/PATCH resource for teardown and trickle ICE.",
|
||||||
|
"consumes": [
|
||||||
|
"application/sdp"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/sdp"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"v16.16.0"
|
||||||
|
],
|
||||||
|
"summary": "Subscribe to a WebRTC stream via WHEP",
|
||||||
|
"operationId": "webrtc-3-whep-subscribe",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID with config.webrtc.enabled=true",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "SDP answer",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing stream id, malformed body, or invalid SDP",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "no stream registered for this process id",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"406": {
|
||||||
|
"description": "offer SDP missing required H264 / Opus rtpmap",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "peer cap reached (per-stream or total)",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"504": {
|
||||||
|
"description": "ICE gathering timeout",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v3/whep/{id}/{resource}": {
|
||||||
|
"delete": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Idempotent peer teardown by resource id (returned in the Location header by Subscribe). Returns 204 even when the resource is unknown, per the WHEP spec.",
|
||||||
|
"tags": [
|
||||||
|
"v16.16.0"
|
||||||
|
],
|
||||||
|
"summary": "Tear down a WHEP subscription",
|
||||||
|
"operationId": "webrtc-3-whep-unsubscribe",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Resource ID from the Subscribe Location header",
|
||||||
|
"name": "resource",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "no content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing resource id",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Add ICE candidates to an existing WebRTC peer. Body is application/trickle-ice-sdpfrag.",
|
||||||
|
"consumes": [
|
||||||
|
"application/trickle-ice-sdpfrag"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"v16.16.0"
|
||||||
|
],
|
||||||
|
"summary": "Trickle ICE candidates for a WHEP subscription",
|
||||||
|
"operationId": "webrtc-3-whep-trickle",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Process ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Resource ID from the Subscribe Location header",
|
||||||
|
"name": "resource",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "no content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "missing resource id or unreadable body",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "peer not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v3/widget/process/{id}": {
|
"/api/v3/widget/process/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Fetch minimal statistics about a process, which is not protected by any auth.",
|
"description": "Fetch minimal statistics about a process, which is not protected by any auth.",
|
||||||
|
|
@ -2075,6 +2234,10 @@
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"fork": {
|
||||||
|
"description": "Fork is the human-readable fork name (e.g. \"Datarhei — Dragon Fork\").",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -2084,6 +2247,10 @@
|
||||||
"uptime_seconds": {
|
"uptime_seconds": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"variant": {
|
||||||
|
"description": "Variant identifies the build flavor — empty (or \"core\") for an\nupstream Datarhei build, \"dragonfork\" for the Dragon Fork.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"$ref": "#/definitions/api.Version"
|
"$ref": "#/definitions/api.Version"
|
||||||
}
|
}
|
||||||
|
|
@ -2622,6 +2789,9 @@
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/config.DataWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -3102,6 +3272,9 @@
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
""
|
""
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/api.ProcessConfigWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -3169,6 +3342,29 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"api.ProcessConfigWebRTC": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"audio_map": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"audio_pt": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"force_transcode": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"video_map": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"video_pt": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"api.ProcessReport": {
|
"api.ProcessReport": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -4434,6 +4630,9 @@
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"webrtc": {
|
||||||
|
"$ref": "#/definitions/config.DataWebRTC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -4702,6 +4901,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"config.DataWebRTC": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"nat_1_to_1_ips": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"public_ip": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"udp_mux_port": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"github_com_datarhei_core_v16_http_api.Config": {
|
"github_com_datarhei_core_v16_http_api.Config": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
||||||
|
|
@ -56,12 +56,21 @@ definitions:
|
||||||
type: array
|
type: array
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
|
fork:
|
||||||
|
description: Fork is the human-readable fork name (e.g. "Datarhei — Dragon
|
||||||
|
Fork").
|
||||||
|
type: string
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
uptime_seconds:
|
uptime_seconds:
|
||||||
type: integer
|
type: integer
|
||||||
|
variant:
|
||||||
|
description: |-
|
||||||
|
Variant identifies the build flavor — empty (or "core") for an
|
||||||
|
upstream Datarhei build, "dragonfork" for the Dragon Fork.
|
||||||
|
type: string
|
||||||
version:
|
version:
|
||||||
$ref: '#/definitions/api.Version'
|
$ref: '#/definitions/api.Version'
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -420,6 +429,8 @@ definitions:
|
||||||
version:
|
version:
|
||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
|
webrtc:
|
||||||
|
$ref: '#/definitions/config.DataWebRTC'
|
||||||
type: object
|
type: object
|
||||||
api.ConfigError:
|
api.ConfigError:
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
|
|
@ -743,6 +754,8 @@ definitions:
|
||||||
- ffmpeg
|
- ffmpeg
|
||||||
- ""
|
- ""
|
||||||
type: string
|
type: string
|
||||||
|
webrtc:
|
||||||
|
$ref: '#/definitions/api.ProcessConfigWebRTC'
|
||||||
required:
|
required:
|
||||||
- input
|
- input
|
||||||
- output
|
- output
|
||||||
|
|
@ -790,6 +803,21 @@ definitions:
|
||||||
format: uint64
|
format: uint64
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
api.ProcessConfigWebRTC:
|
||||||
|
properties:
|
||||||
|
audio_map:
|
||||||
|
type: string
|
||||||
|
audio_pt:
|
||||||
|
type: integer
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
force_transcode:
|
||||||
|
type: boolean
|
||||||
|
video_map:
|
||||||
|
type: string
|
||||||
|
video_pt:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
api.ProcessReport:
|
api.ProcessReport:
|
||||||
properties:
|
properties:
|
||||||
created_at:
|
created_at:
|
||||||
|
|
@ -1709,6 +1737,8 @@ definitions:
|
||||||
version:
|
version:
|
||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
|
webrtc:
|
||||||
|
$ref: '#/definitions/config.DataWebRTC'
|
||||||
type: object
|
type: object
|
||||||
api.Skills:
|
api.Skills:
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -1882,6 +1912,20 @@ definitions:
|
||||||
uptime:
|
uptime:
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
config.DataWebRTC:
|
||||||
|
properties:
|
||||||
|
enable:
|
||||||
|
type: boolean
|
||||||
|
nat_1_to_1_ips:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
public_ip:
|
||||||
|
type: string
|
||||||
|
udp_mux_port:
|
||||||
|
format: int
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
github_com_datarhei_core_v16_http_api.Config:
|
github_com_datarhei_core_v16_http_api.Config:
|
||||||
properties:
|
properties:
|
||||||
config:
|
config:
|
||||||
|
|
@ -3186,6 +3230,113 @@ paths:
|
||||||
summary: List all publishing SRT treams
|
summary: List all publishing SRT treams
|
||||||
tags:
|
tags:
|
||||||
- v16.9.0
|
- v16.9.0
|
||||||
|
/api/v3/whep/{id}:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/sdp
|
||||||
|
description: 'Subscribe to a process''s WebRTC egress stream. Body is the SDP
|
||||||
|
offer (Content-Type: application/sdp). Response is the SDP answer; the Location
|
||||||
|
header points at the DELETE/PATCH resource for teardown and trickle ICE.'
|
||||||
|
operationId: webrtc-3-whep-subscribe
|
||||||
|
parameters:
|
||||||
|
- description: Process ID with config.webrtc.enabled=true
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/sdp
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: SDP answer
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"400":
|
||||||
|
description: missing stream id, malformed body, or invalid SDP
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: no stream registered for this process id
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"406":
|
||||||
|
description: offer SDP missing required H264 / Opus rtpmap
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"503":
|
||||||
|
description: peer cap reached (per-stream or total)
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"504":
|
||||||
|
description: ICE gathering timeout
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Subscribe to a WebRTC stream via WHEP
|
||||||
|
tags:
|
||||||
|
- v16.16.0
|
||||||
|
/api/v3/whep/{id}/{resource}:
|
||||||
|
delete:
|
||||||
|
description: Idempotent peer teardown by resource id (returned in the Location
|
||||||
|
header by Subscribe). Returns 204 even when the resource is unknown, per the
|
||||||
|
WHEP spec.
|
||||||
|
operationId: webrtc-3-whep-unsubscribe
|
||||||
|
parameters:
|
||||||
|
- description: Process ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Resource ID from the Subscribe Location header
|
||||||
|
in: path
|
||||||
|
name: resource
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: no content
|
||||||
|
"400":
|
||||||
|
description: missing resource id
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Tear down a WHEP subscription
|
||||||
|
tags:
|
||||||
|
- v16.16.0
|
||||||
|
patch:
|
||||||
|
consumes:
|
||||||
|
- application/trickle-ice-sdpfrag
|
||||||
|
description: Add ICE candidates to an existing WebRTC peer. Body is application/trickle-ice-sdpfrag.
|
||||||
|
operationId: webrtc-3-whep-trickle
|
||||||
|
parameters:
|
||||||
|
- description: Process ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Resource ID from the Subscribe Location header
|
||||||
|
in: path
|
||||||
|
name: resource
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: no content
|
||||||
|
"400":
|
||||||
|
description: missing resource id or unreadable body
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
"404":
|
||||||
|
description: peer not found
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
security:
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
summary: Trickle ICE candidates for a WHEP subscription
|
||||||
|
tags:
|
||||||
|
- v16.16.0
|
||||||
/api/v3/widget/process/{id}:
|
/api/v3/widget/process/{id}:
|
||||||
get:
|
get:
|
||||||
description: Fetch minimal statistics about a process, which is not protected
|
description: Fetch minimal statistics about a process, which is not protected
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ package api
|
||||||
// About is some general information about the API
|
// About is some general information about the API
|
||||||
type About struct {
|
type About struct {
|
||||||
App string `json:"app"`
|
App string `json:"app"`
|
||||||
|
// Variant identifies the build flavor — empty (or "core") for an
|
||||||
|
// upstream Datarhei build, "dragonfork" for the Dragon Fork.
|
||||||
|
Variant string `json:"variant,omitempty"`
|
||||||
|
// Fork is the human-readable fork name (e.g. "Datarhei — Dragon Fork").
|
||||||
|
Fork string `json:"fork,omitempty"`
|
||||||
Auths []string `json:"auths"`
|
Auths []string `json:"auths"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,23 @@ type ProcessConfigLimits struct {
|
||||||
WaitFor uint64 `json:"waitfor_seconds" jsonschema:"minimum=0" format:"uint64"`
|
WaitFor uint64 `json:"waitfor_seconds" jsonschema:"minimum=0" format:"uint64"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessConfig represents the configuration of an ffmpeg process
|
// ProcessConfigWebRTC represents the WHEP (egress) WebRTC configuration for a process
|
||||||
|
type ProcessConfigWebRTC struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
VideoPT uint8 `json:"video_pt,omitempty"`
|
||||||
|
AudioPT uint8 `json:"audio_pt,omitempty"`
|
||||||
|
ForceTranscode bool `json:"force_transcode,omitempty"`
|
||||||
|
VideoMap string `json:"video_map,omitempty"`
|
||||||
|
AudioMap string `json:"audio_map,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessConfigWHIPIngest represents the WHIP (ingest) WebRTC configuration for a process
|
||||||
|
type ProcessConfigWHIPIngest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
VideoPT uint8 `json:"video_pt,omitempty"`
|
||||||
|
AudioPT uint8 `json:"audio_pt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProcessConfig struct {
|
type ProcessConfig struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type" validate:"oneof='ffmpeg' ''" jsonschema:"enum=ffmpeg,enum="`
|
Type string `json:"type" validate:"oneof='ffmpeg' ''" jsonschema:"enum=ffmpeg,enum="`
|
||||||
|
|
@ -55,6 +71,8 @@ type ProcessConfig struct {
|
||||||
Autostart bool `json:"autostart"`
|
Autostart bool `json:"autostart"`
|
||||||
StaleTimeout uint64 `json:"stale_timeout_seconds" format:"uint64"`
|
StaleTimeout uint64 `json:"stale_timeout_seconds" format:"uint64"`
|
||||||
Limits ProcessConfigLimits `json:"limits"`
|
Limits ProcessConfigLimits `json:"limits"`
|
||||||
|
WebRTC ProcessConfigWebRTC `json:"webrtc"`
|
||||||
|
WHIPIngest ProcessConfigWHIPIngest `json:"whip_ingest"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marshal converts a process config in API representation to a restreamer process config
|
// Marshal converts a process config in API representation to a restreamer process config
|
||||||
|
|
@ -70,6 +88,19 @@ func (cfg *ProcessConfig) Marshal() *app.Config {
|
||||||
LimitCPU: cfg.Limits.CPU,
|
LimitCPU: cfg.Limits.CPU,
|
||||||
LimitMemory: cfg.Limits.Memory * 1024 * 1024,
|
LimitMemory: cfg.Limits.Memory * 1024 * 1024,
|
||||||
LimitWaitFor: cfg.Limits.WaitFor,
|
LimitWaitFor: cfg.Limits.WaitFor,
|
||||||
|
WebRTC: app.ConfigWebRTC{
|
||||||
|
Enabled: cfg.WebRTC.Enabled,
|
||||||
|
VideoPT: cfg.WebRTC.VideoPT,
|
||||||
|
AudioPT: cfg.WebRTC.AudioPT,
|
||||||
|
ForceTranscode: cfg.WebRTC.ForceTranscode,
|
||||||
|
VideoMap: cfg.WebRTC.VideoMap,
|
||||||
|
AudioMap: cfg.WebRTC.AudioMap,
|
||||||
|
},
|
||||||
|
WHIPIngest: app.ConfigWHIPIngest{
|
||||||
|
Enabled: cfg.WHIPIngest.Enabled,
|
||||||
|
VideoPT: cfg.WHIPIngest.VideoPT,
|
||||||
|
AudioPT: cfg.WHIPIngest.AudioPT,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.generateInputOutputIDs(cfg.Input)
|
cfg.generateInputOutputIDs(cfg.Input)
|
||||||
|
|
@ -150,6 +181,17 @@ func (cfg *ProcessConfig) Unmarshal(c *app.Config) {
|
||||||
cfg.Limits.Memory = c.LimitMemory / 1024 / 1024
|
cfg.Limits.Memory = c.LimitMemory / 1024 / 1024
|
||||||
cfg.Limits.WaitFor = c.LimitWaitFor
|
cfg.Limits.WaitFor = c.LimitWaitFor
|
||||||
|
|
||||||
|
cfg.WebRTC.Enabled = c.WebRTC.Enabled
|
||||||
|
cfg.WebRTC.VideoPT = c.WebRTC.VideoPT
|
||||||
|
cfg.WebRTC.AudioPT = c.WebRTC.AudioPT
|
||||||
|
cfg.WebRTC.ForceTranscode = c.WebRTC.ForceTranscode
|
||||||
|
cfg.WebRTC.VideoMap = c.WebRTC.VideoMap
|
||||||
|
cfg.WebRTC.AudioMap = c.WebRTC.AudioMap
|
||||||
|
|
||||||
|
cfg.WHIPIngest.Enabled = c.WHIPIngest.Enabled
|
||||||
|
cfg.WHIPIngest.VideoPT = c.WHIPIngest.VideoPT
|
||||||
|
cfg.WHIPIngest.AudioPT = c.WHIPIngest.AudioPT
|
||||||
|
|
||||||
cfg.Options = make([]string, len(c.Options))
|
cfg.Options = make([]string, len(c.Options))
|
||||||
copy(cfg.Options, c.Options)
|
copy(cfg.Options, c.Options)
|
||||||
|
|
||||||
|
|
@ -200,7 +242,7 @@ type ProcessReport struct {
|
||||||
History []ProcessReportHistoryEntry `json:"history"`
|
History []ProcessReportHistoryEntry `json:"history"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unmarshal converts a restream log to a report
|
// Unmarshal converts a restreamer log to a report
|
||||||
func (report *ProcessReport) Unmarshal(l *app.Log) {
|
func (report *ProcessReport) Unmarshal(l *app.Log) {
|
||||||
if l == nil {
|
if l == nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
109
http/api/process_webrtc_test.go
Normal file
109
http/api/process_webrtc_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/datarhei/core/v16/restream/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProcessConfigWebRTCRoundtrip locks down the API DTO ↔ restream
|
||||||
|
// app.Config mapping for the per-process WebRTC block.
|
||||||
|
//
|
||||||
|
// Regression: the M2 cut shipped without WebRTC on ProcessConfig, so
|
||||||
|
// JSON arriving at POST /api/v3/process was silently stripped of
|
||||||
|
// `webrtc.enabled`, the restream config never saw it, the start hook
|
||||||
|
// never bound a Source, and WHEP returned 404. This test fails on the
|
||||||
|
// pre-fix code (Marshal would yield `app.ConfigWebRTC{}`) and passes
|
||||||
|
// once the DTO carries the field.
|
||||||
|
func TestProcessConfigWebRTCRoundtrip(t *testing.T) {
|
||||||
|
// 1. JSON in → DTO → app.Config
|
||||||
|
body := []byte(`{
|
||||||
|
"id":"p","input":[{"id":"i","address":"x"}],"output":[{"id":"o","address":"-"}],
|
||||||
|
"webrtc":{"enabled":true,"video_pt":102,"audio_pt":111,"force_transcode":true}
|
||||||
|
}`)
|
||||||
|
var dto ProcessConfig
|
||||||
|
if err := json.Unmarshal(body, &dto); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if !dto.WebRTC.Enabled {
|
||||||
|
t.Fatalf("DTO.WebRTC.Enabled lost on JSON decode: %+v", dto.WebRTC)
|
||||||
|
}
|
||||||
|
cfg := dto.Marshal()
|
||||||
|
if !cfg.WebRTC.Enabled || cfg.WebRTC.VideoPT != 102 || cfg.WebRTC.AudioPT != 111 || !cfg.WebRTC.ForceTranscode {
|
||||||
|
t.Fatalf("app.Config.WebRTC mapped wrong: %+v", cfg.WebRTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. app.Config → DTO → JSON out
|
||||||
|
stored := &app.Config{
|
||||||
|
ID: "p",
|
||||||
|
Input: []app.ConfigIO{{ID: "i", Address: "x"}},
|
||||||
|
Output: []app.ConfigIO{{ID: "o", Address: "-"}},
|
||||||
|
WebRTC: app.ConfigWebRTC{
|
||||||
|
Enabled: true,
|
||||||
|
VideoPT: 102,
|
||||||
|
AudioPT: 111,
|
||||||
|
ForceTranscode: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var dto2 ProcessConfig
|
||||||
|
dto2.Unmarshal(stored)
|
||||||
|
if !dto2.WebRTC.Enabled || dto2.WebRTC.VideoPT != 102 {
|
||||||
|
t.Fatalf("Unmarshal lost WebRTC: %+v", dto2.WebRTC)
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(dto2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
// Decode again and compare.
|
||||||
|
var dto3 ProcessConfig
|
||||||
|
if err := json.Unmarshal(out, &dto3); err != nil {
|
||||||
|
t.Fatalf("re-unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if dto3.WebRTC != dto.WebRTC {
|
||||||
|
t.Fatalf("roundtrip diverged: in=%+v out=%+v", dto.WebRTC, dto3.WebRTC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessConfigWebRTCDefaults: when "webrtc" is absent in the
|
||||||
|
// inbound JSON, Marshal must still produce a valid app.Config — the
|
||||||
|
// zero ConfigWebRTC means "disabled" and the start hook should no-op.
|
||||||
|
func TestProcessConfigWebRTCDefaults(t *testing.T) {
|
||||||
|
body := []byte(`{"id":"p","input":[{"id":"i","address":"x"}],"output":[{"id":"o","address":"-"}]}`)
|
||||||
|
var dto ProcessConfig
|
||||||
|
if err := json.Unmarshal(body, &dto); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
cfg := dto.Marshal()
|
||||||
|
if cfg.WebRTC.Enabled {
|
||||||
|
t.Fatalf("default should be disabled, got %+v", cfg.WebRTC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessConfigWebRTCMapsRoundtrip extends the WebRTC DTO
|
||||||
|
// roundtrip with the issue-#2 VideoMap/AudioMap fields so the
|
||||||
|
// regression doesn't repeat: a multi-input pipeline that sets
|
||||||
|
// `audio_map: "1:a:0"` must reach the restream config layer
|
||||||
|
// unchanged.
|
||||||
|
func TestProcessConfigWebRTCMapsRoundtrip(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"id":"p","input":[{"id":"i","address":"x"}],"output":[{"id":"o","address":"-"}],
|
||||||
|
"webrtc":{"enabled":true,"video_map":"0:v:1","audio_map":"1:a:0"}
|
||||||
|
}`)
|
||||||
|
var dto ProcessConfig
|
||||||
|
if err := json.Unmarshal(body, &dto); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if dto.WebRTC.VideoMap != "0:v:1" || dto.WebRTC.AudioMap != "1:a:0" {
|
||||||
|
t.Fatalf("DTO maps lost: %+v", dto.WebRTC)
|
||||||
|
}
|
||||||
|
cfg := dto.Marshal()
|
||||||
|
if cfg.WebRTC.VideoMap != "0:v:1" || cfg.WebRTC.AudioMap != "1:a:0" {
|
||||||
|
t.Fatalf("app.Config maps lost: %+v", cfg.WebRTC)
|
||||||
|
}
|
||||||
|
var back ProcessConfig
|
||||||
|
back.Unmarshal(cfg)
|
||||||
|
if back.WebRTC.VideoMap != "0:v:1" || back.WebRTC.AudioMap != "1:a:0" {
|
||||||
|
t.Fatalf("Unmarshal lost maps: %+v", back.WebRTC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,8 @@ func (p *AboutHandler) About(c echo.Context) error {
|
||||||
|
|
||||||
about := api.About{
|
about := api.About{
|
||||||
App: app.Name,
|
App: app.Name,
|
||||||
|
Variant: app.Variant,
|
||||||
|
Fork: app.Fork,
|
||||||
Name: p.restream.Name(),
|
Name: p.restream.Name(),
|
||||||
Auths: p.auths,
|
Auths: p.auths,
|
||||||
ID: p.restream.ID(),
|
ID: p.restream.ID(),
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
appwebrtc "github.com/datarhei/core/v16/app/webrtc"
|
||||||
cfgstore "github.com/datarhei/core/v16/config/store"
|
cfgstore "github.com/datarhei/core/v16/config/store"
|
||||||
"github.com/datarhei/core/v16/http/cache"
|
"github.com/datarhei/core/v16/http/cache"
|
||||||
"github.com/datarhei/core/v16/http/errorhandler"
|
"github.com/datarhei/core/v16/http/errorhandler"
|
||||||
|
|
@ -86,6 +87,8 @@ type Config struct {
|
||||||
Cors CorsConfig
|
Cors CorsConfig
|
||||||
RTMP rtmp.Server
|
RTMP rtmp.Server
|
||||||
SRT srt.Server
|
SRT srt.Server
|
||||||
|
WebRTC *appwebrtc.Handler // WHEP egress handler
|
||||||
|
WHIP *appwebrtc.WHIPHandler // WHIP ingest handler
|
||||||
JWT jwt.JWT
|
JWT jwt.JWT
|
||||||
Config cfgstore.Store
|
Config cfgstore.Store
|
||||||
Cache cache.Cacher
|
Cache cache.Cacher
|
||||||
|
|
@ -124,6 +127,8 @@ type server struct {
|
||||||
session *api.SessionHandler
|
session *api.SessionHandler
|
||||||
widget *api.WidgetHandler
|
widget *api.WidgetHandler
|
||||||
resources *api.MetricsHandler
|
resources *api.MetricsHandler
|
||||||
|
webrtc *appwebrtc.Handler // WHEP egress
|
||||||
|
whip *appwebrtc.WHIPHandler // WHIP ingest
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware struct {
|
middleware struct {
|
||||||
|
|
@ -238,6 +243,14 @@ func NewServer(config Config) (Server, error) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.WebRTC != nil {
|
||||||
|
s.v3handler.webrtc = config.WebRTC
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.WHIP != nil {
|
||||||
|
s.v3handler.whip = config.WHIP
|
||||||
|
}
|
||||||
|
|
||||||
if config.Prometheus != nil {
|
if config.Prometheus != nil {
|
||||||
s.handler.prometheus = handler.NewPrometheus(
|
s.handler.prometheus = handler.NewPrometheus(
|
||||||
config.Prometheus.HTTPHandler(),
|
config.Prometheus.HTTPHandler(),
|
||||||
|
|
@ -545,6 +558,18 @@ func (s *server) setRoutesV3(v3 *echo.Group) {
|
||||||
s.router.GET("/api/v3/widget/process/:id", s.v3handler.widget.Get)
|
s.router.GET("/api/v3/widget/process/:id", s.v3handler.widget.Get)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v3 WebRTC WHEP egress. Mounted on the v3 group so JWT auth
|
||||||
|
// covers it in M2; public embed tokens will ship in M3.
|
||||||
|
if s.v3handler.webrtc != nil {
|
||||||
|
s.v3handler.webrtc.Register(v3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// v3 WebRTC WHIP ingest. Mounted alongside WHEP on the same v3
|
||||||
|
// group — both share JWT auth and live under /api/v3/whip/*.
|
||||||
|
if s.v3handler.whip != nil {
|
||||||
|
s.v3handler.whip.Register(v3)
|
||||||
|
}
|
||||||
|
|
||||||
// v3 Restreamer
|
// v3 Restreamer
|
||||||
if s.v3handler.restream != nil {
|
if s.v3handler.restream != nil {
|
||||||
v3.GET("/skills", s.v3handler.restream.Skills)
|
v3.GET("/skills", s.v3handler.restream.Skills)
|
||||||
|
|
|
||||||
75
prometheus/webrtc.go
Normal file
75
prometheus/webrtc.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package prometheus
|
||||||
|
|
||||||
|
import (
|
||||||
|
// Hybrid instrumentation rationale: direct client_golang instrumentation
|
||||||
|
// lives in app/webrtc/metrics.go (hot-path counters and histograms);
|
||||||
|
// this file owns the snapshot-style gauges. See the design doc at
|
||||||
|
// docs/design/2026-05-03-datarhei-dragon-fork-webrtc-prometheus-metrics-design.md.
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WebRTCStats is a point-in-time snapshot of the WebRTC subsystem state.
|
||||||
|
// Populated by Handler.Stats() in app/webrtc and consumed by the collector
|
||||||
|
// at scrape time.
|
||||||
|
type WebRTCStats struct {
|
||||||
|
StreamCount int
|
||||||
|
PeersByStream map[string]int
|
||||||
|
UDPPortsInUse int
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebRTCStatsSource is implemented by app/webrtc.Handler (via its Stats()
|
||||||
|
// method). The interface lives here so prometheus/ does not import app/webrtc,
|
||||||
|
// keeping the dependency arrow one-directional.
|
||||||
|
type WebRTCStatsSource interface {
|
||||||
|
Stats() WebRTCStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type webrtcCollector struct {
|
||||||
|
core string
|
||||||
|
source WebRTCStatsSource
|
||||||
|
|
||||||
|
activeStreamsDesc *prometheus.Desc
|
||||||
|
activePeersDesc *prometheus.Desc
|
||||||
|
udpPortsInUseDesc *prometheus.Desc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebRTCCollector returns a prometheus.Collector that emits three gauge
|
||||||
|
// metrics at scrape time by calling source.Stats(). Register it with the
|
||||||
|
// shared Metrics registry before the /metrics endpoint starts serving.
|
||||||
|
func NewWebRTCCollector(core string, source WebRTCStatsSource) prometheus.Collector {
|
||||||
|
cl := prometheus.Labels{"core": core}
|
||||||
|
return &webrtcCollector{
|
||||||
|
core: core,
|
||||||
|
source: source,
|
||||||
|
activeStreamsDesc: prometheus.NewDesc(
|
||||||
|
"dragonfork_webrtc_active_streams",
|
||||||
|
"Streams currently registered (processes with webrtc.enabled=true running).",
|
||||||
|
nil, cl,
|
||||||
|
),
|
||||||
|
activePeersDesc: prometheus.NewDesc(
|
||||||
|
"dragonfork_webrtc_active_peers",
|
||||||
|
"Currently subscribed WHEP peers per stream.",
|
||||||
|
[]string{"stream_id"}, cl,
|
||||||
|
),
|
||||||
|
udpPortsInUseDesc: prometheus.NewDesc(
|
||||||
|
"dragonfork_webrtc_udp_ports_in_use",
|
||||||
|
"UDP ports currently allocated (2 per active stream).",
|
||||||
|
nil, cl,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *webrtcCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||||
|
ch <- c.activeStreamsDesc
|
||||||
|
ch <- c.activePeersDesc
|
||||||
|
ch <- c.udpPortsInUseDesc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *webrtcCollector) Collect(ch chan<- prometheus.Metric) {
|
||||||
|
stats := c.source.Stats()
|
||||||
|
ch <- prometheus.MustNewConstMetric(c.activeStreamsDesc, prometheus.GaugeValue, float64(stats.StreamCount))
|
||||||
|
ch <- prometheus.MustNewConstMetric(c.udpPortsInUseDesc, prometheus.GaugeValue, float64(stats.UDPPortsInUse))
|
||||||
|
for streamID, count := range stats.PeersByStream {
|
||||||
|
ch <- prometheus.MustNewConstMetric(c.activePeersDesc, prometheus.GaugeValue, float64(count), streamID)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
prometheus/webrtc_test.go
Normal file
97
prometheus/webrtc_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package prometheus_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
coreprom "github.com/datarhei/core/v16/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeStats implements coreprom.WebRTCStatsSource for testing.
|
||||||
|
type fakeStats struct{ s coreprom.WebRTCStats }
|
||||||
|
|
||||||
|
func (f *fakeStats) Stats() coreprom.WebRTCStats { return f.s }
|
||||||
|
|
||||||
|
func newReg(t *testing.T, source coreprom.WebRTCStatsSource) *prometheus.Registry {
|
||||||
|
t.Helper()
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
if err := reg.Register(coreprom.NewWebRTCCollector("test", source)); err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
return reg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebRTCCollector_NoStreams(t *testing.T) {
|
||||||
|
reg := newReg(t, &fakeStats{})
|
||||||
|
if err := testutil.GatherAndCompare(reg, strings.NewReader(`
|
||||||
|
# HELP dragonfork_webrtc_active_streams Streams currently registered (processes with webrtc.enabled=true running).
|
||||||
|
# TYPE dragonfork_webrtc_active_streams gauge
|
||||||
|
dragonfork_webrtc_active_streams{core="test"} 0
|
||||||
|
# HELP dragonfork_webrtc_udp_ports_in_use UDP ports currently allocated (2 per active stream).
|
||||||
|
# TYPE dragonfork_webrtc_udp_ports_in_use gauge
|
||||||
|
dragonfork_webrtc_udp_ports_in_use{core="test"} 0
|
||||||
|
`),
|
||||||
|
"dragonfork_webrtc_active_streams",
|
||||||
|
"dragonfork_webrtc_udp_ports_in_use",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebRTCCollector_OneStreamWithPeers(t *testing.T) {
|
||||||
|
src := &fakeStats{s: coreprom.WebRTCStats{
|
||||||
|
StreamCount: 1,
|
||||||
|
PeersByStream: map[string]int{"live": 3},
|
||||||
|
UDPPortsInUse: 2,
|
||||||
|
}}
|
||||||
|
reg := newReg(t, src)
|
||||||
|
if err := testutil.GatherAndCompare(reg, strings.NewReader(`
|
||||||
|
# HELP dragonfork_webrtc_active_peers Currently subscribed WHEP peers per stream.
|
||||||
|
# TYPE dragonfork_webrtc_active_peers gauge
|
||||||
|
dragonfork_webrtc_active_peers{core="test",stream_id="live"} 3
|
||||||
|
# HELP dragonfork_webrtc_active_streams Streams currently registered (processes with webrtc.enabled=true running).
|
||||||
|
# TYPE dragonfork_webrtc_active_streams gauge
|
||||||
|
dragonfork_webrtc_active_streams{core="test"} 1
|
||||||
|
# HELP dragonfork_webrtc_udp_ports_in_use UDP ports currently allocated (2 per active stream).
|
||||||
|
# TYPE dragonfork_webrtc_udp_ports_in_use gauge
|
||||||
|
dragonfork_webrtc_udp_ports_in_use{core="test"} 2
|
||||||
|
`)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebRTCCollector_MultipleStreams(t *testing.T) {
|
||||||
|
src := &fakeStats{s: coreprom.WebRTCStats{
|
||||||
|
StreamCount: 2,
|
||||||
|
PeersByStream: map[string]int{"live": 3, "cam": 1},
|
||||||
|
UDPPortsInUse: 4,
|
||||||
|
}}
|
||||||
|
reg := newReg(t, src)
|
||||||
|
mfs, err := reg.Gather()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Gather: %v", err)
|
||||||
|
}
|
||||||
|
// Check stream count and udp ports
|
||||||
|
for _, mf := range mfs {
|
||||||
|
switch mf.GetName() {
|
||||||
|
case "dragonfork_webrtc_active_streams":
|
||||||
|
if got := mf.GetMetric()[0].GetGauge().GetValue(); got != 2 {
|
||||||
|
t.Errorf("active_streams: want 2, got %v", got)
|
||||||
|
}
|
||||||
|
case "dragonfork_webrtc_udp_ports_in_use":
|
||||||
|
if got := mf.GetMetric()[0].GetGauge().GetValue(); got != 4 {
|
||||||
|
t.Errorf("udp_ports_in_use: want 4, got %v", got)
|
||||||
|
}
|
||||||
|
case "dragonfork_webrtc_active_peers":
|
||||||
|
total := 0.0
|
||||||
|
for _, m := range mf.GetMetric() {
|
||||||
|
total += m.GetGauge().GetValue()
|
||||||
|
}
|
||||||
|
if total != 4 {
|
||||||
|
t.Errorf("active_peers total: want 4, got %v", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,53 @@ type ConfigIO struct {
|
||||||
Cleanup []ConfigIOCleanup `json:"cleanup"`
|
Cleanup []ConfigIOCleanup `json:"cleanup"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfigWebRTC carries per-process WebRTC egress settings.
|
||||||
|
//
|
||||||
|
// When Enabled is true the restream manager will (via the app/webrtc
|
||||||
|
// subsystem) append an additional FFmpeg output leg that emits H.264/Opus
|
||||||
|
// RTP to a loopback UDP port the subsystem allocates. The subsystem reads
|
||||||
|
// that RTP and fans it out to WHEP subscribers.
|
||||||
|
type ConfigWebRTC struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
VideoPT uint8 `json:"video_pt"`
|
||||||
|
AudioPT uint8 `json:"audio_pt"`
|
||||||
|
ForceTranscode bool `json:"force_transcode"`
|
||||||
|
|
||||||
|
// VideoMap / AudioMap select which input stream the WebRTC RTP
|
||||||
|
// legs draw from. Defaults are "0:v:0" and "0:a:0" — correct for
|
||||||
|
// any RTMP / SRT publisher (single input, both A and V on input
|
||||||
|
// 0). For multi-input pipelines (lavfi test sources, SDI capture
|
||||||
|
// fed alongside file audio, etc.) the operator can override.
|
||||||
|
VideoMap string `json:"video_map,omitempty"`
|
||||||
|
AudioMap string `json:"audio_map,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone returns a deep copy of the WebRTC config (currently a value copy;
|
||||||
|
// provided for symmetry with other Clone methods and future-proofing).
|
||||||
|
func (w ConfigWebRTC) Clone() ConfigWebRTC { return w }
|
||||||
|
|
||||||
|
// ConfigWHIPIngest carries per-process WHIP ingest settings.
|
||||||
|
//
|
||||||
|
// When Enabled is true the app/webrtc subsystem will, at process start,
|
||||||
|
// allocate two adjacent loopback UDP ports and prepend them as RTP input
|
||||||
|
// legs to the FFmpeg command. A browser or OBS publisher then connects
|
||||||
|
// to POST /api/v3/whip/{id} and the received WebRTC tracks are forwarded
|
||||||
|
// to those ports, giving FFmpeg its video+audio input via WebRTC.
|
||||||
|
//
|
||||||
|
// Flow (symmetric to WHEP egress):
|
||||||
|
// browser → WHIP → Pion → UDP → FFmpeg input → FFmpeg outputs (RTMP/SRT/HLS…)
|
||||||
|
//
|
||||||
|
// VideoPT / AudioPT are the RTP payload types Pion will stamp on forwarded
|
||||||
|
// packets. Defaults match the WHEP egress defaults (102/111).
|
||||||
|
type ConfigWHIPIngest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
VideoPT uint8 `json:"video_pt"`
|
||||||
|
AudioPT uint8 `json:"audio_pt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone returns a value copy of the WHIP ingest config.
|
||||||
|
func (w ConfigWHIPIngest) Clone() ConfigWHIPIngest { return w }
|
||||||
|
|
||||||
func (io ConfigIO) Clone() ConfigIO {
|
func (io ConfigIO) Clone() ConfigIO {
|
||||||
clone := ConfigIO{
|
clone := ConfigIO{
|
||||||
ID: io.ID,
|
ID: io.ID,
|
||||||
|
|
@ -47,6 +94,8 @@ type Config struct {
|
||||||
LimitCPU float64 `json:"limit_cpu_usage"` // percent
|
LimitCPU float64 `json:"limit_cpu_usage"` // percent
|
||||||
LimitMemory uint64 `json:"limit_memory_bytes"` // bytes
|
LimitMemory uint64 `json:"limit_memory_bytes"` // bytes
|
||||||
LimitWaitFor uint64 `json:"limit_waitfor_seconds"` // seconds
|
LimitWaitFor uint64 `json:"limit_waitfor_seconds"` // seconds
|
||||||
|
WebRTC ConfigWebRTC `json:"webrtc"`
|
||||||
|
WHIPIngest ConfigWHIPIngest `json:"whip_ingest,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config *Config) Clone() *Config {
|
func (config *Config) Clone() *Config {
|
||||||
|
|
@ -61,6 +110,8 @@ func (config *Config) Clone() *Config {
|
||||||
LimitCPU: config.LimitCPU,
|
LimitCPU: config.LimitCPU,
|
||||||
LimitMemory: config.LimitMemory,
|
LimitMemory: config.LimitMemory,
|
||||||
LimitWaitFor: config.LimitWaitFor,
|
LimitWaitFor: config.LimitWaitFor,
|
||||||
|
WebRTC: config.WebRTC.Clone(),
|
||||||
|
WHIPIngest: config.WHIPIngest.Clone(),
|
||||||
}
|
}
|
||||||
|
|
||||||
clone.Input = make([]ConfigIO, len(config.Input))
|
clone.Input = make([]ConfigIO, len(config.Input))
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,38 @@ type Restreamer interface {
|
||||||
GetProcessMetadata(id, key string) (interface{}, error) // Get previously set metadata from a process
|
GetProcessMetadata(id, key string) (interface{}, error) // Get previously set metadata from a process
|
||||||
SetMetadata(key string, data interface{}) error // Set general metadata
|
SetMetadata(key string, data interface{}) error // Set general metadata
|
||||||
GetMetadata(key string) (interface{}, error) // Get previously set general metadata
|
GetMetadata(key string) (interface{}, error) // Get previously set general metadata
|
||||||
|
SetHooks(hooks ProcessHooks) // Install per-process lifecycle hooks (e.g., WebRTC subsystem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessStartHook is invoked synchronously inside startProcess just
|
||||||
|
// before FFmpeg is started. It receives a pointer to the task config;
|
||||||
|
// returning a non-empty slice of ConfigIO causes the command to be
|
||||||
|
// rebuilt before Start(). Returning a non-nil error aborts the start.
|
||||||
|
//
|
||||||
|
// For OnStart the returned ConfigIO slices are appended to cfg.Output.
|
||||||
|
// For OnInputStart the returned ConfigIO slices are prepended to cfg.Input.
|
||||||
|
//
|
||||||
|
// Hooks run with the restream write lock held, so they must not call
|
||||||
|
// back into the Restreamer interface (it would deadlock). They can,
|
||||||
|
// however, mutate cfg.WebRTC metadata or read cfg fields freely.
|
||||||
|
type ProcessStartHook func(id string, cfg *app.Config) ([]app.ConfigIO, error)
|
||||||
|
|
||||||
|
// ProcessStopHook is invoked synchronously inside stopProcess just
|
||||||
|
// after FFmpeg has been stopped. It is a notification for subsystems
|
||||||
|
// to tear down any per-process state they attached at start.
|
||||||
|
type ProcessStopHook func(id string)
|
||||||
|
|
||||||
|
// ProcessHooks bundles the lifecycle callbacks a sibling subsystem
|
||||||
|
// (currently: app/webrtc) installs via SetHooks.
|
||||||
|
//
|
||||||
|
// OnStart returns ConfigIO entries appended to cfg.Output (WHEP RTP egress legs).
|
||||||
|
// OnInputStart returns ConfigIO entries prepended to cfg.Input (WHIP RTP ingest legs).
|
||||||
|
// OnStop and OnInputStop are called after FFmpeg stops.
|
||||||
|
type ProcessHooks struct {
|
||||||
|
OnStart ProcessStartHook
|
||||||
|
OnStop ProcessStopHook
|
||||||
|
OnInputStart ProcessStartHook // WHIP ingest: returned legs prepended to cfg.Input
|
||||||
|
OnInputStop ProcessStopHook // WHIP ingest: teardown notification
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config is the required configuration for a new restreamer instance.
|
// Config is the required configuration for a new restreamer instance.
|
||||||
|
|
@ -102,12 +134,24 @@ type restream struct {
|
||||||
logger log.Logger
|
logger log.Logger
|
||||||
metadata map[string]interface{}
|
metadata map[string]interface{}
|
||||||
|
|
||||||
|
hooks ProcessHooks
|
||||||
|
|
||||||
lock sync.RWMutex
|
lock sync.RWMutex
|
||||||
|
|
||||||
startOnce sync.Once
|
startOnce sync.Once
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetHooks installs the process lifecycle hooks. The caller is
|
||||||
|
// responsible for installing hooks before Start() is invoked; calling
|
||||||
|
// SetHooks on a running instance is safe but only affects subsequent
|
||||||
|
// start/stop transitions (not the one currently in flight).
|
||||||
|
func (r *restream) SetHooks(hooks ProcessHooks) {
|
||||||
|
r.lock.Lock()
|
||||||
|
defer r.lock.Unlock()
|
||||||
|
r.hooks = hooks
|
||||||
|
}
|
||||||
|
|
||||||
// New returns a new instance that implements the Restreamer interface
|
// New returns a new instance that implements the Restreamer interface
|
||||||
func New(config Config) (Restreamer, error) {
|
func New(config Config) (Restreamer, error) {
|
||||||
r := &restream{
|
r := &restream{
|
||||||
|
|
@ -1062,6 +1106,57 @@ func (r *restream) startProcess(id string) error {
|
||||||
|
|
||||||
task.process.Order = "start"
|
task.process.Order = "start"
|
||||||
|
|
||||||
|
// Invoke the per-process lifecycle hooks. OnInputStart returns
|
||||||
|
// ConfigIO entries prepended to cfg.Input (WHIP ingest legs);
|
||||||
|
// OnStart returns ConfigIO entries appended to cfg.Output (WHEP
|
||||||
|
// egress legs). Both rebuild the FFmpeg process if non-empty.
|
||||||
|
needsRebuild := false
|
||||||
|
|
||||||
|
if r.hooks.OnInputStart != nil {
|
||||||
|
inputExtras, err := r.hooks.OnInputStart(task.id, task.config)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.WithField("id", task.id).WithError(err).Error().Log("WHIP input hook aborted process start")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(inputExtras) > 0 {
|
||||||
|
task.config.Input = append(inputExtras, task.config.Input...)
|
||||||
|
needsRebuild = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.hooks.OnStart != nil {
|
||||||
|
extras, err := r.hooks.OnStart(task.id, task.config)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.WithField("id", task.id).WithError(err).Error().Log("Start hook aborted process start")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(extras) > 0 {
|
||||||
|
task.config.Output = append(task.config.Output, extras...)
|
||||||
|
needsRebuild = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsRebuild {
|
||||||
|
task.command = task.config.CreateCommand()
|
||||||
|
|
||||||
|
newFFmpeg, ferr := r.ffmpeg.New(ffmpeg.ProcessConfig{
|
||||||
|
Reconnect: task.config.Reconnect,
|
||||||
|
ReconnectDelay: time.Duration(task.config.ReconnectDelay) * time.Second,
|
||||||
|
StaleTimeout: time.Duration(task.config.StaleTimeout) * time.Second,
|
||||||
|
LimitCPU: task.config.LimitCPU,
|
||||||
|
LimitMemory: task.config.LimitMemory,
|
||||||
|
LimitDuration: time.Duration(task.config.LimitWaitFor) * time.Second,
|
||||||
|
Command: task.command,
|
||||||
|
Parser: task.parser,
|
||||||
|
Logger: task.logger,
|
||||||
|
})
|
||||||
|
if ferr != nil {
|
||||||
|
r.logger.WithField("id", task.id).WithError(ferr).Error().Log("Failed to rebuild FFmpeg after hooks")
|
||||||
|
return ferr
|
||||||
|
}
|
||||||
|
task.ffmpeg = newFFmpeg
|
||||||
|
}
|
||||||
|
|
||||||
task.ffmpeg.Start()
|
task.ffmpeg.Start()
|
||||||
|
|
||||||
r.nProc++
|
r.nProc++
|
||||||
|
|
@ -1105,6 +1200,16 @@ func (r *restream) stopProcess(id string) error {
|
||||||
|
|
||||||
r.nProc--
|
r.nProc--
|
||||||
|
|
||||||
|
// Notify subsystems (app/webrtc) that this process has been
|
||||||
|
// stopped so they can tear down any per-process state. Hooks are
|
||||||
|
// best-effort: errors are the hook's problem to log.
|
||||||
|
if r.hooks.OnStop != nil {
|
||||||
|
r.hooks.OnStop(task.id)
|
||||||
|
}
|
||||||
|
if r.hooks.OnInputStop != nil {
|
||||||
|
r.hooks.OnInputStop(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
24
src/misc/Logo/index.js
Normal file
24
src/misc/Logo/index.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import makeStyles from '@mui/styles/makeStyles';
|
||||||
|
|
||||||
|
import company_logo from './images/logo.png';
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
Logo: {
|
||||||
|
height: 27,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function Logo(props) {
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
let link = 'https://forge.wilddragon.net/zgaetano/datarhei-dragonfork-core';
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-useless-escape
|
||||||
|
return (
|
||||||
|
<a href={link} className={classes.Logo} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src={company_logo} alt="Wild Dragon logo" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/misc/Logo/rsLogo.js
Normal file
24
src/misc/Logo/rsLogo.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import makeStyles from '@mui/styles/makeStyles';
|
||||||
|
|
||||||
|
import company_logo from './images/logo.png';
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
Logo: {
|
||||||
|
height: 95,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function Logo(props) {
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
let link = 'https://forge.wilddragon.net/zgaetano/datarhei-dragonfork-core';
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-useless-escape
|
||||||
|
return (
|
||||||
|
<a href={link} className={classes.Logo} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src={company_logo} alt="Wild Dragon mark" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
test/TESTING.md
Normal file
86
test/TESTING.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Testing the WebRTC egress path
|
||||||
|
|
||||||
|
## In-process (CI)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -race -count=1 ./app/webrtc/... ./core/webrtc/...
|
||||||
|
```
|
||||||
|
|
||||||
|
The integration tests under `app/webrtc/` allocate UDP ports on
|
||||||
|
loopback, spin up an Echo handler, attach a Pion subscriber, and
|
||||||
|
spray synthetic RTP into the registered Source. `TestIntegration_FiveViewerFanout`
|
||||||
|
covers the 5-concurrent-viewer acceptance path from the M3 design.
|
||||||
|
|
||||||
|
## Manual / browser
|
||||||
|
|
||||||
|
`whep-player.html` is a self-contained WHEP subscriber a human can
|
||||||
|
point at any live deploy. Open it directly in a browser:
|
||||||
|
|
||||||
|
```
|
||||||
|
file:///path/to/datarhei-dragonfork-core/test/whep-player.html
|
||||||
|
```
|
||||||
|
|
||||||
|
…or copy it onto a static host (no server-side dependency). It accepts
|
||||||
|
the WHEP URL and an optional bearer token (the deploy uses Core's
|
||||||
|
JWT, so paste an `access_token` from `POST /api/login`). It POSTs an
|
||||||
|
SDP offer with a recvonly video + audio transceiver, applies the
|
||||||
|
answer, and renders the stream in `<video>`. Stats panel shows ICE +
|
||||||
|
PeerConnection states, the codec pulled from the answer SDP, and a
|
||||||
|
1-Hz inbound-bitrate sample. Disconnect issues a WHEP `DELETE` on
|
||||||
|
the resource URL the server returned in `Location`.
|
||||||
|
|
||||||
|
Shareable URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
file:///.../whep-player.html?url=http://10.0.0.25:8090/api/v3/whep/myStream&token=eyJhbGciOi...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pion CLI helper
|
||||||
|
|
||||||
|
`test/whep-client/` is the same handshake in Go, useful for scripting
|
||||||
|
or running on the same machine as Core for an apples-to-apples loopback
|
||||||
|
test:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd test/whep-client
|
||||||
|
go build -o /tmp/whep-client .
|
||||||
|
/tmp/whep-client -url http://10.0.0.25:8090/api/v3/whep/myStream -token "$JWT" -timeout 15s
|
||||||
|
```
|
||||||
|
|
||||||
|
Exits 0 once both video and audio tracks have received their first
|
||||||
|
RTP packet. Used in the M2 deploy verification on TrueNAS.
|
||||||
|
|
||||||
|
## Latency p95 gate
|
||||||
|
|
||||||
|
Wired into CI via the `latency-gate` job in `.forgejo/workflows/test.yml`.
|
||||||
|
Run locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test -tags latency -timeout 90s -race -count=1 \
|
||||||
|
-run TestLatencyServerHop ./app/webrtc/...
|
||||||
|
```
|
||||||
|
|
||||||
|
### What it measures
|
||||||
|
|
||||||
|
Server-hop latency from `corewebrtc.Source` ingest through Pion's
|
||||||
|
DTLS-SRTP egress to a subscriber's `track.ReadRTP()`. The publisher
|
||||||
|
embeds a wall-clock UnixNano timestamp in each RTP payload; the
|
||||||
|
subscriber reads it on arrival and diffs.
|
||||||
|
|
||||||
|
### What it does NOT measure
|
||||||
|
|
||||||
|
True glass-to-glass latency would include FFmpeg encode and a real
|
||||||
|
H.264 decoder on the subscriber side. The design (`webrtc-design.md`
|
||||||
|
§7) calls for `drawtext`-burned frame counters + decode-side pixel
|
||||||
|
sampling; implementing that in pure Go would require a cgo H.264
|
||||||
|
decoder or an FFmpeg-as-sidecar pipe, neither of which pays off for
|
||||||
|
the dominant CI question (*"did anybody regress the server hop?"*).
|
||||||
|
Encode/decode latency is fixed by the codec stack — Core code changes
|
||||||
|
won't move it.
|
||||||
|
|
||||||
|
### Threshold
|
||||||
|
|
||||||
|
`p95 < 50 ms` on the CI runner. Locally observed on a quiet host:
|
||||||
|
`p50 ≈ 110 µs`, `p95 ≈ 240 µs`, `p99 ≈ 320 µs`. The 50ms gate is two
|
||||||
|
orders of magnitude above that — generous, but a regression that
|
||||||
|
crosses it indicates a genuine slowdown rather than runner noise.
|
||||||
0
test/load/results/.gitkeep
Normal file
0
test/load/results/.gitkeep
Normal file
385
test/load/sustained.go
Normal file
385
test/load/sustained.go
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
// Dragon Fork — headless WHEP subscriber load test.
|
||||||
|
//
|
||||||
|
// Drives N concurrent WHEP peers against a single stream for a configurable
|
||||||
|
// duration and produces a markdown report suitable for committing to
|
||||||
|
// test/load/results/.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// go run ./test/load/sustained.go \
|
||||||
|
// -url http://10.0.0.25:8080 \
|
||||||
|
// -stream mystream \
|
||||||
|
// -peers 5 \
|
||||||
|
// -duration 10m \
|
||||||
|
// -auth "Bearer <TOKEN>" \
|
||||||
|
// -out test/load/results/
|
||||||
|
//
|
||||||
|
// The program exits 0 on success or 1 if any peer failed to connect.
|
||||||
|
// Run the Grafana dashboard alongside to observe Prometheus metrics
|
||||||
|
// during the test (see deploy/truenas/core/docker-compose.yml).
|
||||||
|
|
||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// peerResult holds per-peer stats collected over the run.
|
||||||
|
type peerResult struct {
|
||||||
|
index int
|
||||||
|
connected bool
|
||||||
|
iceEstablishMs float64
|
||||||
|
packetsReceived uint64
|
||||||
|
seqGaps uint64 // proxy for packet loss
|
||||||
|
jitterSamplesMs []float64
|
||||||
|
disconnectedAt time.Time
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
coreURL := flag.String("url", "http://localhost:8080", "Dragon Fork Core base URL")
|
||||||
|
streamID := flag.String("stream", "", "Process ID with webrtc.enabled=true")
|
||||||
|
nPeers := flag.Int("peers", 5, "Number of concurrent WHEP subscribers")
|
||||||
|
duration := flag.Duration("duration", 10*time.Minute, "Test duration")
|
||||||
|
auth := flag.String("auth", "", "Authorization header value (e.g. 'Bearer TOKEN')")
|
||||||
|
outDir := flag.String("out", "test/load/results", "Output directory for markdown report")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *streamID == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: -stream is required")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Dragon Fork WHEP load test\n")
|
||||||
|
fmt.Printf(" target: %s\n", *coreURL)
|
||||||
|
fmt.Printf(" stream: %s\n", *streamID)
|
||||||
|
fmt.Printf(" peers: %d\n", *nPeers)
|
||||||
|
fmt.Printf(" duration: %s\n", *duration)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), *duration+30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
results := make([]peerResult, *nPeers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var anyFail int32
|
||||||
|
|
||||||
|
for i := 0; i < *nPeers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
res := runPeer(ctx, idx, *coreURL, *streamID, *auth, *duration)
|
||||||
|
results[idx] = res
|
||||||
|
if !res.connected {
|
||||||
|
atomic.StoreInt32(&anyFail, 1)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
// Stagger connection attempts 200ms apart to avoid thundering herd.
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
report := buildReport(*coreURL, *streamID, *nPeers, *duration, results)
|
||||||
|
fmt.Print(report)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(*outDir, 0o755); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "mkdir %s: %v\n", *outDir, err)
|
||||||
|
} else {
|
||||||
|
ts := time.Now().UTC().Format("2006-01-02T150405Z")
|
||||||
|
fname := filepath.Join(*outDir, fmt.Sprintf("%s-%s-%dp.md", ts, *streamID, *nPeers))
|
||||||
|
if err := os.WriteFile(fname, []byte(report), 0o644); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "write report: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("\nReport written to %s\n", fname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if anyFail != 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPeer connects one WHEP subscriber, reads packets for the test duration,
|
||||||
|
// and collects statistics.
|
||||||
|
func runPeer(ctx context.Context, idx int, coreURL, streamID, auth string, dur time.Duration) peerResult {
|
||||||
|
res := peerResult{index: idx}
|
||||||
|
|
||||||
|
// Build a minimal SDP offer using Pion.
|
||||||
|
me := &webrtc.MediaEngine{}
|
||||||
|
if err := me.RegisterDefaultCodecs(); err != nil {
|
||||||
|
res.err = fmt.Sprintf("media engine: %v", err)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
api := webrtc.NewAPI(webrtc.WithMediaEngine(me))
|
||||||
|
cfg := webrtc.Configuration{ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}}
|
||||||
|
pc, err := api.NewPeerConnection(cfg)
|
||||||
|
if err != nil {
|
||||||
|
res.err = fmt.Sprintf("new peer connection: %v", err)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
defer pc.Close()
|
||||||
|
|
||||||
|
// Add receive-only transceivers so the SDP offer contains the right m= lines.
|
||||||
|
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||||
|
res.err = fmt.Sprintf("add video transceiver: %v", err)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||||
|
res.err = fmt.Sprintf("add audio transceiver: %v", err)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
offer, err := pc.CreateOffer(nil)
|
||||||
|
if err != nil {
|
||||||
|
res.err = fmt.Sprintf("create offer: %v", err)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if err := pc.SetLocalDescription(offer); err != nil {
|
||||||
|
res.err = fmt.Sprintf("set local desc: %v", err)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST the offer to the WHEP endpoint.
|
||||||
|
whepURL := strings.TrimRight(coreURL, "/") + "/api/v3/whep/" + streamID
|
||||||
|
t0 := time.Now()
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, whepURL, bytes.NewReader([]byte(offer.SDP)))
|
||||||
|
if err != nil {
|
||||||
|
res.err = fmt.Sprintf("build http request: %v", err)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/sdp")
|
||||||
|
if auth != "" {
|
||||||
|
httpReq.Header.Set("Authorization", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
res.err = fmt.Sprintf("POST /whep: %v", err)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
res.err = fmt.Sprintf("WHEP POST returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
answerSDP, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
res.err = fmt.Sprintf("read answer: %v", err)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
answer := webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: string(answerSDP)}
|
||||||
|
if err := pc.SetRemoteDescription(answer); err != nil {
|
||||||
|
res.err = fmt.Sprintf("set remote desc: %v", err)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for ICE connected.
|
||||||
|
connCh := make(chan struct{})
|
||||||
|
var connOnce sync.Once
|
||||||
|
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
|
||||||
|
if s == webrtc.PeerConnectionStateConnected {
|
||||||
|
connOnce.Do(func() { close(connCh) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-connCh:
|
||||||
|
res.iceEstablishMs = float64(time.Since(t0).Milliseconds())
|
||||||
|
res.connected = true
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
res.err = "ICE connection timeout (15s)"
|
||||||
|
return res
|
||||||
|
case <-ctx.Done():
|
||||||
|
res.err = "context cancelled before ICE connected"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" peer %d connected (ICE: %.0fms)\n", idx, res.iceEstablishMs)
|
||||||
|
|
||||||
|
// Collect RTP statistics for the test duration.
|
||||||
|
var mu sync.Mutex
|
||||||
|
var lastSeq uint16
|
||||||
|
var seenFirst bool
|
||||||
|
|
||||||
|
pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||||
|
prevArrival := time.Now()
|
||||||
|
var prevRTPTimestamp uint32
|
||||||
|
var jitter float64
|
||||||
|
clockRate := float64(track.Codec().ClockRate)
|
||||||
|
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
n, _, err := track.Read(buf)
|
||||||
|
if err != nil || n < 12 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
arrivalTime := time.Now()
|
||||||
|
|
||||||
|
// Sequence number gap tracking.
|
||||||
|
seq := uint16(buf[2])<<8 | uint16(buf[3])
|
||||||
|
mu.Lock()
|
||||||
|
atomic.AddUint64(&res.packetsReceived, 1)
|
||||||
|
if seenFirst {
|
||||||
|
expected := lastSeq + 1
|
||||||
|
if seq != expected {
|
||||||
|
gaps := uint64(seq - expected)
|
||||||
|
if gaps < 1000 {
|
||||||
|
atomic.AddUint64(&res.seqGaps, gaps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seenFirst = true
|
||||||
|
}
|
||||||
|
lastSeq = seq
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
// RFC 3550 jitter (simplified: interarrival time deviation).
|
||||||
|
rtpTS := uint32(buf[4])<<24 | uint32(buf[5])<<16 | uint32(buf[6])<<8 | uint32(buf[7])
|
||||||
|
if prevRTPTimestamp != 0 {
|
||||||
|
sendDiff := float64(int32(rtpTS-prevRTPTimestamp)) / clockRate
|
||||||
|
recvDiff := arrivalTime.Sub(prevArrival).Seconds()
|
||||||
|
d := math.Abs(recvDiff - sendDiff)
|
||||||
|
jitter += (d - jitter) / 16 // running average
|
||||||
|
mu.Lock()
|
||||||
|
res.jitterSamplesMs = append(res.jitterSamplesMs, jitter*1000)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
prevRTPTimestamp = rtpTS
|
||||||
|
prevArrival = arrivalTime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for test duration or context cancellation.
|
||||||
|
testTimer := time.NewTimer(dur)
|
||||||
|
defer testTimer.Stop()
|
||||||
|
select {
|
||||||
|
case <-testTimer.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
res.disconnectedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE the WHEP resource.
|
||||||
|
location := resp.Header.Get("Location")
|
||||||
|
if location != "" {
|
||||||
|
delURL := strings.TrimRight(coreURL, "/") + location
|
||||||
|
delReq, _ := http.NewRequest(http.MethodDelete, delURL, nil)
|
||||||
|
if auth != "" {
|
||||||
|
delReq.Header.Set("Authorization", auth)
|
||||||
|
}
|
||||||
|
_, _ = http.DefaultClient.Do(delReq)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildReport(coreURL, streamID string, nPeers int, dur time.Duration, results []peerResult) string {
|
||||||
|
var b strings.Builder
|
||||||
|
ts := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
b.WriteString("# Dragon Fork WHEP Sustained Load Test\n\n")
|
||||||
|
fmt.Fprintf(&b, "**Date:** %s \n", ts)
|
||||||
|
fmt.Fprintf(&b, "**Target:** %s \n", coreURL)
|
||||||
|
fmt.Fprintf(&b, "**Stream:** %s \n", streamID)
|
||||||
|
fmt.Fprintf(&b, "**Peers:** %d \n", nPeers)
|
||||||
|
fmt.Fprintf(&b, "**Duration:** %s \n\n", dur)
|
||||||
|
|
||||||
|
// Summary table.
|
||||||
|
connected := 0
|
||||||
|
var iceMs []float64
|
||||||
|
var allJitter []float64
|
||||||
|
var totalPkts, totalGaps uint64
|
||||||
|
for _, r := range results {
|
||||||
|
if r.connected {
|
||||||
|
connected++
|
||||||
|
iceMs = append(iceMs, r.iceEstablishMs)
|
||||||
|
allJitter = append(allJitter, r.jitterSamplesMs...)
|
||||||
|
totalPkts += r.packetsReceived
|
||||||
|
totalGaps += r.seqGaps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("## Summary\n\n")
|
||||||
|
fmt.Fprintf(&b, "| Metric | Value |\n|---|---|\n")
|
||||||
|
fmt.Fprintf(&b, "| Peers connected | %d / %d |\n", connected, nPeers)
|
||||||
|
fmt.Fprintf(&b, "| Total packets received | %d |\n", totalPkts)
|
||||||
|
lossRate := 0.0
|
||||||
|
if totalPkts+totalGaps > 0 {
|
||||||
|
lossRate = float64(totalGaps) / float64(totalPkts+totalGaps) * 100
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "| Packet loss estimate | %.2f%% |\n", lossRate)
|
||||||
|
if len(iceMs) > 0 {
|
||||||
|
fmt.Fprintf(&b, "| ICE establishment p50 | %.0fms |\n", percentile(iceMs, 50))
|
||||||
|
fmt.Fprintf(&b, "| ICE establishment p95 | %.0fms |\n", percentile(iceMs, 95))
|
||||||
|
}
|
||||||
|
if len(allJitter) > 0 {
|
||||||
|
fmt.Fprintf(&b, "| Jitter p50 | %.2fms |\n", percentile(allJitter, 50))
|
||||||
|
fmt.Fprintf(&b, "| Jitter p95 | %.2fms |\n", percentile(allJitter, 95))
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Per-peer detail.
|
||||||
|
b.WriteString("## Per-Peer Detail\n\n")
|
||||||
|
b.WriteString("| Peer | Connected | ICE ms | Packets | Loss est. | Jitter p95 |\n")
|
||||||
|
b.WriteString("|---|---|---|---|---|---|\n")
|
||||||
|
for _, r := range results {
|
||||||
|
if r.err != "" {
|
||||||
|
fmt.Fprintf(&b, "| %d | ❌ | — | — | — | %s |\n", r.index, r.err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lossEst := 0.0
|
||||||
|
if r.packetsReceived+r.seqGaps > 0 {
|
||||||
|
lossEst = float64(r.seqGaps) / float64(r.packetsReceived+r.seqGaps) * 100
|
||||||
|
}
|
||||||
|
jP95 := 0.0
|
||||||
|
if len(r.jitterSamplesMs) > 0 {
|
||||||
|
jP95 = percentile(r.jitterSamplesMs, 95)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "| %d | ✓ | %.0f | %d | %.2f%% | %.2fms |\n",
|
||||||
|
r.index, r.iceEstablishMs, r.packetsReceived, lossEst, jP95)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
b.WriteString("---\n")
|
||||||
|
b.WriteString("*Generated by `test/load/sustained.go`. Observe Prometheus metrics during run via Grafana.*\n")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentile(data []float64, p float64) float64 {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
sorted := make([]float64, len(data))
|
||||||
|
copy(sorted, data)
|
||||||
|
sort.Float64s(sorted)
|
||||||
|
idx := (p / 100) * float64(len(sorted)-1)
|
||||||
|
lo := int(idx)
|
||||||
|
hi := lo + 1
|
||||||
|
if hi >= len(sorted) {
|
||||||
|
return sorted[lo]
|
||||||
|
}
|
||||||
|
frac := idx - float64(lo)
|
||||||
|
return sorted[lo]*(1-frac) + sorted[hi]*frac
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import (
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
whepURL = flag.String("url", "http://127.0.0.1:8787/whep/test", "WHEP endpoint URL")
|
whepURL = flag.String("url", "http://127.0.0.1:8787/whep/test", "WHEP endpoint URL")
|
||||||
|
token = flag.String("token", "", "Authorization: Bearer <token>; empty means no auth header")
|
||||||
timeout = flag.Duration("timeout", 10*time.Second, "overall subscribe+receive timeout")
|
timeout = flag.Duration("timeout", 10*time.Second, "overall subscribe+receive timeout")
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
@ -30,7 +31,7 @@ func main() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := Subscribe(ctx, *whepURL); err != nil {
|
if err := Subscribe(ctx, *whepURL, *token); err != nil {
|
||||||
log.Fatalf("subscribe failed: %v", err)
|
log.Fatalf("subscribe failed: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Println("OK: received video and audio RTP")
|
fmt.Println("OK: received video and audio RTP")
|
||||||
|
|
@ -40,7 +41,7 @@ func main() {
|
||||||
// Subscribe performs a full WHEP subscribe against whepURL and returns
|
// Subscribe performs a full WHEP subscribe against whepURL and returns
|
||||||
// nil when both a video and an audio RTP packet have been observed
|
// nil when both a video and an audio RTP packet have been observed
|
||||||
// before ctx expires. It is exported so tests can exercise it.
|
// before ctx expires. It is exported so tests can exercise it.
|
||||||
func Subscribe(ctx context.Context, whepURL string) error {
|
func Subscribe(ctx context.Context, whepURL, token string) error {
|
||||||
me := &webrtc.MediaEngine{}
|
me := &webrtc.MediaEngine{}
|
||||||
if err := me.RegisterDefaultCodecs(); err != nil {
|
if err := me.RegisterDefaultCodecs(); err != nil {
|
||||||
return fmt.Errorf("register codecs: %w", err)
|
return fmt.Errorf("register codecs: %w", err)
|
||||||
|
|
@ -105,7 +106,7 @@ func Subscribe(ctx context.Context, whepURL string) error {
|
||||||
return fmt.Errorf("ice gather: %w", ctx.Err())
|
return fmt.Errorf("ice gather: %w", ctx.Err())
|
||||||
}
|
}
|
||||||
|
|
||||||
answerSDP, err := postOffer(ctx, whepURL, pc.LocalDescription().SDP)
|
answerSDP, err := postOffer(ctx, whepURL, token, pc.LocalDescription().SDP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -130,13 +131,16 @@ func Subscribe(ctx context.Context, whepURL string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func postOffer(ctx context.Context, url, sdp string) (string, error) {
|
func postOffer(ctx context.Context, url, token, sdp string) (string, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url,
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url,
|
||||||
bytes.NewReader([]byte(sdp)))
|
bytes.NewReader([]byte(sdp)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("new request: %w", err)
|
return "", fmt.Errorf("new request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/sdp")
|
req.Header.Set("Content-Type", "application/sdp")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ func TestSubscribe_EndToEnd(t *testing.T) {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
whepURL := strings.TrimRight(ts.URL, "/") + "/whep/stream-e2e"
|
whepURL := strings.TrimRight(ts.URL, "/") + "/whep/stream-e2e"
|
||||||
if err := Subscribe(ctx, whepURL); err != nil {
|
if err := Subscribe(ctx, whepURL, ""); err != nil {
|
||||||
t.Fatalf("Subscribe: %v", err)
|
t.Fatalf("Subscribe: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
354
test/whep-player.html
Normal file
354
test/whep-player.html
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Dragon Fork — WHEP Player</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--fg: #e7e7ea;
|
||||||
|
--bg: #0d0e12;
|
||||||
|
--accent: #ff6633;
|
||||||
|
--muted: #8b8e98;
|
||||||
|
--good: #5dd29c;
|
||||||
|
--warn: #ffb45e;
|
||||||
|
--bad: #ff6470;
|
||||||
|
--panel: #1a1c22;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #232530;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
header h1 .accent { color: var(--accent); }
|
||||||
|
header .subtitle { color: var(--muted); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
main {
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
input[type=text] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
background: #0d0e12;
|
||||||
|
border: 1px solid #2a2c36;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
input[type=text]:focus { border-color: var(--accent); outline: none; }
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
button.secondary { background: #2a2c36; color: var(--fg); }
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 10px;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.stats .label { color: var(--muted); }
|
||||||
|
.stats .value { font-variant-numeric: tabular-nums; }
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: #2a2c36;
|
||||||
|
}
|
||||||
|
.pill.good { background: rgba(93,210,156,0.18); color: var(--good); }
|
||||||
|
.pill.warn { background: rgba(255,180,94,0.18); color: var(--warn); }
|
||||||
|
.pill.bad { background: rgba(255,100,112,0.20); color: var(--bad); }
|
||||||
|
|
||||||
|
.log {
|
||||||
|
margin-top: 1rem;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #0d0e12;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.log .ts { color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Dragon Fork <span class="accent">WHEP</span></h1>
|
||||||
|
<span class="subtitle">manual smoke test for the WebRTC egress path</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="panel">
|
||||||
|
<label for="whep-url">WHEP endpoint</label>
|
||||||
|
<input id="whep-url" type="text" placeholder="http://10.0.0.25:8090/api/v3/whep/myStream"
|
||||||
|
value="">
|
||||||
|
<label for="bearer">JWT bearer token</label>
|
||||||
|
<input id="bearer" type="text" placeholder="eyJhbGciOi…">
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="btn-play">Subscribe</button>
|
||||||
|
<button id="btn-stop" class="secondary" disabled>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<span class="label">ICE</span> <span id="stat-ice" class="value pill">idle</span>
|
||||||
|
<span class="label">Connection</span> <span id="stat-conn" class="value pill">idle</span>
|
||||||
|
<span class="label">Resource</span> <span id="stat-res" class="value">—</span>
|
||||||
|
<span class="label">Video codec</span> <span id="stat-vcodec" class="value">—</span>
|
||||||
|
<span class="label">Audio codec</span> <span id="stat-acodec" class="value">—</span>
|
||||||
|
<span class="label">Inbound bitrate</span><span id="stat-bitrate" class="value">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="log" class="log" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" style="padding:0;background:#000;">
|
||||||
|
<video id="video" controls autoplay playsinline muted></video>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- tiny state -------------------------------------------------
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const log = (line, level='info') => {
|
||||||
|
const ts = new Date().toLocaleTimeString();
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = `<span class="ts">${ts}</span> <span class="lvl-${level}">${line}</span>`;
|
||||||
|
$('log').prepend(div);
|
||||||
|
};
|
||||||
|
const setPill = (el, text, klass) => { el.textContent = text; el.className = 'value pill ' + klass; };
|
||||||
|
|
||||||
|
let pc = null;
|
||||||
|
let resourceURL = null; // absolute or path; whichever the server returned
|
||||||
|
let bitrateTimer = null;
|
||||||
|
|
||||||
|
// --- subscribe / disconnect -------------------------------------
|
||||||
|
$('btn-play').addEventListener('click', subscribe);
|
||||||
|
$('btn-stop').addEventListener('click', disconnect);
|
||||||
|
|
||||||
|
// Pre-populate WHEP endpoint from query string for shareable URLs
|
||||||
|
// (e.g. file:///.../whep-player.html?url=http://.../whep/foo&token=…).
|
||||||
|
(function bootstrap() {
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
if (q.get('url')) $('whep-url').value = q.get('url');
|
||||||
|
if (q.get('token')) $('bearer').value = q.get('token');
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function subscribe() {
|
||||||
|
if (pc) { log('already connected; disconnect first', 'warn'); return; }
|
||||||
|
const url = $('whep-url').value.trim();
|
||||||
|
const token = $('bearer').value.trim();
|
||||||
|
if (!url) { log('WHEP URL is required', 'bad'); return; }
|
||||||
|
|
||||||
|
$('btn-play').disabled = true;
|
||||||
|
$('btn-stop').disabled = false;
|
||||||
|
setPill($('stat-ice'), 'gathering', 'warn');
|
||||||
|
setPill($('stat-conn'), 'connecting', 'warn');
|
||||||
|
|
||||||
|
pc = new RTCPeerConnection({
|
||||||
|
// No ICE servers: production deploy advertises NAT1To1 host
|
||||||
|
// candidates, which work over the LAN. Add stun:/turn: here
|
||||||
|
// if you're testing across NAT.
|
||||||
|
iceServers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.ontrack = (evt) => {
|
||||||
|
log(`ontrack: kind=${evt.track.kind}`, 'info');
|
||||||
|
// Both tracks share the same MediaStream; attach once.
|
||||||
|
if ($('video').srcObject !== evt.streams[0]) {
|
||||||
|
$('video').srcObject = evt.streams[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
const s = pc.iceConnectionState;
|
||||||
|
let klass = 'warn';
|
||||||
|
if (s === 'connected' || s === 'completed') klass = 'good';
|
||||||
|
else if (s === 'failed' || s === 'disconnected' || s === 'closed') klass = 'bad';
|
||||||
|
setPill($('stat-ice'), s, klass);
|
||||||
|
log(`ICE state: ${s}`);
|
||||||
|
};
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
const s = pc.connectionState;
|
||||||
|
let klass = 'warn';
|
||||||
|
if (s === 'connected') klass = 'good';
|
||||||
|
else if (s === 'failed' || s === 'disconnected' || s === 'closed') klass = 'bad';
|
||||||
|
setPill($('stat-conn'), s, klass);
|
||||||
|
log(`PC state: ${s}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
// Wait for ICE gathering to complete so the offer is non-trickle.
|
||||||
|
await new Promise((res) => {
|
||||||
|
if (pc.iceGatheringState === 'complete') return res();
|
||||||
|
pc.addEventListener('icegatheringstatechange', () => {
|
||||||
|
if (pc.iceGatheringState === 'complete') res();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = { 'Content-Type': 'application/sdp' };
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: pc.localDescription.sdp,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.text();
|
||||||
|
throw new Error(`WHEP POST ${resp.status}: ${body || resp.statusText}`);
|
||||||
|
}
|
||||||
|
// Per WHEP spec: server returns SDP answer; Location is the resource.
|
||||||
|
const loc = resp.headers.get('Location');
|
||||||
|
if (loc) {
|
||||||
|
// Resolve relative Location against the WHEP URL.
|
||||||
|
try { resourceURL = new URL(loc, url).toString(); }
|
||||||
|
catch { resourceURL = loc; }
|
||||||
|
$('stat-res').textContent = resourceURL;
|
||||||
|
}
|
||||||
|
const answer = await resp.text();
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
||||||
|
log(`subscribed (${resp.status})`, 'good');
|
||||||
|
|
||||||
|
// Pull codec info out of the SDP for a quick UI hint.
|
||||||
|
const codec = (kind, sdp) => {
|
||||||
|
const m = new RegExp(`m=${kind}[^\r\n]*[\r\n](?:[abc][^\r\n]*[\r\n]){0,30}?a=rtpmap:\\d+ ([^/\r\n]+)`).exec(sdp);
|
||||||
|
return m ? m[1] : '?';
|
||||||
|
};
|
||||||
|
$('stat-vcodec').textContent = codec('video', answer);
|
||||||
|
$('stat-acodec').textContent = codec('audio', answer);
|
||||||
|
|
||||||
|
bitrateTimer = setInterval(updateBitrate, 1000);
|
||||||
|
} catch (err) {
|
||||||
|
log(`error: ${err.message}`, 'bad');
|
||||||
|
await disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnect() {
|
||||||
|
if (bitrateTimer) { clearInterval(bitrateTimer); bitrateTimer = null; }
|
||||||
|
$('btn-play').disabled = false;
|
||||||
|
$('btn-stop').disabled = true;
|
||||||
|
|
||||||
|
// WHEP: best-effort DELETE on the resource URL the server gave us.
|
||||||
|
if (resourceURL) {
|
||||||
|
try {
|
||||||
|
const headers = {};
|
||||||
|
const token = $('bearer').value.trim();
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
const r = await fetch(resourceURL, { method: 'DELETE', headers });
|
||||||
|
log(`DELETE ${r.status}`, r.ok ? 'good' : 'warn');
|
||||||
|
} catch (e) {
|
||||||
|
log(`DELETE failed: ${e.message}`, 'warn');
|
||||||
|
}
|
||||||
|
resourceURL = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pc) { pc.close(); pc = null; }
|
||||||
|
$('video').srcObject = null;
|
||||||
|
setPill($('stat-ice'), 'idle', '');
|
||||||
|
setPill($('stat-conn'), 'idle', '');
|
||||||
|
$('stat-res').textContent = '—';
|
||||||
|
$('stat-vcodec').textContent = '—';
|
||||||
|
$('stat-acodec').textContent = '—';
|
||||||
|
$('stat-bitrate').textContent = '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bitrate sampling -------------------------------------------
|
||||||
|
let lastBytes = null;
|
||||||
|
let lastTs = null;
|
||||||
|
async function updateBitrate() {
|
||||||
|
if (!pc || pc.connectionState !== 'connected') return;
|
||||||
|
const stats = await pc.getStats();
|
||||||
|
let bytes = 0;
|
||||||
|
stats.forEach((r) => {
|
||||||
|
if (r.type === 'inbound-rtp' && !r.isRemote) bytes += r.bytesReceived || 0;
|
||||||
|
});
|
||||||
|
const now = performance.now();
|
||||||
|
if (lastBytes !== null) {
|
||||||
|
const kbps = ((bytes - lastBytes) * 8) / ((now - lastTs) || 1);
|
||||||
|
$('stat-bitrate').textContent = kbps.toFixed(0) + ' kbps';
|
||||||
|
}
|
||||||
|
lastBytes = bytes;
|
||||||
|
lastTs = now;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue