@camstack/addon-pipeline 1.2.89 → 1.2.93

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 (30) hide show
  1. package/dist/{addon-utils-yc-chXuC.js → addon-utils-CTRpEiSd.js} +1 -1
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +1 -1
  4. package/dist/detection-pipeline/index.js +4 -4
  5. package/dist/detection-pipeline/index.mjs +2 -2
  6. package/dist/{dist-BJYwWEZ9.mjs → dist-6POVIYmC.mjs} +91 -4
  7. package/dist/{dist-n4Nbnxx5.js → dist-D9To_r_d.js} +96 -3
  8. package/dist/{event-loop-stall-monitor-BiPffk0D.mjs → event-loop-stall-monitor-C-VvWOxS.mjs} +1 -1
  9. package/dist/{event-loop-stall-monitor-DG7QY00k.js → event-loop-stall-monitor-CGacbzXq.js} +1 -1
  10. package/dist/{lazy-sharp-EoSOHgoj.js → lazy-sharp-C3zOnmiU.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 +4 -4
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/recorder/index.js +680 -37
  16. package/dist/recorder/index.mjs +679 -36
  17. package/dist/session-decode/decode-worker-child.js +2 -2
  18. package/dist/session-decode/decode-worker-child.mjs +1 -1
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CsrMZYLB.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C2woB6bY.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Dut56EeQ.mjs +26 -0
  22. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DgivrPsH.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DsIi4G5Q.mjs} +1 -1
  23. package/dist/stream-broker/{hostInit-DoFovQmc.mjs → hostInit-VCURcZLt.mjs} +2 -2
  24. package/dist/stream-broker/index.js +194 -30
  25. package/dist/stream-broker/index.mjs +194 -30
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-DjuxhGo3.js → worker-protocol-Bq28qobd.js} +1 -1
  28. package/dist/{worker-protocol-XZ1e36rM.mjs → worker-protocol-PzAKg8mC.mjs} +1 -1
  29. package/package.json +1 -1
  30. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DNSELPGi.mjs +0 -26
@@ -1,6 +1,6 @@
1
- const require_dist = require("../dist-n4Nbnxx5.js");
1
+ const require_dist = require("../dist-D9To_r_d.js");
2
2
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
3
- const require_addon_utils = require("../addon-utils-yc-chXuC.js");
3
+ const require_addon_utils = require("../addon-utils-CTRpEiSd.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");
@@ -507,14 +507,14 @@ function parseSegmentPath(relPath) {
507
507
  //#region src/recorder/recording-index.ts
508
508
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
509
509
  * already gives us — so "have I walked this?" costs one Set lookup. */
510
- var HOUR_MS$4 = 36e5;
510
+ var HOUR_MS$5 = 36e5;
511
511
  /** A device walked end-to-end. Cheaper and clearer than inserting every hour of
512
512
  * the retention window into the set. */
513
513
  var ALL_HOURS = Symbol("all-hours");
514
514
  /** The hour buckets a half-open span touches. */
515
515
  function* hourBuckets(fromMs, toMs) {
516
- const first = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
517
- for (let h = first; h < toMs; h += HOUR_MS$4) yield h;
516
+ const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
517
+ for (let h = first; h < toMs; h += HOUR_MS$5) yield h;
518
518
  }
519
519
  var RecordingIndex = class {
520
520
  byDevice = /* @__PURE__ */ new Map();
@@ -630,7 +630,7 @@ var RecordingIndex = class {
630
630
  });
631
631
  }
632
632
  this.invalidateSorted(deviceId);
633
- this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$4);
633
+ this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$5);
634
634
  }
635
635
  /** Insert/replace one finalized segment (idempotent by path). */
636
636
  addSegment(s) {
@@ -1069,6 +1069,7 @@ var RelocateEngine = class {
1069
1069
  deviceId: input.deviceId ?? null,
1070
1070
  profiles: input.profiles ? [...input.profiles] : [],
1071
1071
  footageClass: input.footageClass,
1072
+ sinceMs: input.sinceMs ?? null,
1072
1073
  throttleMbps: input.throttleMbps ?? DEFAULT_THROTTLE_MBPS,
1073
1074
  entities: input.entities ? [...input.entities] : ["segments"],
1074
1075
  filesMoved: 0,
@@ -1129,7 +1130,7 @@ var RelocateEngine = class {
1129
1130
  const bytesPerMs = job.throttleMbps * 1024 * 1024 / 1e3;
1130
1131
  try {
1131
1132
  const samePhysicalRoot = await rootsPhysicallySame(from.root, to.root);
1132
- const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).filter((r) => job.profiles.length === 0 || job.profiles.includes(r.profile)).filter((r) => profileBelongsToClass(r.profile, job.footageClass)).sort((a, b) => a.startMs - b.startMs);
1133
+ const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).filter((r) => job.profiles.length === 0 || job.profiles.includes(r.profile)).filter((r) => profileBelongsToClass(r.profile, job.footageClass)).filter((r) => job.sinceMs === null || r.startMs >= job.sinceMs).sort((a, b) => a.startMs - b.startMs);
1133
1134
  job.filesTotal = segRows.length;
1134
1135
  for (const row of segRows) {
1135
1136
  if (job.cancelRequested) break;
@@ -1165,9 +1166,26 @@ var RelocateEngine = class {
1165
1166
  }
1166
1167
  }
1167
1168
  /** Move one segment file and re-point its index row to the target location
1168
- * (same location-relative path — the layout is identical on every root). */
1169
+ * (same location-relative path — the layout is identical on every root).
1170
+ * A source that is already gone (evicted, never flushed, ENOSPC) is dropped
1171
+ * from the index and skipped — one ghost file must not abort a drain. */
1169
1172
  async moveSegment(row, from, to, samePhysicalRoot) {
1170
- if (!samePhysicalRoot) await moveFile(node_path.default.join(from.root, row.path), node_path.default.join(to.root, row.path), row.bytes);
1173
+ if (!samePhysicalRoot) try {
1174
+ await moveFile(node_path.default.join(from.root, row.path), node_path.default.join(to.root, row.path), row.bytes);
1175
+ } catch (err) {
1176
+ if (isEnoent(err)) {
1177
+ this.deps.index.removeSegments([row.path]);
1178
+ this.deps.logger.warn("relocate skipped missing source", {
1179
+ tags: { deviceId: row.deviceId },
1180
+ meta: {
1181
+ path: row.path,
1182
+ locationId: from.id
1183
+ }
1184
+ });
1185
+ return;
1186
+ }
1187
+ throw err;
1188
+ }
1171
1189
  this.deps.index.removeSegments([row.path]);
1172
1190
  this.deps.index.addSegment({
1173
1191
  ...row,
@@ -1175,6 +1193,9 @@ var RelocateEngine = class {
1175
1193
  });
1176
1194
  }
1177
1195
  };
1196
+ function isEnoent(err) {
1197
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1198
+ }
1178
1199
  /** Logical migration classes are profile-derived, never location-derived:
1179
1200
  * high+mid are `recordings`; low is `recordingsLow`. */
1180
1201
  var profileBelongsToClass = (profile, footageClass) => {
@@ -1371,6 +1392,8 @@ var SegmentStore = class SegmentStore {
1371
1392
  if (removed.length > 0) {
1372
1393
  this.deps.index.removeSegments(removed);
1373
1394
  this.notifyEvicted(rows);
1395
+ const gone = new Set(removed);
1396
+ this.deps.onDeindexed?.(rows.filter((r) => gone.has(r.path)));
1374
1397
  }
1375
1398
  await this.pruneEmptyAncestors(location, [...byBucket.keys()]);
1376
1399
  this.deps.logger.info("recorder: eviction round complete", { meta: {
@@ -1936,7 +1959,7 @@ async function profileHasFootage(root, deviceId, profile) {
1936
1959
  //#endregion
1937
1960
  //#region src/recorder/addon/calendar-index.ts
1938
1961
  var DAY_MS$3 = 864e5;
1939
- var HOUR_MS$3 = 36e5;
1962
+ var HOUR_MS$4 = 36e5;
1940
1963
  /**
1941
1964
  * UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
1942
1965
  *
@@ -2109,7 +2132,7 @@ var CalendarIndex = class {
2109
2132
  }));
2110
2133
  const days = /* @__PURE__ */ new Set();
2111
2134
  for (const { day, hours } of perDay) for (const h of hours) {
2112
- const at = day + h * HOUR_MS$3;
2135
+ const at = day + h * HOUR_MS$4;
2113
2136
  if (at < fromMs || at >= toMs) continue;
2114
2137
  days.add(localDayStart(at, tzOffsetMinutes));
2115
2138
  }
@@ -2171,7 +2194,7 @@ var CalendarIndex = class {
2171
2194
  const day = utcDayOf(utcDayMs);
2172
2195
  for (let h = 0; h < 24; h++) {
2173
2196
  await this.awaitQuiet();
2174
- await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$3, true);
2197
+ await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$4, true);
2175
2198
  }
2176
2199
  }
2177
2200
  /**
@@ -2192,10 +2215,10 @@ var CalendarIndex = class {
2192
2215
  */
2193
2216
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
2194
2217
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
2195
- const hour = Math.floor(hourStartMs / HOUR_MS$3) * HOUR_MS$3;
2218
+ const hour = Math.floor(hourStartMs / HOUR_MS$4) * HOUR_MS$4;
2196
2219
  const key = `${deviceId}:${profile}:${hour}`;
2197
2220
  const now = this.deps.now?.() ?? Date.now();
2198
- const isCurrentHour = hour === Math.floor(now / HOUR_MS$3) * HOUR_MS$3;
2221
+ const isCurrentHour = hour === Math.floor(now / HOUR_MS$4) * HOUR_MS$4;
2199
2222
  const cached = this.hourSegs.get(key);
2200
2223
  if (cached !== void 0) {
2201
2224
  if (!isCurrentHour) return {
@@ -2259,7 +2282,7 @@ var CalendarIndex = class {
2259
2282
  */
2260
2283
  async dayRanges(deviceId, profile, utcDayMs) {
2261
2284
  const day = utcDayOf(utcDayMs);
2262
- return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$3), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
2285
+ return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$4), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
2263
2286
  }
2264
2287
  /**
2265
2288
  * Warm the days either side of the one being viewed, in the BACKGROUND.
@@ -2437,7 +2460,7 @@ function activeModeForConfig(config) {
2437
2460
  }
2438
2461
  //#endregion
2439
2462
  //#region src/recorder/addon/directory-handler.ts
2440
- var HOUR_MS$2 = 36e5;
2463
+ var HOUR_MS$3 = 36e5;
2441
2464
  /** A window wider than this is refused: it is a client bug, and answering it
2442
2465
  * would walk the archive on the read path — the thing this replaces. */
2443
2466
  var MAX_WINDOW_MS = 32 * 864e5;
@@ -2484,9 +2507,9 @@ function withDirectoryRoute(deps, next) {
2484
2507
  return;
2485
2508
  }
2486
2509
  const rows = [];
2487
- const firstHour = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
2510
+ const firstHour = Math.floor(fromMs / HOUR_MS$3) * HOUR_MS$3;
2488
2511
  let hydrated = true;
2489
- for (let hour = firstHour; hour < toMs; hour += HOUR_MS$2) {
2512
+ for (let hour = firstHour; hour < toMs; hour += HOUR_MS$3) {
2490
2513
  const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
2491
2514
  if (!complete) hydrated = false;
2492
2515
  for (const s of segments) {
@@ -4211,6 +4234,7 @@ function buildRecordingProvider(deps) {
4211
4234
  rescanStorage: async ({ deviceId }) => {
4212
4235
  const locations = await deps.refreshLocations();
4213
4236
  await deps.hydrateDevice(deviceId, locations);
4237
+ await deps.reconcileHourLedger?.(deviceId);
4214
4238
  const config = await loadDeviceConfig(deps.configStore, deviceId);
4215
4239
  await deps.opsLog?.append({
4216
4240
  op: "rescan",
@@ -5074,7 +5098,11 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
5074
5098
  try {
5075
5099
  entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
5076
5100
  } catch (err) {
5077
- if (err.code !== "ENOENT") logger.warn("recorder: hydrateDevice walk failed for location", {
5101
+ if (err.code === "ENOENT") {
5102
+ for (const location of aliases) index.hydrateDevice(deviceId, location.id, []);
5103
+ continue;
5104
+ }
5105
+ logger.warn("recorder: hydrateDevice walk failed for location", {
5078
5106
  tags: { deviceId },
5079
5107
  meta: {
5080
5108
  locationIds: aliases.map((location) => location.id),
@@ -5102,6 +5130,7 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
5102
5130
  handled += 1;
5103
5131
  if (handled % chunkSize === 0) await yieldBetween();
5104
5132
  }
5133
+ for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
5105
5134
  let indexed = 0;
5106
5135
  for (const [locationId, relPaths] of pathsByLocation) {
5107
5136
  index.hydrateDevice(deviceId, locationId, relPaths);
@@ -5186,7 +5215,7 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
5186
5215
  const p2 = (n) => String(n).padStart(2, "0");
5187
5216
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
5188
5217
  }
5189
- var HOUR_MS$1 = 36e5;
5218
+ var HOUR_MS$2 = 36e5;
5190
5219
  /**
5191
5220
  * Hydrate ONLY the hours a window spans, instead of walking the archive.
5192
5221
  *
@@ -5221,8 +5250,8 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
5221
5250
  });
5222
5251
  continue;
5223
5252
  }
5224
- const first = Math.floor(fromMs / HOUR_MS$1) * HOUR_MS$1;
5225
- for (let hour = first; hour < toMs; hour += HOUR_MS$1) for (const profile of profiles) {
5253
+ const first = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
5254
+ for (let hour = first; hour < toMs; hour += HOUR_MS$2) for (const profile of profiles) {
5226
5255
  const rel = hourDirRelPath(deviceId, profile, hour);
5227
5256
  let files;
5228
5257
  try {
@@ -6561,6 +6590,13 @@ var RecordingController = class {
6561
6590
  * records again.
6562
6591
  */
6563
6592
  unrecordableReported = /* @__PURE__ */ new Set();
6593
+ /**
6594
+ * Profiles the last attach skipped (stream not published / getStream threw).
6595
+ * `evaluateDevice` retries only these on the converge tick so a dead `low`
6596
+ * cannot keep `high` dark, without a `listAllProfileSlots` RPC on every
6597
+ * healthy device every 30s.
6598
+ */
6599
+ skippedProfiles = /* @__PURE__ */ new Map();
6564
6600
  restore = null;
6565
6601
  /** Cheap, never-delayed: is any pinned writer producing nothing? */
6566
6602
  livenessPass = null;
@@ -6767,7 +6803,10 @@ var RecordingController = class {
6767
6803
  return;
6768
6804
  }
6769
6805
  this.unrecordableReported.delete(deviceId);
6770
- if (this.active.has(deviceId)) return;
6806
+ if (this.active.has(deviceId)) {
6807
+ if (this.skippedProfiles.has(deviceId)) await this.fillMissingProfiles(deviceId, config);
6808
+ return;
6809
+ }
6771
6810
  if (this.attaching.has(deviceId)) return;
6772
6811
  this.attaching.add(deviceId);
6773
6812
  const attaching = this.performAttach(deviceId, config);
@@ -6818,8 +6857,12 @@ var RecordingController = class {
6818
6857
  /**
6819
6858
  * The attach body — extracted so the {@link attaching} in-flight guard in
6820
6859
  * `evaluateDevice` wraps it cleanly. Resolves the profiles, spawns a
6821
- * SegmentWriter + watcher per profile, and records the set in `active`. A throw
6822
- * (broker not ready / no sources) rolls back partial attaches and re-throws.
6860
+ * SegmentWriter + watcher per profile, and records the set in `active`.
6861
+ *
6862
+ * Per-profile: a throw from one slot is logged and skipped. Rolling back the
6863
+ * whole device when a single stream was dead (618 `derived` on `low`) left
6864
+ * ZERO footage for hours. If every profile fails, throw so the restore queue
6865
+ * retries the device.
6823
6866
  */
6824
6867
  async performAttach(deviceId, config) {
6825
6868
  this.restore?.add(deviceId, true);
@@ -6827,38 +6870,106 @@ var RecordingController = class {
6827
6870
  if (profiles.length === 0) throw new Error(`recorder controller: no assigned broker sources for device ${deviceId}`);
6828
6871
  const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
6829
6872
  const recordings = [];
6830
- try {
6831
- for (const profile of profiles) recordings.push(await this.attachProfile(deviceId, profile, segmentSeconds));
6873
+ const failed = [];
6874
+ for (const profile of profiles) try {
6875
+ recordings.push(await this.attachProfile(deviceId, profile, segmentSeconds));
6832
6876
  } catch (err) {
6833
- for (const r of recordings) await this.teardownProfile(deviceId, r);
6834
- throw err;
6877
+ failed.push(profile);
6878
+ this.deps.logger.warn("recorder: profile attach failed — recording the remaining profiles", {
6879
+ tags: { deviceId },
6880
+ meta: {
6881
+ profile,
6882
+ error: require_dist.errMsg(err)
6883
+ }
6884
+ });
6835
6885
  }
6886
+ if (recordings.length === 0) throw new Error(`recorder controller: no profile attached for device ${deviceId}` + (failed.length > 0 ? ` (failed: ${failed.join(",")})` : ""));
6836
6887
  if (this.paused) {
6837
6888
  await Promise.all(recordings.map((recording) => this.teardownProfile(deviceId, recording)));
6838
6889
  return;
6839
6890
  }
6840
6891
  this.active.set(deviceId, recordings);
6892
+ if (failed.length > 0) this.skippedProfiles.set(deviceId, new Set(failed));
6893
+ else this.skippedProfiles.delete(deviceId);
6841
6894
  this.deps.logger.info("recorder pinned broker source(s) for recording", {
6842
6895
  tags: { deviceId },
6843
6896
  meta: {
6844
- profiles,
6897
+ profiles: recordings.map((r) => r.profile),
6898
+ failedProfiles: failed,
6845
6899
  segmentSeconds,
6846
6900
  pipelineKeys: recordings.map((r) => r.pipelineKey)
6847
6901
  }
6848
6902
  });
6849
6903
  }
6850
6904
  /**
6905
+ * Retry profiles the last attach skipped, without tearing down writers that
6906
+ * are already producing. Called from `evaluateDevice` when the device is
6907
+ * already in `active` and {@link skippedProfiles} is non-empty. Failures stay
6908
+ * logged; the next converge tick retries.
6909
+ */
6910
+ async fillMissingProfiles(deviceId, config) {
6911
+ if (this.attaching.has(deviceId)) return;
6912
+ const current = this.active.get(deviceId);
6913
+ const skipped = this.skippedProfiles.get(deviceId);
6914
+ if (!current || !skipped || skipped.size === 0) return;
6915
+ this.attaching.add(deviceId);
6916
+ const filling = this.runFillMissingProfiles(deviceId, config, current, skipped);
6917
+ this.attachingTasks.set(deviceId, filling);
6918
+ try {
6919
+ await filling;
6920
+ } finally {
6921
+ this.attachingTasks.delete(deviceId);
6922
+ this.attaching.delete(deviceId);
6923
+ }
6924
+ }
6925
+ async runFillMissingProfiles(deviceId, config, current, skipped) {
6926
+ const have = new Set(current.map((r) => r.profile));
6927
+ const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
6928
+ const added = [];
6929
+ for (const profile of [...skipped]) {
6930
+ if (have.has(profile)) {
6931
+ skipped.delete(profile);
6932
+ continue;
6933
+ }
6934
+ try {
6935
+ added.push(await this.attachProfile(deviceId, profile, segmentSeconds));
6936
+ skipped.delete(profile);
6937
+ } catch (err) {
6938
+ this.deps.logger.warn("recorder: profile attach retry failed — keeping the healthy profiles", {
6939
+ tags: { deviceId },
6940
+ meta: {
6941
+ profile,
6942
+ error: require_dist.errMsg(err)
6943
+ }
6944
+ });
6945
+ }
6946
+ }
6947
+ if (skipped.size === 0) this.skippedProfiles.delete(deviceId);
6948
+ if (added.length === 0) return;
6949
+ const existing = this.active.get(deviceId);
6950
+ if (!existing || this.paused || this.stopped) {
6951
+ await Promise.all(added.map((recording) => this.teardownProfile(deviceId, recording)));
6952
+ return;
6953
+ }
6954
+ this.active.set(deviceId, [...existing, ...added]);
6955
+ this.deps.logger.info("recorder pinned additional broker source(s) for recording", {
6956
+ tags: { deviceId },
6957
+ meta: {
6958
+ profiles: added.map((r) => r.profile),
6959
+ pipelineKeys: added.map((r) => r.pipelineKey)
6960
+ }
6961
+ });
6962
+ }
6963
+ /**
6851
6964
  * Pick which profiles to record. The broker enumerates assigned profile slots,
6852
6965
  * deduped by physical source (`selectAssignedProfileSlots`) so the same camera
6853
- * encoder is never recorded twice. An operator `override` DISABLES the
6854
- * unselected profiles (assigned ∩ selection); a selection matching none of the
6855
- * assigned sources is ignored (record every assigned source minimum of 1).
6966
+ * encoder is never recorded twice. Default is high+low among those slots
6967
+ * (`resolveRecordingProfiles`); an operator `override` DISABLES the
6968
+ * unselected profiles. A selection matching none of the assigned sources
6969
+ * falls back to the default/assigned set (minimum of 1 when anything is assigned).
6856
6970
  */
6857
6971
  async resolveProfiles(deviceId, override) {
6858
- const assigned = require_dist.selectAssignedProfileSlots(await this.deps.brokerCall(() => withDeadline(this.deps.api.streamBroker.listAllProfileSlots.query(void 0, require_dist.nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "listAllProfileSlots")), deviceId).map((slot) => slot.profile);
6859
- if (!override || override.length === 0) return assigned;
6860
- const selected = assigned.filter((p) => override.includes(p));
6861
- return selected.length > 0 ? selected : assigned;
6972
+ return require_dist.resolveRecordingProfiles(require_dist.selectAssignedProfileSlots(await this.deps.brokerCall(() => withDeadline(this.deps.api.streamBroker.listAllProfileSlots.query(void 0, require_dist.nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "listAllProfileSlots")), deviceId).map((slot) => slot.profile), override);
6862
6973
  }
6863
6974
  /**
6864
6975
  * Attach one (device, profile): acquire the broker source, mkdir the staging
@@ -6988,6 +7099,7 @@ var RecordingController = class {
6988
7099
  const recordings = this.active.get(deviceId);
6989
7100
  if (!recordings) return;
6990
7101
  this.active.delete(deviceId);
7102
+ this.skippedProfiles.delete(deviceId);
6991
7103
  for (const r of recordings) this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
6992
7104
  await Promise.all(recordings.map((r) => this.teardownProfile(deviceId, r)));
6993
7105
  this.deps.logger.info("recorder unpinned broker source(s) — recording stopped", {
@@ -7040,6 +7152,503 @@ function makeBrokerCall(handle) {
7040
7152
  return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
7041
7153
  }
7042
7154
  //#endregion
7155
+ //#region src/durable/durable-ledger.ts
7156
+ /** Default reseed cap — every current consumer's row set is installation-bounded. */
7157
+ var DEFAULT_LOAD_LIMIT = 1e5;
7158
+ var DurableLedger = class DurableLedger {
7159
+ mirror = /* @__PURE__ */ new Map();
7160
+ spec;
7161
+ store;
7162
+ logger;
7163
+ constructor(deps) {
7164
+ this.spec = deps.spec;
7165
+ this.store = deps.store;
7166
+ this.logger = deps.logger;
7167
+ }
7168
+ /**
7169
+ * Register the collection. MUST run at boot, before any read or write: the
7170
+ * SQLite backend answers 412 for an undeclared collection and takes the whole
7171
+ * runner down with it (the addon-ai boot-crash lesson).
7172
+ */
7173
+ static declare(store, spec) {
7174
+ return store.declareCollection.mutate({
7175
+ collection: spec.collection,
7176
+ columns: [...spec.columns],
7177
+ ...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
7178
+ });
7179
+ }
7180
+ declare() {
7181
+ return DurableLedger.declare(this.store, this.spec);
7182
+ }
7183
+ /** The collection this ledger owns — for the caller's own log lines. */
7184
+ get collection() {
7185
+ return this.spec.collection;
7186
+ }
7187
+ /**
7188
+ * Boot reseed. Replaces the mirror with what the store holds and returns the
7189
+ * rows, so a caller that must hydrate something else (a watcher, a registry)
7190
+ * gets them without a second read.
7191
+ *
7192
+ * **A failure returns what is already mirrored** rather than clearing it —
7193
+ * see contract rule 2. The count is worth logging out loud at the call site:
7194
+ * "loaded 0" after a container recreate is the one line that explains a
7195
+ * silent flood.
7196
+ */
7197
+ async load() {
7198
+ try {
7199
+ const records = await this.store.query.query({
7200
+ collection: this.spec.collection,
7201
+ filter: { limit: this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT }
7202
+ });
7203
+ const next = /* @__PURE__ */ new Map();
7204
+ let skipped = 0;
7205
+ for (const record of records) {
7206
+ const row = this.spec.fromRecord(record.id, record.data);
7207
+ if (row === null) {
7208
+ skipped += 1;
7209
+ continue;
7210
+ }
7211
+ next.set(this.spec.keyOf(row), row);
7212
+ }
7213
+ this.mirror.clear();
7214
+ for (const [key, row] of next) this.mirror.set(key, row);
7215
+ if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
7216
+ collection: this.spec.collection,
7217
+ skipped
7218
+ } });
7219
+ return [...this.mirror.values()];
7220
+ } catch (err) {
7221
+ this.logger.warn("durable load failed — keeping the state already in memory", { meta: {
7222
+ collection: this.spec.collection,
7223
+ error: String(err),
7224
+ held: this.mirror.size
7225
+ } });
7226
+ return [...this.mirror.values()];
7227
+ }
7228
+ }
7229
+ /** Every mirrored row, insertion-ordered. */
7230
+ snapshot() {
7231
+ return [...this.mirror.values()];
7232
+ }
7233
+ /** The row currently held for a key, if any. Pure RAM — never I/O. */
7234
+ get(key) {
7235
+ return this.mirror.get(key);
7236
+ }
7237
+ has(key) {
7238
+ return this.mirror.has(key);
7239
+ }
7240
+ get size() {
7241
+ return this.mirror.size;
7242
+ }
7243
+ /**
7244
+ * Judge ONE observation against what the ledger already accepted, and advance
7245
+ * it.
7246
+ *
7247
+ * Synchronous on purpose: the verdict is a function of the in-RAM mirror
7248
+ * alone, so a decision can never be gated on an I/O that might fail (D49).
7249
+ * The durable write is kicked off behind it and its failure changes no
7250
+ * verdict.
7251
+ *
7252
+ * `no-flip` leaves the held row UNTOUCHED — including any timestamp it
7253
+ * carries, which therefore means "when this key last CHANGED", not "when it
7254
+ * last spoke". That is the timestamp anyone reading the table wants.
7255
+ */
7256
+ observe(row) {
7257
+ const equalFact = this.spec.equalFact;
7258
+ if (equalFact === void 0) throw new Error(`DurableLedger(${this.spec.collection}): observe() requires the spec to declare equalFact`);
7259
+ const key = this.spec.keyOf(row);
7260
+ const held = this.mirror.get(key);
7261
+ if (held !== void 0 && equalFact(held, row)) return "no-flip";
7262
+ this.mirror.set(key, row);
7263
+ this.persist(row);
7264
+ return held === void 0 ? "seeded" : "flip";
7265
+ }
7266
+ /**
7267
+ * Upsert a row without a verdict — the write path for a ledger whose owner
7268
+ * has already decided the value changed.
7269
+ *
7270
+ * The order of the mirror advance and the durable write is the spec's
7271
+ * {@link DurableWriteMode}, not the call site's: two call sites that
7272
+ * disagreed about it would be two different durability guarantees on one
7273
+ * collection.
7274
+ */
7275
+ async put(row) {
7276
+ const key = this.spec.keyOf(row);
7277
+ if (this.spec.writeMode === "write-behind") {
7278
+ this.mirror.set(key, row);
7279
+ await this.persist(row);
7280
+ return;
7281
+ }
7282
+ await this.store.set.mutate({
7283
+ collection: this.spec.collection,
7284
+ key,
7285
+ value: this.spec.toValue(row)
7286
+ });
7287
+ this.mirror.set(key, row);
7288
+ }
7289
+ /**
7290
+ * Advance the mirror WITHOUT persisting, for an owner that deliberately
7291
+ * coalesces its writes.
7292
+ *
7293
+ * The stationary registry is the reason this exists: a parked car is
7294
+ * re-confirmed on every processed frame (5–30 Hz), and persisting each
7295
+ * confirmation would offer thousands of commits a day to the checkpoint
7296
+ * lottery (D96) to maintain a handful of rows. It stages the advance and
7297
+ * flushes on its 5-minute sweep — 288 writes a day instead of ~10⁶.
7298
+ *
7299
+ * **The cost is stated, not hidden**: a staged value that is never flushed
7300
+ * is lost on a crash. An owner may only stage a field whose staleness its
7301
+ * own TTL absorbs. Anything that GATES work must go through {@link put} or
7302
+ * {@link observe}.
7303
+ */
7304
+ stage(row) {
7305
+ this.mirror.set(this.spec.keyOf(row), row);
7306
+ }
7307
+ /**
7308
+ * Drop a key from the MIRROR only — the durable row survives.
7309
+ *
7310
+ * What a scope-unbind needs: this process stops holding the value, and a
7311
+ * rebind reloads it from the store. Deliberately distinct from
7312
+ * {@link forget}, which deletes; conflating the two is how an unbind turns
7313
+ * into a wipe.
7314
+ */
7315
+ evict(key) {
7316
+ this.mirror.delete(key);
7317
+ }
7318
+ /**
7319
+ * Drop one key, mirror and row. Best-effort on the durable half: a failed
7320
+ * delete leaves a row that the next load will re-mirror, which is a stale
7321
+ * value rather than a lost one.
7322
+ */
7323
+ async forget(key) {
7324
+ this.mirror.delete(key);
7325
+ try {
7326
+ await this.store.delete.mutate({
7327
+ collection: this.spec.collection,
7328
+ key
7329
+ });
7330
+ } catch (err) {
7331
+ this.logger.debug("durable delete failed", { meta: {
7332
+ collection: this.spec.collection,
7333
+ key,
7334
+ error: String(err)
7335
+ } });
7336
+ }
7337
+ }
7338
+ /**
7339
+ * Drop every mirrored key NOT in `activeKeys`. Returns how many rows went.
7340
+ *
7341
+ * **The caller must hold an AUTHORITATIVE active set.** A prune driven by a
7342
+ * fallible read is work destroyed on an error (D49/D130) — that is why this
7343
+ * is a method a feature opts into rather than a policy the primitive runs.
7344
+ * Best-effort per row: a failed delete keeps the key (retried next prune)
7345
+ * rather than aborting the sweep.
7346
+ */
7347
+ async pruneExcept(activeKeys) {
7348
+ let pruned = 0;
7349
+ for (const key of [...this.mirror.keys()]) {
7350
+ if (activeKeys.has(key)) continue;
7351
+ try {
7352
+ await this.store.delete.mutate({
7353
+ collection: this.spec.collection,
7354
+ key
7355
+ });
7356
+ this.mirror.delete(key);
7357
+ pruned += 1;
7358
+ } catch (err) {
7359
+ this.logger.debug("durable prune delete failed", { meta: {
7360
+ collection: this.spec.collection,
7361
+ key,
7362
+ error: String(err)
7363
+ } });
7364
+ }
7365
+ }
7366
+ return pruned;
7367
+ }
7368
+ /**
7369
+ * Write-behind durable upsert. Best-effort and logged, never thrown at the
7370
+ * decision path: the mirror already holds the truth for this process, and the
7371
+ * worst a lost write can do is one silent re-seed after the next restart.
7372
+ */
7373
+ async persist(row) {
7374
+ const deviceId = this.spec.deviceIdOf?.(row);
7375
+ try {
7376
+ await this.store.set.mutate({
7377
+ collection: this.spec.collection,
7378
+ key: this.spec.keyOf(row),
7379
+ value: this.spec.toValue(row)
7380
+ });
7381
+ } catch (err) {
7382
+ this.logger.warn("durable persist failed — this key may re-seed on boot", {
7383
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
7384
+ meta: {
7385
+ collection: this.spec.collection,
7386
+ key: this.spec.keyOf(row),
7387
+ error: String(err)
7388
+ }
7389
+ });
7390
+ }
7391
+ }
7392
+ };
7393
+ //#endregion
7394
+ //#region src/recorder/addon/segment-hour-ledger.ts
7395
+ var HOUR_MS$1 = 36e5;
7396
+ /**
7397
+ * @durable class=mirror owner=recorder
7398
+ * write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
7399
+ * retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
7400
+ */
7401
+ var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
7402
+ var RECORDING_SEGMENT_HOURS_COLUMNS = [
7403
+ {
7404
+ name: "key",
7405
+ type: "TEXT",
7406
+ primaryKey: true,
7407
+ notNull: true
7408
+ },
7409
+ {
7410
+ name: "deviceId",
7411
+ type: "INTEGER",
7412
+ notNull: true
7413
+ },
7414
+ {
7415
+ name: "profile",
7416
+ type: "TEXT",
7417
+ notNull: true
7418
+ },
7419
+ {
7420
+ name: "locationId",
7421
+ type: "TEXT",
7422
+ notNull: true
7423
+ },
7424
+ {
7425
+ name: "hourStartMs",
7426
+ type: "INTEGER",
7427
+ notNull: true
7428
+ },
7429
+ {
7430
+ name: "paths",
7431
+ type: "TEXT",
7432
+ notNull: true
7433
+ }
7434
+ ];
7435
+ var RECORDING_SEGMENT_HOURS_INDEXES = [{
7436
+ name: "idx_recorder_segment_hours_device",
7437
+ columns: ["deviceId"]
7438
+ }, {
7439
+ name: "idx_recorder_segment_hours_hour",
7440
+ columns: ["hourStartMs"]
7441
+ }];
7442
+ function hourStartMs(startMs) {
7443
+ return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
7444
+ }
7445
+ function segmentHourKey(deviceId, profile, locationId, hour) {
7446
+ return `${deviceId}|${profile}|${locationId}|${hour}`;
7447
+ }
7448
+ function hourOf(row) {
7449
+ const hour = hourStartMs(row.startMs);
7450
+ return {
7451
+ hourStartMs: hour,
7452
+ key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
7453
+ };
7454
+ }
7455
+ function uniquePaths(paths) {
7456
+ const seen = /* @__PURE__ */ new Set();
7457
+ const out = [];
7458
+ for (const p of paths) {
7459
+ if (seen.has(p)) continue;
7460
+ seen.add(p);
7461
+ out.push(p);
7462
+ }
7463
+ return out;
7464
+ }
7465
+ function samePaths(a, b) {
7466
+ if (a.length !== b.length) return false;
7467
+ const set = new Set(a);
7468
+ for (const p of b) if (!set.has(p)) return false;
7469
+ return true;
7470
+ }
7471
+ function rowToValue(row) {
7472
+ return {
7473
+ deviceId: row.deviceId,
7474
+ profile: row.profile,
7475
+ locationId: row.locationId,
7476
+ hourStartMs: row.hourStartMs,
7477
+ paths: JSON.stringify(row.paths)
7478
+ };
7479
+ }
7480
+ function recordToRow(key, data) {
7481
+ const deviceId = Number(data["deviceId"]);
7482
+ const profile = data["profile"];
7483
+ const locationId = data["locationId"];
7484
+ const hour = Number(data["hourStartMs"]);
7485
+ let raw = data["paths"];
7486
+ if (typeof raw === "string") try {
7487
+ raw = JSON.parse(raw);
7488
+ } catch {
7489
+ return null;
7490
+ }
7491
+ if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour) || !Array.isArray(raw) || !raw.every((p) => typeof p === "string" && p.length > 0)) return null;
7492
+ return {
7493
+ key,
7494
+ deviceId,
7495
+ profile,
7496
+ locationId,
7497
+ hourStartMs: hour,
7498
+ paths: raw
7499
+ };
7500
+ }
7501
+ var SPEC = {
7502
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
7503
+ columns: RECORDING_SEGMENT_HOURS_COLUMNS,
7504
+ indexes: RECORDING_SEGMENT_HOURS_INDEXES,
7505
+ writeMode: "write-behind",
7506
+ loadLimit: 1e5,
7507
+ keyOf: (row) => row.key,
7508
+ toValue: rowToValue,
7509
+ fromRecord: recordToRow,
7510
+ deviceIdOf: (row) => row.deviceId
7511
+ };
7512
+ var SegmentHourLedger = class {
7513
+ ledger;
7514
+ logger;
7515
+ constructor(deps) {
7516
+ this.logger = deps.logger;
7517
+ this.ledger = new DurableLedger({
7518
+ spec: SPEC,
7519
+ store: deps.store,
7520
+ logger: deps.logger
7521
+ });
7522
+ }
7523
+ static declare(store) {
7524
+ return DurableLedger.declare(store, SPEC);
7525
+ }
7526
+ declare() {
7527
+ return this.ledger.declare();
7528
+ }
7529
+ async load() {
7530
+ const rows = await this.ledger.load();
7531
+ this.logger.info("recorder: segment-hour ledger loaded", { meta: {
7532
+ hours: rows.length,
7533
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION
7534
+ } });
7535
+ return rows;
7536
+ }
7537
+ snapshot() {
7538
+ return this.ledger.snapshot();
7539
+ }
7540
+ /**
7541
+ * Seed {@link RecordingIndex} from the mirrored hour rows.
7542
+ *
7543
+ * Hours present in the ledger become `hydrated` so a seek in those windows
7544
+ * does not pay for a disk walk. Hours the ledger never held stay `unknown`
7545
+ * — that is the fail-toward-walk direction.
7546
+ */
7547
+ hydrateIndex(index) {
7548
+ for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
7549
+ }
7550
+ /** Write-behind: a just-finalized segment joins its hour row. */
7551
+ async recordSegment(row) {
7552
+ const { key, hourStartMs: hour } = hourOf(row);
7553
+ const held = this.ledger.get(key);
7554
+ if (held !== void 0 && held.paths.includes(row.path)) return;
7555
+ await this.ledger.put({
7556
+ key,
7557
+ deviceId: row.deviceId,
7558
+ profile: row.profile,
7559
+ locationId: row.locationId,
7560
+ hourStartMs: hour,
7561
+ paths: held === void 0 ? [row.path] : [...held.paths, row.path]
7562
+ });
7563
+ }
7564
+ /**
7565
+ * Eviction confirmed these paths are gone from disk. Drop them; forget the
7566
+ * hour when it holds nothing.
7567
+ */
7568
+ async dropSegments(rows) {
7569
+ const doomed = /* @__PURE__ */ new Map();
7570
+ for (const row of rows) {
7571
+ const { key } = hourOf(row);
7572
+ let set = doomed.get(key);
7573
+ if (!set) {
7574
+ set = /* @__PURE__ */ new Set();
7575
+ doomed.set(key, set);
7576
+ }
7577
+ set.add(row.path);
7578
+ }
7579
+ for (const [key, paths] of doomed) {
7580
+ const held = this.ledger.get(key);
7581
+ if (held === void 0) continue;
7582
+ const next = held.paths.filter((p) => !paths.has(p));
7583
+ if (next.length === 0) {
7584
+ await this.ledger.forget(key);
7585
+ continue;
7586
+ }
7587
+ if (next.length === held.paths.length) continue;
7588
+ await this.ledger.put({
7589
+ ...held,
7590
+ paths: next
7591
+ });
7592
+ }
7593
+ }
7594
+ /**
7595
+ * After a disk walk of `deviceIds`, make the ledger match the index — with
7596
+ * two D148 exceptions:
7597
+ *
7598
+ * - an hour the walk did not see is dropped ONLY if it is in the past;
7599
+ * the current hour may still hold a live writer whose file the readdir
7600
+ * raced;
7601
+ * - the current hour UNIONs ledger paths with index paths rather than
7602
+ * replacing, for the same race.
7603
+ *
7604
+ * Devices not in `deviceIds` are left untouched (a partial walk must not
7605
+ * prune cameras it did not look at).
7606
+ */
7607
+ async reconcileFromIndex(index, deviceIds, nowMs) {
7608
+ const walked = new Set(deviceIds);
7609
+ const currentHour = hourStartMs(nowMs);
7610
+ const fromIndex = /* @__PURE__ */ new Map();
7611
+ for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
7612
+ const { key, hourStartMs: hour } = hourOf(seg);
7613
+ const existing = fromIndex.get(key);
7614
+ if (existing === void 0) fromIndex.set(key, {
7615
+ key,
7616
+ deviceId: seg.deviceId,
7617
+ profile: seg.profile,
7618
+ locationId: seg.locationId,
7619
+ hourStartMs: hour,
7620
+ paths: [seg.path]
7621
+ });
7622
+ else fromIndex.set(key, {
7623
+ ...existing,
7624
+ paths: [...existing.paths, seg.path]
7625
+ });
7626
+ }
7627
+ for (const row of this.ledger.snapshot()) {
7628
+ if (!walked.has(row.deviceId)) continue;
7629
+ const disk = fromIndex.get(row.key);
7630
+ if (disk === void 0) {
7631
+ if (row.hourStartMs === currentHour) continue;
7632
+ await this.ledger.forget(row.key);
7633
+ continue;
7634
+ }
7635
+ if (row.hourStartMs === currentHour) {
7636
+ const union = uniquePaths([...disk.paths, ...row.paths]);
7637
+ if (!samePaths(union, row.paths)) await this.ledger.put({
7638
+ ...row,
7639
+ paths: union
7640
+ });
7641
+ continue;
7642
+ }
7643
+ if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
7644
+ }
7645
+ for (const [key, disk] of fromIndex) {
7646
+ if (this.ledger.has(key)) continue;
7647
+ await this.ledger.put(disk);
7648
+ }
7649
+ }
7650
+ };
7651
+ //#endregion
7043
7652
  //#region src/recorder/addon/recording-export-provider.ts
7044
7653
  /** Content type of every rendered export — the engine only ever writes MP4. */
7045
7654
  var EXPORT_CONTENT_TYPE = "video/mp4";
@@ -7861,6 +8470,12 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7861
8470
  isRecordingNode = true;
7862
8471
  index = new RecordingIndex();
7863
8472
  /**
8473
+ * Durable hour-directory copy of {@link index}. Null on an inert
8474
+ * (non-recording) node. Boot reseeds the RAM index from this; the
8475
+ * deferred archive walk reconciles it against disk.
8476
+ */
8477
+ segmentHours = null;
8478
+ /**
7864
8479
  * The calendar rungs — which days, which hours, and a day's exact ranges.
7865
8480
  *
7866
8481
  * Separate from `RecordingIndex` on purpose: that one is the SEGMENT model
@@ -7932,6 +8547,16 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7932
8547
  } });
7933
8548
  return { providers: [] };
7934
8549
  }
8550
+ this.segmentHours = new SegmentHourLedger({
8551
+ store: this.ctx.api.settingsStore,
8552
+ logger: this.ctx.logger
8553
+ });
8554
+ try {
8555
+ await this.segmentHours.declare();
8556
+ } catch (err) {
8557
+ this.ctx.logger.warn("recorder: segment-hour ledger declare failed — index stays RAM-only this boot", { meta: { error: require_dist.errMsg(err) } });
8558
+ this.segmentHours = null;
8559
+ }
7935
8560
  const ingestOwner = await this.resolveIngestOwner();
7936
8561
  const consumerHostname = this.nodeId === ingestOwner.ownerNodeId ? void 0 : ingestOwner.reachableHost ?? resolveRecordingHubHostname(this.config.recordingHubHostname, process.env["CAMSTACK_HUB_URL"]);
7937
8562
  if (this.nodeId !== ingestOwner.ownerNodeId) {
@@ -7968,7 +8593,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7968
8593
  onEvicted: (deviceId, atMs) => {
7969
8594
  this.calendar.invalidate(deviceId, atMs);
7970
8595
  },
8596
+ onDeindexed: (rows) => {
8597
+ this.segmentHours?.dropSegments(rows);
8598
+ },
7971
8599
  onIndexed: (row, absPath) => {
8600
+ this.segmentHours?.recordSegment(row);
7972
8601
  if (!isMfraTableServable(row.startMs, Date.now())) return;
7973
8602
  readMfraTable(absPath, row.bytes).then((table) => {
7974
8603
  this.mfraTables.record(row.deviceId, row.profile, row.startMs, table);
@@ -8087,6 +8716,9 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
8087
8716
  refreshLocations: () => this.refreshLocations(),
8088
8717
  capacity: (root) => this.locationCapacity(root),
8089
8718
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
8719
+ reconcileHourLedger: async (deviceId) => {
8720
+ await this.segmentHours?.reconcileFromIndex(this.index, [deviceId], Date.now());
8721
+ },
8090
8722
  calendar: this.calendar,
8091
8723
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
8092
8724
  hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
@@ -8345,6 +8977,12 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
8345
8977
  this.controller?.onTrigger(deviceId, "motion", atMs);
8346
8978
  }
8347
8979
  });
8980
+ try {
8981
+ await this.segmentHours?.load();
8982
+ this.segmentHours?.hydrateIndex(this.index);
8983
+ } catch (err) {
8984
+ this.ctx.logger.warn("recorder: segment-hour ledger load failed — falling through to the archive walk", { meta: { error: require_dist.errMsg(err) } });
8985
+ }
8348
8986
  let configs;
8349
8987
  try {
8350
8988
  configs = await readDeviceConfigs(this.configStore());
@@ -8832,6 +9470,11 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
8832
9470
  locationCount: this.resolvedLocations.length,
8833
9471
  ms: Date.now() - hydrateStartedMs
8834
9472
  } });
9473
+ try {
9474
+ await this.segmentHours?.reconcileFromIndex(this.index, deviceIds, Date.now());
9475
+ } catch (err) {
9476
+ this.ctx.logger.warn("recorder: segment-hour ledger reconcile failed — walk remains the authority", { meta: { error: require_dist.errMsg(err) } });
9477
+ }
8835
9478
  const loggedDomains = /* @__PURE__ */ new Set();
8836
9479
  for (const loc of this.resolvedLocations) {
8837
9480
  const domain = this.locationDomain(loc.id);