Video Streaming¶
CCTV adapters do not stream video through the adapter process. Instead, they use go2rtc — an external RTSP-to-WebRTC bridge — as a sidecar. The adapter's only job is to tell go2rtc which RTSP sources to serve and under what names.
Why go2rtc¶
Browsers cannot speak RTSP directly. go2rtc accepts an RTSP source URL and transcodes it to WebRTC for browser delivery. The adapter acts as a control plane: it registers and removes streams; go2rtc handles the actual video pipeline.
Stream Aliases¶
Every stream is identified by an alias — a string name registered with go2rtc. The alias is what the client uses to connect.
| Stream type | Alias convention | Example |
|---|---|---|
| Live stream | cam-{deviceId:N} |
cam-a1b2c3d4e5f67890abcdef1234567890 |
| Playback | pb-{txnId:N} |
pb-00112233445566778899aabbccddeeff |
{guid:N} means the GUID without dashes. The cam- and pb- prefixes distinguish live from playback aliases and prevent collisions when the same device has concurrent live and playback sessions.
StreamRegistry¶
go2rtc is stateless from the adapter's perspective — it does not push alias lifecycle events back. The adapter must therefore maintain its own record of which aliases it has registered.
StreamRegistry is a thread-safe HashSet<string> singleton scoped to the adapter instance:
| Method | Purpose |
|---|---|
Add(alias) |
Record alias as active after successful registration |
Contains(alias) |
Check before registering (avoid duplicate registration) |
Remove(alias) |
Record alias as gone after teardown |
Clear() |
Atomically remove all aliases and return the cleared set (used on NVR disconnect) |
Clear() returns the cleared set so the caller can issue StreamApi.Remove for each alias before discarding them.
Stream Lifecycle¶
1. Create on demand¶
Streams are registered only when a client requests them — never pre-registered on connect.
client sends pq.command.video.live
→ adapter builds RTSP URL for that channel
→ pq.StreamApi.Register(alias, rtspUrl)
→ StreamRegistry.Add(alias)
→ adapter returns alias to client
2. Tear down on camera offline¶
When a channel goes offline — detected via the alertStream or status poll — the adapter tears down that channel's stream:
camera offline event received
→ SetStatus("VideoSignal", "video.signal.lost", ct)
→ check StreamRegistry.Contains(liveAlias)
→ pq.StreamApi.Remove(alias, ct)
→ StreamRegistry.Remove(alias)
Both the alertStream path and the status poll path must perform this teardown. They are two independent code paths for the same transition.
3. Tear down on NVR disconnect¶
When the NVR itself disconnects, all streams become invalid:
NVR disconnect
→ var cleared = StreamRegistry.Clear()
→ foreach alias in cleared: pq.StreamApi.Remove(alias, ct)
→ framework SignalDisconnected
4. Client-driven reconnect¶
The adapter never re-registers a stream after teardown. Once a stream is removed, the client is responsible for requesting a new one.
The client detects a stale stream two ways:
- Stall detection: 8-second
ontimeupdatetimeout with!videoEl.pausedguard — if the video is playing but no frames arrive, the stream is stale - ICE disconnect: WebRTC connection event
On detection, the client re-sends pq.command.video.live at 5-second intervals until the adapter accepts it (i.e., until the camera is back online and the adapter can build a valid RTSP URL).
This design keeps the adapter stateless with respect to viewers. The adapter does not know how many clients are watching — that is go2rtc's concern.
Playback¶
Playback uses the same mechanism as live, but with a time-bounded RTSP replay URL:
client sends pq.command.video.playback (with startTime, endTime)
→ adapter builds RTSP replay URL with time parameters
→ pq.StreamApi.Register("pb-{txnId:N}", replayUrl)
→ StreamRegistry.Add(playbackAlias)
Seek is implemented by removing the existing playback alias and registering a new one with the updated starttime:
client sends pq.command.video.playback.seek (with newStartTime)
→ pq.StreamApi.Remove(existingAlias, ct)
→ StreamRegistry.Remove(existingAlias)
→ pq.StreamApi.Register(newAlias, replayUrlWithNewStartTime)
→ StreamRegistry.Add(newAlias)
Pause and resume are handled client-side by pausing the HTML video element. The adapter receives no command and takes no action.
Pre-roll: the client subtracts a fixed lead-in (CctvCommandDispatcher.PlaybackPreRollSeconds, 10s) from any requested playback time — bookmark clicks, slider scrub, and the initial playback request from the wall / device navigator / monitor — so the moments leading up to the chosen instant are visible. Relative ±1 min nudges and reused times (viewer pop-out, reconnect) are left exact to avoid compounding the offset.
Reconnect (client-driven)¶
When the WebRTC session stalls (8s ontimeupdate timeout) or ICE drops, the client reconnects:
- Live: re-sends
pq.command.video.live(nopersonId→ no popup) so the adapter re-registers thecam-alias once the camera is back, then re-establishes WebRTC. - Playback: does NOT re-send the command. The
pb-alias survives transient stalls, andpq.command.video.playbackrequires atransaction_idthe stream component does not own — re-sending the bare command caused400 requires parameter 'transaction_id'on every reconnect (e.g. after a seek's stop/restart). The client just re-establishes the WebRTC session to the existing alias.
Stream Reconciliation (server-owned)¶
Cleanup of streams abandoned without an explicit teardown — for example, a client that disconnected without sending a stop command — is not an adapter responsibility. Do not add a reconciliation background service to a new adapter.
PQ Server runs a single StreamReconciliationService (Pq.Server.Streaming) that polls go2rtc directly and removes any alias with 0 consumers after a grace re-check. Because it talks to go2rtc rather than to a per-adapter registry, it reclaims orphaned streams even if the owning adapter has crashed — which an adapter-hosted loop could never do.
The server reconciliation loop:
- Fetch all streams from go2rtc with their consumer counts
- Collect only adapter-created aliases (
cam-/pb-prefixes) with 0 consumers - Wait the grace period (
Streaming:ReconciliationGracePeriodSeconds, default 10s), then re-fetch - Delete from go2rtc any such alias still at 0 consumers
Interval and grace are server config keys (Streaming:ReconciliationIntervalSeconds / ReconciliationGracePeriodSeconds, defaults 60s / 10s).
Reconcile only
cam-/pb-aliases — never arbitrary streams. go2rtc persists API stream changes back to itsgo2rtc.yaml, so aDELETE /api/streamsremoves the stream from the config file permanently. If reconciliation reaps a config-defined source (e.g. the simulator'stest-srctemplate, which always has 0 consumers), go2rtc rewrites its config with that stream gone, and every subsequentcam-/pb-registration that sources from it fails with400 "streams: source not supported". Scope the idle sweep to the documented alias prefixes.
The adapter's only cleanup duty is immediate, targeted teardown on camera offline / NVR disconnect (see "Stream Lifecycle" above) — periodic idle sweeping is the server's job.
HTTP Client Configuration¶
The HTTP client used for the alertStream (persistent long-poll event feed) requires special configuration:
| Setting | Value | Reason |
|---|---|---|
Timeout |
Timeout.InfiniteTimeSpan |
Event stream is a persistent connection; finite timeout kills it silently |
MaxConnectionsPerServer |
4 | Allows concurrent alertStream + REST operations to the same NVR host |
PreAuthenticate |
false |
Required for HTTP Digest — let the server send the 401 challenge first |
Do not reuse this client for REST calls. REST calls should use a client with a finite timeout (e.g., 10–30 seconds).
See Also¶
- patterns/19-stream-registration.md — the registration + reconciliation lifecycle recipe and the RTSP-URL strategy decision
- archetypes/cctv-nvr.md — overall CCTV adapter shape
- patterns/16-video-events.md — video event taxonomy and alert+poll duality
- patterns/12-status-polling.md — per-channel status polling