Skip to content

Pattern 19: Stream Registration & Reconciliation

CCTV adapters are a control plane for video, not a video pipeline. The adapter never carries video frames. It registers named RTSP sources with the platform stream API (which proxies to the go2rtc RTSP→WebRTC bridge), tears them down on the right transitions, and leaves periodic cleanup to the server. This pattern is the authoritative recipe for the registration + reconciliation lifecycle and the one architectural decision it hinges on: how the adapter obtains the RTSP source URL.

For the underlying model (why go2rtc, alias conventions, the server reconciliation loop) see concepts/video-streaming.md. For the event taxonomy and the alert+poll duality that drives teardown see patterns/16-video-events.md. This pattern focuses on the invariants an implementer must get right.


The one principle

The adapter registers and removes aliases. It never streams, transcodes, or proxies video bytes. The browser connects to go2rtc directly over WebRTC.

The only byte-upload the adapter ever performs is the still snapshot (a single JPEG for a camera poster) — never a video stream. The source the adapter registers does not have to be RTSP: the bridge can also pull an HTTP MJPEG stream or a JPEG snapshot URL and transcode it (see Non-RTSP sources). A device only falls outside this archetype if it exposes no pullable HTTP/RTSP video endpoint at all.


Adapter state: the stream registry

The bridge does not report alias lifecycle back to the adapter, so the adapter keeps its own record of what it has registered. Model it as a thread-safe, case-insensitive set of alias strings, a single instance shared by the command handlers and the teardown paths.

Method Purpose
Add(alias) Record an alias as active after a successful register
Contains(alias) Membership test used by teardown (see the guard rule below)
Remove(alias) Drop an alias after teardown
Clear() Atomically empty the set and return the removed aliases (used on disconnect)

It must be safe under concurrent access: registration (command threads), teardown (event thread + poll thread), and disconnect can all touch it at once. A plain lock around a HashSet<string> (ordinal-ignore-case) is sufficient — do not assume a single-threaded queue.

Alias conventions

Stream Alias Keyed on
Live cam-{deviceId:N} the channel's PQ device id (GUID, no dashes)
Playback pb-{transactionId:N} the playback transaction id

Live/snapshot aliases key on the PQ device id, not the device's internal channel number. The device channel number belongs in the RTSP URL, not the alias. Only these two prefixes are adapter-owned — the server reconciliation loop keys off them, so never invent other prefixes.


Decision: how to obtain the stream source

The value registered with StreamSource is an opaque go2rtc source expression — the server forwards it to the bridge verbatim (no scheme validation), so RTSP is the common case, not a requirement. This is the one place CCTV adapters genuinely differ. Pick the strategy that matches what the device exposes. If the device has no RTSP endpoint at all, skip to Non-RTSP sources.

Strategy A — Template-built Strategy B — Device-provided
How Adapter composes the RTSP URL from a known template for that device family Adapter asks the device for its own RTSP URI, then injects credentials
Adapter must know The device's RTSP path scheme (live path, replay path, substream suffix, time query) Only the query that returns a URI (e.g. an ONVIF/CGI stream-uri call)
Credentials Embedded inline while building the URL Injected into the returned URI's authority (scheme://user:pass@host:port/...)
Best when The RTSP scheme is stable and documented across the firmware you support The device can report its stream URI (ONVIF GetStreamUri, vendor CGI)
Risk Hardcoded paths drift across firmware/models Must handle both text (URI=...) and JSON ({"URI":"..."}) response shapes

Prefer Strategy B when the device can report its URI — it does not bake a per-firmware RTSP path into the adapter. Fall back to Strategy A when the device has no URI query.

Synthetic examples (paths are illustrative — consult the device's own protocol reference):

Strategy A — build it:
  live:     rtsp://{user}:{pass}@{host}:{rtspPort}/<live-path>/{channel}
  playback: rtsp://{user}:{pass}@{host}:{rtspPort}/<replay-path>/{channel}?start={t0}&end={t1}

Strategy B — ask, then inject creds:
  device returns:  rtsp://{host}:{port}/<device-chosen-path>
  adapter sends:   rtsp://{user}:{pass}@{host}:{port}/<device-chosen-path>

Time-format caution (Strategy A playback): some devices interpret replay timestamps as local device time regardless of a trailing Z/UTC marker. Confirm the semantics against the device protocol reference; a wrong assumption silently seeks to the wrong moment.


Non-RTSP sources

Some devices — many VMS platforms and older cameras — expose no RTSP endpoint, only an HTTP MJPEG stream or a JPEG snapshot URL. (Several VMS platforms expose RTSP only through an optional ONVIF/RTSP bridge component; without it you get MJPEG/JPEG.) These still fit this pattern unchanged — because the registered source is just a string the bridge understands, a non-RTSP device is only a different source string, not a different lifecycle.

The transcode constraint

The browser plays video over WebRTC, which carries H.264/VP8/VP9/AV1 — not motion-JPEG. An MJPEG or JPEG source must therefore be transcoded to H.264 before WebRTC delivery. The bridge does this with its ffmpeg: source wrapper. A bare MJPEG URL will register, but it will not play over WebRTC without the transcode.

Device exposes Register this source string Bridge behavior
RTSP rtsp://{user}:{pass}@{host}:{port}/<path> Native — no transcode
HTTP-MJPEG stream (multipart/x-mixed-replace) ffmpeg:http://{user}:{pass}@{host}/<mjpeg-path>#video=h264 Pulls the MJPEG stream, transcodes to H.264 for WebRTC
HTTP-JPEG snapshot (image/jpeg) ffmpeg:http://{user}:{pass}@{host}/<snapshot-path>#video=h264 Turns the repeating snapshot into a frame sequence, transcodes to H.264

Add #hardware to the ffmpeg expression to use hardware-accelerated encoding where the bridge host supports it (e.g. #video=h264#hardware). If the device needs a bearer/session token rather than basic credentials, fetch it in the adapter and append it as a header on the source (#header=Authorization: Bearer <token>) or inject it into the URL query.

Cost & preference

Transcoding is not free: it consumes CPU on the bridge host and adds startup latency versus a native RTSP pull. Prefer RTSP whenever the device — or an RTSP/ONVIF bridge in front of it — can provide it; fall back to MJPEG transcoding only when there is no RTSP path. Like the A/B URL choice, this decision is per device family and belongs in the same source-building step.

Only the source-building step changes

Everything else in this pattern is identical — alias conventions, idempotent register, the two teardown paths, Clear() on disconnect, server-owned reconciliation. A non-RTSP device is a different BuildOrFetchStreamSource(...) return value, nothing more.

// Strategy C — device has no RTSP, only an HTTP MJPEG stream.
// WebRTC needs H.264, so wrap the MJPEG URL in the bridge's ffmpeg transcoder.
static string BuildMjpegSource(string mjpegUrl) => $"ffmpeg:{mjpegUrl}#video=h264";

Registration invariants

1. Register on demand only

Register a stream only when a client sends pq.command.video.live (or .playback). Never pre-register on connect. The adapter has no idea how many clients are watching — that is the bridge's concern — so there is nothing to pre-warm.

2. Live registration is idempotent — do not guard on the local registry

On each live request, always (re-)register, then Add the alias:

build/fetch RTSP url  →  StreamApi.Register(cam-{id}, url)  →  registry.Add(cam-{id})

Do not wrap live registration in if (!registry.Contains(alias)). That is an anti-pattern: the server reconciliation loop can remove an idle alias (0 consumers) from the bridge between a viewer closing and re-opening the stream. If the adapter trusts its local set and skips re-registration on the way back, the client's WebRTC connect hits a missing alias and fails. The register call is idempotent for an unchanged name+source, so re-registering is cheap and correct. Add on a set is naturally idempotent.

(The Contains guard is correct on the teardown paths — see below — because there it prevents a redundant remove, not a needed register.)

3. Snapshot is the only byte push

pq.command.video.snapshot fetches a JPEG from the device and uploads the raw bytes to the live alias via the snapshot endpoint (content type image/jpeg). It does not register or mutate the registry — it attaches a poster image to an existing camera alias.


Teardown invariants

Camera offline — two independent paths, same three actions

Video loss reaches the adapter two ways, and both must perform teardown (they cover each other during an event-stream reconnect window — see patterns/16-video-events.md):

Path Trigger
Event/alert stream device pushes a video-loss event
Status poll periodic per-channel status query reports offline

Each path, for the affected channel's live alias only:

SetStatus("VideoSignal", "video.signal.lost")
  →  if (!registry.Contains(cam-{id})) return;      // guard: skip redundant remove
  →  StreamApi.Remove(cam-{id})
  →  registry.Remove(cam-{id})

Tear down only the cam- alias for that channel. Do not touch other channels' aliases or active pb- playback aliases. On recovery, clear the status slot and emit the cleared event — but do not re-register; the client owns reconnect (see below).

NVR/root disconnect — clear local state

When the connection root drops (event-stream watchdog fires → framework signals disconnected), empty the registry from the root's disconnect hook:

var cleared = registry.Clear();   // mark every adapter-owned alias stale
log(cleared.Length)

Clear() returns the removed aliases. Immediately DELETEing each from the bridge is optional and usually unnecessary: on a full disconnect the RTSP sources are dead, their consumers drop to zero, and the server reconciliation loop reaps the orphaned aliases within its grace window — even if the adapter itself has crashed. The client re-registers what it still needs on reconnect. Clearing the local set (so a later live request re-registers per invariant #2) is the required behavior; per-alias remove-on-disconnect is a latency optimization, not a correctness requirement.


Reconciliation ownership

There are two cleanup mechanisms with a hard split of responsibility:

Mechanism Owner Scope
Immediate, targeted teardown the adapter remove the specific alias on camera-offline / root-disconnect
Periodic idle sweep the server remove any 0-consumer cam-/pb- alias after a grace re-check

Do not add a reconciliation background service, timer, or idle sweep to the adapter, and do not call the bridge's stream-list API from the adapter. Orphan cleanup is server-owned. A per-adapter loop cannot reclaim streams after the adapter crashes; the server loop can, because it talks to the bridge directly. It also scopes itself to the adapter-owned alias prefixes so it never reaps config-defined sources.

The full server-side algorithm (poll interval, grace re-check, prefix scoping, why it must never delete arbitrary streams) is documented in concepts/video-streaming.md.


Playback specifics

Operation Adapter action
playback Register pb-{txn:N} with a time-bounded replay URL (Strategy A) or the device's replay URI (Strategy B)
playback.seek Remove the pb- alias, then re-register the same alias with the new start position
playback.pause / .resume No-op — handled client-side by pausing the video element

Pre-roll (a short lead-in before the requested instant) is applied client-side; the adapter registers exactly the time it is handed.

Framework constraint — playback positioning: the source registered with the bridge is a plain source string. If a device requires RTSP-level control to position playback (for example a Range:/clock= header on PLAY rather than a start/end query in the URL), a plain source string cannot express it. Strategy-A devices that accept start/end in the URL position correctly; devices that need an RTSP header will register at their default position until the framework carries a richer source descriptor. Verify replay-position fidelity against the device before claiming seek accuracy.


Copy-ready skeleton (synthetic)

// Live — always (re-)register, never guard on the local set.
public static async Task<DeviceCommandResult> Live(
    CameraChannel channel, Video.Live cmd, IStreamApi streamApi, StreamRegistry registry, CancellationToken ct)
{
    var alias = StreamRegistry.LiveAlias(channel.DeviceId);          // cam-{id:N}
    var url   = await BuildOrFetchRtspUrl(channel, ct);              // Strategy A or B
    await streamApi.Register(alias, new StreamSource(url), ct);      // idempotent
    registry.Add(alias);
    await channel.StreamReady(alias, cmd.PersonId, ct: ct);
    return DeviceCommandResult.Succeeded();
}

// Teardown — one helper, called from BOTH the event path and the poll path.
static async Task TeardownLive(CameraChannel channel, IStreamApi streamApi, StreamRegistry registry, CancellationToken ct)
{
    var alias = StreamRegistry.LiveAlias(channel.DeviceId);
    if (!registry.Contains(alias)) return;                          // guard: avoid redundant remove
    await streamApi.Remove(alias, ct);
    registry.Remove(alias);
}

// Root disconnect — clear local state; server reconciliation reaps the bridge.
Task OnDisconnected(ConnectionContext _)
{
    var cleared = registry.Clear();
    logger.LogInformation("Root disconnected, cleared {Count} stream aliases", cleared.Length);
    return Task.CompletedTask;
}

Types (CameraChannel, IStreamApi, StreamRegistry) are illustrative — map them to your adapter's channel Thing, the framework stream API, and your registry.


Invariant checklist

  • [ ] Stream registry is thread-safe and shared by command + teardown paths.
  • [ ] Aliases use cam-{deviceId:N} / pb-{transactionId:N} — no other prefixes.
  • [ ] Source strategy chosen deliberately (A template RTSP vs B device-provided RTSP vs C non-RTSP transcode).
  • [ ] Non-RTSP devices register a transcoding source (ffmpeg:...#video=h264) — MJPEG/JPEG cannot play over WebRTC untranscoded.
  • [ ] Live registration always re-registers — no local-registry guard on register.
  • [ ] Streams registered on demand only — never pre-registered on connect.
  • [ ] Both the event path and the poll path tear down on camera offline.
  • [ ] Teardown removes only the affected channel's cam- alias; Contains guard prevents redundant removes.
  • [ ] Root disconnect calls Clear(); no per-alias remove is required for correctness.
  • [ ] Adapter never re-registers after teardown — the client owns reconnect.
  • [ ] No reconciliation loop / timer / stream-list call in the adapter — idle sweep is server-owned.
  • [ ] Snapshot is the only byte upload; it reuses the live alias and does not touch the registry.

See Also