@camstack/addon-pipeline 1.2.215 → 1.2.217

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 (37) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-C2vYSX3O.mjs → default-detection-model-BwNEV8Wl.mjs} +1 -1
  4. package/dist/{default-detection-model-f0PImZnh.js → default-detection-model-DzzuINe-.js} +1 -1
  5. package/dist/detection-pipeline/index.js +5 -5
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-DoHcimdu.js → dist-D7hxiBnC.js} +237 -16
  8. package/dist/{dist-DttqKvsQ.mjs → dist-DgeXZfwn.mjs} +237 -16
  9. package/dist/{lazy-sharp-BeeQGCUu.js → lazy-sharp-BSkbiuz5.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-NtM7__8q.mjs → node-C9GcBrwk.mjs} +1 -1
  13. package/dist/{node-6uF72wri.js → node-DDhmPcOx.js} +1 -1
  14. package/dist/pipeline-runner/index.js +538 -24
  15. package/dist/pipeline-runner/index.mjs +537 -23
  16. package/dist/{process-memory-CddpCDWL.mjs → process-memory-Dw3CGS3D.mjs} +1 -1
  17. package/dist/{process-memory-BRdyl5fE.js → process-memory-ENqmiiis.js} +1 -1
  18. package/dist/recorder/index.js +148 -40
  19. package/dist/recorder/index.mjs +148 -40
  20. package/dist/{segment-demux-js-CsFtPVzH.mjs → segment-demux-js-BGbKlotF.mjs} +1 -1
  21. package/dist/{segment-demux-js-CkJPdyDH.js → segment-demux-js-sLs8mbZQ.js} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +58 -45
  23. package/dist/session-decode/decode-worker-child.mjs +57 -44
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ZIja2AOe.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BULotDCb.mjs} +3 -3
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-yJ2CyixO.mjs +26 -0
  27. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Uxkwkgy5.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DICVQkNi.mjs} +1 -1
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-DpatMS4g.mjs → hostInit-_RQxF72h.mjs} +3 -3
  31. package/dist/stream-broker/index.js +20 -21
  32. package/dist/stream-broker/index.mjs +20 -21
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/dist/{worker-protocol-BWO2WNjw.js → worker-protocol-DGql9R4g.js} +1 -1
  35. package/dist/{worker-protocol-BKwkLZWT.mjs → worker-protocol-SzmaLNnP.mjs} +1 -1
  36. package/package.json +1 -1
  37. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-0MF-KL1v.mjs +0 -26
@@ -1,4 +1,4 @@
1
- import { mt as parseProcStatus } from "./dist-DttqKvsQ.mjs";
1
+ import { mt as parseProcStatus } from "./dist-DgeXZfwn.mjs";
2
2
  import * as fs from "node:fs";
3
3
  import { execFile } from "node:child_process";
4
4
  //#region src/shared/node-topology-platform.ts
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-DoHcimdu.js");
1
+ const require_dist = require("./dist-D7hxiBnC.js");
2
2
  let node_fs = require("node:fs");
3
3
  node_fs = require_dist.__toESM(node_fs);
4
4
  let node_child_process = require("node:child_process");
@@ -2,8 +2,8 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DoHcimdu.js");
6
- const require_node = require("../node-6uF72wri.js");
5
+ const require_dist = require("../dist-D7hxiBnC.js");
6
+ const require_node = require("../node-DDhmPcOx.js");
7
7
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
8
  const require_restream_intent = require("../restream-intent-B1Difcnq.js");
9
9
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
@@ -1333,27 +1333,6 @@ var RecordingIndex = class {
1333
1333
  return [...out.values()];
1334
1334
  }
1335
1335
  /**
1336
- * WHICH devices hold footage on any of `locationIds` — the census, without
1337
- * the archive.
1338
- *
1339
- * `getStorageUsage` used to answer this with `segmentsOnLocation(id)` per
1340
- * location: a full multi-device COPY of every row on the disk plus a sort of
1341
- * it (294 k rows on the live hub), to read one number off each row and throw
1342
- * the array away. This visits each device only until its first matching row.
1343
- * Same membership rule as before — a device is listed when at least one of
1344
- * its rows sits on one of the given locations, so footage stranded on a
1345
- * location id that no longer resolves stays invisible here exactly as it was.
1346
- * @residency archive-shaped
1347
- */
1348
- deviceIdsOnLocations(locationIds) {
1349
- const out = /* @__PURE__ */ new Set();
1350
- for (const [deviceId, m] of this.byDevice) for (const s of m.values()) if (locationIds.has(s.locationId)) {
1351
- out.add(deviceId);
1352
- break;
1353
- }
1354
- return out;
1355
- }
1356
- /**
1357
1336
  * Every RESIDENT segment on a storage location, across devices, oldest-first.
1358
1337
  *
1359
1338
  * ## The one that bit (D295)
@@ -6866,6 +6845,81 @@ function buildRecordingDeviceSchema() {
6866
6845
  }]
6867
6846
  }] };
6868
6847
  }
6848
+ //#endregion
6849
+ //#region src/recorder/addon/device-storage-usage.ts
6850
+ /**
6851
+ * Gap between the per-camera sum and the per-volume total that is worth
6852
+ * saying out loud, as a share of the volume total.
6853
+ *
6854
+ * The two are aggregations of the SAME hour rows over the same location set —
6855
+ * one folded per camera, one summed in SQL — so in a healthy recorder they
6856
+ * agree to the byte. A few tenths of a percent is a write landing between the
6857
+ * two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
6858
+ * rebuilt somewhere new.
6859
+ */
6860
+ var USAGE_MISMATCH_SHARE = .02;
6861
+ /** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
6862
+ * warn on a single in-flight segment. */
6863
+ var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
6864
+ /**
6865
+ * One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
6866
+ * first (deviceId breaks a tie, so two consecutive calls compare).
6867
+ *
6868
+ * A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
6869
+ * 'unavailable'`, and a line saying so. Falling back to the index would answer
6870
+ * the same question with the wrong quantity and look exactly like a correct
6871
+ * answer, which is how this shipped in the first place.
6872
+ */
6873
+ function buildDeviceUsage(input) {
6874
+ if (input.archive === null) {
6875
+ input.logger.warn("recorder: no hour ledger on this node — the per-camera storage rows are UNANSWERED, not empty", { meta: { locations: [...input.locationIds].toSorted().join(",") } });
6876
+ return {
6877
+ devices: [],
6878
+ totalUsedBytes: 0,
6879
+ read: "unavailable"
6880
+ };
6881
+ }
6882
+ const devices = [];
6883
+ let totalUsedBytes = 0;
6884
+ for (const [deviceId, archived] of input.archive) {
6885
+ const resident = input.resident(deviceId);
6886
+ totalUsedBytes += archived.bytes;
6887
+ devices.push({
6888
+ deviceId,
6889
+ usedBytes: archived.bytes,
6890
+ oldestMs: archived.oldestMs,
6891
+ segments: archived.segments,
6892
+ residentBytes: resident.bytes,
6893
+ residentOldestMs: resident.oldestMs
6894
+ });
6895
+ }
6896
+ return {
6897
+ devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
6898
+ totalUsedBytes,
6899
+ read: "archive"
6900
+ };
6901
+ }
6902
+ /**
6903
+ * Say it when the per-camera sum and the per-volume total disagree.
6904
+ *
6905
+ * The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
6906
+ * the same rows counted two ways, so a gap means one of the two reads saw a
6907
+ * fraction of the archive — and the whole point of D418 is that a page which
6908
+ * shows both without comparing them lets a 97% gap read as a fact about the
6909
+ * disks.
6910
+ */
6911
+ function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
6912
+ if (read !== "archive") return;
6913
+ if (volumeBytes === 0) return;
6914
+ const gap = Math.abs(volumeBytes - deviceBytes);
6915
+ if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
6916
+ if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
6917
+ logger.warn("recorder: the per-camera storage sum disagrees with the per-volume total — one of the two reads saw a fraction of the archive", { meta: {
6918
+ deviceBytes,
6919
+ volumeBytes,
6920
+ gap
6921
+ } });
6922
+ }
6869
6923
  /** The placement-store key for one (camera, profile). Mirrors `assignmentKey`
6870
6924
  * in `placement-store.ts`; kept local so this module stays dependency-free. */
6871
6925
  function sourceKey(source) {
@@ -7453,18 +7507,15 @@ function buildRecordingProvider(deps) {
7453
7507
  getPlaybackManifest: ({ deviceId, fromMs, toMs }) => buildPlaybackManifest(deps, codecCache, deviceId, fromMs, toMs),
7454
7508
  getStorageUsage: async () => {
7455
7509
  const locations = deps.locations();
7456
- const devices = [];
7457
- const seenDevices = deps.index.deviceIdsOnLocations(new Set(locations.map((l) => l.id)));
7458
- let totalUsedBytes = 0;
7459
- for (const deviceId of seenDevices) {
7460
- const accounting = deps.index.accounting(deviceId);
7461
- totalUsedBytes += accounting.bytes;
7462
- devices.push({
7463
- deviceId,
7464
- usedBytes: accounting.bytes,
7465
- oldestMs: accounting.oldestMs
7466
- });
7467
- }
7510
+ const locationIds = new Set(locations.map((l) => l.id));
7511
+ const deviceUsage = buildDeviceUsage({
7512
+ locationIds,
7513
+ archive: deps.archiveDeviceUsage?.(locationIds) ?? null,
7514
+ resident: (deviceId) => deps.index.accounting(deviceId),
7515
+ logger: deps.logger
7516
+ });
7517
+ const devices = deviceUsage.devices;
7518
+ const totalUsedBytes = deviceUsage.totalUsedBytes;
7468
7519
  const byPhysical = /* @__PURE__ */ new Map();
7469
7520
  for (const loc of locations) {
7470
7521
  const key = physicalRootKey(loc.root);
@@ -7493,11 +7544,12 @@ function buildRecordingProvider(deps) {
7493
7544
  totalBytes: cap.totalBytes
7494
7545
  };
7495
7546
  }));
7496
- devices.sort((a, b) => b.usedBytes - a.usedBytes);
7547
+ warnOnUsageMismatch(deps.logger, totalUsedBytes, locationUsages.reduce((sum, l) => sum + l.usedBytes, 0), deviceUsage.read);
7497
7548
  return {
7498
7549
  nodeId: deps.nodeId,
7499
7550
  totalUsedBytes,
7500
- devices,
7551
+ devices: [...devices],
7552
+ devicesRead: deviceUsage.read,
7501
7553
  locations: locationUsages
7502
7554
  };
7503
7555
  },
@@ -8391,6 +8443,14 @@ var DurableLedger = class DurableLedger {
8391
8443
  //#endregion
8392
8444
  //#region src/recorder/addon/segment-hour-ledger.ts
8393
8445
  var HOUR_MS$3 = 36e5;
8446
+ /** `Math.min` where a missing left operand means "nothing yet", not `NaN`. */
8447
+ function minOf(a, b) {
8448
+ return a === null ? b : Math.min(a, b);
8449
+ }
8450
+ /** `Math.max` with the same rule as {@link minOf}. */
8451
+ function maxOf(a, b) {
8452
+ return a === null ? b : Math.max(a, b);
8453
+ }
8394
8454
  var macrotask$1 = () => new Promise((resolve) => {
8395
8455
  setImmediate(resolve);
8396
8456
  });
@@ -9041,7 +9101,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9041
9101
  *
9042
9102
  * The reader repairs the durable index it just reconstructed, so the next
9043
9103
  * cold read of this hour is a point read instead of another `readdir`
9044
- * (`docs/design/2026-08-29-scrypted-nvr-recording-model.md` §4.2). Two
9104
+ * (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2). Two
9045
9105
  * constraints, both enforced by the caller and re-stated here because
9046
9106
  * getting either wrong turns a READ into durable damage: the hour must be
9047
9107
  * CLOSED (a live hour's directory races the writer, so a walk of it is a
@@ -9274,6 +9334,53 @@ var SegmentHourLedger = class SegmentHourLedger {
9274
9334
  for (const row of this.ledger.snapshot()) if (locationIds.has(row.locationId)) out.add(row.deviceId);
9275
9335
  return out;
9276
9336
  }
9337
+ /**
9338
+ * Bytes / segments / oldest / newest per CAMERA on these locations — the
9339
+ * per-camera half of the storage page, answered by the ARCHIVE (D418).
9340
+ *
9341
+ * ## Why the T1 fold and not `census({ deviceId })` per camera
9342
+ *
9343
+ * `census` is the right authority for a SCOPE, and it cannot express this
9344
+ * scope: {@link SegmentHourLedger.predicate} takes ONE `locationId`, so
9345
+ * "camera 615 across the three resolvable volumes" is three aggregates, and
9346
+ * `census({ deviceId })` alone would silently count footage stranded on a
9347
+ * location id that no longer resolves — bytes no volume row on the same page
9348
+ * accounts for. Per camera per volume that is `cameras × volumes` SQL
9349
+ * aggregates for one operator click, and it grows with the fleet.
9350
+ *
9351
+ * The T1 hour summary answers all of them in ONE pass with no I/O, exactly
9352
+ * as {@link devicesWithFootage} and {@link profilesWithFootage} already do,
9353
+ * and it is the same rows `accountingForLocations` sums in SQL — the mirror
9354
+ * is write-behind, so it is never BEHIND the store. The cost is one pass
9355
+ * over the hour rows the mirror holds — O(writers × profiles × hours), ~19 k
9356
+ * on the live hub (see {@link REPAIR_MAX_ROWS}) against 98 445 segments,
9357
+ * i.e. it does NOT scale with the segment count — and it is capped for good
9358
+ * by {@link SEGMENT_HOUR_SUMMARY_LIMIT}, the mirror's own ceiling.
9359
+ *
9360
+ * ## What an empty answer means
9361
+ *
9362
+ * The same thing it means for {@link devicesWithFootage}: "the mirror knows
9363
+ * no hour here" — which is also what a ledger whose load failed reports. The
9364
+ * caller must not render that as "no camera has footage"; `getStorageUsage`
9365
+ * cross-checks the per-camera sum against the volume aggregate and SAYS so
9366
+ * when the two disagree.
9367
+ */
9368
+ usageByDevice(locationIds) {
9369
+ const out = /* @__PURE__ */ new Map();
9370
+ if (locationIds.size === 0) return out;
9371
+ for (const row of this.ledger.snapshot()) {
9372
+ if (!locationIds.has(row.locationId)) continue;
9373
+ const empty = row.segments === 0;
9374
+ const prev = out.get(row.deviceId);
9375
+ out.set(row.deviceId, {
9376
+ bytes: (prev?.bytes ?? 0) + row.bytes,
9377
+ segments: (prev?.segments ?? 0) + row.segments,
9378
+ oldestMs: empty ? prev?.oldestMs ?? null : minOf(prev?.oldestMs ?? null, row.minStartMs),
9379
+ newestMs: empty ? prev?.newestMs ?? null : maxOf(prev?.newestMs ?? null, row.maxStartMs)
9380
+ });
9381
+ }
9382
+ return out;
9383
+ }
9277
9384
  async oldestSegmentsScoped(locationIds, deviceIds, targetBytes) {
9278
9385
  const ids = [...locationIds];
9279
9386
  if (ids.length === 0 || targetBytes <= 0) return {
@@ -14533,7 +14640,7 @@ var RecordingController = class {
14533
14640
  /**
14534
14641
  * The ADAPTIVE attach bound. A transient `spawn ffmpeg` failure (EAGAIN) is
14535
14642
  * the host saying it cannot fork right now, and the answer is to attach
14536
- * FEWER cameras at once — not to serialise them, which `e2fa17a2b` removed
14643
+ * FEWER cameras at once — not to serialise them, which removed
14537
14644
  * for good reason. Every bounded-parallel attach in this file reads it.
14538
14645
  */
14539
14646
  spawnPressure;
@@ -16038,7 +16145,7 @@ function selectRedundantSegments(segs) {
16038
16145
  * The pruner that used to remove it, `runRedundancyJanitor`, was reachable ONLY
16039
16146
  * from `runFullArchiveWalk`, which was reachable only from
16040
16147
  * `scheduleDeferredFullWalk`, which had no caller at all — so on 2026-08-27
16041
- * (commit `9308428b4`) all three were deleted together as dead code. That was
16148
+ * all three were deleted together as dead code. That was
16042
16149
  * correct about the walk and left the system with **no duplicate pruner
16043
16150
  * whatsoever**. The precedent for letting this class of waste run unattended is
16044
16151
  * the 179 GB of stranded staging segments in `staging-reconcile.ts`.
@@ -17449,6 +17556,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17449
17556
  refreshLocations: () => this.refreshLocations(),
17450
17557
  capacity: (root) => this.locationCapacity(root),
17451
17558
  archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
17559
+ archiveDeviceUsage: (locationIds) => this.segmentHours?.usageByDevice(locationIds) ?? null,
17452
17560
  archive: () => this.segmentHours,
17453
17561
  ledgerWalk: this.ledgerWalkReconciler(),
17454
17562
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
@@ -1,5 +1,5 @@
1
- import { At as storageEvictableCapability, Ct as recordingSignalCapability, D as RecordingSignalStatusSchema, E as RecordingConfigSchema, Et as resolveLocationMode, Gt as DeviceFeature, H as deriveBatteryPresence, It as errMsg, Jt as hydrateSchema, Kt as DeviceType, Nt as stripRetiredBandPreBufferSec, Ot as resolveRecordingProfiles, P as batteryCapability, Q as evictionPolicyOfLocation, Qt as nodePin, R as customAction, St as recordingExportCapability, T as RECORDING_EXPORT_MAX_READ_BYTES, Ut as BaseAddon, V as defineCustomActions, W as deriveRecordingMode, Y as evaluateSensorEdge, Z as evictionPolicyForMode, an as boolean, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dn as preprocess, dt as modeMayWrite, et as gbToBytes, fn as record, g as ExportRecordSchema, hn as EventCategory, i as BatteryStatusSchema, in as array, it as loadContributionCapability, jt as storageOccupancyCapability, ln as number, lt as mayWriteToLocation, nn as sleep$1, pn as string, rn as _enum, rt as isFirstLevelMacroClass, tn as selectAssignedProfileSlots, un as object, ut as migrateLegacyDeviceSignalConfig, x as OpsLogEntrySchema, xt as recordingCapability } from "../dist-DttqKvsQ.mjs";
2
- import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-NtM7__8q.mjs";
1
+ import { At as storageEvictableCapability, Ct as recordingSignalCapability, D as RecordingSignalStatusSchema, E as RecordingConfigSchema, Et as resolveLocationMode, Gt as DeviceFeature, H as deriveBatteryPresence, It as errMsg, Jt as hydrateSchema, Kt as DeviceType, Nt as stripRetiredBandPreBufferSec, Ot as resolveRecordingProfiles, P as batteryCapability, Q as evictionPolicyOfLocation, Qt as nodePin, R as customAction, St as recordingExportCapability, T as RECORDING_EXPORT_MAX_READ_BYTES, Ut as BaseAddon, V as defineCustomActions, W as deriveRecordingMode, Y as evaluateSensorEdge, Z as evictionPolicyForMode, an as boolean, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dn as preprocess, dt as modeMayWrite, et as gbToBytes, fn as record, g as ExportRecordSchema, hn as EventCategory, i as BatteryStatusSchema, in as array, it as loadContributionCapability, jt as storageOccupancyCapability, ln as number, lt as mayWriteToLocation, nn as sleep$1, pn as string, rn as _enum, rt as isFirstLevelMacroClass, tn as selectAssignedProfileSlots, un as object, ut as migrateLegacyDeviceSignalConfig, x as OpsLogEntrySchema, xt as recordingCapability } from "../dist-DgeXZfwn.mjs";
2
+ import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-C9GcBrwk.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { c as PREBUFFER_MAX_MS, r as RECORDING_PRE_ROLL_REQUEST_MS, s as withRecordingIntent } from "../restream-intent-DWe2kInY.mjs";
5
5
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
@@ -1328,27 +1328,6 @@ var RecordingIndex = class {
1328
1328
  return [...out.values()];
1329
1329
  }
1330
1330
  /**
1331
- * WHICH devices hold footage on any of `locationIds` — the census, without
1332
- * the archive.
1333
- *
1334
- * `getStorageUsage` used to answer this with `segmentsOnLocation(id)` per
1335
- * location: a full multi-device COPY of every row on the disk plus a sort of
1336
- * it (294 k rows on the live hub), to read one number off each row and throw
1337
- * the array away. This visits each device only until its first matching row.
1338
- * Same membership rule as before — a device is listed when at least one of
1339
- * its rows sits on one of the given locations, so footage stranded on a
1340
- * location id that no longer resolves stays invisible here exactly as it was.
1341
- * @residency archive-shaped
1342
- */
1343
- deviceIdsOnLocations(locationIds) {
1344
- const out = /* @__PURE__ */ new Set();
1345
- for (const [deviceId, m] of this.byDevice) for (const s of m.values()) if (locationIds.has(s.locationId)) {
1346
- out.add(deviceId);
1347
- break;
1348
- }
1349
- return out;
1350
- }
1351
- /**
1352
1331
  * Every RESIDENT segment on a storage location, across devices, oldest-first.
1353
1332
  *
1354
1333
  * ## The one that bit (D295)
@@ -6860,6 +6839,81 @@ function buildRecordingDeviceSchema() {
6860
6839
  }]
6861
6840
  }] };
6862
6841
  }
6842
+ //#endregion
6843
+ //#region src/recorder/addon/device-storage-usage.ts
6844
+ /**
6845
+ * Gap between the per-camera sum and the per-volume total that is worth
6846
+ * saying out loud, as a share of the volume total.
6847
+ *
6848
+ * The two are aggregations of the SAME hour rows over the same location set —
6849
+ * one folded per camera, one summed in SQL — so in a healthy recorder they
6850
+ * agree to the byte. A few tenths of a percent is a write landing between the
6851
+ * two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
6852
+ * rebuilt somewhere new.
6853
+ */
6854
+ var USAGE_MISMATCH_SHARE = .02;
6855
+ /** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
6856
+ * warn on a single in-flight segment. */
6857
+ var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
6858
+ /**
6859
+ * One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
6860
+ * first (deviceId breaks a tie, so two consecutive calls compare).
6861
+ *
6862
+ * A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
6863
+ * 'unavailable'`, and a line saying so. Falling back to the index would answer
6864
+ * the same question with the wrong quantity and look exactly like a correct
6865
+ * answer, which is how this shipped in the first place.
6866
+ */
6867
+ function buildDeviceUsage(input) {
6868
+ if (input.archive === null) {
6869
+ input.logger.warn("recorder: no hour ledger on this node — the per-camera storage rows are UNANSWERED, not empty", { meta: { locations: [...input.locationIds].toSorted().join(",") } });
6870
+ return {
6871
+ devices: [],
6872
+ totalUsedBytes: 0,
6873
+ read: "unavailable"
6874
+ };
6875
+ }
6876
+ const devices = [];
6877
+ let totalUsedBytes = 0;
6878
+ for (const [deviceId, archived] of input.archive) {
6879
+ const resident = input.resident(deviceId);
6880
+ totalUsedBytes += archived.bytes;
6881
+ devices.push({
6882
+ deviceId,
6883
+ usedBytes: archived.bytes,
6884
+ oldestMs: archived.oldestMs,
6885
+ segments: archived.segments,
6886
+ residentBytes: resident.bytes,
6887
+ residentOldestMs: resident.oldestMs
6888
+ });
6889
+ }
6890
+ return {
6891
+ devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
6892
+ totalUsedBytes,
6893
+ read: "archive"
6894
+ };
6895
+ }
6896
+ /**
6897
+ * Say it when the per-camera sum and the per-volume total disagree.
6898
+ *
6899
+ * The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
6900
+ * the same rows counted two ways, so a gap means one of the two reads saw a
6901
+ * fraction of the archive — and the whole point of D418 is that a page which
6902
+ * shows both without comparing them lets a 97% gap read as a fact about the
6903
+ * disks.
6904
+ */
6905
+ function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
6906
+ if (read !== "archive") return;
6907
+ if (volumeBytes === 0) return;
6908
+ const gap = Math.abs(volumeBytes - deviceBytes);
6909
+ if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
6910
+ if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
6911
+ logger.warn("recorder: the per-camera storage sum disagrees with the per-volume total — one of the two reads saw a fraction of the archive", { meta: {
6912
+ deviceBytes,
6913
+ volumeBytes,
6914
+ gap
6915
+ } });
6916
+ }
6863
6917
  /** The placement-store key for one (camera, profile). Mirrors `assignmentKey`
6864
6918
  * in `placement-store.ts`; kept local so this module stays dependency-free. */
6865
6919
  function sourceKey(source) {
@@ -7447,18 +7501,15 @@ function buildRecordingProvider(deps) {
7447
7501
  getPlaybackManifest: ({ deviceId, fromMs, toMs }) => buildPlaybackManifest(deps, codecCache, deviceId, fromMs, toMs),
7448
7502
  getStorageUsage: async () => {
7449
7503
  const locations = deps.locations();
7450
- const devices = [];
7451
- const seenDevices = deps.index.deviceIdsOnLocations(new Set(locations.map((l) => l.id)));
7452
- let totalUsedBytes = 0;
7453
- for (const deviceId of seenDevices) {
7454
- const accounting = deps.index.accounting(deviceId);
7455
- totalUsedBytes += accounting.bytes;
7456
- devices.push({
7457
- deviceId,
7458
- usedBytes: accounting.bytes,
7459
- oldestMs: accounting.oldestMs
7460
- });
7461
- }
7504
+ const locationIds = new Set(locations.map((l) => l.id));
7505
+ const deviceUsage = buildDeviceUsage({
7506
+ locationIds,
7507
+ archive: deps.archiveDeviceUsage?.(locationIds) ?? null,
7508
+ resident: (deviceId) => deps.index.accounting(deviceId),
7509
+ logger: deps.logger
7510
+ });
7511
+ const devices = deviceUsage.devices;
7512
+ const totalUsedBytes = deviceUsage.totalUsedBytes;
7462
7513
  const byPhysical = /* @__PURE__ */ new Map();
7463
7514
  for (const loc of locations) {
7464
7515
  const key = physicalRootKey(loc.root);
@@ -7487,11 +7538,12 @@ function buildRecordingProvider(deps) {
7487
7538
  totalBytes: cap.totalBytes
7488
7539
  };
7489
7540
  }));
7490
- devices.sort((a, b) => b.usedBytes - a.usedBytes);
7541
+ warnOnUsageMismatch(deps.logger, totalUsedBytes, locationUsages.reduce((sum, l) => sum + l.usedBytes, 0), deviceUsage.read);
7491
7542
  return {
7492
7543
  nodeId: deps.nodeId,
7493
7544
  totalUsedBytes,
7494
- devices,
7545
+ devices: [...devices],
7546
+ devicesRead: deviceUsage.read,
7495
7547
  locations: locationUsages
7496
7548
  };
7497
7549
  },
@@ -8385,6 +8437,14 @@ var DurableLedger = class DurableLedger {
8385
8437
  //#endregion
8386
8438
  //#region src/recorder/addon/segment-hour-ledger.ts
8387
8439
  var HOUR_MS$3 = 36e5;
8440
+ /** `Math.min` where a missing left operand means "nothing yet", not `NaN`. */
8441
+ function minOf(a, b) {
8442
+ return a === null ? b : Math.min(a, b);
8443
+ }
8444
+ /** `Math.max` with the same rule as {@link minOf}. */
8445
+ function maxOf(a, b) {
8446
+ return a === null ? b : Math.max(a, b);
8447
+ }
8388
8448
  var macrotask$1 = () => new Promise((resolve) => {
8389
8449
  setImmediate(resolve);
8390
8450
  });
@@ -9035,7 +9095,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9035
9095
  *
9036
9096
  * The reader repairs the durable index it just reconstructed, so the next
9037
9097
  * cold read of this hour is a point read instead of another `readdir`
9038
- * (`docs/design/2026-08-29-scrypted-nvr-recording-model.md` §4.2). Two
9098
+ * (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2). Two
9039
9099
  * constraints, both enforced by the caller and re-stated here because
9040
9100
  * getting either wrong turns a READ into durable damage: the hour must be
9041
9101
  * CLOSED (a live hour's directory races the writer, so a walk of it is a
@@ -9268,6 +9328,53 @@ var SegmentHourLedger = class SegmentHourLedger {
9268
9328
  for (const row of this.ledger.snapshot()) if (locationIds.has(row.locationId)) out.add(row.deviceId);
9269
9329
  return out;
9270
9330
  }
9331
+ /**
9332
+ * Bytes / segments / oldest / newest per CAMERA on these locations — the
9333
+ * per-camera half of the storage page, answered by the ARCHIVE (D418).
9334
+ *
9335
+ * ## Why the T1 fold and not `census({ deviceId })` per camera
9336
+ *
9337
+ * `census` is the right authority for a SCOPE, and it cannot express this
9338
+ * scope: {@link SegmentHourLedger.predicate} takes ONE `locationId`, so
9339
+ * "camera 615 across the three resolvable volumes" is three aggregates, and
9340
+ * `census({ deviceId })` alone would silently count footage stranded on a
9341
+ * location id that no longer resolves — bytes no volume row on the same page
9342
+ * accounts for. Per camera per volume that is `cameras × volumes` SQL
9343
+ * aggregates for one operator click, and it grows with the fleet.
9344
+ *
9345
+ * The T1 hour summary answers all of them in ONE pass with no I/O, exactly
9346
+ * as {@link devicesWithFootage} and {@link profilesWithFootage} already do,
9347
+ * and it is the same rows `accountingForLocations` sums in SQL — the mirror
9348
+ * is write-behind, so it is never BEHIND the store. The cost is one pass
9349
+ * over the hour rows the mirror holds — O(writers × profiles × hours), ~19 k
9350
+ * on the live hub (see {@link REPAIR_MAX_ROWS}) against 98 445 segments,
9351
+ * i.e. it does NOT scale with the segment count — and it is capped for good
9352
+ * by {@link SEGMENT_HOUR_SUMMARY_LIMIT}, the mirror's own ceiling.
9353
+ *
9354
+ * ## What an empty answer means
9355
+ *
9356
+ * The same thing it means for {@link devicesWithFootage}: "the mirror knows
9357
+ * no hour here" — which is also what a ledger whose load failed reports. The
9358
+ * caller must not render that as "no camera has footage"; `getStorageUsage`
9359
+ * cross-checks the per-camera sum against the volume aggregate and SAYS so
9360
+ * when the two disagree.
9361
+ */
9362
+ usageByDevice(locationIds) {
9363
+ const out = /* @__PURE__ */ new Map();
9364
+ if (locationIds.size === 0) return out;
9365
+ for (const row of this.ledger.snapshot()) {
9366
+ if (!locationIds.has(row.locationId)) continue;
9367
+ const empty = row.segments === 0;
9368
+ const prev = out.get(row.deviceId);
9369
+ out.set(row.deviceId, {
9370
+ bytes: (prev?.bytes ?? 0) + row.bytes,
9371
+ segments: (prev?.segments ?? 0) + row.segments,
9372
+ oldestMs: empty ? prev?.oldestMs ?? null : minOf(prev?.oldestMs ?? null, row.minStartMs),
9373
+ newestMs: empty ? prev?.newestMs ?? null : maxOf(prev?.newestMs ?? null, row.maxStartMs)
9374
+ });
9375
+ }
9376
+ return out;
9377
+ }
9271
9378
  async oldestSegmentsScoped(locationIds, deviceIds, targetBytes) {
9272
9379
  const ids = [...locationIds];
9273
9380
  if (ids.length === 0 || targetBytes <= 0) return {
@@ -14527,7 +14634,7 @@ var RecordingController = class {
14527
14634
  /**
14528
14635
  * The ADAPTIVE attach bound. A transient `spawn ffmpeg` failure (EAGAIN) is
14529
14636
  * the host saying it cannot fork right now, and the answer is to attach
14530
- * FEWER cameras at once — not to serialise them, which `e2fa17a2b` removed
14637
+ * FEWER cameras at once — not to serialise them, which removed
14531
14638
  * for good reason. Every bounded-parallel attach in this file reads it.
14532
14639
  */
14533
14640
  spawnPressure;
@@ -16032,7 +16139,7 @@ function selectRedundantSegments(segs) {
16032
16139
  * The pruner that used to remove it, `runRedundancyJanitor`, was reachable ONLY
16033
16140
  * from `runFullArchiveWalk`, which was reachable only from
16034
16141
  * `scheduleDeferredFullWalk`, which had no caller at all — so on 2026-08-27
16035
- * (commit `9308428b4`) all three were deleted together as dead code. That was
16142
+ * all three were deleted together as dead code. That was
16036
16143
  * correct about the walk and left the system with **no duplicate pruner
16037
16144
  * whatsoever**. The precedent for letting this class of waste run unattended is
16038
16145
  * the 179 GB of stranded staging segments in `staging-reconcile.ts`.
@@ -17443,6 +17550,7 @@ var RecorderV2Addon = class extends BaseAddon {
17443
17550
  refreshLocations: () => this.refreshLocations(),
17444
17551
  capacity: (root) => this.locationCapacity(root),
17445
17552
  archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
17553
+ archiveDeviceUsage: (locationIds) => this.segmentHours?.usageByDevice(locationIds) ?? null,
17446
17554
  archive: () => this.segmentHours,
17447
17555
  ledgerWalk: this.ledgerWalkReconciler(),
17448
17556
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
@@ -1,5 +1,5 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "./chunk-DnnnRqeS.mjs";
2
- import { It as errMsg } from "./dist-DttqKvsQ.mjs";
2
+ import { It as errMsg } from "./dist-DgeXZfwn.mjs";
3
3
  import { spawn } from "node:child_process";
4
4
  //#region ../../node_modules/mp4box/dist/mp4box.all.js
5
5
  var require_mp4box_all = /* @__PURE__ */ __commonJSMin(((exports) => {
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-DoHcimdu.js");
1
+ const require_dist = require("./dist-D7hxiBnC.js");
2
2
  let node_child_process = require("node:child_process");
3
3
  //#region ../../node_modules/mp4box/dist/mp4box.all.js
4
4
  var require_mp4box_all = /* @__PURE__ */ require_dist.__commonJSMin(((exports) => {