@camstack/addon-pipeline 1.2.96 → 1.2.98

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 (31) hide show
  1. package/dist/{addon-utils-B6usm-YO.js → addon-utils-eUS6n_Zj.js} +1 -1
  2. package/dist/audio-analyzer/index.js +3 -3
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/detection-pipeline/index.js +5 -5
  5. package/dist/detection-pipeline/index.mjs +3 -3
  6. package/dist/{dist-w9QWDvWn.js → dist-dm3t4BOt.js} +29 -3
  7. package/dist/{dist-LIWfUNC_.mjs → dist-gXdWP96z.mjs} +29 -3
  8. package/dist/{event-loop-stall-monitor-DndI0S2s.js → event-loop-stall-monitor-Lki2_mYY.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-BDC8qURn.mjs → event-loop-stall-monitor-TnQ21mu0.mjs} +1 -1
  10. package/dist/{lazy-sharp-BVqBydNU.js → lazy-sharp-oYppDIZR.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +51 -4
  14. package/dist/pipeline-runner/index.mjs +50 -3
  15. package/dist/{process-memory-k45KJaKH.mjs → process-memory-CKWh609B.mjs} +1 -1
  16. package/dist/{process-memory-C6zWIgos.js → process-memory-vkFVOdkV.js} +1 -1
  17. package/dist/recorder/index.js +296 -48
  18. package/dist/recorder/index.mjs +295 -47
  19. package/dist/session-decode/decode-worker-child.js +2 -2
  20. package/dist/session-decode/decode-worker-child.mjs +1 -1
  21. package/dist/stream-broker/_stub.js +1 -1
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CJWMZMhQ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B7ERJocD.mjs} +3 -3
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-zT-cYUdF.mjs +26 -0
  24. package/dist/stream-broker/{hostInit-CGIo3d4K.mjs → hostInit-Cm2rl8-e.mjs} +3 -3
  25. package/dist/stream-broker/index.js +21 -6
  26. package/dist/stream-broker/index.mjs +21 -6
  27. package/dist/stream-broker/remoteEntry.js +1 -1
  28. package/dist/{worker-protocol-DqrXmX0g.js → worker-protocol-BrlrMxXu.js} +1 -1
  29. package/dist/{worker-protocol-DTe7Ntat.mjs → worker-protocol-DJWdPLKF.mjs} +1 -1
  30. package/package.json +1 -1
  31. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D2WOTeYi.mjs +0 -26
@@ -1,6 +1,6 @@
1
- const require_dist = require("../dist-w9QWDvWn.js");
1
+ const require_dist = require("../dist-dm3t4BOt.js");
2
2
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
3
- const require_addon_utils = require("../addon-utils-B6usm-YO.js");
3
+ const require_addon_utils = require("../addon-utils-eUS6n_Zj.js");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_child_process = require("node:child_process");
6
6
  let node_path = require("node:path");
@@ -4333,9 +4333,15 @@ function buildRecordingProvider(deps) {
4333
4333
  const first = group[0];
4334
4334
  const cap = await deps.capacity(first.root);
4335
4335
  const ids = group.map((l) => l.id);
4336
+ if (cap.error !== void 0) deps.logger.warn("recorder: volume capacity unavailable (statfs failed)", { meta: {
4337
+ locationId: first.id,
4338
+ root: first.root,
4339
+ error: cap.error
4340
+ } });
4336
4341
  return {
4337
4342
  locationId: first.id,
4338
4343
  locationIds: ids,
4344
+ root: first.root,
4339
4345
  usedBytes: deps.index.accountingForLocations(new Set(ids)).bytes,
4340
4346
  availableBytes: cap.availableBytes,
4341
4347
  totalBytes: cap.totalBytes
@@ -5569,13 +5575,20 @@ async function probeLocationVolume(location, rememberedVolumeId, deps) {
5569
5575
  }
5570
5576
  /**
5571
5577
  * Where to write instead when a location fails its volume probe: the normal
5572
- * placement chain over every OTHER location. With N locations this is a
5573
- * sibling of the same type; with one it is the unusable location itself —
5574
- * a shouting recorder beats a stopped camera, and refusing to record is the
5575
- * one outcome no operator asked for.
5576
- */
5577
- function failoverForUnusableLocation(unusable, locations, profile) {
5578
- const siblings = locations.filter((l) => l.id !== unusable.id);
5578
+ * placement chain over every location NOT already known unusable. With N
5579
+ * locations this is a healthy sibling of the same type; with none left it is
5580
+ * the unusable location itself — a shouting recorder beats a stopped camera,
5581
+ * and refusing to record is the one outcome no operator asked for.
5582
+ *
5583
+ * `alreadyUnusable` is not an optimisation. Without it the search happily
5584
+ * returned a sibling that had ALSO failed its probe, so with two broken disks
5585
+ * every attach handed back the other one and the stored assignment flipped
5586
+ * A→B→A→B forever — one durable write and one audit row per attach, per camera,
5587
+ * per profile. Observed live on 2026-08-19 (the UD disk did not come back after
5588
+ * a reboot): 716 EACCES/min, with the ops-log ring flooded by the flapping.
5589
+ */
5590
+ function failoverForUnusableLocation(unusable, locations, profile, alreadyUnusable = /* @__PURE__ */ new Set()) {
5591
+ const siblings = locations.filter((l) => l.id !== unusable.id && !alreadyUnusable.has(l.id));
5579
5592
  try {
5580
5593
  return resolvePlacement(siblings, profile);
5581
5594
  } catch {
@@ -5626,6 +5639,13 @@ var PlacementService = class {
5626
5639
  verified = /* @__PURE__ */ new Set();
5627
5640
  /** Locations the probe declared unusable — re-probed after a change. */
5628
5641
  unusable = /* @__PURE__ */ new Set();
5642
+ /** Cameras that have already announced "every location is unusable". One line
5643
+ * per camera per episode: the fault is asked per-camera, and repeating it per
5644
+ * attach is the noise this state exists to end. */
5645
+ allUnusableReported = /* @__PURE__ */ new Set();
5646
+ /** When the current all-unusable episode last (re-)probed. `null` ⇒ no
5647
+ * episode in progress. */
5648
+ allUnusableProbedAt = null;
5629
5649
  /** The fair tie-break the PURE planner defers to (D37). Advisory state, and
5630
5650
  * consulted only for a tie the capacity signal cannot separate. */
5631
5651
  rotation = new PlacementRotation();
@@ -5635,6 +5655,12 @@ var PlacementService = class {
5635
5655
  get probeDeps() {
5636
5656
  return this.deps.probe ?? fsVolumeProbeDeps;
5637
5657
  }
5658
+ get now() {
5659
+ return this.deps.now?.() ?? Date.now();
5660
+ }
5661
+ get reprobeMs() {
5662
+ return this.deps.reprobeMs ?? 6e4;
5663
+ }
5638
5664
  /**
5639
5665
  * Forget every probe verdict. Called whenever the location set is re-resolved
5640
5666
  * (rescan, storage migration) — a remount is exactly the event the probe
@@ -5643,6 +5669,8 @@ var PlacementService = class {
5643
5669
  onLocationsChanged() {
5644
5670
  this.verified.clear();
5645
5671
  this.unusable.clear();
5672
+ this.allUnusableReported.clear();
5673
+ this.allUnusableProbedAt = null;
5646
5674
  }
5647
5675
  /**
5648
5676
  * Resolve the location for one (camera, profile) at an attach boundary.
@@ -5651,12 +5679,92 @@ var PlacementService = class {
5651
5679
  */
5652
5680
  async place(deviceId, profile) {
5653
5681
  const pool = this.pool();
5682
+ this.reprobeAfterBackoff(pool);
5654
5683
  const resolved = await this.assignedLocation(deviceId, profile, pool) ?? resolvePlacement(pool, profile);
5655
5684
  const usable = await this.ensureUsable(resolved, pool, profile, deviceId);
5685
+ if (this.everyLocationUnusable(pool)) {
5686
+ this.reportEveryLocationUnusable(deviceId, profile, resolved, pool);
5687
+ return resolved;
5688
+ }
5689
+ this.allUnusableReported.delete(deviceId);
5656
5690
  await this.recordDecision(deviceId, profile, usable, resolved.id === usable.id);
5657
5691
  return usable;
5658
5692
  }
5659
5693
  /**
5694
+ * Has the marker probe declared this location's volume NOT the one we last
5695
+ * wrote to?
5696
+ *
5697
+ * Read by the segment-hour ledger: a walk over a root whose volume failed its
5698
+ * probe found nothing because nothing is mounted there, and that must never be
5699
+ * allowed to delete durable rows (D49). Unprobed ⇒ usable, which is honest —
5700
+ * the probe runs at the first writer attach, so at boot it has usually not run
5701
+ * at all, and the ledger's own magnitude guard covers that window.
5702
+ */
5703
+ isLocationUsable(locationId) {
5704
+ return !this.unusable.has(locationId);
5705
+ }
5706
+ /** True when the pool is non-empty and the probe has failed EVERY member. */
5707
+ everyLocationUnusable(pool) {
5708
+ return pool.length > 0 && pool.every((location) => this.unusable.has(location.id));
5709
+ }
5710
+ /**
5711
+ * Announce the terminal state — ONCE per camera per episode.
5712
+ *
5713
+ * Before this, two unusable locations produced an infinite failover
5714
+ * ping-pong: `failoverForUnusableLocation` did not consult the unusable set,
5715
+ * so each attach handed back the other broken disk, `recordDecision` saw a
5716
+ * changed assignment and wrote the store plus an audit row, and the writer
5717
+ * hit EACCES on a path that was never going to work. Measured on the live hub
5718
+ * on 2026-08-19 with the UD disk unmounted: 716 EACCES/min.
5719
+ *
5720
+ * Recording is NOT refused. With a single location this state is reached the
5721
+ * moment that one location fails, and the recorder keeps attaching to it on
5722
+ * purpose — a shouting recorder beats a stopped camera. What changes is that
5723
+ * it shouts once instead of once per attach, and writes nothing durable while
5724
+ * shouting.
5725
+ */
5726
+ reportEveryLocationUnusable(deviceId, profile, target, pool) {
5727
+ this.allUnusableProbedAt ??= this.now;
5728
+ if (this.allUnusableReported.has(deviceId)) return;
5729
+ this.allUnusableReported.add(deviceId);
5730
+ this.deps.logger.warn("recorder placement: EVERY recordings location failed its volume probe — the assignment is FROZEN on its current target and nothing more is written until a disk comes back", {
5731
+ tags: { deviceId },
5732
+ meta: {
5733
+ profile,
5734
+ targetLocationId: target.id,
5735
+ root: target.root,
5736
+ locationIds: pool.map((location) => location.id),
5737
+ reprobeMs: this.reprobeMs
5738
+ }
5739
+ });
5740
+ }
5741
+ /**
5742
+ * While every location is unusable, re-probe them all — at most once per
5743
+ * {@link ALL_UNUSABLE_REPROBE_MS}. This is the backoff: without it the probe
5744
+ * memo would hold for the life of the process (only `onLocationsChanged`
5745
+ * clears it) and a disk remounted by an operator would need an addon restart
5746
+ * to be noticed.
5747
+ */
5748
+ reprobeAfterBackoff(pool) {
5749
+ if (!this.everyLocationUnusable(pool)) {
5750
+ this.allUnusableProbedAt = null;
5751
+ return;
5752
+ }
5753
+ const now = this.now;
5754
+ if (this.allUnusableProbedAt === null) {
5755
+ this.allUnusableProbedAt = now;
5756
+ return;
5757
+ }
5758
+ if (now - this.allUnusableProbedAt < this.reprobeMs) return;
5759
+ this.allUnusableProbedAt = now;
5760
+ this.deps.logger.info("recorder placement: re-probing every storage location after backoff — a disk that came back is picked up here", { meta: {
5761
+ locationIds: pool.map((location) => location.id),
5762
+ backoffMs: this.reprobeMs
5763
+ } });
5764
+ this.unusable.clear();
5765
+ this.verified.clear();
5766
+ }
5767
+ /**
5660
5768
  * The locations placement may write to: operator-ENABLED and alias-guarded.
5661
5769
  * A location the operator has not opted in is still read, swept and drained —
5662
5770
  * it is simply never a write target. Creating a disk must not start writing
@@ -5805,13 +5913,13 @@ var PlacementService = class {
5805
5913
  * ONCE per location per location-set generation — it is a claim, not a poll.
5806
5914
  */
5807
5915
  async ensureUsable(chosen, locations, profile, deviceId) {
5808
- if (this.unusable.has(chosen.id)) return failoverForUnusableLocation(chosen, locations, profile);
5916
+ if (this.unusable.has(chosen.id)) return failoverForUnusableLocation(chosen, locations, profile, this.unusable);
5809
5917
  if (this.verified.has(chosen.id)) return chosen;
5810
5918
  const result = await probeLocationVolume(chosen, (await readPlacementState(this.deps.state)).volumeIds[chosen.id] ?? null, this.probeDeps);
5811
5919
  if (result.outcome === "mismatch") {
5812
5920
  this.unusable.add(chosen.id);
5813
5921
  this.reportMountFault(result, deviceId);
5814
- return failoverForUnusableLocation(chosen, locations, profile);
5922
+ return failoverForUnusableLocation(chosen, locations, profile, this.unusable);
5815
5923
  }
5816
5924
  if (result.outcome === "unreadable") {
5817
5925
  this.deps.logger.warn("recorder placement: volume probe unreadable — assignment unchanged", {
@@ -7925,6 +8033,34 @@ function makeBrokerCall(handle) {
7925
8033
  return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
7926
8034
  }
7927
8035
  //#endregion
8036
+ //#region src/recorder/addon/location-capacity.ts
8037
+ /** Human-readable failure text for a thrown value of unknown shape. */
8038
+ function reasonOf(err) {
8039
+ return err instanceof Error ? err.message : String(err);
8040
+ }
8041
+ /**
8042
+ * Free + total bytes on the volume backing `root`.
8043
+ *
8044
+ * Never throws: an unreachable volume is one degraded ROW, not a failed
8045
+ * `getStorageUsage` — a NAS that stops answering must not blank the capacity of
8046
+ * every other disk on the node.
8047
+ */
8048
+ async function readLocationCapacity(root, statfs) {
8049
+ try {
8050
+ const st = await statfs(root);
8051
+ return {
8052
+ availableBytes: st.bavail * st.bsize,
8053
+ totalBytes: st.blocks * st.bsize
8054
+ };
8055
+ } catch (err) {
8056
+ return {
8057
+ availableBytes: null,
8058
+ totalBytes: null,
8059
+ error: reasonOf(err)
8060
+ };
8061
+ }
8062
+ }
8063
+ //#endregion
7928
8064
  //#region src/durable/durable-ledger.ts
7929
8065
  /** Default reseed cap — every current consumer's row set is installation-bounded. */
7930
8066
  var DEFAULT_LOAD_LIMIT = 1e5;
@@ -8212,6 +8348,17 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [{
8212
8348
  name: "idx_recorder_segment_hours_hour",
8213
8349
  columns: ["hourStartMs"]
8214
8350
  }];
8351
+ /**
8352
+ * The largest share of a device's held hours a single walk may delete.
8353
+ *
8354
+ * On 2026-08-19 the recorder respawned while `/recordings` was an empty tmpfs —
8355
+ * the UD disk had not mounted — and one walk took the ledger from 873 hours to
8356
+ * 32 (a 96% drop). The ledger is precisely what lets the NEXT boot skip a
8357
+ * ~400 s recursive walk, so a single bad walk taxed every boot after it. A
8358
+ * retention sweep does not delete half a camera's archive in one pass; a
8359
+ * `readdir` over an unmounted root deletes all of it.
8360
+ */
8361
+ var LEDGER_PRUNE_MAX_SHARE = .5;
8215
8362
  function hourStartMs(startMs) {
8216
8363
  return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
8217
8364
  }
@@ -8285,8 +8432,10 @@ var SPEC = {
8285
8432
  var SegmentHourLedger = class {
8286
8433
  ledger;
8287
8434
  logger;
8435
+ isLocationUsable;
8288
8436
  constructor(deps) {
8289
8437
  this.logger = deps.logger;
8438
+ this.isLocationUsable = deps.isLocationUsable ?? (() => true);
8290
8439
  this.ledger = new DurableLedger({
8291
8440
  spec: SPEC,
8292
8441
  store: deps.store,
@@ -8376,6 +8525,10 @@ var SegmentHourLedger = class {
8376
8525
  *
8377
8526
  * Devices not in `deviceIds` are left untouched (a partial walk must not
8378
8527
  * prune cameras it did not look at).
8528
+ *
8529
+ * And a walk this ledger does not TRUST prunes nothing at all — see
8530
+ * {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
8531
+ * are unioned in; what it did not find decides nothing (D49).
8379
8532
  */
8380
8533
  async reconcileFromIndex(index, deviceIds, nowMs) {
8381
8534
  const walked = new Set(deviceIds);
@@ -8397,15 +8550,17 @@ var SegmentHourLedger = class {
8397
8550
  paths: [...existing.paths, seg.path]
8398
8551
  });
8399
8552
  }
8553
+ const refused = this.refusedPrunes(walked, fromIndex);
8400
8554
  for (const row of this.ledger.snapshot()) {
8401
8555
  if (!walked.has(row.deviceId)) continue;
8402
8556
  const disk = fromIndex.get(row.key);
8557
+ const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
8403
8558
  if (disk === void 0) {
8404
- if (row.hourStartMs === currentHour) continue;
8559
+ if (additiveOnly) continue;
8405
8560
  await this.ledger.forget(row.key);
8406
8561
  continue;
8407
8562
  }
8408
- if (row.hourStartMs === currentHour) {
8563
+ if (additiveOnly) {
8409
8564
  const union = uniquePaths([...disk.paths, ...row.paths]);
8410
8565
  if (!samePaths(union, row.paths)) await this.ledger.put({
8411
8566
  ...row,
@@ -8420,7 +8575,85 @@ var SegmentHourLedger = class {
8420
8575
  await this.ledger.put(disk);
8421
8576
  }
8422
8577
  }
8578
+ /**
8579
+ * Which of this walk's deletions are REFUSED, and why.
8580
+ *
8581
+ * Two independent reasons, because the live fault of 2026-08-19 would have
8582
+ * escaped either one alone:
8583
+ *
8584
+ * - **the volume said no.** `PlacementService`'s marker probe knows the bytes
8585
+ * under a root are not the bytes we wrote. A walk of such a root found
8586
+ * nothing because nothing is mounted there, not because the footage is
8587
+ * gone.
8588
+ * - **the magnitude said no.** At BOOT the probe has not run yet — it runs at
8589
+ * the first writer attach — so on the morning the recorder respawned onto an
8590
+ * empty tmpfs the volume was not flagged and the prune went through: 873
8591
+ * hours to 32. A walk that deletes more than
8592
+ * {@link LEDGER_PRUNE_MAX_SHARE} of a device's held hours is refused on its
8593
+ * size alone.
8594
+ *
8595
+ * A refusal is one line per device (or per device+location), carrying the
8596
+ * delta, tagged with `deviceId` — a ledger that silently declined to prune and
8597
+ * a ledger with nothing to prune must never read the same.
8598
+ */
8599
+ refusedPrunes(walked, fromIndex) {
8600
+ const heldByDevice = /* @__PURE__ */ new Map();
8601
+ const unusable = /* @__PURE__ */ new Map();
8602
+ for (const row of this.ledger.snapshot()) {
8603
+ if (!walked.has(row.deviceId)) continue;
8604
+ heldByDevice.set(row.deviceId, (heldByDevice.get(row.deviceId) ?? 0) + 1);
8605
+ if (this.isLocationUsable(row.locationId)) continue;
8606
+ const key = locationKey(row.deviceId, row.locationId);
8607
+ const seen = unusable.get(key);
8608
+ unusable.set(key, seen === void 0 ? {
8609
+ deviceId: row.deviceId,
8610
+ locationId: row.locationId,
8611
+ held: 1
8612
+ } : {
8613
+ ...seen,
8614
+ held: seen.held + 1
8615
+ });
8616
+ }
8617
+ const onDiskByDevice = /* @__PURE__ */ new Map();
8618
+ for (const row of fromIndex.values()) onDiskByDevice.set(row.deviceId, (onDiskByDevice.get(row.deviceId) ?? 0) + 1);
8619
+ const devices = /* @__PURE__ */ new Set();
8620
+ for (const [deviceId, held] of heldByDevice) {
8621
+ if (held < 8) continue;
8622
+ const onDisk = onDiskByDevice.get(deviceId) ?? 0;
8623
+ if (onDisk >= held * .5) continue;
8624
+ devices.add(deviceId);
8625
+ this.logger.warn("recorder: segment-hour ledger prune REFUSED — this walk would delete most of the camera archive, which is what an unmounted root looks like; keeping the previous ledger", {
8626
+ tags: { deviceId },
8627
+ meta: {
8628
+ held,
8629
+ onDisk,
8630
+ dropped: held - onDisk,
8631
+ maxShare: LEDGER_PRUNE_MAX_SHARE
8632
+ }
8633
+ });
8634
+ }
8635
+ const locations = /* @__PURE__ */ new Set();
8636
+ for (const [key, entry] of unusable) {
8637
+ if (devices.has(entry.deviceId)) continue;
8638
+ locations.add(key);
8639
+ this.logger.warn("recorder: segment-hour ledger prune REFUSED — the volume under this location failed its probe, so the walk found nothing because nothing is mounted there", {
8640
+ tags: { deviceId: entry.deviceId },
8641
+ meta: {
8642
+ locationId: entry.locationId,
8643
+ held: entry.held,
8644
+ onDisk: onDiskByDevice.get(entry.deviceId) ?? 0
8645
+ }
8646
+ });
8647
+ }
8648
+ return {
8649
+ devices,
8650
+ locations
8651
+ };
8652
+ }
8423
8653
  };
8654
+ function locationKey(deviceId, locationId) {
8655
+ return `${deviceId}|${locationId}`;
8656
+ }
8424
8657
  //#endregion
8425
8658
  //#region src/recorder/addon/recording-export-provider.ts
8426
8659
  /** Content type of every rendered export — the engine only ever writes MP4. */
@@ -8910,7 +9143,9 @@ var macrotask = () => new Promise((resolve) => {
8910
9143
  * unnoticed for six weeks precisely because nothing said anything.
8911
9144
  */
8912
9145
  async function recoverStagingOrphans(dir, input, deps) {
8913
- let names;
9146
+ const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
9147
+ const yieldBetween = deps.yieldBetween ?? macrotask;
9148
+ let names = null;
8914
9149
  try {
8915
9150
  names = await deps.listDir(dir);
8916
9151
  } catch (err) {
@@ -8918,13 +9153,14 @@ async function recoverStagingOrphans(dir, input, deps) {
8918
9153
  dir,
8919
9154
  error: err instanceof Error ? err.message : String(err)
8920
9155
  } });
8921
- return {
8922
- found: 0,
8923
- recovered: 0,
8924
- failed: 0,
8925
- aborted: false
8926
- };
8927
9156
  }
9157
+ await yieldBetween();
9158
+ if (names === null) return {
9159
+ found: 0,
9160
+ recovered: 0,
9161
+ failed: 0,
9162
+ aborted: false
9163
+ };
8928
9164
  let playlistNames;
8929
9165
  const body = await deps.readPlaylist(dir).catch(() => null);
8930
9166
  if (body !== null) playlistNames = new Set(parseLivePlaylist(body).map((e) => e.segPath.replace(/^.*\//, "")));
@@ -8946,8 +9182,6 @@ async function recoverStagingOrphans(dir, input, deps) {
8946
9182
  oldestMs: plan[0]?.startMs,
8947
9183
  newestMs: plan[plan.length - 1]?.startMs
8948
9184
  } });
8949
- const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
8950
- const yieldBetween = deps.yieldBetween ?? macrotask;
8951
9185
  let recovered = 0;
8952
9186
  let failed = 0;
8953
9187
  let aborted = false;
@@ -9047,24 +9281,41 @@ async function recoverAllStagedOrphans(deps) {
9047
9281
  locations: locations.map((l) => l.id),
9048
9282
  bootMs: deps.bootMs
9049
9283
  } });
9050
- const work = [];
9051
- for (const location of locations) {
9052
- const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
9053
- let deviceDirs;
9284
+ /**
9285
+ * One directory listing on the walk, paced through the pass's own pacer.
9286
+ *
9287
+ * "A handful of listings" is what this walk was costed as, and on a WARM
9288
+ * share it is — 4.1 s for the whole thing. Cold, after a power-cycle, each
9289
+ * one is a ~9.4 s FUSE round-trip and 58 of them summed to 547 s with the
9290
+ * loop to themselves, while the controller re-pinned writers 118 times and
9291
+ * the first recording landed at t+714 s.
9292
+ *
9293
+ * Paced per LISTING and not per entry, because that is where the latency
9294
+ * is; and through the SAME `yieldBetween` the recovery uses, so there is one
9295
+ * pacer, one back-off state and one set of counters for the whole pass —
9296
+ * never a second scheduling authority (D167).
9297
+ */
9298
+ const listPaced = async (dir) => {
9299
+ let entries = null;
9054
9300
  try {
9055
- deviceDirs = await deps.listDir(stagingRoot);
9301
+ entries = await deps.listDir(dir);
9056
9302
  } catch {
9057
- continue;
9303
+ entries = null;
9058
9304
  }
9305
+ await yieldBetween();
9306
+ return entries;
9307
+ };
9308
+ const walkStartedMs = Date.now();
9309
+ const work = [];
9310
+ for (const location of locations) {
9311
+ const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
9312
+ const deviceDirs = await listPaced(stagingRoot);
9313
+ if (deviceDirs === null) continue;
9059
9314
  for (const deviceDir of deviceDirs) {
9060
9315
  const deviceId = Number(deviceDir);
9061
9316
  if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
9062
- let profileDirs;
9063
- try {
9064
- profileDirs = await deps.listDir(`${stagingRoot}/${deviceDir}`);
9065
- } catch {
9066
- continue;
9067
- }
9317
+ const profileDirs = await listPaced(`${stagingRoot}/${deviceDir}`);
9318
+ if (profileDirs === null) continue;
9068
9319
  const segmentSeconds = await deps.segmentSecondsFor(deviceId);
9069
9320
  for (const profile of profileDirs) {
9070
9321
  if (!KNOWN_PROFILES.has(profile)) continue;
@@ -9078,6 +9329,7 @@ async function recoverAllStagedOrphans(deps) {
9078
9329
  }
9079
9330
  }
9080
9331
  }
9332
+ const walkMs = Date.now() - walkStartedMs;
9081
9333
  const queue = [...work];
9082
9334
  const lane = async () => {
9083
9335
  for (;;) {
@@ -9120,6 +9372,7 @@ async function recoverAllStagedOrphans(deps) {
9120
9372
  failed,
9121
9373
  aborted,
9122
9374
  ms: Date.now() - startedMs,
9375
+ walkMs,
9123
9376
  ...pacer === null ? {} : { pacing: pacer.stats() }
9124
9377
  } });
9125
9378
  return {
@@ -9372,7 +9625,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
9372
9625
  }
9373
9626
  this.segmentHours = new SegmentHourLedger({
9374
9627
  store: this.ctx.api.settingsStore,
9375
- logger: this.ctx.logger
9628
+ logger: this.ctx.logger,
9629
+ isLocationUsable: (locationId) => this.placement?.isLocationUsable(locationId) ?? true
9376
9630
  });
9377
9631
  try {
9378
9632
  await this.segmentHours.declare();
@@ -10201,20 +10455,14 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
10201
10455
  playbackRoots() {
10202
10456
  return [node_path.default.join(this.ctx.dataDir, "playback"), ...this.resolvedLocations.map((l) => l.root)];
10203
10457
  }
10204
- /** Free + total bytes on a location's volume (`statfs`); null when unstattable. */
10458
+ /**
10459
+ * Free + total bytes on a location's volume (`statfs`); null plus a reason
10460
+ * when the volume will not answer. The reason is NOT logged here — the caller
10461
+ * (`getStorageUsage`) knows which location the root belongs to and logs it
10462
+ * against that location.
10463
+ */
10205
10464
  async locationCapacity(root) {
10206
- try {
10207
- const st = await node_fs.promises.statfs(root);
10208
- return {
10209
- availableBytes: st.bavail * st.bsize,
10210
- totalBytes: st.blocks * st.bsize
10211
- };
10212
- } catch {
10213
- return {
10214
- availableBytes: null,
10215
- totalBytes: null
10216
- };
10217
- }
10465
+ return readLocationCapacity(root, (target) => node_fs.promises.statfs(target));
10218
10466
  }
10219
10467
  /**
10220
10468
  * Global settings surfaced in the admin UI (and persisted to the addon