Skip to content

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.

NVR / camera  ──RTSP──►  go2rtc  ──WebRTC──►  browser
       adapter registers alias here

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 ontimeupdate timeout with !videoEl.paused guard — 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 (no personId → no popup) so the adapter re-registers the cam- alias once the camera is back, then re-establishes WebRTC.
  • Playback: does NOT re-send the command. The pb- alias survives transient stalls, and pq.command.video.playback requires a transaction_id the stream component does not own — re-sending the bare command caused 400 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:

  1. Fetch all streams from go2rtc with their consumer counts
  2. Collect only adapter-created aliases (cam- / pb- prefixes) with 0 consumers
  3. Wait the grace period (Streaming:ReconciliationGracePeriodSeconds, default 10s), then re-fetch
  4. 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 its go2rtc.yaml, so a DELETE /api/streams removes the stream from the config file permanently. If reconciliation reaps a config-defined source (e.g. the simulator's test-src template, which always has 0 consumers), go2rtc rewrites its config with that stream gone, and every subsequent cam-/pb- registration that sources from it fails with 400 "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