@camstack/addon-pipeline 1.2.372 → 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-C6C2cSwd.mjs → custom-model-catalog-1LDuDw8S.mjs} +2 -2
  4. package/dist/{custom-model-catalog-n9v8MUL6.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-DmuX7IFJ.js → dist-CBEUVjTS.js} +197 -29
  8. package/dist/{dist-8AEiKOFO.mjs → dist-CmdLCb_F.mjs} +192 -30
  9. package/dist/{model-catalogs-BSWAHZJh.mjs → model-catalogs-BnCdB2A3.mjs} +1 -1
  10. package/dist/{model-catalogs-DO4aA1qo.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-Bd4va7c-.mjs → node-C3r8kiOo.mjs} +1 -1
  14. package/dist/{node-DqA6nD1z.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-2OQ2oxrz.js → process-memory-GFR7fxpJ.js} +1 -1
  18. package/dist/{process-memory-BvS2Z88w.mjs → process-memory-X0ws1SGJ.mjs} +1 -1
  19. package/dist/recorder/index.js +267 -94
  20. package/dist/recorder/index.mjs +265 -92
  21. package/dist/{segment-demux-js-BkGK1rhu.js → segment-demux-js-CSQVhbFI.js} +1 -1
  22. package/dist/{segment-demux-js-By9MLn_g.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-DjoAY42S.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-nua-KuDg.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-DPiGNc5u.mjs → hostInit-BOp1_Hhk.mjs} +2 -2
  30. package/dist/stream-broker/index.js +1087 -334
  31. package/dist/stream-broker/index.mjs +1087 -334
  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
@@ -3,12 +3,11 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-DmuX7IFJ.js");
7
- const require_node = require("../node-DqA6nD1z.js");
6
+ const require_dist = require("../dist-CBEUVjTS.js");
7
+ const require_node = require("../node-DMgi6pND.js");
8
8
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
9
9
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
10
- const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
- const require_segment_demux_js = require("../segment-demux-js-BkGK1rhu.js");
10
+ const require_segment_demux_js = require("../segment-demux-js-CSQVhbFI.js");
12
11
  let node_os = require("node:os");
13
12
  node_os = require_chunk.__toESM(node_os);
14
13
  let node_crypto = require("node:crypto");
@@ -364,207 +363,253 @@ function formatProbedSummary(declared, snapshot, opts) {
364
363
  return parts.join(SEP);
365
364
  }
366
365
  //#endregion
367
- //#region src/stream-broker/broker-row-stores.ts
368
- /**
369
- * The stream-broker's flattened settings maps — one row per entry.
370
- *
371
- * ## What this replaced
372
- *
373
- * `stream-broker:addon-settings` / `root` was 33 155 bytes on the reference
374
- * hub, and three keys were 32 735 of it: `probeSnapshots` (17 282 B, 109
375
- * streams), `rtspTokens` (13 147 B, 243 streams) and `profileMap` (2 306 B, 29
376
- * cameras — a per-DEVICE map, so it grows with the fleet). Every read of that
377
- * row parsed all of it, including `BaseAddon.resolveConfig`, and every write
378
- * rewrote all of it — a probe flush every few seconds while streams are live.
379
- *
380
- * Two keys deliberately STAY blob keys on that row, and the reasons are not
381
- * "they are small":
382
- *
383
- * - `rtspEnabled` — brokerId → boolean, EMPTY on the reference hub. Its
384
- * persister replaces the whole map from the set of registered restreamers,
385
- * and reproducing that on rows means an authoritative prune. A prune driven
386
- * by a map that shrinks whenever a broker unregisters is exactly the shape
387
- * that turns an unbind into a wipe (D130, D49), and the twin key
388
- * `rtspTokens` carries a comment about precisely that loss. Nothing is
389
- * bought by moving 0 bytes at that risk.
390
- * - `deviceOverrides` — 354 B, two cameras, same replace-the-whole-map
391
- * persister, written only when an operator edits an override.
392
- *
393
- * The row survives either way: it is also where `BaseAddon.resolveConfig` reads
394
- * the operator's config fields from. The problem was a 33 KB row, not a blob.
395
- */
396
- /**
397
- * The addon id — the settings-store namespace whose `root` row the flattened
398
- * keys are being retired from. Matches `packages/addon-pipeline/package.json`
399
- * (`camstack.addons[].id`).
400
- */
401
- var STREAM_BROKER_ADDON_ID = "stream-broker";
402
- /**
403
- * Every one of these collections is keyed by the BROKER ID
404
- * (`<deviceId>/<camStreamId>`) or by the numeric deviceId. `brokerDeviceId`
405
- * recovers the numeric device from either, for the projected column that makes
406
- * a per-camera question an indexed query — and for `tags: { deviceId }` on
407
- * every line these stores log.
408
- *
409
- * Returns `null` rather than guessing: a key whose head is not a number is a
410
- * key this addon did not write, and inventing a device for it would put a row
411
- * on a camera at random.
412
- */
413
- function brokerDeviceId(key) {
414
- const head = key.includes("/") ? key.slice(0, key.indexOf("/")) : key;
415
- const id = Number(head);
416
- return Number.isInteger(id) && id > 0 ? id : null;
417
- }
418
- /**
419
- * Bound for a whole-map read. `settings-store.query` silently caps an unbounded
420
- * read at 2 000 rows and answers as if that were everything; at 974 devices ×
421
- * up to 5 streams the default is HALF the margin, so every read here names its
422
- * own ceiling and a truncation is reported rather than shrunk.
423
- */
424
- var BROKER_ROWS_LIMIT = 2e4;
425
- /**
426
- * @durable class=mirror owner=stream-broker
427
- * write="one row per broker (`<deviceId>/<camStreamId>`), replaced wholesale from the
428
- * manager's live probe map by the throttled metrics flush and once on shutdown"
429
- * retention="the flush is authoritative: a broker the manager no longer holds loses its
430
- * row. Losing the whole collection costs the settings 'Probed' field showing 'not yet
431
- * probed' until the stream is pulled again — it is re-derived from the next probe."
432
- */
433
- var BROKER_PROBE_SNAPSHOTS_COLLECTION = "stream-broker:probe-snapshots";
434
- var BROKER_PROBE_SNAPSHOTS_SPEC = {
435
- collection: BROKER_PROBE_SNAPSHOTS_COLLECTION,
436
- schema: ProbedSnapshotSchema,
437
- columns: [{
438
- name: "deviceId",
439
- type: "INTEGER"
440
- }, {
441
- name: "at",
442
- type: "INTEGER",
443
- notNull: true
444
- }],
445
- indexes: [{
446
- name: "idx_stream_broker_probe_device",
447
- columns: ["deviceId"]
448
- }],
449
- project: (key, value) => ({
450
- deviceId: brokerDeviceId(key),
451
- at: value.at
452
- }),
453
- deviceIdColumn: "deviceId",
454
- loadLimit: BROKER_ROWS_LIMIT
455
- };
456
- function probeSnapshotsStore(store, logger) {
457
- return new require_retire_root_keys.RowMapStore({
458
- spec: BROKER_PROBE_SNAPSHOTS_SPEC,
459
- store,
460
- logger
461
- });
462
- }
463
- /**
464
- * The token, wrapped in an object rather than stored bare.
465
- *
466
- * A bare string in a `JSON` column round-trips only because the engine's
467
- * decoder JSON-parses a stored string ONLY when it starts with `{` or `[`. That
468
- * is a heuristic, not a contract; a token is opaque hex today and a future
469
- * format that starts with a brace would come back as an object. One field costs
470
- * nothing and makes the row self-describing.
471
- */
472
- var RtspTokenRowSchema = require_dist.object({ token: require_dist.string().min(1) });
473
- /**
474
- * @durable class=ledger owner=stream-broker
475
- * write="one row per broker (`<deviceId>/<camStreamId>`), upserted when the restream
476
- * server mints a token. MERGE only — a token is stable forever per stream unless it
477
- * is explicitly regenerated"
478
- * retention="nothing deletes a row. The persister receives only the CURRENTLY registered
479
- * restreamers, so a replace would erase the token of an unregistered broker and turn
480
- * an external NVR URL into a dead 404 at the next idle or reboot."
481
- */
482
- var BROKER_RTSP_TOKENS_COLLECTION = "stream-broker:rtsp-tokens";
483
- var BROKER_RTSP_TOKENS_SPEC = {
484
- collection: BROKER_RTSP_TOKENS_COLLECTION,
485
- schema: RtspTokenRowSchema,
486
- columns: [{
487
- name: "deviceId",
488
- type: "INTEGER"
489
- }],
490
- indexes: [{
491
- name: "idx_stream_broker_rtsp_tokens_device",
492
- columns: ["deviceId"]
493
- }],
494
- project: (key) => ({ deviceId: brokerDeviceId(key) }),
495
- deviceIdColumn: "deviceId",
496
- loadLimit: BROKER_ROWS_LIMIT
497
- };
498
- function rtspTokensStore(store, logger) {
499
- return new require_retire_root_keys.RowMapStore({
500
- spec: BROKER_RTSP_TOKENS_SPEC,
501
- store,
502
- logger
503
- });
504
- }
505
- /** One camera's profile → camStreamId assignment, plus whether it is automatic. */
366
+ //#region src/stream-broker/broker-device-state.ts
367
+ /**
368
+ * The stream-broker's per-camera state, kept as per-device settings (D870).
369
+ *
370
+ * Three maps used to be collections of their own (`stream-broker:probe-snapshots`,
371
+ * `:profile-map`, `:rtsp-tokens`), each keyed by the camera. A camera's data in
372
+ * a side table is a row somebody must remember to delete when the camera goes;
373
+ * as a KEY of the camera's own settings it is cleared by the central device
374
+ * delete (D865) with everything else.
375
+ *
376
+ * | key | value for one camera |
377
+ * | ---------------- | --------------------------------------------------------- |
378
+ * | `probeSnapshots` | `{ [camStreamId]: ProbedSnapshot }` - what the broker last probed |
379
+ * | `brokerProfile` | `{ map, auto }` - the profile -> camStreamId assignment |
380
+ * | `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 |
381
+ *
382
+ * Failure direction: a load that cannot ask the store THROWS (D49). The writers
383
+ * built on {@link DeviceKeyMirror} refuse to write until a load has succeeded,
384
+ * because a replace driven by an empty "known" set would otherwise drop or
385
+ * shrink rows it never saw.
386
+ */
387
+ /** Per-device settings keys. */
388
+ var PROBE_SNAPSHOTS_KEY = "probeSnapshots";
389
+ var BROKER_PROFILE_KEY = "brokerProfile";
390
+ var RTSP_TOKENS_KEY = "rtspTokens";
391
+ /** One camera's profile -> camStreamId assignment, plus whether it is automatic. */
506
392
  var ProfileAssignmentSchema = require_dist.object({
507
393
  map: require_dist.record(require_dist.string(), require_dist.string()),
508
394
  auto: require_dist.boolean()
509
395
  });
396
+ var ProbeSnapshotsOfDeviceSchema = require_dist.record(require_dist.string(), ProbedSnapshotSchema);
397
+ var RtspTokensOfDeviceSchema = require_dist.record(require_dist.string(), require_dist.string().min(1));
510
398
  /**
511
- * @durable class=config owner=stream-broker
512
- * write="one row per camera, replaced wholesale from the manager's assignment map on
513
- * every profile mutation — the map is authoritative and its boot pass PRUNES cameras
514
- * the device registry no longer knows, which is why the write is a replace and not
515
- * an upsert"
516
- * retention="the replace is the retention: a camera dropped from the manager's map loses
517
- * its row. Losing a row costs one automatic re-assignment on the next catalog pull."
518
- */
519
- var BROKER_PROFILE_MAP_COLLECTION = "stream-broker:profile-map";
520
- var BROKER_PROFILE_MAP_SPEC = {
521
- collection: BROKER_PROFILE_MAP_COLLECTION,
522
- schema: ProfileAssignmentSchema,
523
- columns: [{
524
- name: "deviceId",
525
- type: "INTEGER",
526
- notNull: true
527
- }, {
528
- name: "auto",
529
- type: "BOOLEAN",
530
- notNull: true
531
- }],
532
- project: (key, value) => ({
533
- deviceId: Number(key),
534
- auto: value.auto
535
- }),
536
- deviceIdColumn: "deviceId",
537
- loadLimit: BROKER_ROWS_LIMIT
399
+ * A broker id is `<deviceId>/<camStreamId>`. Returns `null` rather than
400
+ * guessing: a key whose head is not a number is a key this addon did not
401
+ * write, and inventing a device for it would put a value on a camera at random.
402
+ */
403
+ function splitBrokerId(brokerId) {
404
+ const slash = brokerId.indexOf("/");
405
+ if (slash <= 0) return null;
406
+ const deviceId = Number(brokerId.slice(0, slash));
407
+ if (!Number.isInteger(deviceId) || deviceId <= 0) return null;
408
+ return {
409
+ deviceId,
410
+ camStreamId: brokerId.slice(slash + 1)
411
+ };
412
+ }
413
+ /** Group `<deviceId>/<camStreamId>` -> value into deviceId -> camStreamId -> value. */
414
+ function groupByDevice(flat) {
415
+ const entries = flat instanceof Map ? [...flat.entries()] : Object.entries(flat);
416
+ const out = /* @__PURE__ */ new Map();
417
+ for (const [brokerId, value] of entries) {
418
+ const parts = splitBrokerId(brokerId);
419
+ if (parts === null) continue;
420
+ const group = out.get(parts.deviceId) ?? {};
421
+ group[parts.camStreamId] = value;
422
+ out.set(parts.deviceId, group);
423
+ }
424
+ return out;
425
+ }
426
+ /** The inverse of {@link groupByDevice}. */
427
+ function flattenByDevice(grouped) {
428
+ const out = /* @__PURE__ */ new Map();
429
+ for (const [deviceId, group] of grouped) for (const [camStreamId, value] of Object.entries(group)) out.set(`${deviceId}/${camStreamId}`, value);
430
+ return out;
431
+ }
432
+ /** A stored value that exists but cannot be read now (see `DeviceKeyMirrorDeps.decode`). */
433
+ var HELD_UNAVAILABLE = Symbol("held-unavailable");
434
+ /**
435
+ * One settings key across the fleet: loaded once, then written by diff.
436
+ *
437
+ * `replace` is authoritative (a camera the caller no longer holds loses the
438
+ * key, but only if this mirror has SEEN it - it never deletes what it did not
439
+ * load); `merge` only ever adds and changes.
440
+ */
441
+ var DeviceKeyMirror = class {
442
+ deps;
443
+ /** The JSON of what the store holds per device, as of the last load or write. */
444
+ held = /* @__PURE__ */ new Map();
445
+ values = /* @__PURE__ */ new Map();
446
+ blocked = /* @__PURE__ */ new Map();
447
+ /** Cameras whose stored value is GONE: a load never re-blocks them. */
448
+ released = /* @__PURE__ */ new Set();
449
+ loaded = false;
450
+ constructor(deps) {
451
+ this.deps = deps;
452
+ }
453
+ /** Read the key of every camera that has it. THROWS when the store cannot be asked. */
454
+ async load() {
455
+ const ids = await this.deps.settings.listDeviceStoreIds();
456
+ const blobs = await this.deps.settings.readDeviceStoreBatch(ids);
457
+ const out = /* @__PURE__ */ new Map();
458
+ this.held.clear();
459
+ this.values.clear();
460
+ this.loaded = false;
461
+ this.blocked.clear();
462
+ for (const [deviceId, blob] of blobs) {
463
+ const stored = blob[this.deps.key];
464
+ if (stored === void 0) continue;
465
+ const value = this.deps.decode(stored);
466
+ if (value === HELD_UNAVAILABLE) {
467
+ if (!this.released.has(deviceId)) this.blocked.set(deviceId, stored);
468
+ continue;
469
+ }
470
+ if (value === null) {
471
+ this.deps.logger.warn("broker device state skipped - it does not parse", {
472
+ tags: { deviceId },
473
+ meta: { key: this.deps.key }
474
+ });
475
+ continue;
476
+ }
477
+ out.set(deviceId, value);
478
+ this.values.set(deviceId, value);
479
+ this.held.set(deviceId, JSON.stringify(this.deps.encode(value)));
480
+ }
481
+ this.loaded = true;
482
+ return out;
483
+ }
484
+ /** Cameras whose stored value exists but could not be read: never written, never replaced. */
485
+ heldDevices() {
486
+ return this.blocked;
487
+ }
488
+ /**
489
+ * Stop protecting a camera whose stored value is GONE (the secret was deleted
490
+ * or the keychain restored without it): there is nothing left to overwrite.
491
+ */
492
+ release(deviceId) {
493
+ this.blocked.delete(deviceId);
494
+ this.released.add(deviceId);
495
+ }
496
+ /** Is this camera's token write (and restream URL) blocked right now? */
497
+ isBlocked(deviceId) {
498
+ return this.blocked.has(deviceId);
499
+ }
500
+ assertLoaded() {
501
+ if (!this.loaded) throw new Error(`broker device state "${this.deps.key}" was never loaded - refusing to write`);
502
+ }
503
+ async writeChanged(next) {
504
+ let written = 0;
505
+ for (const [deviceId, value] of next) {
506
+ if (this.blocked.has(deviceId)) continue;
507
+ const encoded = this.deps.encode(value);
508
+ const text = JSON.stringify(encoded);
509
+ if (this.held.get(deviceId) === text) continue;
510
+ await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: encoded });
511
+ this.held.set(deviceId, text);
512
+ this.values.set(deviceId, value);
513
+ written += 1;
514
+ }
515
+ return written;
516
+ }
517
+ /** Make the store hold exactly `next` for the cameras this mirror knows. */
518
+ async replace(next) {
519
+ this.assertLoaded();
520
+ const written = await this.writeChanged(next);
521
+ let removed = 0;
522
+ for (const deviceId of [...this.held.keys()]) {
523
+ if (next.has(deviceId) || this.blocked.has(deviceId)) continue;
524
+ await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: void 0 });
525
+ this.held.delete(deviceId);
526
+ this.values.delete(deviceId);
527
+ removed += 1;
528
+ }
529
+ return {
530
+ written,
531
+ removed
532
+ };
533
+ }
534
+ /** Add or change; never remove. */
535
+ async merge(next) {
536
+ this.assertLoaded();
537
+ return this.writeChanged(next);
538
+ }
539
+ /** Drop one camera's key (a device that changed hardware under its number). */
540
+ async remove(deviceId) {
541
+ this.assertLoaded();
542
+ if (!this.held.has(deviceId) || this.blocked.has(deviceId)) return false;
543
+ await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: void 0 });
544
+ this.held.delete(deviceId);
545
+ this.values.delete(deviceId);
546
+ return true;
547
+ }
548
+ /** What the store holds for the camera as of the last load or write. */
549
+ current(deviceId) {
550
+ return this.values.get(deviceId);
551
+ }
552
+ /** Whether a load has answered; a writer that was never loaded refuses. */
553
+ get isLoaded() {
554
+ return this.loaded;
555
+ }
538
556
  };
539
- function profileMapStore(store, logger) {
540
- return new require_retire_root_keys.RowMapStore({
541
- spec: BROKER_PROFILE_MAP_SPEC,
542
- store,
543
- logger
557
+ function parseWith(schema) {
558
+ return (stored) => {
559
+ const parsed = schema.safeParse(stored);
560
+ return parsed.success ? parsed.data : null;
561
+ };
562
+ }
563
+ function probeSnapshotsMirror(settings, logger) {
564
+ return new DeviceKeyMirror({
565
+ settings,
566
+ key: PROBE_SNAPSHOTS_KEY,
567
+ logger,
568
+ decode: parseWith(ProbeSnapshotsOfDeviceSchema),
569
+ encode: (value) => value
570
+ });
571
+ }
572
+ function profileMapMirror(settings, logger) {
573
+ return new DeviceKeyMirror({
574
+ settings,
575
+ key: BROKER_PROFILE_KEY,
576
+ logger,
577
+ decode: parseWith(ProfileAssignmentSchema),
578
+ encode: (value) => value
579
+ });
580
+ }
581
+ /** The tokens of one camera are ONE keychain-held text: JSON of `{ camStreamId: token }`. */
582
+ function rtspTokensMirror(settings, logger) {
583
+ return new DeviceKeyMirror({
584
+ settings,
585
+ key: RTSP_TOKENS_KEY,
586
+ logger,
587
+ decode: (stored) => {
588
+ if (require_dist.isSecretMarker(stored)) return HELD_UNAVAILABLE;
589
+ if (typeof stored !== "string") return null;
590
+ try {
591
+ const parsed = RtspTokensOfDeviceSchema.safeParse(JSON.parse(stored));
592
+ return parsed.success ? parsed.data : null;
593
+ } catch {
594
+ return null;
595
+ }
596
+ },
597
+ encode: (value) => JSON.stringify(value)
544
598
  });
545
599
  }
546
600
  /**
547
- * The dead `addon-settings` / `root` keys. A CLAIM that each key is dead,
548
- * enforced by nothing else — the migration script names the same three, keep
549
- * the two in lockstep.
601
+ * Fold freshly minted tokens (`<deviceId>/<camStreamId>` -> token) into what
602
+ * each camera already holds. Only ever adds or changes: a token is stable
603
+ * forever per stream unless explicitly regenerated.
550
604
  */
551
- var STREAM_BROKER_RETIRED_ROOT_KEYS = [
552
- {
553
- key: "probeSnapshots",
554
- successor: BROKER_PROBE_SNAPSHOTS_COLLECTION,
555
- 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"
556
- },
557
- {
558
- key: "rtspTokens",
559
- successor: BROKER_RTSP_TOKENS_COLLECTION,
560
- reason: "one row per broker since the flatten; as a blob a single minted token rewrote 33 KB"
561
- },
562
- {
563
- key: "profileMap",
564
- successor: BROKER_PROFILE_MAP_COLLECTION,
565
- 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"
566
- }
567
- ];
605
+ function mergeRtspTokens(mirror, minted) {
606
+ const out = /* @__PURE__ */ new Map();
607
+ for (const [deviceId, group] of groupByDevice(minted)) out.set(deviceId, {
608
+ ...mirror.current(deviceId),
609
+ ...group
610
+ });
611
+ return out;
612
+ }
568
613
  //#endregion
569
614
  //#region src/stream-broker/live-viewers.ts
570
615
  /**
@@ -3089,6 +3134,223 @@ var RtpPacketizer = class {
3089
3134
  };
3090
3135
  }
3091
3136
  };
3137
+ function isTransferable(buf) {
3138
+ return typeof Reflect.get(buf, "transfer") === "function";
3139
+ }
3140
+ var Slab = class {
3141
+ now;
3142
+ buffer;
3143
+ offset = 0;
3144
+ holds = 0;
3145
+ state = "active";
3146
+ releasedAt = 0;
3147
+ constructor(buffer, now) {
3148
+ this.now = now;
3149
+ this.buffer = buffer;
3150
+ }
3151
+ };
3152
+ /** ArrayBuffer -> slab, for every slab ArrayBuffer currently in service. */
3153
+ var slabOf = /* @__PURE__ */ new WeakMap();
3154
+ var slabsLive = 0;
3155
+ var slabsFree = 0;
3156
+ var slabAllocs = 0;
3157
+ var slabRecycles = 0;
3158
+ function readSlabTotals() {
3159
+ return {
3160
+ live: slabsLive,
3161
+ free: slabsFree,
3162
+ allocs: slabAllocs,
3163
+ recycles: slabRecycles
3164
+ };
3165
+ }
3166
+ /** True when `buf` is a view of a pool slab. */
3167
+ function isSlabView(buf) {
3168
+ return slabOf.has(buf.buffer);
3169
+ }
3170
+ /** A retaining structure keeps `buf`: its slab cannot be recycled until released. */
3171
+ function holdPacket(buf) {
3172
+ const slab = slabOf.get(buf.buffer);
3173
+ if (slab === void 0) return;
3174
+ slab.holds += 1;
3175
+ if (slab.state === "quarantined") slab.state = "held";
3176
+ }
3177
+ /** The matching drop. Safe on a non-slab buffer and on a stale (recycled) view. */
3178
+ function releasePacket(buf) {
3179
+ const slab = slabOf.get(buf.buffer);
3180
+ if (slab === void 0 || slab.holds === 0) return;
3181
+ slab.holds -= 1;
3182
+ if (slab.holds === 0 && slab.state === "held") {
3183
+ slab.state = "quarantined";
3184
+ slab.releasedAt = slab.now();
3185
+ }
3186
+ }
3187
+ function releasePackets(bufs) {
3188
+ for (const b of bufs) releasePacket(b);
3189
+ }
3190
+ /**
3191
+ * A consumer's own hold on the last `limit` packets it forwarded.
3192
+ *
3193
+ * werift keeps the last RTP_HISTORY_SIZE (4096) packets of each sender for NACK
3194
+ * retransmission, and those packets' payloads are views of the broker's slabs.
3195
+ * The session mirrors that window here: each forwarded packet is held, and
3196
+ * the one falling out of the window is released, so a retransmit asked for
3197
+ * within the history is served from bytes nobody has recycled.
3198
+ */
3199
+ var HeldPacketWindow = class {
3200
+ limit;
3201
+ slots;
3202
+ next = 0;
3203
+ constructor(limit) {
3204
+ this.limit = limit;
3205
+ this.slots = new Array(limit).fill(void 0);
3206
+ }
3207
+ hold(buf) {
3208
+ holdPacket(buf);
3209
+ const evicted = this.slots[this.next];
3210
+ if (evicted !== void 0) releasePacket(evicted);
3211
+ this.slots[this.next] = buf;
3212
+ this.next = (this.next + 1) % this.limit;
3213
+ }
3214
+ releaseAll() {
3215
+ for (let i = 0; i < this.slots.length; i++) {
3216
+ const held = this.slots[i];
3217
+ if (held !== void 0) releasePacket(held);
3218
+ this.slots[i] = void 0;
3219
+ }
3220
+ this.next = 0;
3221
+ }
3222
+ };
3223
+ var PacketSlabPool = class {
3224
+ slabBytes;
3225
+ maxPacket;
3226
+ quarantineMs;
3227
+ maxFree;
3228
+ now;
3229
+ active = null;
3230
+ /** Sealed slabs, oldest first; entries may have been re-held since. */
3231
+ sealed = [];
3232
+ free = [];
3233
+ allocs = 0;
3234
+ recycles = 0;
3235
+ ownBuffers = 0;
3236
+ constructor(options = {}) {
3237
+ this.slabBytes = options.slabBytes ?? 65536;
3238
+ this.maxPacket = this.slabBytes >> 2;
3239
+ this.quarantineMs = options.quarantineMs ?? 2e3;
3240
+ this.maxFree = options.maxFree ?? 2;
3241
+ this.now = options.now ?? Date.now;
3242
+ }
3243
+ /**
3244
+ * A copy of `src[start, end)` in this pool's active slab. An oversized
3245
+ * packet gets its own buffer (it owns its ArrayBuffer, like the old
3246
+ * `copyOwned`).
3247
+ */
3248
+ copy(src, start, end) {
3249
+ const length = end - start;
3250
+ recordCopyOwned(length);
3251
+ if (length > this.maxPacket) {
3252
+ this.ownBuffers += 1;
3253
+ const own = Buffer.allocUnsafeSlow(length);
3254
+ src.copy(own, 0, start, end);
3255
+ return own;
3256
+ }
3257
+ let slab = this.active;
3258
+ if (slab === null || slab.offset + length > this.slabBytes) slab = this.rotate();
3259
+ const view = Buffer.from(slab.buffer, slab.offset, length);
3260
+ src.copy(view, 0, start, end);
3261
+ slab.offset += length;
3262
+ return view;
3263
+ }
3264
+ /**
3265
+ * `buf` itself when it is already owned (a slab view or a whole-ArrayBuffer
3266
+ * buffer), else a copy in the active slab. Used for packets that arrive from
3267
+ * paths that do not cut through {@link copy} (push-rtp).
3268
+ */
3269
+ adopt(buf) {
3270
+ if (isSlabView(buf)) return buf;
3271
+ if (buf.byteOffset === 0 && buf.buffer.byteLength === buf.length) return buf;
3272
+ return this.copy(buf, 0, buf.length);
3273
+ }
3274
+ stats() {
3275
+ return {
3276
+ live: this.sealed.length + (this.active === null ? 0 : 1),
3277
+ free: this.free.length,
3278
+ allocs: this.allocs,
3279
+ recycles: this.recycles,
3280
+ ownBuffers: this.ownBuffers
3281
+ };
3282
+ }
3283
+ /** Drop every slab of this pool from the process counters (stream torn down). */
3284
+ dispose() {
3285
+ const live = this.sealed.length + (this.active === null ? 0 : 1);
3286
+ slabsLive -= live;
3287
+ slabsFree -= this.free.length;
3288
+ this.sealed = [];
3289
+ this.free = [];
3290
+ this.active = null;
3291
+ }
3292
+ rotate() {
3293
+ const previous = this.active;
3294
+ if (previous !== null) {
3295
+ this.sealed.push(previous);
3296
+ if (previous.holds === 0) {
3297
+ previous.state = "quarantined";
3298
+ previous.releasedAt = this.now();
3299
+ } else previous.state = "held";
3300
+ }
3301
+ this.reclaim();
3302
+ const next = this.free.pop() ?? this.allocate();
3303
+ if (next.state === "free") {
3304
+ slabsFree -= 1;
3305
+ slabsLive += 1;
3306
+ }
3307
+ next.state = "active";
3308
+ next.offset = 0;
3309
+ next.holds = 0;
3310
+ this.active = next;
3311
+ return next;
3312
+ }
3313
+ allocate() {
3314
+ this.allocs += 1;
3315
+ slabAllocs += 1;
3316
+ slabsLive += 1;
3317
+ const slab = new Slab(new ArrayBuffer(this.slabBytes), this.now);
3318
+ slabOf.set(slab.buffer, slab);
3319
+ return slab;
3320
+ }
3321
+ /** Move quarantined slabs whose time is up to the free list. */
3322
+ reclaim() {
3323
+ if (this.sealed.length === 0) return;
3324
+ const cutoff = this.now() - this.quarantineMs;
3325
+ const keep = [];
3326
+ for (const slab of this.sealed) if (slab.state === "quarantined" && slab.releasedAt <= cutoff) this.recycle(slab);
3327
+ else keep.push(slab);
3328
+ this.sealed = keep;
3329
+ }
3330
+ recycle(slab) {
3331
+ slabsLive -= 1;
3332
+ if (this.free.length >= this.maxFree) {
3333
+ slabOf.delete(slab.buffer);
3334
+ slab.state = "free";
3335
+ return;
3336
+ }
3337
+ const old = slab.buffer;
3338
+ slabOf.delete(old);
3339
+ slab.buffer = isTransferable(old) ? old.transfer() : new ArrayBuffer(this.slabBytes);
3340
+ if (!isTransferable(old)) {
3341
+ this.allocs += 1;
3342
+ slabAllocs += 1;
3343
+ }
3344
+ slabOf.set(slab.buffer, slab);
3345
+ slab.state = "free";
3346
+ slab.offset = 0;
3347
+ slab.holds = 0;
3348
+ this.free.push(slab);
3349
+ slabsFree += 1;
3350
+ this.recycles += 1;
3351
+ slabRecycles += 1;
3352
+ }
3353
+ };
3092
3354
  //#endregion
3093
3355
  //#region src/stream-broker/stream-broker/owned-buffer.ts
3094
3356
  /**
@@ -3115,10 +3377,20 @@ function ownsBackingStore(buf) {
3115
3377
  }
3116
3378
  /**
3117
3379
  * What retaining `buf` really costs: its own length when it owns its
3118
- * ArrayBuffer, the whole shared slab when it is a view of one.
3380
+ * ArrayBuffer (a packet slab is densely packed, so a slab view counts as its
3381
+ * own length too), the whole shared slab when it is a view of a pooled one.
3382
+ * The diagnostic `BackingTally` still sums the distinct ArrayBuffers.
3119
3383
  */
3120
3384
  function backingStoreBytes(buf) {
3121
- return ownsBackingStore(buf) ? buf.length : buf.buffer.byteLength;
3385
+ return ownsBackingStore(buf) || isSlabView(buf) ? buf.length : buf.buffer.byteLength;
3386
+ }
3387
+ /**
3388
+ * What `buf` PINS: its own length when it is the whole of its ArrayBuffer, the
3389
+ * whole ArrayBuffer otherwise — a packet slab view included. This is the
3390
+ * diagnostic figure (`BackingTally`); caps charge {@link backingStoreBytes}.
3391
+ */
3392
+ function pinnedBackingBytes(buf) {
3393
+ return buf.byteOffset === 0 && buf.buffer.byteLength === buf.length ? buf.length : buf.buffer.byteLength;
3122
3394
  }
3123
3395
  /**
3124
3396
  * `buf` itself when it already owns its ArrayBuffer (decided by inspecting the
@@ -3167,7 +3439,7 @@ var BackingTally = class {
3167
3439
  }
3168
3440
  this.entries += 1;
3169
3441
  this.payload += buf.length;
3170
- const backing = backingStoreBytes(buf);
3442
+ const backing = pinnedBackingBytes(buf);
3171
3443
  this.naive += backing;
3172
3444
  if (!this.seen.has(buf.buffer)) {
3173
3445
  this.seen.add(buf.buffer);
@@ -3460,6 +3732,7 @@ function formatBrokerMem(input) {
3460
3732
  const w = input.worker;
3461
3733
  if (w !== void 0 && w !== null) parts.push(`workers=${w.workers}`, `workerHeapMb=${mb(w.heapBytes)}`, `workerPhysicalMb=${mb(w.physicalBytes)}`, `workerUsedMb=${mb(w.usedBytes)}`, `workerAgeS=${Math.round(w.maxAgeMs / 1e3)}`, `workerIdleExit=${w.idleTerminates ? "yes" : "never"}`);
3462
3734
  parts.push(`brokers=${samples.length}`, `ingesting=${ingesting}`, `annexBPlanes=${planes}`, `rtsp=${rtsp}`, `webrtc=${input.webrtcSessions}`, `encoded=${kindList(consumers)}`, `audioSubs=${audio}`, `pipeClients=${pipe}`, `egress=${input.egressTranscodes}`, `children=${input.childProcesses}`, `opened=${churn.opened}`, `closed=${churn.closed}`, `openedKinds=${kindList(churn.openedByKind)}`, `closedKinds=${kindList(churn.closedByKind)}`, `preBufferMb=${mb(pre)}`, `clipMb=${mb(clip)}`, `rtpRingMb=${mb(rtp)}`, `keyframeMb=${mb(kf)}`, `writeQueueSumMb=${mb(qSum)}`, `writeQueueMaxMb=${mb(qMax)}`, `copiedPackets=${copied.packets}`, `copiedMb=${mb(copied.bytes)}`, `topRingMb=${top === "" ? "none" : top}`);
3735
+ if (input.slabs !== void 0) parts.push(`slabs=${input.slabs.live}/${input.slabs.free}`, `slabAllocs=${input.slabs.allocsDelta}`);
3463
3736
  const sessions = input.sessions;
3464
3737
  if (sessions !== void 0) parts.push(...formatSessionMem(sessions, input));
3465
3738
  return parts.join(" ");
@@ -3849,8 +4122,7 @@ var RtspRestreamer = class {
3849
4122
  this.mutedSdp = null;
3850
4123
  this.deframer = null;
3851
4124
  this.detectedCodec = null;
3852
- this.lastKeyframeRtpPackets = [];
3853
- this.lastKeyframeRtpBytes = 0;
4125
+ this.dropKeyframeRtpCache();
3854
4126
  this._rtpPassthroughMode = false;
3855
4127
  this.collectingKeyframeRtp = false;
3856
4128
  this.egressAudioBlock = null;
@@ -4203,21 +4475,21 @@ var RtspRestreamer = class {
4203
4475
  if (isKeyframe || isParameterSet) {
4204
4476
  if (!this.collectingKeyframeRtp) {
4205
4477
  this.collectingKeyframeRtp = true;
4206
- this.lastKeyframeRtpPackets = [];
4207
- this.lastKeyframeRtpBytes = 0;
4478
+ this.dropKeyframeRtpCache();
4208
4479
  }
4209
4480
  }
4210
4481
  if (this.collectingKeyframeRtp && !isKeyframe && !isParameterSet) this.collectingKeyframeRtp = false;
4211
4482
  if (this.collectingKeyframeRtp) {
4483
+ const cached = isSlabView(rtpData) ? rtpData : detachFromPool(rtpData);
4484
+ holdPacket(cached);
4212
4485
  this.lastKeyframeRtpPackets.push({
4213
- data: detachFromPool(rtpData),
4486
+ data: cached,
4214
4487
  marker: rtpPacket.marker
4215
4488
  });
4216
4489
  this.lastKeyframeRtpBytes += rtpData.length;
4217
4490
  if (this.lastKeyframeRtpBytes > MAX_KEYFRAME_CACHE_BYTES) {
4218
4491
  this.warnCacheCapExceeded("rtp", this.lastKeyframeRtpBytes, this.lastKeyframeRtpPackets.length);
4219
- this.lastKeyframeRtpPackets = [];
4220
- this.lastKeyframeRtpBytes = 0;
4492
+ this.dropKeyframeRtpCache();
4221
4493
  this.collectingKeyframeRtp = false;
4222
4494
  }
4223
4495
  }
@@ -4655,6 +4927,7 @@ var RtspRestreamer = class {
4655
4927
  }
4656
4928
  destroy() {
4657
4929
  this._onSessionCountChanged = null;
4930
+ this.dropKeyframeRtpCache();
4658
4931
  this.sentryAudioSilence?.release("restreamer destroyed");
4659
4932
  this.abortAllReplays();
4660
4933
  this.deframer?.flush();
@@ -4663,6 +4936,12 @@ var RtspRestreamer = class {
4663
4936
  this.packetizer = null;
4664
4937
  this.deframer = null;
4665
4938
  }
4939
+ /** Empty the keyframe RTP cache and let go of the slabs it pinned. */
4940
+ dropKeyframeRtpCache() {
4941
+ for (const cached of this.lastKeyframeRtpPackets) releasePacket(cached.data);
4942
+ this.lastKeyframeRtpPackets = [];
4943
+ this.lastKeyframeRtpBytes = 0;
4944
+ }
4666
4945
  /** Called by deframer for each complete NAL unit. */
4667
4946
  handleNal(nal, isKeyframe) {
4668
4947
  if (!this.codecParams) this.tryInitCodec(nal);
@@ -4792,6 +5071,13 @@ function resolveJoinBurstBound(input) {
4792
5071
  };
4793
5072
  }
4794
5073
  //#endregion
5074
+ //#region src/stream-broker/stream-broker/held-write.ts
5075
+ /** Write `payload`, holding its slab until the write completes (or fails). */
5076
+ function writeHeld(sink, payload) {
5077
+ holdPacket(payload);
5078
+ return sink.write(payload, () => releasePacket(payload));
5079
+ }
5080
+ //#endregion
4795
5081
  //#region src/stream-broker/rtsp/rtsp-session.ts
4796
5082
  /**
4797
5083
  * Handles one RTSP client connection.
@@ -5107,15 +5393,17 @@ var RtspSession = class {
5107
5393
  *
5108
5394
  * ## Why sharing the payload is safe, and what would make it unsafe
5109
5395
  *
5110
- * The payload buffer handed here is a private allocation per packet —
5111
- * `copyOwned(readBuffer, …)` in the readers' `onData`, for video and audio
5112
- * alike — never a view of the reader's socket buffer, which advances on the
5113
- * next read. Nothing in the broker writes into a captured packet: every
5114
- * mutation in this path targets a freshly allocated buffer. (The same object
5115
- * is what the broker's `rtpRing` and the restreamer's keyframe cache retain —
5116
- * it owns its backing store, so `detachFromPool` keeps it as is — and that is
5117
- * fine: a pending write holds its own reference, and the rule below is about
5118
- * mutation, not retention.)
5396
+ * The payload is a view of the broker's packet slab (`slab-pool.ts`, D868),
5397
+ * never of the reader's socket buffer, which advances on the next read.
5398
+ * Nothing in the broker writes into a captured packet: every mutation in this
5399
+ * path targets a freshly allocated buffer. A slab is RECYCLED once the
5400
+ * broker's rings let go of it, and a queued socket write outlives that: Node
5401
+ * keeps the chunk behind a full kernel buffer for as long as the peer takes to
5402
+ * drain (seconds, for a recorder or a LAN decoder), and libuv reads the bytes
5403
+ * only when it writes. So the payload goes out through `writeHeld`, which
5404
+ * holds its slab until Node's write callback (success, error or destroy). A
5405
+ * write that skipped the hold would send a header promising N bytes followed
5406
+ * by zeros.
5119
5407
  *
5120
5408
  * The rule to keep: **`socket.write` forbids MUTATION before the write
5121
5409
  * completes, not retention.** So the header, which IS built per call, must
@@ -5152,7 +5440,7 @@ var RtspSession = class {
5152
5440
  process.nextTick(this.uncorkAccessUnit);
5153
5441
  }
5154
5442
  this.socket.write(header);
5155
- this.socket.write(payload);
5443
+ writeHeld(this.socket, payload);
5156
5444
  if (endOfAccessUnit) this.uncorkAccessUnit();
5157
5445
  this.lastRtpAt = Date.now();
5158
5446
  this.bytesSent += 4 + payload.length;
@@ -5289,6 +5577,18 @@ var RtspListenServer = class RtspListenServer {
5289
5577
  brokerTokens = /* @__PURE__ */ new Map();
5290
5578
  /** token → brokerId (reverse map) */
5291
5579
  tokenBrokers = /* @__PURE__ */ new Map();
5580
+ /**
5581
+ * brokerId → INTERNAL loopback-only alias token (D870). A camera whose
5582
+ * external restream token is held in an unreadable keychain has NO external
5583
+ * URL, but the broker's own consumers (recording, decode, snapshots, grid,
5584
+ * egress) still ride its restream: they get this ephemeral token, which is
5585
+ * never persisted, never handed out through the consumer URL APIs, and only
5586
+ * answers loopback / local-network peers.
5587
+ */
5588
+ internalAliases = /* @__PURE__ */ new Map();
5589
+ internalAliasTokens = /* @__PURE__ */ new Set();
5590
+ /** Which peers may dial an internal alias; replaceable for tests. */
5591
+ aliasPeerPolicy = isClusterLocalAddress;
5292
5592
  /** brokerId → enabled (default true) */
5293
5593
  brokerEnabled = /* @__PURE__ */ new Map();
5294
5594
  /** brokerId → warm-up closure registered alongside the restreamer */
@@ -5319,7 +5619,37 @@ var RtspListenServer = class RtspListenServer {
5319
5619
  if (sentryOpener) this.brokerPrestreamOpeners.set(brokerId, sentryOpener);
5320
5620
  return token;
5321
5621
  }
5622
+ /**
5623
+ * Register (or refresh) the internal loopback-only alias of a broker. Returns
5624
+ * its token. The external token map is untouched, so the alias can never be
5625
+ * listed, persisted or surfaced as a consumer URL.
5626
+ */
5627
+ registerInternalAlias(brokerId, restreamer, warmUp, sentryOpener) {
5628
+ const token = this.internalAliases.get(brokerId) ?? RtspListenServer.generateToken();
5629
+ this.restreamers.set(token, restreamer);
5630
+ this.tokenBrokers.set(token, brokerId);
5631
+ this.internalAliases.set(brokerId, token);
5632
+ this.internalAliasTokens.add(token);
5633
+ if (warmUp) this.brokerWarmUps.set(brokerId, warmUp);
5634
+ if (sentryOpener) this.brokerPrestreamOpeners.set(brokerId, sentryOpener);
5635
+ return token;
5636
+ }
5637
+ /** Replace the peer policy of the internal aliases (tests only). */
5638
+ setAliasPeerPolicy(policy) {
5639
+ this.aliasPeerPolicy = policy;
5640
+ }
5641
+ /** The token an IN-PROCESS consumer dials: the internal alias when there is one, else the external token. */
5642
+ getInternalToken(brokerId) {
5643
+ return this.internalAliases.get(brokerId) ?? this.brokerTokens.get(brokerId);
5644
+ }
5322
5645
  unregisterRestreamer(brokerId) {
5646
+ const alias = this.internalAliases.get(brokerId);
5647
+ if (alias !== void 0) {
5648
+ this.restreamers.delete(alias);
5649
+ this.tokenBrokers.delete(alias);
5650
+ this.internalAliasTokens.delete(alias);
5651
+ this.internalAliases.delete(brokerId);
5652
+ }
5323
5653
  const token = this.brokerTokens.get(brokerId);
5324
5654
  if (token) {
5325
5655
  this.restreamers.delete(token);
@@ -5341,6 +5671,17 @@ var RtspListenServer = class RtspListenServer {
5341
5671
  this.restreamers.set(newToken, restreamer);
5342
5672
  this.brokerTokens.set(brokerId, newToken);
5343
5673
  this.tokenBrokers.set(newToken, brokerId);
5674
+ const alias = this.internalAliases.get(brokerId);
5675
+ if (alias !== void 0) {
5676
+ this.restreamers.delete(alias);
5677
+ this.tokenBrokers.delete(alias);
5678
+ this.internalAliasTokens.delete(alias);
5679
+ const newAlias = RtspListenServer.generateToken();
5680
+ this.restreamers.set(newAlias, restreamer);
5681
+ this.tokenBrokers.set(newAlias, brokerId);
5682
+ this.internalAliases.set(brokerId, newAlias);
5683
+ this.internalAliasTokens.add(newAlias);
5684
+ }
5344
5685
  return newToken;
5345
5686
  }
5346
5687
  /** Get the current token for a broker. */
@@ -5424,6 +5765,11 @@ var RtspListenServer = class RtspListenServer {
5424
5765
  socket.destroy();
5425
5766
  return;
5426
5767
  }
5768
+ if (this.internalAliasTokens.has(lookupPath) && !this.aliasPeerPolicy(socket.remoteAddress)) {
5769
+ socket.write(`RTSP/1.0 403 Forbidden\r\nCSeq: 1\r\n\r\n`);
5770
+ socket.destroy();
5771
+ return;
5772
+ }
5427
5773
  const brokerId = this.tokenBrokers.get(lookupPath);
5428
5774
  if (brokerId && !this.isEnabled(brokerId)) {
5429
5775
  socket.write(`RTSP/1.0 403 Forbidden\r\nCSeq: 1\r\n\r\n`);
@@ -5482,6 +5828,24 @@ var RtspListenServer = class RtspListenServer {
5482
5828
  });
5483
5829
  }
5484
5830
  };
5831
+ /**
5832
+ * Is the peer on the loopback interface or the local network (RFC 1918, link-local,
5833
+ * ULA)? The internal alias answers only these: a recorder or decoder on another
5834
+ * node of the cluster is a legitimate consumer, the public internet is not.
5835
+ */
5836
+ function isClusterLocalAddress(address) {
5837
+ if (address === void 0) return false;
5838
+ const v4 = address.startsWith("::ffff:") ? address.slice(7) : address;
5839
+ if (v4 === "::1") return true;
5840
+ const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(v4);
5841
+ if (m !== null) {
5842
+ const a = Number(m[1]);
5843
+ const b = Number(m[2]);
5844
+ 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;
5845
+ }
5846
+ const lower = address.toLowerCase();
5847
+ return lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd");
5848
+ }
5485
5849
  //#endregion
5486
5850
  //#region src/stream-broker/rtsp/rtsp-restream-provider.ts
5487
5851
  /**
@@ -5563,6 +5927,24 @@ var RtspRestreamProviderImpl = class {
5563
5927
  enabled: this.server.isEnabled(brokerId)
5564
5928
  };
5565
5929
  }
5930
+ /**
5931
+ * The entry an IN-PROCESS consumer dials (recording, decode, snapshots, grid,
5932
+ * egress): the loopback-only internal alias when the camera's external token
5933
+ * is held back (D870), else the external entry. NEVER hand this out through a
5934
+ * consumer URL API - use {@link getEntry} for that.
5935
+ */
5936
+ getInternalEntry(brokerId, hostname) {
5937
+ const token = this.server.getInternalToken(brokerId);
5938
+ if (!token) return null;
5939
+ const port = this.server.getPort();
5940
+ const host = hostname ?? "127.0.0.1";
5941
+ return {
5942
+ brokerId,
5943
+ url: `rtsp://${host}:${port}/${token}`,
5944
+ mutedUrl: `rtsp://${host}:${port}/${token}/muted`,
5945
+ enabled: this.server.isEnabled(brokerId)
5946
+ };
5947
+ }
5566
5948
  regenerateToken(brokerId) {
5567
5949
  const token = this.server.regenerateToken(brokerId);
5568
5950
  if (token) {
@@ -10515,6 +10897,7 @@ var Rfc4571Reader = class {
10515
10897
  options;
10516
10898
  callbacks;
10517
10899
  logger;
10900
+ allocatePacket;
10518
10901
  host;
10519
10902
  port;
10520
10903
  authLine;
@@ -10528,6 +10911,7 @@ var Rfc4571Reader = class {
10528
10911
  constructor(options, callbacks) {
10529
10912
  this.options = options;
10530
10913
  this.callbacks = callbacks;
10914
+ this.allocatePacket = options.packetAllocator ?? copyOwned;
10531
10915
  this.logger = options.logger;
10532
10916
  const parsed = new URL(options.url);
10533
10917
  this.host = parsed.hostname;
@@ -10592,7 +10976,7 @@ var Rfc4571Reader = class {
10592
10976
  while (this.readBuffer.length >= 2) {
10593
10977
  const length = this.readBuffer.readUInt16BE(0);
10594
10978
  if (this.readBuffer.length < 2 + length) return;
10595
- const rtpPacket = copyOwned(this.readBuffer, 2, 2 + length);
10979
+ const rtpPacket = this.allocatePacket(this.readBuffer, 2, 2 + length);
10596
10980
  this.readBuffer = this.readBuffer.subarray(2 + length);
10597
10981
  this.dispatchRtp(rtpPacket);
10598
10982
  }
@@ -11113,6 +11497,7 @@ var NativeRtspClient = class {
11113
11497
  keepaliveIntervalMs;
11114
11498
  logger;
11115
11499
  callbacks;
11500
+ allocatePacket;
11116
11501
  /** Parsed SDP from DESCRIBE response. */
11117
11502
  parsedSdp = null;
11118
11503
  rawSdpText = null;
@@ -11139,6 +11524,7 @@ var NativeRtspClient = class {
11139
11524
  tornDown = false;
11140
11525
  constructor(options, callbacks) {
11141
11526
  this.callbacks = callbacks;
11527
+ this.allocatePacket = options.packetAllocator ?? copyOwned;
11142
11528
  this.logger = options.logger;
11143
11529
  this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
11144
11530
  this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS;
@@ -11311,7 +11697,7 @@ var NativeRtspClient = class {
11311
11697
  const channel = this.readBuffer[1];
11312
11698
  const length = this.readBuffer.readUInt16BE(2);
11313
11699
  if (this.readBuffer.length < 4 + length) return;
11314
- const payload = copyOwned(this.readBuffer, 4, 4 + length);
11700
+ const payload = this.allocatePacket(this.readBuffer, 4, 4 + length);
11315
11701
  this.readBuffer = this.readBuffer.subarray(4 + length);
11316
11702
  this.handleInterleavedPacket(channel, payload);
11317
11703
  } else {
@@ -11766,7 +12152,7 @@ var AudioCodecSession = class {
11766
12152
  processPacket(rtpData) {
11767
12153
  if (this.closed) return;
11768
12154
  if (rtpData.length <= RTP_HEADER_LENGTH) return;
11769
- const payload = rtpData.subarray(RTP_HEADER_LENGTH);
12155
+ const payload = Buffer.from(rtpData.subarray(RTP_HEADER_LENGTH));
11770
12156
  this.packetsIn++;
11771
12157
  this.ensureSession().then((sid) => {
11772
12158
  if (!sid || this.closed) return;
@@ -13384,6 +13770,13 @@ var StreamBroker = class StreamBroker {
13384
13770
  * Only populated for RTP sources (`isRtpSource()`).
13385
13771
  */
13386
13772
  rtpRing = [];
13773
+ /**
13774
+ * Where this stream's source packets are cut (`slab-pool.ts`, D868): the
13775
+ * readers copy into the pool's slabs, the ring and the restreamer's keyframe
13776
+ * cache hold/release them, and a slab no ring holds is recycled.
13777
+ */
13778
+ packetPool = new PacketSlabPool();
13779
+ packetAllocator = (src, start, end) => this.packetPool.copy(src, start, end);
13387
13780
  rtpRingCurAuStart = 0;
13388
13781
  rtpRingPrevMarker = true;
13389
13782
  rtpRingHasKeyframe = false;
@@ -13904,6 +14297,8 @@ var StreamBroker = class StreamBroker {
13904
14297
  this.annexBAssembler.reset();
13905
14298
  this.encodedCallbacks.clear();
13906
14299
  this.audioChunkPlane.dispose();
14300
+ this.resetRtpPreBuffer();
14301
+ this.packetPool.dispose();
13907
14302
  }
13908
14303
  /** Whether the broker is currently suspended (idle, no RTSP connection). */
13909
14304
  get suspended() {
@@ -14658,7 +15053,8 @@ var StreamBroker = class StreamBroker {
14658
15053
  captureRtpForPreBuffer(rtpData) {
14659
15054
  const marker = rtpData.length > 1 && (rtpData[1] & 128) !== 0;
14660
15055
  if (this.rtpRingPrevMarker || this.rtpRing.length === 0) this.rtpRingCurAuStart = this.rtpRing.length;
14661
- const owned = detachFromPool(rtpData);
15056
+ const owned = this.packetPool.adopt(rtpData);
15057
+ holdPacket(owned);
14662
15058
  this.rtpRing.push(owned);
14663
15059
  this.rtpRingBytes += backingStoreBytes(owned);
14664
15060
  if (this._lastNalParamSet || this._lastNalKeyframe) {
@@ -14754,6 +15150,7 @@ var StreamBroker = class StreamBroker {
14754
15150
  * non-decodable slice instead of waiting for the new dial's first keyframe.
14755
15151
  */
14756
15152
  resetRtpPreBuffer() {
15153
+ releasePackets(this.rtpRing);
14757
15154
  this.rtpRing = [];
14758
15155
  this.rtpRingBytes = 0;
14759
15156
  this.rtpRingCurAuStart = 0;
@@ -14850,6 +15247,7 @@ var StreamBroker = class StreamBroker {
14850
15247
  if (cut <= 0) return;
14851
15248
  const dropped = this.rtpRing.splice(0, cut);
14852
15249
  for (const b of dropped) this.rtpRingBytes -= backingStoreBytes(b);
15250
+ releasePackets(dropped);
14853
15251
  this.rtpRingKeyframeMarks = this.rtpRingKeyframeMarks.filter((mark) => mark.index >= cut).map((mark) => ({
14854
15252
  index: mark.index - cut,
14855
15253
  ts: mark.ts
@@ -15530,7 +15928,7 @@ var StreamBroker = class StreamBroker {
15530
15928
  this.audioRtpDecoder?.processPacket(rtpData);
15531
15929
  if (this.hasAudioConsumer()) this.audioCodecSession?.processPacket(rtpData);
15532
15930
  if ((this.audioCodec === "PCMU" || this.audioCodec === "PCMA") && this.encodedCallbacks.size > 0 && rtpData.length > 12) {
15533
- const payload = rtpData.subarray(12);
15931
+ const payload = Buffer.from(rtpData.subarray(12));
15534
15932
  if (payload.length > 0) {
15535
15933
  const rtpTimestamp = rtpData.readUInt32BE(4);
15536
15934
  const pts = Math.floor(rtpTimestamp / 8);
@@ -15593,7 +15991,8 @@ var StreamBroker = class StreamBroker {
15593
15991
  this.logger?.info("starting native RTSP reader");
15594
15992
  const client = new NativeRtspClient({
15595
15993
  url: source.url,
15596
- logger: this.logger?.child("rtsp-native")
15994
+ logger: this.logger?.child("rtsp-native"),
15995
+ packetAllocator: this.packetAllocator
15597
15996
  }, this.buildRtpStreamCallbacks("native RTSP", () => this.destroyNativeClient(), () => this.nativeClient !== client));
15598
15997
  this.nativeClient = client;
15599
15998
  client.connect().catch((err) => {
@@ -16100,7 +16499,8 @@ var StreamBroker = class StreamBroker {
16100
16499
  const reader = new Rfc4571Reader({
16101
16500
  url: source.url,
16102
16501
  sdp,
16103
- ...this.logger ? { logger: this.logger.child("rfc4571") } : {}
16502
+ ...this.logger ? { logger: this.logger.child("rfc4571") } : {},
16503
+ packetAllocator: this.packetAllocator
16104
16504
  }, this.buildRtpStreamCallbacks("rfc4571", () => this.destroyRfc4571Reader(), () => this.rfc4571Reader !== reader));
16105
16505
  this.rfc4571Reader = reader;
16106
16506
  reader.connect().catch((err) => {
@@ -17278,6 +17678,14 @@ var StreamBrokerManager = class StreamBrokerManager {
17278
17678
  rtspProvider;
17279
17679
  persistedTokens = /* @__PURE__ */ new Map();
17280
17680
  /**
17681
+ * True for a camera whose restream tokens are held in an unreadable keychain
17682
+ * (D870). Such a camera gets NO restream URL - minting a temporary token would
17683
+ * hand consumers a URL that dies as soon as the real one is restored - while
17684
+ * every other camera, and the camera's own streams and recording, are
17685
+ * untouched.
17686
+ */
17687
+ restreamGate = () => false;
17688
+ /**
17281
17689
  * Last runtime probe per broker, captured while the broker was streaming.
17282
17690
  * Survives the stream going idle so the per-stream "Probed" settings field
17283
17691
  * stays populated (with an "as of" tag). In-memory only — repopulates on the
@@ -17477,7 +17885,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17477
17885
  hwaccel: this.hwaccel ?? void 0,
17478
17886
  cameraStreamLookup: (deviceId) => this.getCameraStreamsForDevice(deviceId),
17479
17887
  rtspEntryLookup: (brokerId) => {
17480
- const entry = this.rtspProvider.getEntry(brokerId);
17888
+ const entry = this.rtspProvider.getInternalEntry(brokerId);
17481
17889
  return entry ? {
17482
17890
  url: entry.url,
17483
17891
  mutedUrl: entry.mutedUrl
@@ -17536,7 +17944,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17536
17944
  cameraStreamLookup: (deviceId) => this.getCameraStreamsForDevice(deviceId),
17537
17945
  resolveProfileSource: (deviceId, profile) => this.assignments.get(deviceId)?.map[profile] ?? null,
17538
17946
  rtspEntryLookup: (brokerId) => {
17539
- const entry = this.rtspProvider.getEntry(brokerId);
17947
+ const entry = this.rtspProvider.getInternalEntry(brokerId);
17540
17948
  return entry ? { url: entry.url } : null;
17541
17949
  },
17542
17950
  sourceHasMedia: (brokerId) => {
@@ -18600,6 +19008,20 @@ var StreamBrokerManager = class StreamBrokerManager {
18600
19008
  loadPersistedTokens(tokens) {
18601
19009
  this.persistedTokens = new Map(tokens);
18602
19010
  }
19011
+ /** See {@link restreamGate}. */
19012
+ setRestreamGate(gate) {
19013
+ this.restreamGate = gate;
19014
+ }
19015
+ /**
19016
+ * Register the restream URLs of ONE camera now (its tokens became readable, or
19017
+ * were issued fresh): each of its source brokers and the profile restreamers
19018
+ * over them. No broker is torn down or recreated, so nothing stops recording.
19019
+ */
19020
+ registerRestreamersForDevice(deviceId) {
19021
+ if (this.restreamGate(deviceId)) return;
19022
+ for (const [brokerId, broker] of this.brokers) if (brokerId.startsWith(`${deviceId}/`)) this.registerSourceRestreamer(brokerId, broker);
19023
+ this.syncProfileRestreamers(deviceId);
19024
+ }
18603
19025
  /** Upsert a single SOURCE (non-derived) cam-stream into the registry
18604
19026
  * WITHOUT ranking or broker creation. Returns the prior entry (if any)
18605
19027
  * so callers can detect url refresh / already-existed. */
@@ -19343,6 +19765,20 @@ var StreamBrokerManager = class StreamBrokerManager {
19343
19765
  return { success: true };
19344
19766
  }
19345
19767
  /**
19768
+ * Drop a DELETED camera's whole override entry and persist (D870). The
19769
+ * device-removal hook's one reach into the override map; `forgetDeviceHardware`
19770
+ * keeps the entry's non-hardware fields, a deleted camera keeps nothing.
19771
+ */
19772
+ dropDeviceOverride(deviceId) {
19773
+ if (!this.deviceOverrides.delete(deviceId)) return false;
19774
+ this.deviceOverridePersister?.(this.deviceOverrides);
19775
+ return true;
19776
+ }
19777
+ /** Cameras that have an override entry. */
19778
+ listDeviceOverrideIds() {
19779
+ return [...this.deviceOverrides.keys()];
19780
+ }
19781
+ /**
19346
19782
  * The hardware behind `deviceId` was replaced (`deviceManager.migrateDevice`)
19347
19783
  * — forget everything this broker knows that described the OLD box. See the
19348
19784
  * cap doc for the inventory. What deliberately SURVIVES is the role-scoped
@@ -20121,6 +20557,7 @@ var StreamBrokerManager = class StreamBrokerManager {
20121
20557
  const sourceBrokerId = brokerIdFor(deviceId, camStreamId);
20122
20558
  const sourceBroker = this.brokers.get(sourceBrokerId);
20123
20559
  if (!sourceBroker) continue;
20560
+ if (this.restreamGate(deviceId)) continue;
20124
20561
  const existingToken = this.persistedTokens.get(profileBrokerId);
20125
20562
  const warmUp = () => sourceBroker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
20126
20563
  const openPrestreamWindow = () => sourceBroker.openPrestreamWindow();
@@ -21780,11 +22217,7 @@ var StreamBrokerManager = class StreamBrokerManager {
21780
22217
  }
21781
22218
  this.brokers.set(brokerId, broker);
21782
22219
  this.applyClipRetention(deviceId);
21783
- const existingToken = this.persistedTokens.get(brokerId);
21784
- const warmUp = () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
21785
- const openPrestreamWindow = () => broker.openPrestreamWindow();
21786
- this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
21787
- if (!existingToken) this.rtspProvider.persistTokens();
22220
+ this.registerSourceRestreamer(brokerId, broker);
21788
22221
  await this.fanOutRegistration(deviceId, brokerId, cam, broker);
21789
22222
  this.syncProfileRestreamers(deviceId);
21790
22223
  if (cam.kind === "derived") {
@@ -21793,6 +22226,28 @@ var StreamBrokerManager = class StreamBrokerManager {
21793
22226
  }
21794
22227
  }
21795
22228
  /**
22229
+ * Register one source broker's RTSP restreamer with its persisted token (or a
22230
+ * freshly minted one). A camera whose tokens are held in an unreadable keychain
22231
+ * gets no EXTERNAL token, only the internal loopback-only alias.
22232
+ */
22233
+ registerSourceRestreamer(brokerId, broker) {
22234
+ const deviceId = parseBrokerId(brokerId)?.deviceId;
22235
+ if (deviceId === void 0) return;
22236
+ if (this.restreamGate(deviceId)) {
22237
+ this.logger.warn("external restream URL withheld - the tokens are held but unreadable", {
22238
+ tags: { deviceId },
22239
+ meta: { brokerId }
22240
+ });
22241
+ this.rtspServer.registerInternalAlias(brokerId, broker.getRtspRestreamer(), () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION), () => broker.openPrestreamWindow());
22242
+ return;
22243
+ }
22244
+ const existingToken = this.persistedTokens.get(brokerId);
22245
+ const warmUp = () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
22246
+ const openPrestreamWindow = () => broker.openPrestreamWindow();
22247
+ this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
22248
+ if (!existingToken) this.rtspProvider.persistTokens();
22249
+ }
22250
+ /**
21796
22251
  * Tear down the broker for a `(deviceId, camStreamId)` pair. No-op
21797
22252
  * when no broker exists. Called by `retractCameraStream` and by the
21798
22253
  * teardown half of `restartBroker`.
@@ -21912,7 +22367,7 @@ var StreamBrokerManager = class StreamBrokerManager {
21912
22367
  };
21913
22368
  } else {
21914
22369
  const sourceBrokerId = brokerIdFor(deviceId, request.camStreamId);
21915
- const entry = this.rtspProvider.getEntry(sourceBrokerId, "127.0.0.1");
22370
+ const entry = this.rtspProvider.getInternalEntry(sourceBrokerId, "127.0.0.1");
21916
22371
  if (!entry) throw new Error(`derived source camStream loopback unavailable: ${sourceBrokerId}`);
21917
22372
  loopbackUrl = entry.url;
21918
22373
  release = async () => {};
@@ -22690,19 +23145,19 @@ var H265Repacketizer = class {
22690
23145
  return this.codecInfo;
22691
23146
  }
22692
23147
  updateVps(vps) {
22693
- this.ensureCodecInfo().vps = vps;
23148
+ this.ensureCodecInfo().vps = Buffer.from(vps);
22694
23149
  }
22695
23150
  updateSps(sps) {
22696
- this.ensureCodecInfo().sps = sps;
23151
+ this.ensureCodecInfo().sps = Buffer.from(sps);
22697
23152
  }
22698
23153
  updatePps(pps) {
22699
- this.ensureCodecInfo().pps = pps;
23154
+ this.ensureCodecInfo().pps = Buffer.from(pps);
22700
23155
  }
22701
23156
  updateSeiPrefix(sei) {
22702
- this.ensureCodecInfo().seiPrefix = sei;
23157
+ this.ensureCodecInfo().seiPrefix = Buffer.from(sei);
22703
23158
  }
22704
23159
  updateSeiSuffix(sei) {
22705
- this.ensureCodecInfo().seiSuffix = sei;
23160
+ this.ensureCodecInfo().seiSuffix = Buffer.from(sei);
22706
23161
  }
22707
23162
  shouldFilter(_nalType) {
22708
23163
  return false;
@@ -23013,6 +23468,48 @@ var H265Repacketizer = class {
23013
23468
  }
23014
23469
  }
23015
23470
  };
23471
+ //#endregion
23472
+ //#region src/stream-broker/webrtc/output-arena.ts
23473
+ /**
23474
+ * A bump allocator for the buffers the WebRTC send path builds per packet
23475
+ * (repacketizer outputs, STAP-A aggregates).
23476
+ *
23477
+ * `Buffer.from([...])` and `Buffer.concat` below 4 KiB are served from Node's
23478
+ * shared 8 KiB pool: one ~1.2 KB fragment per pool slab share, interleaved
23479
+ * with every other short-lived pooled buffer in the process. werift keeps the
23480
+ * last RTP_HISTORY_SIZE (4096) outputs of a sender for NACK, so each pinned
23481
+ * the shared slab it came from and everything beside it. Here every output is
23482
+ * a dense view of an arena that holds nothing else.
23483
+ *
23484
+ * Nothing is ever reused: an arena is left to the GC once full, and lives as
23485
+ * long as the last view of it (a NACK slot, a datagram still in flight). That
23486
+ * is why there is no hold/release protocol here, unlike `slab-pool.ts`.
23487
+ */
23488
+ /** Bytes per arena: ~50 packets of ~1.2 KB per allocation instead of one per 6. */
23489
+ var OUTPUT_ARENA_BYTES = 64 * 1024;
23490
+ /** A request larger than a quarter of an arena gets its own buffer. */
23491
+ var OUTPUT_ARENA_MAX_REQUEST = 16384;
23492
+ var OutputArena = class {
23493
+ chunk = Buffer.allocUnsafeSlow(0);
23494
+ offset = 0;
23495
+ chunks = 0;
23496
+ /** `size` writable bytes (contents unspecified: the caller fills all of them). */
23497
+ take(size) {
23498
+ if (size > OUTPUT_ARENA_MAX_REQUEST) return Buffer.allocUnsafeSlow(size);
23499
+ if (this.offset + size > this.chunk.length) {
23500
+ this.chunk = Buffer.allocUnsafeSlow(OUTPUT_ARENA_BYTES);
23501
+ this.chunks += 1;
23502
+ this.offset = 0;
23503
+ }
23504
+ const out = this.chunk.subarray(this.offset, this.offset + size);
23505
+ this.offset += size;
23506
+ return out;
23507
+ }
23508
+ /** ArrayBuffers allocated by this arena so far. */
23509
+ get allocations() {
23510
+ return this.chunks;
23511
+ }
23512
+ };
23016
23513
  var NAL_HEADER_SIZE = 1;
23017
23514
  var FU_A_HEADER_SIZE = 2;
23018
23515
  var LENGTH_FIELD_SIZE = 2;
@@ -23057,6 +23554,8 @@ var H264Repacketizer = class {
23057
23554
  pendingFuA;
23058
23555
  stapa;
23059
23556
  fuaMin;
23557
+ /** Dense home for every buffer this repacketizer builds (`output-arena.ts`). */
23558
+ arena = new OutputArena();
23060
23559
  constructor(console, maxPacketSize, codecInfo, jitterBuffer = new JitterBuffer(console, 4)) {
23061
23560
  this.console = console;
23062
23561
  this.maxPacketSize = maxPacketSize;
@@ -23074,70 +23573,78 @@ var H264Repacketizer = class {
23074
23573
  return this.codecInfo;
23075
23574
  }
23076
23575
  updateSps(sps) {
23077
- this.ensureCodecInfo().sps = sps;
23576
+ this.ensureCodecInfo().sps = Buffer.from(sps);
23078
23577
  }
23079
23578
  updatePps(pps) {
23080
- this.ensureCodecInfo().pps = pps;
23579
+ this.ensureCodecInfo().pps = Buffer.from(pps);
23081
23580
  }
23082
23581
  updateSei(sei) {
23083
- this.ensureCodecInfo().sei = sei;
23582
+ this.ensureCodecInfo().sei = Buffer.from(sei);
23084
23583
  }
23085
23584
  shouldFilter(_nalType) {
23086
23585
  return false;
23087
23586
  }
23088
23587
  packetizeFuA(data, noStart, noEnd) {
23588
+ let nalHeader;
23589
+ let bodyStart;
23089
23590
  if ((data[0] & 31) === 28) {
23090
- const fnri = data[0] & 224;
23091
- const originalNalType = data[1] & 31;
23092
23591
  const isFuStart = !!(data[1] & 128);
23093
23592
  const isFuEnd = !!(data[1] & 64);
23094
- const isFuMiddle = !isFuStart && !isFuEnd;
23095
- const originalNalHeader = Buffer.from([fnri | originalNalType]);
23096
- data = Buffer.concat([originalNalHeader, data.subarray(FU_A_HEADER_SIZE)]);
23593
+ nalHeader = data[0] & 224 | data[1] & 31;
23594
+ bodyStart = FU_A_HEADER_SIZE;
23097
23595
  if (isFuStart) noEnd = true;
23098
23596
  else if (isFuEnd) noStart = true;
23099
- else if (isFuMiddle) {
23597
+ else {
23100
23598
  noStart = true;
23101
23599
  noEnd = true;
23102
23600
  }
23601
+ } else {
23602
+ nalHeader = data[0];
23603
+ bodyStart = NAL_HEADER_SIZE;
23103
23604
  }
23104
- const fnri = data[0] & 224;
23105
- const nalType = data[0] & 31;
23106
- const fuIndicator = fnri | 28;
23107
- const fuHeaderMiddle = Buffer.from([fuIndicator, nalType]);
23108
- const fuHeaderStart = noStart ? fuHeaderMiddle : Buffer.from([fuIndicator, nalType | 128]);
23109
- const fuHeaderEnd = noEnd ? fuHeaderMiddle : Buffer.from([fuIndicator, nalType | 64]);
23110
- let fuHeader = fuHeaderStart;
23605
+ const nalType = nalHeader & 31;
23606
+ const fuIndicator = nalHeader & 224 | 28;
23607
+ const startHeader = noStart ? nalType : nalType | 128;
23608
+ const endHeader = noEnd ? nalType : nalType | 64;
23111
23609
  const packages = [];
23112
- let offset = NAL_HEADER_SIZE;
23610
+ let offset = bodyStart;
23611
+ let first = true;
23113
23612
  while (offset < data.length) {
23114
23613
  const packageSize = Math.min(this.fuaMax, data.length - offset);
23115
- const payload = data.subarray(offset, offset + packageSize);
23614
+ const last = offset + packageSize === data.length;
23615
+ const out = this.arena.take(FU_A_HEADER_SIZE + packageSize);
23616
+ out[0] = fuIndicator;
23617
+ out[1] = last ? endHeader : first ? startHeader : nalType;
23618
+ data.copy(out, FU_A_HEADER_SIZE, offset, offset + packageSize);
23619
+ packages.push(out);
23116
23620
  offset += packageSize;
23117
- if (offset === data.length) fuHeader = fuHeaderEnd;
23118
- packages.push(Buffer.concat([fuHeader, payload]));
23119
- fuHeader = fuHeaderMiddle;
23621
+ first = false;
23120
23622
  }
23121
23623
  return packages;
23122
23624
  }
23123
23625
  packetizeOneStapA(datas) {
23124
- const payload = [];
23125
23626
  if (!datas.length) throw new Error("packetizeOneStapA requires at least one NAL");
23126
- let counter = 0;
23627
+ const taken = [];
23127
23628
  let availableSize = this.maxPacketSize - STAP_A_HEADER_SIZE;
23128
23629
  const stapHeader = 24;
23129
- while (datas.length && datas[0].length + LENGTH_FIELD_SIZE <= availableSize && counter < 9) {
23630
+ while (datas.length && datas[0].length + LENGTH_FIELD_SIZE <= availableSize && taken.length < 9) {
23130
23631
  const nalu = datas.shift();
23131
23632
  availableSize -= LENGTH_FIELD_SIZE + nalu.length;
23132
- counter += 1;
23133
- const packed = Buffer.alloc(2);
23134
- packed.writeUInt16BE(nalu.length, 0);
23135
- payload.push(packed, nalu);
23633
+ taken.push(nalu);
23634
+ }
23635
+ if (taken.length === 0) return datas.shift();
23636
+ if (taken.length === 1) return taken[0];
23637
+ let total = 1;
23638
+ for (const nalu of taken) total += LENGTH_FIELD_SIZE + nalu.length;
23639
+ const out = this.arena.take(total);
23640
+ out[0] = stapHeader;
23641
+ let offset = 1;
23642
+ for (const nalu of taken) {
23643
+ out.writeUInt16BE(nalu.length, offset);
23644
+ nalu.copy(out, offset + LENGTH_FIELD_SIZE);
23645
+ offset += LENGTH_FIELD_SIZE + nalu.length;
23136
23646
  }
23137
- if (counter === 0) return datas.shift();
23138
- if (counter === 1) return payload[1];
23139
- payload.unshift(Buffer.from([stapHeader]));
23140
- return Buffer.concat(payload);
23647
+ return out;
23141
23648
  }
23142
23649
  packetizeStapA(datas) {
23143
23650
  const ret = [];
@@ -23169,12 +23676,15 @@ var H264Repacketizer = class {
23169
23676
  const originalNalType = first.payload[1] & 31;
23170
23677
  const hasFuStart = !!(first.payload[1] & 128);
23171
23678
  const hasFuEnd = !!(last.payload[1] & 64);
23172
- const fnri = first.payload[0] & 224;
23173
- const originalNalHeader = Buffer.from([fnri | originalNalType]);
23679
+ const originalNalHeader = first.payload[0] & 224 | originalNalType;
23174
23680
  const getDefragmentedPendingFua = () => {
23175
- const originalFragments = pending.map((packet) => packet.payload.subarray(FU_A_HEADER_SIZE));
23176
- originalFragments.unshift(originalNalHeader);
23177
- return Buffer.concat(originalFragments);
23681
+ let total = NAL_HEADER_SIZE;
23682
+ for (const packet of pending) total += packet.payload.length - FU_A_HEADER_SIZE;
23683
+ const out = this.arena.take(total);
23684
+ out[0] = originalNalHeader;
23685
+ let offset = NAL_HEADER_SIZE;
23686
+ for (const packet of pending) offset += packet.payload.copy(out, offset, FU_A_HEADER_SIZE);
23687
+ return out;
23178
23688
  };
23179
23689
  if (originalNalType === 7) {
23180
23690
  const splits = splitH264NaluStartCode(getDefragmentedPendingFua());
@@ -23699,6 +24209,8 @@ function selectAudioClock(mimeType) {
23699
24209
  */
23700
24210
  /** A REMB unchanged this long is no estimate (`RembWatch`). */
23701
24211
  var REMB_STALE_MS = 3e4;
24212
+ /** werift's NACK history depth (patched RTP_HISTORY_SIZE, `patches/werift+0.22.9.patch`). */
24213
+ var SOURCE_RTP_HOLD_PACKETS = 4096;
23702
24214
  var _werift;
23703
24215
  async function loadWerift() {
23704
24216
  if (_werift) return _werift;
@@ -24246,6 +24758,8 @@ var AdaptiveSession = class AdaptiveSession {
24246
24758
  * recovery). While active, live source-RTP packets are enqueued behind
24247
24759
  * the replay tail to preserve packet order. Aborted on close/rebind. */
24248
24760
  activeRtpReplay = null;
24761
+ /** The source packets werift's NACK history may still read (`slab-pool.ts`). */
24762
+ sourceRtpHold = new HeldPacketWindow(SOURCE_RTP_HOLD_PACKETS);
24249
24763
  /**
24250
24764
  * Trickle ICE: the server's locally-gathered candidates, buffered as werift
24251
24765
  * surfaces them via `onIceCandidate`. The client polls `getIceCandidates`
@@ -25124,6 +25638,7 @@ var AdaptiveSession = class AdaptiveSession {
25124
25638
  this.activeRtpReplay.abort();
25125
25639
  this.activeRtpReplay = null;
25126
25640
  }
25641
+ this.sourceRtpHold.releaseAll();
25127
25642
  await this.releaseTranscodeEgress();
25128
25643
  try {
25129
25644
  await this.source.return(void 0);
@@ -25257,6 +25772,7 @@ var AdaptiveSession = class AdaptiveSession {
25257
25772
  } });
25258
25773
  return false;
25259
25774
  }
25775
+ this.sourceRtpHold.hold(rtpData);
25260
25776
  if (this.sourceVideoSsrc === null) this.sourceVideoSsrc = srcPkt.header.ssrc;
25261
25777
  const pt = this.videoSender.codec?.payloadType ?? (codec === "H265" ? 97 : 96);
25262
25778
  let outPkts;
@@ -37862,6 +38378,16 @@ function dialAnswerFromError(err) {
37862
38378
  }
37863
38379
  //#endregion
37864
38380
  //#region src/stream-broker/broker-mem-report.ts
38381
+ function readSlabReading() {
38382
+ const totals = readSlabTotals();
38383
+ const allocsDelta = totals.allocs - lastSlabAllocs;
38384
+ lastSlabAllocs = totals.allocs;
38385
+ return {
38386
+ live: totals.live,
38387
+ free: totals.free,
38388
+ allocsDelta
38389
+ };
38390
+ }
37865
38391
  /** Linux only: elsewhere (or on a read error) the native view is simply absent. */
37866
38392
  async function readProc() {
37867
38393
  if (process.platform !== "linux") return {
@@ -37874,6 +38400,8 @@ async function readProc() {
37874
38400
  maps
37875
38401
  };
37876
38402
  }
38403
+ /** `slabAllocs` is a delta: the totals the previous line saw. */
38404
+ var lastSlabAllocs = readSlabTotals().allocs;
37877
38405
  async function emitOnce(deps) {
37878
38406
  const now = Date.now();
37879
38407
  const [proc, worker] = await Promise.all([readProc(), readDemuxWorkerHeaps(now)]);
@@ -37886,6 +38414,7 @@ async function emitOnce(deps) {
37886
38414
  proc,
37887
38415
  churn: consumerChurn.drain(),
37888
38416
  copied: readCopyOwnedTotals(),
38417
+ slabs: readSlabReading(),
37889
38418
  v8: readV8Heap(),
37890
38419
  worker,
37891
38420
  sessions: deps.sessionMem(),
@@ -37973,18 +38502,177 @@ function subscribeDeviceRemoval(subscribe, deps) {
37973
38502
  });
37974
38503
  }
37975
38504
  //#endregion
38505
+ //#region src/stream-broker/device-removal-hook.ts
38506
+ /**
38507
+ * The stream-broker's `device-removal-hook` (D870).
38508
+ *
38509
+ * What the broker keeps PER CAMERA is a key of the camera's own settings
38510
+ * (`probeSnapshots`, `brokerProfile`, `rtspTokens` - see `broker-device-state.ts`),
38511
+ * and the central device delete (D865) clears those for every addon. What is
38512
+ * left is the broker's live state and its one addon-level map keyed by camera,
38513
+ * `deviceOverrides`: the hook retracts the camera's streams, purges its slot
38514
+ * assignments and drops its override entry. Idempotent - a camera with nothing
38515
+ * left answers zero counts.
38516
+ */
38517
+ function createBrokerRemovalHook(deps) {
38518
+ return {
38519
+ onDeviceRemoved: async (input) => {
38520
+ const { deviceId } = input;
38521
+ try {
38522
+ await deps.retractDevice(deviceId);
38523
+ } catch (err) {
38524
+ deps.logger.warn("stream-broker: retract of a deleted device failed", {
38525
+ tags: { deviceId },
38526
+ meta: { error: require_dist.errMsg(err) }
38527
+ });
38528
+ throw err;
38529
+ }
38530
+ const overrides = deps.dropDeviceOverride(deviceId) ? 1 : 0;
38531
+ if (overrides > 0) deps.logger.info("stream-broker: removed the override of a deleted device", {
38532
+ tags: { deviceId },
38533
+ meta: { deletedAt: input.deletedAt }
38534
+ });
38535
+ return { counts: { "device-overrides": overrides } };
38536
+ },
38537
+ listDeviceData: async () => deps.overrideDeviceIds().filter((deviceId) => Number.isInteger(deviceId) && deviceId > 0).map((deviceId) => ({
38538
+ store: "device-overrides",
38539
+ deviceId,
38540
+ count: 1
38541
+ }))
38542
+ };
38543
+ }
38544
+ /** Classify every held camera against the keychain: wait for it, or give up on the value. */
38545
+ async function classifyHeld(held, secrets) {
38546
+ const refs = /* @__PURE__ */ new Map();
38547
+ for (const [deviceId, stored] of held) if (require_dist.isSecretMarker(stored)) refs.set(deviceId, stored.$secret);
38548
+ if (refs.size === 0) return [];
38549
+ const resolved = await secrets.resolve([...refs.values()]);
38550
+ const out = [];
38551
+ for (const [deviceId, ref] of refs) {
38552
+ const entry = resolved[ref];
38553
+ if (entry === void 0 || entry.kind === "unavailable") out.push({
38554
+ deviceId,
38555
+ ref,
38556
+ kind: "unavailable",
38557
+ reason: entry?.kind === "unavailable" ? entry.reason : null
38558
+ });
38559
+ else if (entry.kind === "missing") out.push({
38560
+ deviceId,
38561
+ ref,
38562
+ kind: "missing",
38563
+ reason: null
38564
+ });
38565
+ else if (entry.kind === "forbidden") out.push({
38566
+ deviceId,
38567
+ ref,
38568
+ kind: "unavailable",
38569
+ reason: "forbidden"
38570
+ });
38571
+ }
38572
+ return out;
38573
+ }
38574
+ 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.";
38575
+ 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.`;
38576
+ var TokenRecovery = class {
38577
+ deps;
38578
+ /** Cameras alerted as unavailable: the alert is cleared when the value resolves. */
38579
+ waiting = /* @__PURE__ */ new Set();
38580
+ /** Consecutive `missing` reads per camera (D49: two before the value is given up). */
38581
+ missingReads = /* @__PURE__ */ new Map();
38582
+ timer = null;
38583
+ running = false;
38584
+ constructor(deps) {
38585
+ this.deps = deps;
38586
+ }
38587
+ /** Cameras currently blocked. */
38588
+ blocked() {
38589
+ return [...this.deps.mirror.heldDevices().keys()].toSorted((a, b) => a - b);
38590
+ }
38591
+ /** Check now, then on a timer while any camera is still held. */
38592
+ start() {
38593
+ if (this.timer !== null) return;
38594
+ this.tick();
38595
+ this.timer = setInterval(() => void this.tick(), this.deps.recheckMs ?? 3e4);
38596
+ this.timer.unref?.();
38597
+ }
38598
+ stop() {
38599
+ if (this.timer !== null) clearInterval(this.timer);
38600
+ this.timer = null;
38601
+ }
38602
+ /** One scheduled / change-triggered check; never overlaps itself, never throws. */
38603
+ async tick() {
38604
+ if (this.running) return;
38605
+ this.running = true;
38606
+ try {
38607
+ await this.check();
38608
+ } catch (err) {
38609
+ this.deps.logger.warn("restream-token re-check failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
38610
+ } finally {
38611
+ this.running = false;
38612
+ }
38613
+ if (this.blocked().length === 0 && this.waiting.size === 0) this.stop();
38614
+ }
38615
+ /** See the module doc. Idempotent. */
38616
+ async check() {
38617
+ const before = new Set(this.deps.mirror.heldDevices().keys());
38618
+ if (before.size === 0 && this.waiting.size === 0) return;
38619
+ const tokens = await this.deps.mirror.load();
38620
+ const now = new Set(this.deps.mirror.heldDevices().keys());
38621
+ const classified = await classifyHeld(this.deps.mirror.heldDevices(), this.deps.secrets);
38622
+ const released = [];
38623
+ for (const camera of classified) {
38624
+ if (camera.kind === "missing") {
38625
+ const reads = (this.missingReads.get(camera.deviceId) ?? 0) + 1;
38626
+ this.missingReads.set(camera.deviceId, reads);
38627
+ if (reads < 2) continue;
38628
+ this.deps.mirror.release(camera.deviceId);
38629
+ this.missingReads.delete(camera.deviceId);
38630
+ if (this.waiting.delete(camera.deviceId)) await this.deps.alerts.clear(camera.deviceId);
38631
+ released.push(camera.deviceId);
38632
+ continue;
38633
+ }
38634
+ this.missingReads.delete(camera.deviceId);
38635
+ if (this.waiting.has(camera.deviceId)) continue;
38636
+ this.waiting.add(camera.deviceId);
38637
+ this.deps.logger.error("restream tokens held but not readable", {
38638
+ tags: { deviceId: camera.deviceId },
38639
+ meta: {
38640
+ reason: camera.reason,
38641
+ ref: camera.ref
38642
+ }
38643
+ });
38644
+ await this.deps.alerts.raise({
38645
+ deviceId: camera.deviceId,
38646
+ kind: "unavailable",
38647
+ message: UNAVAILABLE_TEXT
38648
+ });
38649
+ }
38650
+ const recovered = [...before].filter((id) => !now.has(id) && !released.includes(id));
38651
+ for (const deviceId of recovered) if (this.waiting.delete(deviceId)) await this.deps.alerts.clear(deviceId);
38652
+ if (recovered.length > 0) this.deps.logger.info("restream tokens readable again", { meta: { cameras: recovered.length } });
38653
+ const cameras = [...recovered, ...released];
38654
+ if (cameras.length > 0) await this.deps.onRegister(tokens, cameras);
38655
+ for (const deviceId of released) {
38656
+ this.deps.logger.error("restream tokens lost - new URLs issued", { tags: { deviceId } });
38657
+ await this.deps.alerts.raise({
38658
+ deviceId,
38659
+ kind: "missing",
38660
+ message: issuedText(deviceId)
38661
+ });
38662
+ }
38663
+ }
38664
+ };
38665
+ //#endregion
37976
38666
  //#region src/stream-broker/addon.ts
37977
38667
  /**
37978
- * Hydrate the persisted RTSP restream state. Tokens come from their own
37979
- * collection (one row per broker); `rtspEnabled` is still a key of the settings
37980
- * row. An unparseable token row is skipped by the store — one dead token, not
37981
- * every token — and an empty set keeps first-boot silence (no loader call, no
37982
- * log).
38668
+ * Hydrate the persisted RTSP restream state. Tokens come from the cameras' own
38669
+ * settings; `rtspEnabled` is still a key of the settings row. An unreadable
38670
+ * token set THROWS (the caller refuses to write tokens afterwards - a
38671
+ * replacement minted over a token that was merely unreadable would turn every
38672
+ * external NVR URL into a 404), and an empty set keeps first-boot silence.
37983
38673
  */
37984
38674
  async function hydratePersistedRtspState(deps) {
37985
- const rows = await deps.tokensStore.readAll();
37986
- const tokens = /* @__PURE__ */ new Map();
37987
- for (const [brokerId, row] of rows) tokens.set(brokerId, row.token);
38675
+ const tokens = flattenByDevice(await deps.tokensMirror.load());
37988
38676
  if (tokens.size > 0) {
37989
38677
  deps.loadTokens(tokens);
37990
38678
  deps.logger.info("Loaded persisted RTSP tokens", { meta: { tokenCount: tokens.size } });
@@ -38053,6 +38741,8 @@ var PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS = 6e4;
38053
38741
  * the sample as absent (the RTCP Receiver-Report path drives the ladder anyway). */
38054
38742
  var NETWORK_QUALITY_STATS_TIMEOUT_MS = 2e3;
38055
38743
  var StreamBrokerAddon = class extends require_dist.BaseAddon {
38744
+ /** The restream tokens of a camera are a per-device setting held in the keychain (D870). */
38745
+ secretStatePaths = ["rtspTokens"];
38056
38746
  brokerManager = null;
38057
38747
  /** The node's pinned ffmpeg, mirrored here so the recorded-playback demuxers
38058
38748
  * (segment-demux/segment-audio) spawn the same binary as the transcode
@@ -38109,6 +38799,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38109
38799
  * restarts). Loaded on activate, flushed throttled from the metrics loop and
38110
38800
  * once on shutdown. */
38111
38801
  probeSnapshots = null;
38802
+ removalHook = null;
38112
38803
  lastProbePersistAt = 0;
38113
38804
  constructor() {
38114
38805
  super({
@@ -38171,24 +38862,26 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38171
38862
  logger: this.ctx.logger
38172
38863
  });
38173
38864
  await this.ffmpegWiring.load();
38174
- await this.declareRowCollections();
38175
- await this.purgeFlattenedSettingsKeys();
38865
+ const deviceSettings = this.deviceSettings();
38176
38866
  const rtspProvider = this.brokerManager.getRtspRestreamProvider();
38177
38867
  const brokerManager = this.brokerManager;
38178
- const rtspTokens = rtspTokensStore(this.ctx.api.settingsStore, this.ctx.logger);
38868
+ const rtspTokens = rtspTokensMirror(deviceSettings, this.ctx.logger);
38179
38869
  const rtspEnabledState = this.state("rtspEnabled", RtspEnabledSchema, {});
38180
38870
  try {
38181
38871
  await hydratePersistedRtspState({
38182
- tokensStore: rtspTokens,
38872
+ tokensMirror: rtspTokens,
38183
38873
  enabledState: rtspEnabledState,
38184
38874
  loadTokens: (tokens) => brokerManager.loadPersistedTokens(tokens),
38185
38875
  loadEnabled: (states) => rtspProvider.loadPersistedEnabled(states),
38186
38876
  logger: this.ctx.logger
38187
38877
  });
38188
- } catch {}
38878
+ } catch (err) {
38879
+ this.ctx.logger.warn("Failed to load persisted RTSP state - tokens will not be written", { meta: { error: require_dist.errMsg(err) } });
38880
+ }
38881
+ this.startTokenRecovery(rtspTokens, brokerManager);
38189
38882
  rtspProvider.setTokenPersister(async (tokens) => {
38190
38883
  try {
38191
- for (const [brokerId, token] of tokens) await rtspTokens.write(brokerId, { token });
38884
+ await rtspTokens.merge(mergeRtspTokens(rtspTokens, tokens));
38192
38885
  this.ctx.logger.info("Persisted RTSP tokens", { meta: { current: tokens.size } });
38193
38886
  } catch (err) {
38194
38887
  this.ctx.logger.warn("Failed to persist RTSP tokens", { meta: { error: require_dist.errMsg(err) } });
@@ -38221,24 +38914,22 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38221
38914
  this.ctx.logger.warn("Failed to persist device overrides", { meta: { error: require_dist.errMsg(err) } });
38222
38915
  });
38223
38916
  });
38224
- const profileMap = profileMapStore(this.ctx.api.settingsStore, this.ctx.logger);
38917
+ const profileMap = profileMapMirror(deviceSettings, this.ctx.logger);
38225
38918
  const profileMapPersister = (assignments) => {
38226
38919
  const next = /* @__PURE__ */ new Map();
38227
- for (const [deviceId, assignment] of assignments) next.set(`${deviceId}`, {
38920
+ for (const [deviceId, assignment] of assignments) next.set(deviceId, {
38228
38921
  map: { ...assignment.map },
38229
38922
  auto: assignment.auto
38230
38923
  });
38231
- profileMap.replaceAll(next).catch((err) => {
38924
+ profileMap.replace(next).catch((err) => {
38232
38925
  this.ctx.logger.warn("Failed to persist profile map", { meta: { error: require_dist.errMsg(err) } });
38233
38926
  });
38234
38927
  };
38235
38928
  this.brokerManager.setProfileMapPersister(profileMapPersister);
38236
38929
  try {
38237
- const rows = await profileMap.readAll();
38930
+ const rows = await profileMap.load();
38238
38931
  const loaded = /* @__PURE__ */ new Map();
38239
- for (const [rawKey, val] of rows) {
38240
- const deviceId = Number(rawKey);
38241
- if (!Number.isInteger(deviceId)) continue;
38932
+ for (const [deviceId, val] of rows) {
38242
38933
  const map = {};
38243
38934
  for (const profile of PROFILE_KEYS) {
38244
38935
  const v = val.map[profile];
@@ -38254,18 +38945,18 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38254
38945
  } catch (err) {
38255
38946
  this.ctx.logger.warn("Failed to load profile map", { meta: { error: require_dist.errMsg(err) } });
38256
38947
  }
38257
- const probeSnapshots = probeSnapshotsStore(this.ctx.api.settingsStore, this.ctx.logger);
38948
+ const probeSnapshots = probeSnapshotsMirror(deviceSettings, this.ctx.logger);
38258
38949
  this.probeSnapshots = probeSnapshots;
38259
38950
  try {
38260
- const rows = await probeSnapshots.readAll();
38261
- const blob = Object.fromEntries(rows);
38951
+ const rows = await probeSnapshots.load();
38952
+ const blob = Object.fromEntries(flattenByDevice(rows));
38262
38953
  this.brokerManager.loadProbeSnapshots(blob);
38263
- this.ctx.logger.info("Loaded persisted probe snapshots", { meta: { streamCount: rows.size } });
38954
+ this.ctx.logger.info("Loaded persisted probe snapshots", { meta: { streamCount: Object.keys(blob).length } });
38264
38955
  } catch (err) {
38265
38956
  this.ctx.logger.warn("Failed to load probe snapshots", { meta: { error: require_dist.errMsg(err) } });
38266
38957
  }
38267
38958
  this.brokerManager.setDeviceHardwareForgetHook(async (deviceId) => {
38268
- const rtspTokenRowsDeleted = await rtspTokens.deleteWhere({ where: { deviceId } });
38959
+ const rtspTokenRowsDeleted = await rtspTokens.remove(deviceId) ? 1 : 0;
38269
38960
  this.flushProbeSnapshots(Date.now(), true);
38270
38961
  return { rtspTokenRowsDeleted };
38271
38962
  });
@@ -38523,6 +39214,14 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38523
39214
  } catch (err) {
38524
39215
  this.ctx.logger.warn("stream-broker: client-log ingest failed to serve", { meta: { error: require_dist.errMsg(err) } });
38525
39216
  }
39217
+ this.removalHook = createBrokerRemovalHook({
39218
+ logger: this.ctx.logger,
39219
+ retractDevice: async (deviceId) => {
39220
+ await retractRemovedDevice(deviceId, this.deviceRemovalDeps());
39221
+ },
39222
+ dropDeviceOverride: (deviceId) => brokerManager.dropDeviceOverride(deviceId),
39223
+ overrideDeviceIds: () => brokerManager.listDeviceOverrideIds()
39224
+ });
38526
39225
  return [
38527
39226
  {
38528
39227
  capability: require_dist.streamBrokerCapability,
@@ -38541,9 +39240,90 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38541
39240
  provider: widgetsProvider
38542
39241
  },
38543
39242
  logChannelsRegistration,
38544
- loadContributionRegistration
39243
+ loadContributionRegistration,
39244
+ {
39245
+ capability: require_dist.deviceRemovalHookCapability,
39246
+ provider: this.removalHook
39247
+ }
38545
39248
  ];
38546
39249
  }
39250
+ /**
39251
+ * Watch the cameras whose restream tokens are held but unreadable (D870): no
39252
+ * restream URL is registered for them (the manager's gate), an alert says so,
39253
+ * and when a value resolves ONLY that camera's restreamers are registered with
39254
+ * the stored tokens - no teardown. A lost secret is released after two
39255
+ * consistent reads and gets new, persisted tokens.
39256
+ */
39257
+ startTokenRecovery(mirror, brokerManager) {
39258
+ const secrets = this.ctx.secrets;
39259
+ if (secrets === void 0) {
39260
+ 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 } });
39261
+ return;
39262
+ }
39263
+ brokerManager.setRestreamGate((deviceId) => mirror.isBlocked(deviceId));
39264
+ if (mirror.heldDevices().size === 0) return;
39265
+ const alertId = (deviceId) => `stream-broker-tokens:${deviceId}`;
39266
+ const warn = (what) => (err) => this.ctx.logger.warn(what, { meta: { error: require_dist.errMsg(err) } });
39267
+ const recovery = new TokenRecovery({
39268
+ mirror,
39269
+ secrets,
39270
+ logger: this.ctx.logger,
39271
+ alerts: {
39272
+ raise: async ({ deviceId, kind, message }) => {
39273
+ const now = Date.now();
39274
+ await this.ctx.api.alerts.emit.mutate({
39275
+ id: alertId(deviceId),
39276
+ category: "streams",
39277
+ severity: "warning",
39278
+ title: kind === "missing" ? "New RTSP restream URLs issued" : "RTSP restream URLs unavailable",
39279
+ message,
39280
+ status: "active",
39281
+ read: false,
39282
+ createdAt: now,
39283
+ updatedAt: now,
39284
+ source: {
39285
+ type: "device",
39286
+ id: String(deviceId)
39287
+ },
39288
+ metadata: {
39289
+ deviceId,
39290
+ kind
39291
+ }
39292
+ }).catch(warn("could not raise the restream-token alert"));
39293
+ },
39294
+ clear: async (deviceId) => {
39295
+ await this.ctx.api.alerts.update.mutate({
39296
+ alertId: alertId(deviceId),
39297
+ patch: {
39298
+ status: "completed",
39299
+ updatedAt: Date.now()
39300
+ }
39301
+ }).catch(warn("could not clear the restream-token alert"));
39302
+ }
39303
+ },
39304
+ onRegister: async (tokens, cameras) => {
39305
+ brokerManager.loadPersistedTokens(flattenByDevice(tokens));
39306
+ for (const deviceId of cameras) brokerManager.registerRestreamersForDevice(deviceId);
39307
+ }
39308
+ });
39309
+ recovery.start();
39310
+ const unsubscribe = secrets.onChange((ref) => {
39311
+ if (ref.startsWith("secret://stream-broker/device/")) recovery.tick();
39312
+ });
39313
+ this.ctx.addDisposer(() => {
39314
+ recovery.stop();
39315
+ unsubscribe();
39316
+ });
39317
+ }
39318
+ async onHubReachable() {
39319
+ const removalHook = this.removalHook;
39320
+ if (removalHook === null) return;
39321
+ this.ctx.addDisposer(require_dist.armRemovalReconcile({
39322
+ api: this.ctx.api,
39323
+ logger: this.ctx.logger,
39324
+ remove: (input) => removalHook.onDeviceRemoved(input)
39325
+ }));
39326
+ }
38547
39327
  async onShutdown() {
38548
39328
  this.logChannels?.stop();
38549
39329
  this.logChannels = null;
@@ -38644,41 +39424,14 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38644
39424
  this.flushProbeSnapshots(timestamp, false);
38645
39425
  }
38646
39426
  /**
38647
- * Declare the three row collections that replaced the `probeSnapshots`,
38648
- * `rtspTokens` and `profileMap` blob keys. Idempotent — a re-declaration of
38649
- * the same shape is a no-op in the engine.
38650
- *
38651
- * Not swallowed: a broker whose `stream-broker:rtsp-tokens` is undeclared
38652
- * mints a new token for every stream and every external NVR URL in the
38653
- * installation goes dead. Crashing loudly is bounded by `CrashSupervisor`
38654
- * (D6); silently reissuing tokens is not.
39427
+ * The per-device settings, as the broker persists through them. Resolved
39428
+ * reads/writes (keychain markers never reach this code), and a context with
39429
+ * no settings is a broker that cannot start.
38655
39430
  */
38656
- async declareRowCollections() {
38657
- const store = this.ctx.api.settingsStore;
38658
- const logger = this.ctx.logger;
38659
- await probeSnapshotsStore(store, logger).declare();
38660
- await rtspTokensStore(store, logger).declare();
38661
- await profileMapStore(store, logger).declare();
38662
- }
38663
- /**
38664
- * Drop the flattened keys from `stream-broker:addon-settings` / `root`, each
38665
- * gated on its own successor collection being non-empty. `rtspEnabled` and
38666
- * `deviceOverrides` stay — see `broker-row-stores.ts` for why.
38667
- *
38668
- * Best-effort: a purge that throws must not take the broker down. The keys
38669
- * are dead weight, not a fault.
38670
- */
38671
- async purgeFlattenedSettingsKeys() {
38672
- try {
38673
- await require_retire_root_keys.retireRootKeys({
38674
- store: this.ctx.api.settingsStore,
38675
- addonId: STREAM_BROKER_ADDON_ID,
38676
- logger: this.ctx.logger,
38677
- specs: STREAM_BROKER_RETIRED_ROOT_KEYS
38678
- });
38679
- } catch (err) {
38680
- this.ctx.logger.warn("stream-broker: retiring the flattened settings keys failed", { meta: { error: require_dist.errMsg(err) } });
38681
- }
39431
+ deviceSettings() {
39432
+ const settings = this.ctx.settings;
39433
+ if (settings === void 0) throw new Error("stream-broker: device settings are not available");
39434
+ return settings;
38682
39435
  }
38683
39436
  /**
38684
39437
  * Persist the manager's probe map to the durable handle. Throttled to
@@ -38691,7 +39444,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
38691
39444
  if (!store || !manager) return;
38692
39445
  if (!force && now - this.lastProbePersistAt < PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS) return;
38693
39446
  this.lastProbePersistAt = now;
38694
- store.replaceAll(new Map(Object.entries(manager.getProbeSnapshots()))).catch((err) => {
39447
+ store.replace(groupByDevice(manager.getProbeSnapshots())).catch((err) => {
38695
39448
  this.ctx.logger.warn("Failed to persist probe snapshots", { meta: { error: require_dist.errMsg(err) } });
38696
39449
  });
38697
39450
  }