@camstack/addon-pipeline 1.2.373 → 1.2.374

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/audio-analyzer/index.js +3 -3
  2. package/dist/audio-analyzer/index.mjs +3 -3
  3. package/dist/{custom-model-catalog-UaaDgBow.mjs → custom-model-catalog-1LDuDw8S.mjs} +2 -2
  4. package/dist/{custom-model-catalog-M-ECFeNJ.js → custom-model-catalog-W0DHDhfo.js} +2 -2
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-QBVFmPYQ.js → dist-CBEUVjTS.js} +172 -24
  8. package/dist/{dist-BT3uSeR7.mjs → dist-CmdLCb_F.mjs} +167 -25
  9. package/dist/{model-catalogs-ChW0i2P6.mjs → model-catalogs-BnCdB2A3.mjs} +1 -1
  10. package/dist/{model-catalogs-C42Mo0jo.js → model-catalogs-D5YksA5A.js} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-BVRFl1hZ.mjs → node-C3r8kiOo.mjs} +1 -1
  14. package/dist/{node-B6ev-Hqa.js → node-DMgi6pND.js} +1 -1
  15. package/dist/pipeline-runner/index.js +3 -3
  16. package/dist/pipeline-runner/index.mjs +3 -3
  17. package/dist/{process-memory-Cdf2A2qg.js → process-memory-GFR7fxpJ.js} +1 -1
  18. package/dist/{process-memory-DdeedEgU.mjs → process-memory-X0ws1SGJ.mjs} +1 -1
  19. package/dist/recorder/index.js +251 -94
  20. package/dist/recorder/index.mjs +249 -92
  21. package/dist/{segment-demux-js-Dp0OI-2n.js → segment-demux-js-CSQVhbFI.js} +1 -1
  22. package/dist/{segment-demux-js-8CfbEFeD.mjs → segment-demux-js-IKEO1XBz.mjs} +1 -1
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Dl3wrJnM.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DPFTlVoW.mjs} +2 -2
  25. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C3ssSNKh.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B7wsxLrK.mjs} +1 -1
  26. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BFRtKBOe.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BohOb2Cl.mjs} +1 -1
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-Bq36nWMP.mjs → hostInit-BOp1_Hhk.mjs} +2 -2
  30. package/dist/stream-broker/index.js +683 -265
  31. package/dist/stream-broker/index.mjs +683 -265
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/package.json +5 -1
  34. package/dist/retire-root-keys-Csb8z2f3.js +0 -338
  35. package/dist/retire-root-keys-DZvlso2l.mjs +0 -327
@@ -1,10 +1,9 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $n as _enum, An as errMsg, At as gifFromMp4Args, Bt as memoizePinnedFfmpeg, D as G711_SCALE_CORRECTION_DB, Dn as isSoftwareDecode, Dt as ffmpegRate, En as invocationFromEncodeProfile, Ft as loadContributionCapability, Gn as makeProfileBrokerId, I as RATE_CONTROL_RELAXED, It as logChannelsCapability, J as addonWidgetsSourceCapability, Jn as nodePin, Kn as makeSourceBrokerId, L as RATE_CONTROL_TIGHT, Mn as BaseAddon, Nn as CAM_PROFILE_ORDER, On as DeviceFeature, Pn as CamProfileSchema, Qt as rankStreams, Rn as createEvent, Vn as expandAudioChunkToF32le, W as RingBuffer, Wn as isEvent, Yn as parseProfileBrokerId, an as resolveEgressDecodeHwAccel, ar as number, bn as webrtcSessionCapability, bt as egressTransportFromRequest, c as CLIP_STILL_REASON_CODE_HEADER, cr as record, dr as EventCategory, dt as declareLogChannel, er as array, gn as streamBrokerCapability, ir as literal, it as cameraStreamsCapability, jn as BOOT_RECOVERY_BACKOFF_MS, kn as DeviceType, kt as gifDeriveSpeed, lr as string, lt as createHwAccelCache, nr as discriminatedUnion, o as BatteryStatusSchema, or as object, ot as clipExportFailureOf, pt as deriveBatteryPresence, qn as maskUrlCredentials, s as CLIP_BROKER_PACED_RATES, tr as boolean, tt as batteryCapability, ur as union, ut as createLogChannelsProvider, w as EncodeProfileSchema, wn as buildFfmpegArgs, xn as AUDIO_PRESETS, yt as egressTranscodeSharingKey } from "../dist-BT3uSeR7.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-BVRFl1hZ.mjs";
2
+ import { An as errMsg, At as gifFromMp4Args, Bt as memoizePinnedFfmpeg, D as G711_SCALE_CORRECTION_DB, Dn as isSoftwareDecode, Dt as ffmpegRate, En as invocationFromEncodeProfile, Ft as loadContributionCapability, Gn as isSecretMarker, I as RATE_CONTROL_RELAXED, It as logChannelsCapability, J as addonWidgetsSourceCapability, Jn as maskUrlCredentials, Kn as makeProfileBrokerId, L as RATE_CONTROL_TIGHT, Mn as BaseAddon, Nn as CAM_PROFILE_ORDER, On as DeviceFeature, Pn as CamProfileSchema, Qt as rankStreams, Rn as createEvent, Vn as expandAudioChunkToF32le, W as RingBuffer, Wn as isEvent, Xn as parseProfileBrokerId, Yn as nodePin, Z as armRemovalReconcile, an as resolveEgressDecodeHwAccel, ar as literal, bn as webrtcSessionCapability, bt as egressTransportFromRequest, c as CLIP_STILL_REASON_CODE_HEADER, dr as union, dt as declareLogChannel, er as _enum, fr as EventCategory, gn as streamBrokerCapability, it as cameraStreamsCapability, jn as BOOT_RECOVERY_BACKOFF_MS, kn as DeviceType, kt as gifDeriveSpeed, lr as record, lt as createHwAccelCache, nr as boolean, o as BatteryStatusSchema, or as number, ot as clipExportFailureOf, pt as deriveBatteryPresence, qn as makeSourceBrokerId, rr as discriminatedUnion, s as CLIP_BROKER_PACED_RATES, sr as object, tr as array, tt as batteryCapability, ur as string, ut as createLogChannelsProvider, vt as deviceRemovalHookCapability, w as EncodeProfileSchema, wn as buildFfmpegArgs, xn as AUDIO_PRESETS, yt as egressTranscodeSharingKey } from "../dist-CmdLCb_F.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-C3r8kiOo.mjs";
4
4
  import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
- import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-8CfbEFeD.mjs";
6
+ import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-IKEO1XBz.mjs";
8
7
  import { createRequire } from "node:module";
9
8
  import * as os$1 from "node:os";
10
9
  import { networkInterfaces, tmpdir } from "node:os";
@@ -360,207 +359,253 @@ function formatProbedSummary(declared, snapshot, opts) {
360
359
  return parts.join(SEP);
361
360
  }
362
361
  //#endregion
363
- //#region src/stream-broker/broker-row-stores.ts
364
- /**
365
- * The stream-broker's flattened settings maps — one row per entry.
366
- *
367
- * ## What this replaced
368
- *
369
- * `stream-broker:addon-settings` / `root` was 33 155 bytes on the reference
370
- * hub, and three keys were 32 735 of it: `probeSnapshots` (17 282 B, 109
371
- * streams), `rtspTokens` (13 147 B, 243 streams) and `profileMap` (2 306 B, 29
372
- * cameras — a per-DEVICE map, so it grows with the fleet). Every read of that
373
- * row parsed all of it, including `BaseAddon.resolveConfig`, and every write
374
- * rewrote all of it — a probe flush every few seconds while streams are live.
375
- *
376
- * Two keys deliberately STAY blob keys on that row, and the reasons are not
377
- * "they are small":
378
- *
379
- * - `rtspEnabled` — brokerId → boolean, EMPTY on the reference hub. Its
380
- * persister replaces the whole map from the set of registered restreamers,
381
- * and reproducing that on rows means an authoritative prune. A prune driven
382
- * by a map that shrinks whenever a broker unregisters is exactly the shape
383
- * that turns an unbind into a wipe (D130, D49), and the twin key
384
- * `rtspTokens` carries a comment about precisely that loss. Nothing is
385
- * bought by moving 0 bytes at that risk.
386
- * - `deviceOverrides` — 354 B, two cameras, same replace-the-whole-map
387
- * persister, written only when an operator edits an override.
388
- *
389
- * The row survives either way: it is also where `BaseAddon.resolveConfig` reads
390
- * the operator's config fields from. The problem was a 33 KB row, not a blob.
391
- */
392
- /**
393
- * The addon id — the settings-store namespace whose `root` row the flattened
394
- * keys are being retired from. Matches `packages/addon-pipeline/package.json`
395
- * (`camstack.addons[].id`).
396
- */
397
- var STREAM_BROKER_ADDON_ID = "stream-broker";
398
- /**
399
- * Every one of these collections is keyed by the BROKER ID
400
- * (`<deviceId>/<camStreamId>`) or by the numeric deviceId. `brokerDeviceId`
401
- * recovers the numeric device from either, for the projected column that makes
402
- * a per-camera question an indexed query — and for `tags: { deviceId }` on
403
- * every line these stores log.
404
- *
405
- * Returns `null` rather than guessing: a key whose head is not a number is a
406
- * key this addon did not write, and inventing a device for it would put a row
407
- * on a camera at random.
408
- */
409
- function brokerDeviceId(key) {
410
- const head = key.includes("/") ? key.slice(0, key.indexOf("/")) : key;
411
- const id = Number(head);
412
- return Number.isInteger(id) && id > 0 ? id : null;
413
- }
414
- /**
415
- * Bound for a whole-map read. `settings-store.query` silently caps an unbounded
416
- * read at 2 000 rows and answers as if that were everything; at 974 devices ×
417
- * up to 5 streams the default is HALF the margin, so every read here names its
418
- * own ceiling and a truncation is reported rather than shrunk.
419
- */
420
- var BROKER_ROWS_LIMIT = 2e4;
421
- /**
422
- * @durable class=mirror owner=stream-broker
423
- * write="one row per broker (`<deviceId>/<camStreamId>`), replaced wholesale from the
424
- * manager's live probe map by the throttled metrics flush and once on shutdown"
425
- * retention="the flush is authoritative: a broker the manager no longer holds loses its
426
- * row. Losing the whole collection costs the settings 'Probed' field showing 'not yet
427
- * probed' until the stream is pulled again — it is re-derived from the next probe."
428
- */
429
- var BROKER_PROBE_SNAPSHOTS_COLLECTION = "stream-broker:probe-snapshots";
430
- var BROKER_PROBE_SNAPSHOTS_SPEC = {
431
- collection: BROKER_PROBE_SNAPSHOTS_COLLECTION,
432
- schema: ProbedSnapshotSchema,
433
- columns: [{
434
- name: "deviceId",
435
- type: "INTEGER"
436
- }, {
437
- name: "at",
438
- type: "INTEGER",
439
- notNull: true
440
- }],
441
- indexes: [{
442
- name: "idx_stream_broker_probe_device",
443
- columns: ["deviceId"]
444
- }],
445
- project: (key, value) => ({
446
- deviceId: brokerDeviceId(key),
447
- at: value.at
448
- }),
449
- deviceIdColumn: "deviceId",
450
- loadLimit: BROKER_ROWS_LIMIT
451
- };
452
- function probeSnapshotsStore(store, logger) {
453
- return new RowMapStore({
454
- spec: BROKER_PROBE_SNAPSHOTS_SPEC,
455
- store,
456
- logger
457
- });
458
- }
459
- /**
460
- * The token, wrapped in an object rather than stored bare.
461
- *
462
- * A bare string in a `JSON` column round-trips only because the engine's
463
- * decoder JSON-parses a stored string ONLY when it starts with `{` or `[`. That
464
- * is a heuristic, not a contract; a token is opaque hex today and a future
465
- * format that starts with a brace would come back as an object. One field costs
466
- * nothing and makes the row self-describing.
467
- */
468
- var RtspTokenRowSchema = object({ token: string().min(1) });
469
- /**
470
- * @durable class=ledger owner=stream-broker
471
- * write="one row per broker (`<deviceId>/<camStreamId>`), upserted when the restream
472
- * server mints a token. MERGE only — a token is stable forever per stream unless it
473
- * is explicitly regenerated"
474
- * retention="nothing deletes a row. The persister receives only the CURRENTLY registered
475
- * restreamers, so a replace would erase the token of an unregistered broker and turn
476
- * an external NVR URL into a dead 404 at the next idle or reboot."
477
- */
478
- var BROKER_RTSP_TOKENS_COLLECTION = "stream-broker:rtsp-tokens";
479
- var BROKER_RTSP_TOKENS_SPEC = {
480
- collection: BROKER_RTSP_TOKENS_COLLECTION,
481
- schema: RtspTokenRowSchema,
482
- columns: [{
483
- name: "deviceId",
484
- type: "INTEGER"
485
- }],
486
- indexes: [{
487
- name: "idx_stream_broker_rtsp_tokens_device",
488
- columns: ["deviceId"]
489
- }],
490
- project: (key) => ({ deviceId: brokerDeviceId(key) }),
491
- deviceIdColumn: "deviceId",
492
- loadLimit: BROKER_ROWS_LIMIT
493
- };
494
- function rtspTokensStore(store, logger) {
495
- return new RowMapStore({
496
- spec: BROKER_RTSP_TOKENS_SPEC,
497
- store,
498
- logger
499
- });
500
- }
501
- /** One camera's profile → camStreamId assignment, plus whether it is automatic. */
362
+ //#region src/stream-broker/broker-device-state.ts
363
+ /**
364
+ * The stream-broker's per-camera state, kept as per-device settings (D870).
365
+ *
366
+ * Three maps used to be collections of their own (`stream-broker:probe-snapshots`,
367
+ * `:profile-map`, `:rtsp-tokens`), each keyed by the camera. A camera's data in
368
+ * a side table is a row somebody must remember to delete when the camera goes;
369
+ * as a KEY of the camera's own settings it is cleared by the central device
370
+ * delete (D865) with everything else.
371
+ *
372
+ * | key | value for one camera |
373
+ * | ---------------- | --------------------------------------------------------- |
374
+ * | `probeSnapshots` | `{ [camStreamId]: ProbedSnapshot }` - what the broker last probed |
375
+ * | `brokerProfile` | `{ map, auto }` - the profile -> camStreamId assignment |
376
+ * | `rtspTokens` | JSON text of `{ [camStreamId]: token }` - a SECRET: declared in `secretStatePaths`, so the settings view stores a keychain marker and the text lives in the keychain |
377
+ *
378
+ * Failure direction: a load that cannot ask the store THROWS (D49). The writers
379
+ * built on {@link DeviceKeyMirror} refuse to write until a load has succeeded,
380
+ * because a replace driven by an empty "known" set would otherwise drop or
381
+ * shrink rows it never saw.
382
+ */
383
+ /** Per-device settings keys. */
384
+ var PROBE_SNAPSHOTS_KEY = "probeSnapshots";
385
+ var BROKER_PROFILE_KEY = "brokerProfile";
386
+ var RTSP_TOKENS_KEY = "rtspTokens";
387
+ /** One camera's profile -> camStreamId assignment, plus whether it is automatic. */
502
388
  var ProfileAssignmentSchema = object({
503
389
  map: record(string(), string()),
504
390
  auto: boolean()
505
391
  });
392
+ var ProbeSnapshotsOfDeviceSchema = record(string(), ProbedSnapshotSchema);
393
+ var RtspTokensOfDeviceSchema = record(string(), string().min(1));
506
394
  /**
507
- * @durable class=config owner=stream-broker
508
- * write="one row per camera, replaced wholesale from the manager's assignment map on
509
- * every profile mutation — the map is authoritative and its boot pass PRUNES cameras
510
- * the device registry no longer knows, which is why the write is a replace and not
511
- * an upsert"
512
- * retention="the replace is the retention: a camera dropped from the manager's map loses
513
- * its row. Losing a row costs one automatic re-assignment on the next catalog pull."
514
- */
515
- var BROKER_PROFILE_MAP_COLLECTION = "stream-broker:profile-map";
516
- var BROKER_PROFILE_MAP_SPEC = {
517
- collection: BROKER_PROFILE_MAP_COLLECTION,
518
- schema: ProfileAssignmentSchema,
519
- columns: [{
520
- name: "deviceId",
521
- type: "INTEGER",
522
- notNull: true
523
- }, {
524
- name: "auto",
525
- type: "BOOLEAN",
526
- notNull: true
527
- }],
528
- project: (key, value) => ({
529
- deviceId: Number(key),
530
- auto: value.auto
531
- }),
532
- deviceIdColumn: "deviceId",
533
- loadLimit: BROKER_ROWS_LIMIT
395
+ * A broker id is `<deviceId>/<camStreamId>`. Returns `null` rather than
396
+ * guessing: a key whose head is not a number is a key this addon did not
397
+ * write, and inventing a device for it would put a value on a camera at random.
398
+ */
399
+ function splitBrokerId(brokerId) {
400
+ const slash = brokerId.indexOf("/");
401
+ if (slash <= 0) return null;
402
+ const deviceId = Number(brokerId.slice(0, slash));
403
+ if (!Number.isInteger(deviceId) || deviceId <= 0) return null;
404
+ return {
405
+ deviceId,
406
+ camStreamId: brokerId.slice(slash + 1)
407
+ };
408
+ }
409
+ /** Group `<deviceId>/<camStreamId>` -> value into deviceId -> camStreamId -> value. */
410
+ function groupByDevice(flat) {
411
+ const entries = flat instanceof Map ? [...flat.entries()] : Object.entries(flat);
412
+ const out = /* @__PURE__ */ new Map();
413
+ for (const [brokerId, value] of entries) {
414
+ const parts = splitBrokerId(brokerId);
415
+ if (parts === null) continue;
416
+ const group = out.get(parts.deviceId) ?? {};
417
+ group[parts.camStreamId] = value;
418
+ out.set(parts.deviceId, group);
419
+ }
420
+ return out;
421
+ }
422
+ /** The inverse of {@link groupByDevice}. */
423
+ function flattenByDevice(grouped) {
424
+ const out = /* @__PURE__ */ new Map();
425
+ for (const [deviceId, group] of grouped) for (const [camStreamId, value] of Object.entries(group)) out.set(`${deviceId}/${camStreamId}`, value);
426
+ return out;
427
+ }
428
+ /** A stored value that exists but cannot be read now (see `DeviceKeyMirrorDeps.decode`). */
429
+ var HELD_UNAVAILABLE = Symbol("held-unavailable");
430
+ /**
431
+ * One settings key across the fleet: loaded once, then written by diff.
432
+ *
433
+ * `replace` is authoritative (a camera the caller no longer holds loses the
434
+ * key, but only if this mirror has SEEN it - it never deletes what it did not
435
+ * load); `merge` only ever adds and changes.
436
+ */
437
+ var DeviceKeyMirror = class {
438
+ deps;
439
+ /** The JSON of what the store holds per device, as of the last load or write. */
440
+ held = /* @__PURE__ */ new Map();
441
+ values = /* @__PURE__ */ new Map();
442
+ blocked = /* @__PURE__ */ new Map();
443
+ /** Cameras whose stored value is GONE: a load never re-blocks them. */
444
+ released = /* @__PURE__ */ new Set();
445
+ loaded = false;
446
+ constructor(deps) {
447
+ this.deps = deps;
448
+ }
449
+ /** Read the key of every camera that has it. THROWS when the store cannot be asked. */
450
+ async load() {
451
+ const ids = await this.deps.settings.listDeviceStoreIds();
452
+ const blobs = await this.deps.settings.readDeviceStoreBatch(ids);
453
+ const out = /* @__PURE__ */ new Map();
454
+ this.held.clear();
455
+ this.values.clear();
456
+ this.loaded = false;
457
+ this.blocked.clear();
458
+ for (const [deviceId, blob] of blobs) {
459
+ const stored = blob[this.deps.key];
460
+ if (stored === void 0) continue;
461
+ const value = this.deps.decode(stored);
462
+ if (value === HELD_UNAVAILABLE) {
463
+ if (!this.released.has(deviceId)) this.blocked.set(deviceId, stored);
464
+ continue;
465
+ }
466
+ if (value === null) {
467
+ this.deps.logger.warn("broker device state skipped - it does not parse", {
468
+ tags: { deviceId },
469
+ meta: { key: this.deps.key }
470
+ });
471
+ continue;
472
+ }
473
+ out.set(deviceId, value);
474
+ this.values.set(deviceId, value);
475
+ this.held.set(deviceId, JSON.stringify(this.deps.encode(value)));
476
+ }
477
+ this.loaded = true;
478
+ return out;
479
+ }
480
+ /** Cameras whose stored value exists but could not be read: never written, never replaced. */
481
+ heldDevices() {
482
+ return this.blocked;
483
+ }
484
+ /**
485
+ * Stop protecting a camera whose stored value is GONE (the secret was deleted
486
+ * or the keychain restored without it): there is nothing left to overwrite.
487
+ */
488
+ release(deviceId) {
489
+ this.blocked.delete(deviceId);
490
+ this.released.add(deviceId);
491
+ }
492
+ /** Is this camera's token write (and restream URL) blocked right now? */
493
+ isBlocked(deviceId) {
494
+ return this.blocked.has(deviceId);
495
+ }
496
+ assertLoaded() {
497
+ if (!this.loaded) throw new Error(`broker device state "${this.deps.key}" was never loaded - refusing to write`);
498
+ }
499
+ async writeChanged(next) {
500
+ let written = 0;
501
+ for (const [deviceId, value] of next) {
502
+ if (this.blocked.has(deviceId)) continue;
503
+ const encoded = this.deps.encode(value);
504
+ const text = JSON.stringify(encoded);
505
+ if (this.held.get(deviceId) === text) continue;
506
+ await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: encoded });
507
+ this.held.set(deviceId, text);
508
+ this.values.set(deviceId, value);
509
+ written += 1;
510
+ }
511
+ return written;
512
+ }
513
+ /** Make the store hold exactly `next` for the cameras this mirror knows. */
514
+ async replace(next) {
515
+ this.assertLoaded();
516
+ const written = await this.writeChanged(next);
517
+ let removed = 0;
518
+ for (const deviceId of [...this.held.keys()]) {
519
+ if (next.has(deviceId) || this.blocked.has(deviceId)) continue;
520
+ await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: void 0 });
521
+ this.held.delete(deviceId);
522
+ this.values.delete(deviceId);
523
+ removed += 1;
524
+ }
525
+ return {
526
+ written,
527
+ removed
528
+ };
529
+ }
530
+ /** Add or change; never remove. */
531
+ async merge(next) {
532
+ this.assertLoaded();
533
+ return this.writeChanged(next);
534
+ }
535
+ /** Drop one camera's key (a device that changed hardware under its number). */
536
+ async remove(deviceId) {
537
+ this.assertLoaded();
538
+ if (!this.held.has(deviceId) || this.blocked.has(deviceId)) return false;
539
+ await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: void 0 });
540
+ this.held.delete(deviceId);
541
+ this.values.delete(deviceId);
542
+ return true;
543
+ }
544
+ /** What the store holds for the camera as of the last load or write. */
545
+ current(deviceId) {
546
+ return this.values.get(deviceId);
547
+ }
548
+ /** Whether a load has answered; a writer that was never loaded refuses. */
549
+ get isLoaded() {
550
+ return this.loaded;
551
+ }
534
552
  };
535
- function profileMapStore(store, logger) {
536
- return new RowMapStore({
537
- spec: BROKER_PROFILE_MAP_SPEC,
538
- store,
539
- logger
553
+ function parseWith(schema) {
554
+ return (stored) => {
555
+ const parsed = schema.safeParse(stored);
556
+ return parsed.success ? parsed.data : null;
557
+ };
558
+ }
559
+ function probeSnapshotsMirror(settings, logger) {
560
+ return new DeviceKeyMirror({
561
+ settings,
562
+ key: PROBE_SNAPSHOTS_KEY,
563
+ logger,
564
+ decode: parseWith(ProbeSnapshotsOfDeviceSchema),
565
+ encode: (value) => value
566
+ });
567
+ }
568
+ function profileMapMirror(settings, logger) {
569
+ return new DeviceKeyMirror({
570
+ settings,
571
+ key: BROKER_PROFILE_KEY,
572
+ logger,
573
+ decode: parseWith(ProfileAssignmentSchema),
574
+ encode: (value) => value
575
+ });
576
+ }
577
+ /** The tokens of one camera are ONE keychain-held text: JSON of `{ camStreamId: token }`. */
578
+ function rtspTokensMirror(settings, logger) {
579
+ return new DeviceKeyMirror({
580
+ settings,
581
+ key: RTSP_TOKENS_KEY,
582
+ logger,
583
+ decode: (stored) => {
584
+ if (isSecretMarker(stored)) return HELD_UNAVAILABLE;
585
+ if (typeof stored !== "string") return null;
586
+ try {
587
+ const parsed = RtspTokensOfDeviceSchema.safeParse(JSON.parse(stored));
588
+ return parsed.success ? parsed.data : null;
589
+ } catch {
590
+ return null;
591
+ }
592
+ },
593
+ encode: (value) => JSON.stringify(value)
540
594
  });
541
595
  }
542
596
  /**
543
- * The dead `addon-settings` / `root` keys. A CLAIM that each key is dead,
544
- * enforced by nothing else — the migration script names the same three, keep
545
- * the two in lockstep.
597
+ * Fold freshly minted tokens (`<deviceId>/<camStreamId>` -> token) into what
598
+ * each camera already holds. Only ever adds or changes: a token is stable
599
+ * forever per stream unless explicitly regenerated.
546
600
  */
547
- var STREAM_BROKER_RETIRED_ROOT_KEYS = [
548
- {
549
- key: "probeSnapshots",
550
- successor: BROKER_PROBE_SNAPSHOTS_COLLECTION,
551
- reason: "one row per broker since the flatten; as a blob it was 17 KB of the 33 KB root row, rewritten whole by the throttled metrics flush"
552
- },
553
- {
554
- key: "rtspTokens",
555
- successor: BROKER_RTSP_TOKENS_COLLECTION,
556
- reason: "one row per broker since the flatten; as a blob a single minted token rewrote 33 KB"
557
- },
558
- {
559
- key: "profileMap",
560
- successor: BROKER_PROFILE_MAP_COLLECTION,
561
- reason: "one row per camera since the flatten; a per-DEVICE map, so as a blob it grew with the fleet — 2.3 KB at 29 cameras, ~77 KB at 974"
562
- }
563
- ];
601
+ function mergeRtspTokens(mirror, minted) {
602
+ const out = /* @__PURE__ */ new Map();
603
+ for (const [deviceId, group] of groupByDevice(minted)) out.set(deviceId, {
604
+ ...mirror.current(deviceId),
605
+ ...group
606
+ });
607
+ return out;
608
+ }
564
609
  //#endregion
565
610
  //#region src/stream-broker/live-viewers.ts
566
611
  /**
@@ -5528,6 +5573,18 @@ var RtspListenServer = class RtspListenServer {
5528
5573
  brokerTokens = /* @__PURE__ */ new Map();
5529
5574
  /** token → brokerId (reverse map) */
5530
5575
  tokenBrokers = /* @__PURE__ */ new Map();
5576
+ /**
5577
+ * brokerId → INTERNAL loopback-only alias token (D870). A camera whose
5578
+ * external restream token is held in an unreadable keychain has NO external
5579
+ * URL, but the broker's own consumers (recording, decode, snapshots, grid,
5580
+ * egress) still ride its restream: they get this ephemeral token, which is
5581
+ * never persisted, never handed out through the consumer URL APIs, and only
5582
+ * answers loopback / local-network peers.
5583
+ */
5584
+ internalAliases = /* @__PURE__ */ new Map();
5585
+ internalAliasTokens = /* @__PURE__ */ new Set();
5586
+ /** Which peers may dial an internal alias; replaceable for tests. */
5587
+ aliasPeerPolicy = isClusterLocalAddress;
5531
5588
  /** brokerId → enabled (default true) */
5532
5589
  brokerEnabled = /* @__PURE__ */ new Map();
5533
5590
  /** brokerId → warm-up closure registered alongside the restreamer */
@@ -5558,7 +5615,37 @@ var RtspListenServer = class RtspListenServer {
5558
5615
  if (sentryOpener) this.brokerPrestreamOpeners.set(brokerId, sentryOpener);
5559
5616
  return token;
5560
5617
  }
5618
+ /**
5619
+ * Register (or refresh) the internal loopback-only alias of a broker. Returns
5620
+ * its token. The external token map is untouched, so the alias can never be
5621
+ * listed, persisted or surfaced as a consumer URL.
5622
+ */
5623
+ registerInternalAlias(brokerId, restreamer, warmUp, sentryOpener) {
5624
+ const token = this.internalAliases.get(brokerId) ?? RtspListenServer.generateToken();
5625
+ this.restreamers.set(token, restreamer);
5626
+ this.tokenBrokers.set(token, brokerId);
5627
+ this.internalAliases.set(brokerId, token);
5628
+ this.internalAliasTokens.add(token);
5629
+ if (warmUp) this.brokerWarmUps.set(brokerId, warmUp);
5630
+ if (sentryOpener) this.brokerPrestreamOpeners.set(brokerId, sentryOpener);
5631
+ return token;
5632
+ }
5633
+ /** Replace the peer policy of the internal aliases (tests only). */
5634
+ setAliasPeerPolicy(policy) {
5635
+ this.aliasPeerPolicy = policy;
5636
+ }
5637
+ /** The token an IN-PROCESS consumer dials: the internal alias when there is one, else the external token. */
5638
+ getInternalToken(brokerId) {
5639
+ return this.internalAliases.get(brokerId) ?? this.brokerTokens.get(brokerId);
5640
+ }
5561
5641
  unregisterRestreamer(brokerId) {
5642
+ const alias = this.internalAliases.get(brokerId);
5643
+ if (alias !== void 0) {
5644
+ this.restreamers.delete(alias);
5645
+ this.tokenBrokers.delete(alias);
5646
+ this.internalAliasTokens.delete(alias);
5647
+ this.internalAliases.delete(brokerId);
5648
+ }
5562
5649
  const token = this.brokerTokens.get(brokerId);
5563
5650
  if (token) {
5564
5651
  this.restreamers.delete(token);
@@ -5580,6 +5667,17 @@ var RtspListenServer = class RtspListenServer {
5580
5667
  this.restreamers.set(newToken, restreamer);
5581
5668
  this.brokerTokens.set(brokerId, newToken);
5582
5669
  this.tokenBrokers.set(newToken, brokerId);
5670
+ const alias = this.internalAliases.get(brokerId);
5671
+ if (alias !== void 0) {
5672
+ this.restreamers.delete(alias);
5673
+ this.tokenBrokers.delete(alias);
5674
+ this.internalAliasTokens.delete(alias);
5675
+ const newAlias = RtspListenServer.generateToken();
5676
+ this.restreamers.set(newAlias, restreamer);
5677
+ this.tokenBrokers.set(newAlias, brokerId);
5678
+ this.internalAliases.set(brokerId, newAlias);
5679
+ this.internalAliasTokens.add(newAlias);
5680
+ }
5583
5681
  return newToken;
5584
5682
  }
5585
5683
  /** Get the current token for a broker. */
@@ -5663,6 +5761,11 @@ var RtspListenServer = class RtspListenServer {
5663
5761
  socket.destroy();
5664
5762
  return;
5665
5763
  }
5764
+ if (this.internalAliasTokens.has(lookupPath) && !this.aliasPeerPolicy(socket.remoteAddress)) {
5765
+ socket.write(`RTSP/1.0 403 Forbidden\r\nCSeq: 1\r\n\r\n`);
5766
+ socket.destroy();
5767
+ return;
5768
+ }
5666
5769
  const brokerId = this.tokenBrokers.get(lookupPath);
5667
5770
  if (brokerId && !this.isEnabled(brokerId)) {
5668
5771
  socket.write(`RTSP/1.0 403 Forbidden\r\nCSeq: 1\r\n\r\n`);
@@ -5721,6 +5824,24 @@ var RtspListenServer = class RtspListenServer {
5721
5824
  });
5722
5825
  }
5723
5826
  };
5827
+ /**
5828
+ * Is the peer on the loopback interface or the local network (RFC 1918, link-local,
5829
+ * ULA)? The internal alias answers only these: a recorder or decoder on another
5830
+ * node of the cluster is a legitimate consumer, the public internet is not.
5831
+ */
5832
+ function isClusterLocalAddress(address) {
5833
+ if (address === void 0) return false;
5834
+ const v4 = address.startsWith("::ffff:") ? address.slice(7) : address;
5835
+ if (v4 === "::1") return true;
5836
+ const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(v4);
5837
+ if (m !== null) {
5838
+ const a = Number(m[1]);
5839
+ const b = Number(m[2]);
5840
+ return a === 127 || a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127;
5841
+ }
5842
+ const lower = address.toLowerCase();
5843
+ return lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd");
5844
+ }
5724
5845
  //#endregion
5725
5846
  //#region src/stream-broker/rtsp/rtsp-restream-provider.ts
5726
5847
  /**
@@ -5802,6 +5923,24 @@ var RtspRestreamProviderImpl = class {
5802
5923
  enabled: this.server.isEnabled(brokerId)
5803
5924
  };
5804
5925
  }
5926
+ /**
5927
+ * The entry an IN-PROCESS consumer dials (recording, decode, snapshots, grid,
5928
+ * egress): the loopback-only internal alias when the camera's external token
5929
+ * is held back (D870), else the external entry. NEVER hand this out through a
5930
+ * consumer URL API - use {@link getEntry} for that.
5931
+ */
5932
+ getInternalEntry(brokerId, hostname) {
5933
+ const token = this.server.getInternalToken(brokerId);
5934
+ if (!token) return null;
5935
+ const port = this.server.getPort();
5936
+ const host = hostname ?? "127.0.0.1";
5937
+ return {
5938
+ brokerId,
5939
+ url: `rtsp://${host}:${port}/${token}`,
5940
+ mutedUrl: `rtsp://${host}:${port}/${token}/muted`,
5941
+ enabled: this.server.isEnabled(brokerId)
5942
+ };
5943
+ }
5805
5944
  regenerateToken(brokerId) {
5806
5945
  const token = this.server.regenerateToken(brokerId);
5807
5946
  if (token) {
@@ -17535,6 +17674,14 @@ var StreamBrokerManager = class StreamBrokerManager {
17535
17674
  rtspProvider;
17536
17675
  persistedTokens = /* @__PURE__ */ new Map();
17537
17676
  /**
17677
+ * True for a camera whose restream tokens are held in an unreadable keychain
17678
+ * (D870). Such a camera gets NO restream URL - minting a temporary token would
17679
+ * hand consumers a URL that dies as soon as the real one is restored - while
17680
+ * every other camera, and the camera's own streams and recording, are
17681
+ * untouched.
17682
+ */
17683
+ restreamGate = () => false;
17684
+ /**
17538
17685
  * Last runtime probe per broker, captured while the broker was streaming.
17539
17686
  * Survives the stream going idle so the per-stream "Probed" settings field
17540
17687
  * stays populated (with an "as of" tag). In-memory only — repopulates on the
@@ -17734,7 +17881,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17734
17881
  hwaccel: this.hwaccel ?? void 0,
17735
17882
  cameraStreamLookup: (deviceId) => this.getCameraStreamsForDevice(deviceId),
17736
17883
  rtspEntryLookup: (brokerId) => {
17737
- const entry = this.rtspProvider.getEntry(brokerId);
17884
+ const entry = this.rtspProvider.getInternalEntry(brokerId);
17738
17885
  return entry ? {
17739
17886
  url: entry.url,
17740
17887
  mutedUrl: entry.mutedUrl
@@ -17793,7 +17940,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17793
17940
  cameraStreamLookup: (deviceId) => this.getCameraStreamsForDevice(deviceId),
17794
17941
  resolveProfileSource: (deviceId, profile) => this.assignments.get(deviceId)?.map[profile] ?? null,
17795
17942
  rtspEntryLookup: (brokerId) => {
17796
- const entry = this.rtspProvider.getEntry(brokerId);
17943
+ const entry = this.rtspProvider.getInternalEntry(brokerId);
17797
17944
  return entry ? { url: entry.url } : null;
17798
17945
  },
17799
17946
  sourceHasMedia: (brokerId) => {
@@ -18857,6 +19004,20 @@ var StreamBrokerManager = class StreamBrokerManager {
18857
19004
  loadPersistedTokens(tokens) {
18858
19005
  this.persistedTokens = new Map(tokens);
18859
19006
  }
19007
+ /** See {@link restreamGate}. */
19008
+ setRestreamGate(gate) {
19009
+ this.restreamGate = gate;
19010
+ }
19011
+ /**
19012
+ * Register the restream URLs of ONE camera now (its tokens became readable, or
19013
+ * were issued fresh): each of its source brokers and the profile restreamers
19014
+ * over them. No broker is torn down or recreated, so nothing stops recording.
19015
+ */
19016
+ registerRestreamersForDevice(deviceId) {
19017
+ if (this.restreamGate(deviceId)) return;
19018
+ for (const [brokerId, broker] of this.brokers) if (brokerId.startsWith(`${deviceId}/`)) this.registerSourceRestreamer(brokerId, broker);
19019
+ this.syncProfileRestreamers(deviceId);
19020
+ }
18860
19021
  /** Upsert a single SOURCE (non-derived) cam-stream into the registry
18861
19022
  * WITHOUT ranking or broker creation. Returns the prior entry (if any)
18862
19023
  * so callers can detect url refresh / already-existed. */
@@ -19600,6 +19761,20 @@ var StreamBrokerManager = class StreamBrokerManager {
19600
19761
  return { success: true };
19601
19762
  }
19602
19763
  /**
19764
+ * Drop a DELETED camera's whole override entry and persist (D870). The
19765
+ * device-removal hook's one reach into the override map; `forgetDeviceHardware`
19766
+ * keeps the entry's non-hardware fields, a deleted camera keeps nothing.
19767
+ */
19768
+ dropDeviceOverride(deviceId) {
19769
+ if (!this.deviceOverrides.delete(deviceId)) return false;
19770
+ this.deviceOverridePersister?.(this.deviceOverrides);
19771
+ return true;
19772
+ }
19773
+ /** Cameras that have an override entry. */
19774
+ listDeviceOverrideIds() {
19775
+ return [...this.deviceOverrides.keys()];
19776
+ }
19777
+ /**
19603
19778
  * The hardware behind `deviceId` was replaced (`deviceManager.migrateDevice`)
19604
19779
  * — forget everything this broker knows that described the OLD box. See the
19605
19780
  * cap doc for the inventory. What deliberately SURVIVES is the role-scoped
@@ -20378,6 +20553,7 @@ var StreamBrokerManager = class StreamBrokerManager {
20378
20553
  const sourceBrokerId = brokerIdFor(deviceId, camStreamId);
20379
20554
  const sourceBroker = this.brokers.get(sourceBrokerId);
20380
20555
  if (!sourceBroker) continue;
20556
+ if (this.restreamGate(deviceId)) continue;
20381
20557
  const existingToken = this.persistedTokens.get(profileBrokerId);
20382
20558
  const warmUp = () => sourceBroker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
20383
20559
  const openPrestreamWindow = () => sourceBroker.openPrestreamWindow();
@@ -22037,11 +22213,7 @@ var StreamBrokerManager = class StreamBrokerManager {
22037
22213
  }
22038
22214
  this.brokers.set(brokerId, broker);
22039
22215
  this.applyClipRetention(deviceId);
22040
- const existingToken = this.persistedTokens.get(brokerId);
22041
- const warmUp = () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
22042
- const openPrestreamWindow = () => broker.openPrestreamWindow();
22043
- this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
22044
- if (!existingToken) this.rtspProvider.persistTokens();
22216
+ this.registerSourceRestreamer(brokerId, broker);
22045
22217
  await this.fanOutRegistration(deviceId, brokerId, cam, broker);
22046
22218
  this.syncProfileRestreamers(deviceId);
22047
22219
  if (cam.kind === "derived") {
@@ -22050,6 +22222,28 @@ var StreamBrokerManager = class StreamBrokerManager {
22050
22222
  }
22051
22223
  }
22052
22224
  /**
22225
+ * Register one source broker's RTSP restreamer with its persisted token (or a
22226
+ * freshly minted one). A camera whose tokens are held in an unreadable keychain
22227
+ * gets no EXTERNAL token, only the internal loopback-only alias.
22228
+ */
22229
+ registerSourceRestreamer(brokerId, broker) {
22230
+ const deviceId = parseBrokerId(brokerId)?.deviceId;
22231
+ if (deviceId === void 0) return;
22232
+ if (this.restreamGate(deviceId)) {
22233
+ this.logger.warn("external restream URL withheld - the tokens are held but unreadable", {
22234
+ tags: { deviceId },
22235
+ meta: { brokerId }
22236
+ });
22237
+ this.rtspServer.registerInternalAlias(brokerId, broker.getRtspRestreamer(), () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION), () => broker.openPrestreamWindow());
22238
+ return;
22239
+ }
22240
+ const existingToken = this.persistedTokens.get(brokerId);
22241
+ const warmUp = () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
22242
+ const openPrestreamWindow = () => broker.openPrestreamWindow();
22243
+ this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
22244
+ if (!existingToken) this.rtspProvider.persistTokens();
22245
+ }
22246
+ /**
22053
22247
  * Tear down the broker for a `(deviceId, camStreamId)` pair. No-op
22054
22248
  * when no broker exists. Called by `retractCameraStream` and by the
22055
22249
  * teardown half of `restartBroker`.
@@ -22169,7 +22363,7 @@ var StreamBrokerManager = class StreamBrokerManager {
22169
22363
  };
22170
22364
  } else {
22171
22365
  const sourceBrokerId = brokerIdFor(deviceId, request.camStreamId);
22172
- const entry = this.rtspProvider.getEntry(sourceBrokerId, "127.0.0.1");
22366
+ const entry = this.rtspProvider.getInternalEntry(sourceBrokerId, "127.0.0.1");
22173
22367
  if (!entry) throw new Error(`derived source camStream loopback unavailable: ${sourceBrokerId}`);
22174
22368
  loopbackUrl = entry.url;
22175
22369
  release = async () => {};
@@ -38304,18 +38498,177 @@ function subscribeDeviceRemoval(subscribe, deps) {
38304
38498
  });
38305
38499
  }
38306
38500
  //#endregion
38501
+ //#region src/stream-broker/device-removal-hook.ts
38502
+ /**
38503
+ * The stream-broker's `device-removal-hook` (D870).
38504
+ *
38505
+ * What the broker keeps PER CAMERA is a key of the camera's own settings
38506
+ * (`probeSnapshots`, `brokerProfile`, `rtspTokens` - see `broker-device-state.ts`),
38507
+ * and the central device delete (D865) clears those for every addon. What is
38508
+ * left is the broker's live state and its one addon-level map keyed by camera,
38509
+ * `deviceOverrides`: the hook retracts the camera's streams, purges its slot
38510
+ * assignments and drops its override entry. Idempotent - a camera with nothing
38511
+ * left answers zero counts.
38512
+ */
38513
+ function createBrokerRemovalHook(deps) {
38514
+ return {
38515
+ onDeviceRemoved: async (input) => {
38516
+ const { deviceId } = input;
38517
+ try {
38518
+ await deps.retractDevice(deviceId);
38519
+ } catch (err) {
38520
+ deps.logger.warn("stream-broker: retract of a deleted device failed", {
38521
+ tags: { deviceId },
38522
+ meta: { error: errMsg(err) }
38523
+ });
38524
+ throw err;
38525
+ }
38526
+ const overrides = deps.dropDeviceOverride(deviceId) ? 1 : 0;
38527
+ if (overrides > 0) deps.logger.info("stream-broker: removed the override of a deleted device", {
38528
+ tags: { deviceId },
38529
+ meta: { deletedAt: input.deletedAt }
38530
+ });
38531
+ return { counts: { "device-overrides": overrides } };
38532
+ },
38533
+ listDeviceData: async () => deps.overrideDeviceIds().filter((deviceId) => Number.isInteger(deviceId) && deviceId > 0).map((deviceId) => ({
38534
+ store: "device-overrides",
38535
+ deviceId,
38536
+ count: 1
38537
+ }))
38538
+ };
38539
+ }
38540
+ /** Classify every held camera against the keychain: wait for it, or give up on the value. */
38541
+ async function classifyHeld(held, secrets) {
38542
+ const refs = /* @__PURE__ */ new Map();
38543
+ for (const [deviceId, stored] of held) if (isSecretMarker(stored)) refs.set(deviceId, stored.$secret);
38544
+ if (refs.size === 0) return [];
38545
+ const resolved = await secrets.resolve([...refs.values()]);
38546
+ const out = [];
38547
+ for (const [deviceId, ref] of refs) {
38548
+ const entry = resolved[ref];
38549
+ if (entry === void 0 || entry.kind === "unavailable") out.push({
38550
+ deviceId,
38551
+ ref,
38552
+ kind: "unavailable",
38553
+ reason: entry?.kind === "unavailable" ? entry.reason : null
38554
+ });
38555
+ else if (entry.kind === "missing") out.push({
38556
+ deviceId,
38557
+ ref,
38558
+ kind: "missing",
38559
+ reason: null
38560
+ });
38561
+ else if (entry.kind === "forbidden") out.push({
38562
+ deviceId,
38563
+ ref,
38564
+ kind: "unavailable",
38565
+ reason: "forbidden"
38566
+ });
38567
+ }
38568
+ return out;
38569
+ }
38570
+ var UNAVAILABLE_TEXT = "The camera's RTSP restream URLs are unavailable (consumers such as Home Assistant or an NVR get errors) until the keychain is readable. Its streams and recording are not affected.";
38571
+ var issuedText = (deviceId) => `New RTSP restream URLs were issued for camera ${deviceId} - update the consumers (NVR, Home Assistant). The previous tokens were lost: the keychain no longer holds them.`;
38572
+ var TokenRecovery = class {
38573
+ deps;
38574
+ /** Cameras alerted as unavailable: the alert is cleared when the value resolves. */
38575
+ waiting = /* @__PURE__ */ new Set();
38576
+ /** Consecutive `missing` reads per camera (D49: two before the value is given up). */
38577
+ missingReads = /* @__PURE__ */ new Map();
38578
+ timer = null;
38579
+ running = false;
38580
+ constructor(deps) {
38581
+ this.deps = deps;
38582
+ }
38583
+ /** Cameras currently blocked. */
38584
+ blocked() {
38585
+ return [...this.deps.mirror.heldDevices().keys()].toSorted((a, b) => a - b);
38586
+ }
38587
+ /** Check now, then on a timer while any camera is still held. */
38588
+ start() {
38589
+ if (this.timer !== null) return;
38590
+ this.tick();
38591
+ this.timer = setInterval(() => void this.tick(), this.deps.recheckMs ?? 3e4);
38592
+ this.timer.unref?.();
38593
+ }
38594
+ stop() {
38595
+ if (this.timer !== null) clearInterval(this.timer);
38596
+ this.timer = null;
38597
+ }
38598
+ /** One scheduled / change-triggered check; never overlaps itself, never throws. */
38599
+ async tick() {
38600
+ if (this.running) return;
38601
+ this.running = true;
38602
+ try {
38603
+ await this.check();
38604
+ } catch (err) {
38605
+ this.deps.logger.warn("restream-token re-check failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
38606
+ } finally {
38607
+ this.running = false;
38608
+ }
38609
+ if (this.blocked().length === 0 && this.waiting.size === 0) this.stop();
38610
+ }
38611
+ /** See the module doc. Idempotent. */
38612
+ async check() {
38613
+ const before = new Set(this.deps.mirror.heldDevices().keys());
38614
+ if (before.size === 0 && this.waiting.size === 0) return;
38615
+ const tokens = await this.deps.mirror.load();
38616
+ const now = new Set(this.deps.mirror.heldDevices().keys());
38617
+ const classified = await classifyHeld(this.deps.mirror.heldDevices(), this.deps.secrets);
38618
+ const released = [];
38619
+ for (const camera of classified) {
38620
+ if (camera.kind === "missing") {
38621
+ const reads = (this.missingReads.get(camera.deviceId) ?? 0) + 1;
38622
+ this.missingReads.set(camera.deviceId, reads);
38623
+ if (reads < 2) continue;
38624
+ this.deps.mirror.release(camera.deviceId);
38625
+ this.missingReads.delete(camera.deviceId);
38626
+ if (this.waiting.delete(camera.deviceId)) await this.deps.alerts.clear(camera.deviceId);
38627
+ released.push(camera.deviceId);
38628
+ continue;
38629
+ }
38630
+ this.missingReads.delete(camera.deviceId);
38631
+ if (this.waiting.has(camera.deviceId)) continue;
38632
+ this.waiting.add(camera.deviceId);
38633
+ this.deps.logger.error("restream tokens held but not readable", {
38634
+ tags: { deviceId: camera.deviceId },
38635
+ meta: {
38636
+ reason: camera.reason,
38637
+ ref: camera.ref
38638
+ }
38639
+ });
38640
+ await this.deps.alerts.raise({
38641
+ deviceId: camera.deviceId,
38642
+ kind: "unavailable",
38643
+ message: UNAVAILABLE_TEXT
38644
+ });
38645
+ }
38646
+ const recovered = [...before].filter((id) => !now.has(id) && !released.includes(id));
38647
+ for (const deviceId of recovered) if (this.waiting.delete(deviceId)) await this.deps.alerts.clear(deviceId);
38648
+ if (recovered.length > 0) this.deps.logger.info("restream tokens readable again", { meta: { cameras: recovered.length } });
38649
+ const cameras = [...recovered, ...released];
38650
+ if (cameras.length > 0) await this.deps.onRegister(tokens, cameras);
38651
+ for (const deviceId of released) {
38652
+ this.deps.logger.error("restream tokens lost - new URLs issued", { tags: { deviceId } });
38653
+ await this.deps.alerts.raise({
38654
+ deviceId,
38655
+ kind: "missing",
38656
+ message: issuedText(deviceId)
38657
+ });
38658
+ }
38659
+ }
38660
+ };
38661
+ //#endregion
38307
38662
  //#region src/stream-broker/addon.ts
38308
38663
  /**
38309
- * Hydrate the persisted RTSP restream state. Tokens come from their own
38310
- * collection (one row per broker); `rtspEnabled` is still a key of the settings
38311
- * row. An unparseable token row is skipped by the store — one dead token, not
38312
- * every token — and an empty set keeps first-boot silence (no loader call, no
38313
- * log).
38664
+ * Hydrate the persisted RTSP restream state. Tokens come from the cameras' own
38665
+ * settings; `rtspEnabled` is still a key of the settings row. An unreadable
38666
+ * token set THROWS (the caller refuses to write tokens afterwards - a
38667
+ * replacement minted over a token that was merely unreadable would turn every
38668
+ * external NVR URL into a 404), and an empty set keeps first-boot silence.
38314
38669
  */
38315
38670
  async function hydratePersistedRtspState(deps) {
38316
- const rows = await deps.tokensStore.readAll();
38317
- const tokens = /* @__PURE__ */ new Map();
38318
- for (const [brokerId, row] of rows) tokens.set(brokerId, row.token);
38671
+ const tokens = flattenByDevice(await deps.tokensMirror.load());
38319
38672
  if (tokens.size > 0) {
38320
38673
  deps.loadTokens(tokens);
38321
38674
  deps.logger.info("Loaded persisted RTSP tokens", { meta: { tokenCount: tokens.size } });
@@ -38384,6 +38737,8 @@ var PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS = 6e4;
38384
38737
  * the sample as absent (the RTCP Receiver-Report path drives the ladder anyway). */
38385
38738
  var NETWORK_QUALITY_STATS_TIMEOUT_MS = 2e3;
38386
38739
  var StreamBrokerAddon = class extends BaseAddon {
38740
+ /** The restream tokens of a camera are a per-device setting held in the keychain (D870). */
38741
+ secretStatePaths = ["rtspTokens"];
38387
38742
  brokerManager = null;
38388
38743
  /** The node's pinned ffmpeg, mirrored here so the recorded-playback demuxers
38389
38744
  * (segment-demux/segment-audio) spawn the same binary as the transcode
@@ -38440,6 +38795,7 @@ var StreamBrokerAddon = class extends BaseAddon {
38440
38795
  * restarts). Loaded on activate, flushed throttled from the metrics loop and
38441
38796
  * once on shutdown. */
38442
38797
  probeSnapshots = null;
38798
+ removalHook = null;
38443
38799
  lastProbePersistAt = 0;
38444
38800
  constructor() {
38445
38801
  super({
@@ -38502,24 +38858,26 @@ var StreamBrokerAddon = class extends BaseAddon {
38502
38858
  logger: this.ctx.logger
38503
38859
  });
38504
38860
  await this.ffmpegWiring.load();
38505
- await this.declareRowCollections();
38506
- await this.purgeFlattenedSettingsKeys();
38861
+ const deviceSettings = this.deviceSettings();
38507
38862
  const rtspProvider = this.brokerManager.getRtspRestreamProvider();
38508
38863
  const brokerManager = this.brokerManager;
38509
- const rtspTokens = rtspTokensStore(this.ctx.api.settingsStore, this.ctx.logger);
38864
+ const rtspTokens = rtspTokensMirror(deviceSettings, this.ctx.logger);
38510
38865
  const rtspEnabledState = this.state("rtspEnabled", RtspEnabledSchema, {});
38511
38866
  try {
38512
38867
  await hydratePersistedRtspState({
38513
- tokensStore: rtspTokens,
38868
+ tokensMirror: rtspTokens,
38514
38869
  enabledState: rtspEnabledState,
38515
38870
  loadTokens: (tokens) => brokerManager.loadPersistedTokens(tokens),
38516
38871
  loadEnabled: (states) => rtspProvider.loadPersistedEnabled(states),
38517
38872
  logger: this.ctx.logger
38518
38873
  });
38519
- } catch {}
38874
+ } catch (err) {
38875
+ this.ctx.logger.warn("Failed to load persisted RTSP state - tokens will not be written", { meta: { error: errMsg(err) } });
38876
+ }
38877
+ this.startTokenRecovery(rtspTokens, brokerManager);
38520
38878
  rtspProvider.setTokenPersister(async (tokens) => {
38521
38879
  try {
38522
- for (const [brokerId, token] of tokens) await rtspTokens.write(brokerId, { token });
38880
+ await rtspTokens.merge(mergeRtspTokens(rtspTokens, tokens));
38523
38881
  this.ctx.logger.info("Persisted RTSP tokens", { meta: { current: tokens.size } });
38524
38882
  } catch (err) {
38525
38883
  this.ctx.logger.warn("Failed to persist RTSP tokens", { meta: { error: errMsg(err) } });
@@ -38552,24 +38910,22 @@ var StreamBrokerAddon = class extends BaseAddon {
38552
38910
  this.ctx.logger.warn("Failed to persist device overrides", { meta: { error: errMsg(err) } });
38553
38911
  });
38554
38912
  });
38555
- const profileMap = profileMapStore(this.ctx.api.settingsStore, this.ctx.logger);
38913
+ const profileMap = profileMapMirror(deviceSettings, this.ctx.logger);
38556
38914
  const profileMapPersister = (assignments) => {
38557
38915
  const next = /* @__PURE__ */ new Map();
38558
- for (const [deviceId, assignment] of assignments) next.set(`${deviceId}`, {
38916
+ for (const [deviceId, assignment] of assignments) next.set(deviceId, {
38559
38917
  map: { ...assignment.map },
38560
38918
  auto: assignment.auto
38561
38919
  });
38562
- profileMap.replaceAll(next).catch((err) => {
38920
+ profileMap.replace(next).catch((err) => {
38563
38921
  this.ctx.logger.warn("Failed to persist profile map", { meta: { error: errMsg(err) } });
38564
38922
  });
38565
38923
  };
38566
38924
  this.brokerManager.setProfileMapPersister(profileMapPersister);
38567
38925
  try {
38568
- const rows = await profileMap.readAll();
38926
+ const rows = await profileMap.load();
38569
38927
  const loaded = /* @__PURE__ */ new Map();
38570
- for (const [rawKey, val] of rows) {
38571
- const deviceId = Number(rawKey);
38572
- if (!Number.isInteger(deviceId)) continue;
38928
+ for (const [deviceId, val] of rows) {
38573
38929
  const map = {};
38574
38930
  for (const profile of PROFILE_KEYS) {
38575
38931
  const v = val.map[profile];
@@ -38585,18 +38941,18 @@ var StreamBrokerAddon = class extends BaseAddon {
38585
38941
  } catch (err) {
38586
38942
  this.ctx.logger.warn("Failed to load profile map", { meta: { error: errMsg(err) } });
38587
38943
  }
38588
- const probeSnapshots = probeSnapshotsStore(this.ctx.api.settingsStore, this.ctx.logger);
38944
+ const probeSnapshots = probeSnapshotsMirror(deviceSettings, this.ctx.logger);
38589
38945
  this.probeSnapshots = probeSnapshots;
38590
38946
  try {
38591
- const rows = await probeSnapshots.readAll();
38592
- const blob = Object.fromEntries(rows);
38947
+ const rows = await probeSnapshots.load();
38948
+ const blob = Object.fromEntries(flattenByDevice(rows));
38593
38949
  this.brokerManager.loadProbeSnapshots(blob);
38594
- this.ctx.logger.info("Loaded persisted probe snapshots", { meta: { streamCount: rows.size } });
38950
+ this.ctx.logger.info("Loaded persisted probe snapshots", { meta: { streamCount: Object.keys(blob).length } });
38595
38951
  } catch (err) {
38596
38952
  this.ctx.logger.warn("Failed to load probe snapshots", { meta: { error: errMsg(err) } });
38597
38953
  }
38598
38954
  this.brokerManager.setDeviceHardwareForgetHook(async (deviceId) => {
38599
- const rtspTokenRowsDeleted = await rtspTokens.deleteWhere({ where: { deviceId } });
38955
+ const rtspTokenRowsDeleted = await rtspTokens.remove(deviceId) ? 1 : 0;
38600
38956
  this.flushProbeSnapshots(Date.now(), true);
38601
38957
  return { rtspTokenRowsDeleted };
38602
38958
  });
@@ -38854,6 +39210,14 @@ var StreamBrokerAddon = class extends BaseAddon {
38854
39210
  } catch (err) {
38855
39211
  this.ctx.logger.warn("stream-broker: client-log ingest failed to serve", { meta: { error: errMsg(err) } });
38856
39212
  }
39213
+ this.removalHook = createBrokerRemovalHook({
39214
+ logger: this.ctx.logger,
39215
+ retractDevice: async (deviceId) => {
39216
+ await retractRemovedDevice(deviceId, this.deviceRemovalDeps());
39217
+ },
39218
+ dropDeviceOverride: (deviceId) => brokerManager.dropDeviceOverride(deviceId),
39219
+ overrideDeviceIds: () => brokerManager.listDeviceOverrideIds()
39220
+ });
38857
39221
  return [
38858
39222
  {
38859
39223
  capability: streamBrokerCapability,
@@ -38872,9 +39236,90 @@ var StreamBrokerAddon = class extends BaseAddon {
38872
39236
  provider: widgetsProvider
38873
39237
  },
38874
39238
  logChannelsRegistration,
38875
- loadContributionRegistration
39239
+ loadContributionRegistration,
39240
+ {
39241
+ capability: deviceRemovalHookCapability,
39242
+ provider: this.removalHook
39243
+ }
38876
39244
  ];
38877
39245
  }
39246
+ /**
39247
+ * Watch the cameras whose restream tokens are held but unreadable (D870): no
39248
+ * restream URL is registered for them (the manager's gate), an alert says so,
39249
+ * and when a value resolves ONLY that camera's restreamers are registered with
39250
+ * the stored tokens - no teardown. A lost secret is released after two
39251
+ * consistent reads and gets new, persisted tokens.
39252
+ */
39253
+ startTokenRecovery(mirror, brokerManager) {
39254
+ const secrets = this.ctx.secrets;
39255
+ if (secrets === void 0) {
39256
+ if (mirror.heldDevices().size > 0) this.ctx.logger.error("restream tokens look held but this addon has no keychain access", { meta: { cameras: mirror.heldDevices().size } });
39257
+ return;
39258
+ }
39259
+ brokerManager.setRestreamGate((deviceId) => mirror.isBlocked(deviceId));
39260
+ if (mirror.heldDevices().size === 0) return;
39261
+ const alertId = (deviceId) => `stream-broker-tokens:${deviceId}`;
39262
+ const warn = (what) => (err) => this.ctx.logger.warn(what, { meta: { error: errMsg(err) } });
39263
+ const recovery = new TokenRecovery({
39264
+ mirror,
39265
+ secrets,
39266
+ logger: this.ctx.logger,
39267
+ alerts: {
39268
+ raise: async ({ deviceId, kind, message }) => {
39269
+ const now = Date.now();
39270
+ await this.ctx.api.alerts.emit.mutate({
39271
+ id: alertId(deviceId),
39272
+ category: "streams",
39273
+ severity: "warning",
39274
+ title: kind === "missing" ? "New RTSP restream URLs issued" : "RTSP restream URLs unavailable",
39275
+ message,
39276
+ status: "active",
39277
+ read: false,
39278
+ createdAt: now,
39279
+ updatedAt: now,
39280
+ source: {
39281
+ type: "device",
39282
+ id: String(deviceId)
39283
+ },
39284
+ metadata: {
39285
+ deviceId,
39286
+ kind
39287
+ }
39288
+ }).catch(warn("could not raise the restream-token alert"));
39289
+ },
39290
+ clear: async (deviceId) => {
39291
+ await this.ctx.api.alerts.update.mutate({
39292
+ alertId: alertId(deviceId),
39293
+ patch: {
39294
+ status: "completed",
39295
+ updatedAt: Date.now()
39296
+ }
39297
+ }).catch(warn("could not clear the restream-token alert"));
39298
+ }
39299
+ },
39300
+ onRegister: async (tokens, cameras) => {
39301
+ brokerManager.loadPersistedTokens(flattenByDevice(tokens));
39302
+ for (const deviceId of cameras) brokerManager.registerRestreamersForDevice(deviceId);
39303
+ }
39304
+ });
39305
+ recovery.start();
39306
+ const unsubscribe = secrets.onChange((ref) => {
39307
+ if (ref.startsWith("secret://stream-broker/device/")) recovery.tick();
39308
+ });
39309
+ this.ctx.addDisposer(() => {
39310
+ recovery.stop();
39311
+ unsubscribe();
39312
+ });
39313
+ }
39314
+ async onHubReachable() {
39315
+ const removalHook = this.removalHook;
39316
+ if (removalHook === null) return;
39317
+ this.ctx.addDisposer(armRemovalReconcile({
39318
+ api: this.ctx.api,
39319
+ logger: this.ctx.logger,
39320
+ remove: (input) => removalHook.onDeviceRemoved(input)
39321
+ }));
39322
+ }
38878
39323
  async onShutdown() {
38879
39324
  this.logChannels?.stop();
38880
39325
  this.logChannels = null;
@@ -38975,41 +39420,14 @@ var StreamBrokerAddon = class extends BaseAddon {
38975
39420
  this.flushProbeSnapshots(timestamp, false);
38976
39421
  }
38977
39422
  /**
38978
- * Declare the three row collections that replaced the `probeSnapshots`,
38979
- * `rtspTokens` and `profileMap` blob keys. Idempotent — a re-declaration of
38980
- * the same shape is a no-op in the engine.
38981
- *
38982
- * Not swallowed: a broker whose `stream-broker:rtsp-tokens` is undeclared
38983
- * mints a new token for every stream and every external NVR URL in the
38984
- * installation goes dead. Crashing loudly is bounded by `CrashSupervisor`
38985
- * (D6); silently reissuing tokens is not.
38986
- */
38987
- async declareRowCollections() {
38988
- const store = this.ctx.api.settingsStore;
38989
- const logger = this.ctx.logger;
38990
- await probeSnapshotsStore(store, logger).declare();
38991
- await rtspTokensStore(store, logger).declare();
38992
- await profileMapStore(store, logger).declare();
38993
- }
38994
- /**
38995
- * Drop the flattened keys from `stream-broker:addon-settings` / `root`, each
38996
- * gated on its own successor collection being non-empty. `rtspEnabled` and
38997
- * `deviceOverrides` stay — see `broker-row-stores.ts` for why.
38998
- *
38999
- * Best-effort: a purge that throws must not take the broker down. The keys
39000
- * are dead weight, not a fault.
39423
+ * The per-device settings, as the broker persists through them. Resolved
39424
+ * reads/writes (keychain markers never reach this code), and a context with
39425
+ * no settings is a broker that cannot start.
39001
39426
  */
39002
- async purgeFlattenedSettingsKeys() {
39003
- try {
39004
- await retireRootKeys({
39005
- store: this.ctx.api.settingsStore,
39006
- addonId: STREAM_BROKER_ADDON_ID,
39007
- logger: this.ctx.logger,
39008
- specs: STREAM_BROKER_RETIRED_ROOT_KEYS
39009
- });
39010
- } catch (err) {
39011
- this.ctx.logger.warn("stream-broker: retiring the flattened settings keys failed", { meta: { error: errMsg(err) } });
39012
- }
39427
+ deviceSettings() {
39428
+ const settings = this.ctx.settings;
39429
+ if (settings === void 0) throw new Error("stream-broker: device settings are not available");
39430
+ return settings;
39013
39431
  }
39014
39432
  /**
39015
39433
  * Persist the manager's probe map to the durable handle. Throttled to
@@ -39022,7 +39440,7 @@ var StreamBrokerAddon = class extends BaseAddon {
39022
39440
  if (!store || !manager) return;
39023
39441
  if (!force && now - this.lastProbePersistAt < PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS) return;
39024
39442
  this.lastProbePersistAt = now;
39025
- store.replaceAll(new Map(Object.entries(manager.getProbeSnapshots()))).catch((err) => {
39443
+ store.replace(groupByDevice(manager.getProbeSnapshots())).catch((err) => {
39026
39444
  this.ctx.logger.warn("Failed to persist probe snapshots", { meta: { error: errMsg(err) } });
39027
39445
  });
39028
39446
  }