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:
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:
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 onPLAYrather 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;Containsguard 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¶
- concepts/video-streaming.md — go2rtc model, alias lifecycle, server reconciliation algorithm
- archetypes/cctv-nvr.md — overall CCTV/NVR adapter shape
- patterns/16-video-events.md — video event taxonomy, alert+poll duality, channel-0 suppression
- patterns/12-status-polling.md — per-channel status polling that drives the poll teardown path
- patterns/03-connection-management.md — connection lifecycle and the disconnect hook