@camstack/addon-pipeline 1.2.90 → 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 +134 -21
  16. package/dist/recorder/index.mjs +133 -20
  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");
@@ -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) => {
@@ -4213,6 +4234,7 @@ function buildRecordingProvider(deps) {
4213
4234
  rescanStorage: async ({ deviceId }) => {
4214
4235
  const locations = await deps.refreshLocations();
4215
4236
  await deps.hydrateDevice(deviceId, locations);
4237
+ await deps.reconcileHourLedger?.(deviceId);
4216
4238
  const config = await loadDeviceConfig(deps.configStore, deviceId);
4217
4239
  await deps.opsLog?.append({
4218
4240
  op: "rescan",
@@ -5076,7 +5098,11 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
5076
5098
  try {
5077
5099
  entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
5078
5100
  } catch (err) {
5079
- 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", {
5080
5106
  tags: { deviceId },
5081
5107
  meta: {
5082
5108
  locationIds: aliases.map((location) => location.id),
@@ -5104,6 +5130,7 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
5104
5130
  handled += 1;
5105
5131
  if (handled % chunkSize === 0) await yieldBetween();
5106
5132
  }
5133
+ for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
5107
5134
  let indexed = 0;
5108
5135
  for (const [locationId, relPaths] of pathsByLocation) {
5109
5136
  index.hydrateDevice(deviceId, locationId, relPaths);
@@ -6563,6 +6590,13 @@ var RecordingController = class {
6563
6590
  * records again.
6564
6591
  */
6565
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();
6566
6600
  restore = null;
6567
6601
  /** Cheap, never-delayed: is any pinned writer producing nothing? */
6568
6602
  livenessPass = null;
@@ -6769,7 +6803,10 @@ var RecordingController = class {
6769
6803
  return;
6770
6804
  }
6771
6805
  this.unrecordableReported.delete(deviceId);
6772
- 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
+ }
6773
6810
  if (this.attaching.has(deviceId)) return;
6774
6811
  this.attaching.add(deviceId);
6775
6812
  const attaching = this.performAttach(deviceId, config);
@@ -6820,8 +6857,12 @@ var RecordingController = class {
6820
6857
  /**
6821
6858
  * The attach body — extracted so the {@link attaching} in-flight guard in
6822
6859
  * `evaluateDevice` wraps it cleanly. Resolves the profiles, spawns a
6823
- * SegmentWriter + watcher per profile, and records the set in `active`. A throw
6824
- * (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.
6825
6866
  */
6826
6867
  async performAttach(deviceId, config) {
6827
6868
  this.restore?.add(deviceId, true);
@@ -6829,38 +6870,106 @@ var RecordingController = class {
6829
6870
  if (profiles.length === 0) throw new Error(`recorder controller: no assigned broker sources for device ${deviceId}`);
6830
6871
  const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
6831
6872
  const recordings = [];
6832
- try {
6833
- 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));
6834
6876
  } catch (err) {
6835
- for (const r of recordings) await this.teardownProfile(deviceId, r);
6836
- 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
+ });
6837
6885
  }
6886
+ if (recordings.length === 0) throw new Error(`recorder controller: no profile attached for device ${deviceId}` + (failed.length > 0 ? ` (failed: ${failed.join(",")})` : ""));
6838
6887
  if (this.paused) {
6839
6888
  await Promise.all(recordings.map((recording) => this.teardownProfile(deviceId, recording)));
6840
6889
  return;
6841
6890
  }
6842
6891
  this.active.set(deviceId, recordings);
6892
+ if (failed.length > 0) this.skippedProfiles.set(deviceId, new Set(failed));
6893
+ else this.skippedProfiles.delete(deviceId);
6843
6894
  this.deps.logger.info("recorder pinned broker source(s) for recording", {
6844
6895
  tags: { deviceId },
6845
6896
  meta: {
6846
- profiles,
6897
+ profiles: recordings.map((r) => r.profile),
6898
+ failedProfiles: failed,
6847
6899
  segmentSeconds,
6848
6900
  pipelineKeys: recordings.map((r) => r.pipelineKey)
6849
6901
  }
6850
6902
  });
6851
6903
  }
6852
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
+ /**
6853
6964
  * Pick which profiles to record. The broker enumerates assigned profile slots,
6854
6965
  * deduped by physical source (`selectAssignedProfileSlots`) so the same camera
6855
- * encoder is never recorded twice. An operator `override` DISABLES the
6856
- * unselected profiles (assigned ∩ selection); a selection matching none of the
6857
- * 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).
6858
6970
  */
6859
6971
  async resolveProfiles(deviceId, override) {
6860
- 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);
6861
- if (!override || override.length === 0) return assigned;
6862
- const selected = assigned.filter((p) => override.includes(p));
6863
- 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);
6864
6973
  }
6865
6974
  /**
6866
6975
  * Attach one (device, profile): acquire the broker source, mkdir the staging
@@ -6990,6 +7099,7 @@ var RecordingController = class {
6990
7099
  const recordings = this.active.get(deviceId);
6991
7100
  if (!recordings) return;
6992
7101
  this.active.delete(deviceId);
7102
+ this.skippedProfiles.delete(deviceId);
6993
7103
  for (const r of recordings) this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
6994
7104
  await Promise.all(recordings.map((r) => this.teardownProfile(deviceId, r)));
6995
7105
  this.deps.logger.info("recorder unpinned broker source(s) — recording stopped", {
@@ -8606,6 +8716,9 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
8606
8716
  refreshLocations: () => this.refreshLocations(),
8607
8717
  capacity: (root) => this.locationCapacity(root),
8608
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
+ },
8609
8722
  calendar: this.calendar,
8610
8723
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
8611
8724
  hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
@@ -1,4 +1,4 @@
1
- import { At as number, Ft as EventCategory, I as deriveRecordingMode, Mt as record, Nt as string, S as RecordingConfigSchema, St as selectAssignedProfileSlots, Tt as array, X as recordingCapability, Z as recordingExportCapability, et as storageEvictableCapability, f as EVENT_PAD_MS, ht as hydrateSchema, it as errMsg, jt as object, l as DEFAULT_EVENTS_BAND_BUFFER_SEC, m as ExportRecordSchema, pt as DeviceType, ut as BaseAddon, v as OpsLogEntrySchema, x as RECORDING_EXPORT_MAX_READ_BYTES, yt as nodePin } from "../dist-BJYwWEZ9.mjs";
1
+ import { $ as resolveRecordingProfiles, Ct as selectAssignedProfileSlots, Et as array, I as deriveRecordingMode, It as EventCategory, Mt as object, Nt as record, Pt as string, S as RecordingConfigSchema, X as recordingCapability, Z as recordingExportCapability, at as errMsg, bt as nodePin, dt as BaseAddon, f as EVENT_PAD_MS, gt as hydrateSchema, jt as number, l as DEFAULT_EVENTS_BAND_BUFFER_SEC, m as ExportRecordSchema, mt as DeviceType, tt as storageEvictableCapability, v as OpsLogEntrySchema, x as RECORDING_EXPORT_MAX_READ_BYTES } from "../dist-6POVIYmC.mjs";
2
2
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
3
  import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-A2S9D7pu.mjs";
4
4
  import { randomUUID } from "node:crypto";
@@ -1068,6 +1068,7 @@ var RelocateEngine = class {
1068
1068
  deviceId: input.deviceId ?? null,
1069
1069
  profiles: input.profiles ? [...input.profiles] : [],
1070
1070
  footageClass: input.footageClass,
1071
+ sinceMs: input.sinceMs ?? null,
1071
1072
  throttleMbps: input.throttleMbps ?? DEFAULT_THROTTLE_MBPS,
1072
1073
  entities: input.entities ? [...input.entities] : ["segments"],
1073
1074
  filesMoved: 0,
@@ -1128,7 +1129,7 @@ var RelocateEngine = class {
1128
1129
  const bytesPerMs = job.throttleMbps * 1024 * 1024 / 1e3;
1129
1130
  try {
1130
1131
  const samePhysicalRoot = await rootsPhysicallySame(from.root, to.root);
1131
- 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);
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)).filter((r) => job.sinceMs === null || r.startMs >= job.sinceMs).sort((a, b) => a.startMs - b.startMs);
1132
1133
  job.filesTotal = segRows.length;
1133
1134
  for (const row of segRows) {
1134
1135
  if (job.cancelRequested) break;
@@ -1164,9 +1165,26 @@ var RelocateEngine = class {
1164
1165
  }
1165
1166
  }
1166
1167
  /** Move one segment file and re-point its index row to the target location
1167
- * (same location-relative path — the layout is identical on every root). */
1168
+ * (same location-relative path — the layout is identical on every root).
1169
+ * A source that is already gone (evicted, never flushed, ENOSPC) is dropped
1170
+ * from the index and skipped — one ghost file must not abort a drain. */
1168
1171
  async moveSegment(row, from, to, samePhysicalRoot) {
1169
- if (!samePhysicalRoot) await moveFile(path.join(from.root, row.path), path.join(to.root, row.path), row.bytes);
1172
+ if (!samePhysicalRoot) try {
1173
+ await moveFile(path.join(from.root, row.path), path.join(to.root, row.path), row.bytes);
1174
+ } catch (err) {
1175
+ if (isEnoent(err)) {
1176
+ this.deps.index.removeSegments([row.path]);
1177
+ this.deps.logger.warn("relocate skipped missing source", {
1178
+ tags: { deviceId: row.deviceId },
1179
+ meta: {
1180
+ path: row.path,
1181
+ locationId: from.id
1182
+ }
1183
+ });
1184
+ return;
1185
+ }
1186
+ throw err;
1187
+ }
1170
1188
  this.deps.index.removeSegments([row.path]);
1171
1189
  this.deps.index.addSegment({
1172
1190
  ...row,
@@ -1174,6 +1192,9 @@ var RelocateEngine = class {
1174
1192
  });
1175
1193
  }
1176
1194
  };
1195
+ function isEnoent(err) {
1196
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1197
+ }
1177
1198
  /** Logical migration classes are profile-derived, never location-derived:
1178
1199
  * high+mid are `recordings`; low is `recordingsLow`. */
1179
1200
  var profileBelongsToClass = (profile, footageClass) => {
@@ -4212,6 +4233,7 @@ function buildRecordingProvider(deps) {
4212
4233
  rescanStorage: async ({ deviceId }) => {
4213
4234
  const locations = await deps.refreshLocations();
4214
4235
  await deps.hydrateDevice(deviceId, locations);
4236
+ await deps.reconcileHourLedger?.(deviceId);
4215
4237
  const config = await loadDeviceConfig(deps.configStore, deviceId);
4216
4238
  await deps.opsLog?.append({
4217
4239
  op: "rescan",
@@ -5075,7 +5097,11 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
5075
5097
  try {
5076
5098
  entries = await promises.readdir(deviceDir, { recursive: true });
5077
5099
  } catch (err) {
5078
- if (err.code !== "ENOENT") logger.warn("recorder: hydrateDevice walk failed for location", {
5100
+ if (err.code === "ENOENT") {
5101
+ for (const location of aliases) index.hydrateDevice(deviceId, location.id, []);
5102
+ continue;
5103
+ }
5104
+ logger.warn("recorder: hydrateDevice walk failed for location", {
5079
5105
  tags: { deviceId },
5080
5106
  meta: {
5081
5107
  locationIds: aliases.map((location) => location.id),
@@ -5103,6 +5129,7 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
5103
5129
  handled += 1;
5104
5130
  if (handled % chunkSize === 0) await yieldBetween();
5105
5131
  }
5132
+ for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
5106
5133
  let indexed = 0;
5107
5134
  for (const [locationId, relPaths] of pathsByLocation) {
5108
5135
  index.hydrateDevice(deviceId, locationId, relPaths);
@@ -6562,6 +6589,13 @@ var RecordingController = class {
6562
6589
  * records again.
6563
6590
  */
6564
6591
  unrecordableReported = /* @__PURE__ */ new Set();
6592
+ /**
6593
+ * Profiles the last attach skipped (stream not published / getStream threw).
6594
+ * `evaluateDevice` retries only these on the converge tick so a dead `low`
6595
+ * cannot keep `high` dark, without a `listAllProfileSlots` RPC on every
6596
+ * healthy device every 30s.
6597
+ */
6598
+ skippedProfiles = /* @__PURE__ */ new Map();
6565
6599
  restore = null;
6566
6600
  /** Cheap, never-delayed: is any pinned writer producing nothing? */
6567
6601
  livenessPass = null;
@@ -6768,7 +6802,10 @@ var RecordingController = class {
6768
6802
  return;
6769
6803
  }
6770
6804
  this.unrecordableReported.delete(deviceId);
6771
- if (this.active.has(deviceId)) return;
6805
+ if (this.active.has(deviceId)) {
6806
+ if (this.skippedProfiles.has(deviceId)) await this.fillMissingProfiles(deviceId, config);
6807
+ return;
6808
+ }
6772
6809
  if (this.attaching.has(deviceId)) return;
6773
6810
  this.attaching.add(deviceId);
6774
6811
  const attaching = this.performAttach(deviceId, config);
@@ -6819,8 +6856,12 @@ var RecordingController = class {
6819
6856
  /**
6820
6857
  * The attach body — extracted so the {@link attaching} in-flight guard in
6821
6858
  * `evaluateDevice` wraps it cleanly. Resolves the profiles, spawns a
6822
- * SegmentWriter + watcher per profile, and records the set in `active`. A throw
6823
- * (broker not ready / no sources) rolls back partial attaches and re-throws.
6859
+ * SegmentWriter + watcher per profile, and records the set in `active`.
6860
+ *
6861
+ * Per-profile: a throw from one slot is logged and skipped. Rolling back the
6862
+ * whole device when a single stream was dead (618 `derived` on `low`) left
6863
+ * ZERO footage for hours. If every profile fails, throw so the restore queue
6864
+ * retries the device.
6824
6865
  */
6825
6866
  async performAttach(deviceId, config) {
6826
6867
  this.restore?.add(deviceId, true);
@@ -6828,38 +6869,106 @@ var RecordingController = class {
6828
6869
  if (profiles.length === 0) throw new Error(`recorder controller: no assigned broker sources for device ${deviceId}`);
6829
6870
  const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
6830
6871
  const recordings = [];
6831
- try {
6832
- for (const profile of profiles) recordings.push(await this.attachProfile(deviceId, profile, segmentSeconds));
6872
+ const failed = [];
6873
+ for (const profile of profiles) try {
6874
+ recordings.push(await this.attachProfile(deviceId, profile, segmentSeconds));
6833
6875
  } catch (err) {
6834
- for (const r of recordings) await this.teardownProfile(deviceId, r);
6835
- throw err;
6876
+ failed.push(profile);
6877
+ this.deps.logger.warn("recorder: profile attach failed — recording the remaining profiles", {
6878
+ tags: { deviceId },
6879
+ meta: {
6880
+ profile,
6881
+ error: errMsg(err)
6882
+ }
6883
+ });
6836
6884
  }
6885
+ if (recordings.length === 0) throw new Error(`recorder controller: no profile attached for device ${deviceId}` + (failed.length > 0 ? ` (failed: ${failed.join(",")})` : ""));
6837
6886
  if (this.paused) {
6838
6887
  await Promise.all(recordings.map((recording) => this.teardownProfile(deviceId, recording)));
6839
6888
  return;
6840
6889
  }
6841
6890
  this.active.set(deviceId, recordings);
6891
+ if (failed.length > 0) this.skippedProfiles.set(deviceId, new Set(failed));
6892
+ else this.skippedProfiles.delete(deviceId);
6842
6893
  this.deps.logger.info("recorder pinned broker source(s) for recording", {
6843
6894
  tags: { deviceId },
6844
6895
  meta: {
6845
- profiles,
6896
+ profiles: recordings.map((r) => r.profile),
6897
+ failedProfiles: failed,
6846
6898
  segmentSeconds,
6847
6899
  pipelineKeys: recordings.map((r) => r.pipelineKey)
6848
6900
  }
6849
6901
  });
6850
6902
  }
6851
6903
  /**
6904
+ * Retry profiles the last attach skipped, without tearing down writers that
6905
+ * are already producing. Called from `evaluateDevice` when the device is
6906
+ * already in `active` and {@link skippedProfiles} is non-empty. Failures stay
6907
+ * logged; the next converge tick retries.
6908
+ */
6909
+ async fillMissingProfiles(deviceId, config) {
6910
+ if (this.attaching.has(deviceId)) return;
6911
+ const current = this.active.get(deviceId);
6912
+ const skipped = this.skippedProfiles.get(deviceId);
6913
+ if (!current || !skipped || skipped.size === 0) return;
6914
+ this.attaching.add(deviceId);
6915
+ const filling = this.runFillMissingProfiles(deviceId, config, current, skipped);
6916
+ this.attachingTasks.set(deviceId, filling);
6917
+ try {
6918
+ await filling;
6919
+ } finally {
6920
+ this.attachingTasks.delete(deviceId);
6921
+ this.attaching.delete(deviceId);
6922
+ }
6923
+ }
6924
+ async runFillMissingProfiles(deviceId, config, current, skipped) {
6925
+ const have = new Set(current.map((r) => r.profile));
6926
+ const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
6927
+ const added = [];
6928
+ for (const profile of [...skipped]) {
6929
+ if (have.has(profile)) {
6930
+ skipped.delete(profile);
6931
+ continue;
6932
+ }
6933
+ try {
6934
+ added.push(await this.attachProfile(deviceId, profile, segmentSeconds));
6935
+ skipped.delete(profile);
6936
+ } catch (err) {
6937
+ this.deps.logger.warn("recorder: profile attach retry failed — keeping the healthy profiles", {
6938
+ tags: { deviceId },
6939
+ meta: {
6940
+ profile,
6941
+ error: errMsg(err)
6942
+ }
6943
+ });
6944
+ }
6945
+ }
6946
+ if (skipped.size === 0) this.skippedProfiles.delete(deviceId);
6947
+ if (added.length === 0) return;
6948
+ const existing = this.active.get(deviceId);
6949
+ if (!existing || this.paused || this.stopped) {
6950
+ await Promise.all(added.map((recording) => this.teardownProfile(deviceId, recording)));
6951
+ return;
6952
+ }
6953
+ this.active.set(deviceId, [...existing, ...added]);
6954
+ this.deps.logger.info("recorder pinned additional broker source(s) for recording", {
6955
+ tags: { deviceId },
6956
+ meta: {
6957
+ profiles: added.map((r) => r.profile),
6958
+ pipelineKeys: added.map((r) => r.pipelineKey)
6959
+ }
6960
+ });
6961
+ }
6962
+ /**
6852
6963
  * Pick which profiles to record. The broker enumerates assigned profile slots,
6853
6964
  * deduped by physical source (`selectAssignedProfileSlots`) so the same camera
6854
- * encoder is never recorded twice. An operator `override` DISABLES the
6855
- * unselected profiles (assigned ∩ selection); a selection matching none of the
6856
- * assigned sources is ignored (record every assigned source minimum of 1).
6965
+ * encoder is never recorded twice. Default is high+low among those slots
6966
+ * (`resolveRecordingProfiles`); an operator `override` DISABLES the
6967
+ * unselected profiles. A selection matching none of the assigned sources
6968
+ * falls back to the default/assigned set (minimum of 1 when anything is assigned).
6857
6969
  */
6858
6970
  async resolveProfiles(deviceId, override) {
6859
- const assigned = selectAssignedProfileSlots(await this.deps.brokerCall(() => withDeadline(this.deps.api.streamBroker.listAllProfileSlots.query(void 0, nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "listAllProfileSlots")), deviceId).map((slot) => slot.profile);
6860
- if (!override || override.length === 0) return assigned;
6861
- const selected = assigned.filter((p) => override.includes(p));
6862
- return selected.length > 0 ? selected : assigned;
6971
+ return resolveRecordingProfiles(selectAssignedProfileSlots(await this.deps.brokerCall(() => withDeadline(this.deps.api.streamBroker.listAllProfileSlots.query(void 0, nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "listAllProfileSlots")), deviceId).map((slot) => slot.profile), override);
6863
6972
  }
6864
6973
  /**
6865
6974
  * Attach one (device, profile): acquire the broker source, mkdir the staging
@@ -6989,6 +7098,7 @@ var RecordingController = class {
6989
7098
  const recordings = this.active.get(deviceId);
6990
7099
  if (!recordings) return;
6991
7100
  this.active.delete(deviceId);
7101
+ this.skippedProfiles.delete(deviceId);
6992
7102
  for (const r of recordings) this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
6993
7103
  await Promise.all(recordings.map((r) => this.teardownProfile(deviceId, r)));
6994
7104
  this.deps.logger.info("recorder unpinned broker source(s) — recording stopped", {
@@ -8605,6 +8715,9 @@ var RecorderV2Addon = class extends BaseAddon {
8605
8715
  refreshLocations: () => this.refreshLocations(),
8606
8716
  capacity: (root) => this.locationCapacity(root),
8607
8717
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
8718
+ reconcileHourLedger: async (deviceId) => {
8719
+ await this.segmentHours?.reconcileFromIndex(this.index, [deviceId], Date.now());
8720
+ },
8608
8721
  calendar: this.calendar,
8609
8722
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
8610
8723
  hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_worker_protocol = require("../worker-protocol-DjuxhGo3.js");
3
- const require_lazy_sharp = require("../lazy-sharp-EoSOHgoj.js");
2
+ const require_worker_protocol = require("../worker-protocol-Bq28qobd.js");
3
+ const require_lazy_sharp = require("../lazy-sharp-C3zOnmiU.js");
4
4
  //#region src/session-decode/color-conversion.ts
5
5
  /**
6
6
  * Explicit YUV→RGB colorspace/range resolution for the session-decode worker.
@@ -1,4 +1,4 @@
1
- import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-XZ1e36rM.mjs";
1
+ import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-PzAKg8mC.mjs";
2
2
  import { n as setSharpWarnSink, r as hostExternalEntryUrls, t as getSharp } from "../lazy-sharp-6oymT_yf.mjs";
3
3
  //#region src/session-decode/color-conversion.ts
4
4
  /**
@@ -1,7 +1,7 @@
1
- import { a as e, c as t, d as n, f as r, i, l as a, n as o, o as s, p as c, r as l, s as u, t as d, u as f } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DgivrPsH.mjs";
1
+ import { a as e, c as t, d as n, f as r, i, l as a, n as o, o as s, p as c, r as l, s as u, t as d, u as f } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DsIi4G5Q.mjs";
2
2
  import { a as p, i as m, n as h, o as g, r as _, t as v } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.js-C9j-2lBe.mjs";
3
3
  import { n as y, r as b, t as x } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-XO0-Pyu6.mjs";
4
- import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DNSELPGi.mjs";
4
+ import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Dut56EeQ.mjs";
5
5
  import { n as C, t as w } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-BO7TIbJV.mjs";
6
6
  //#region ../../node_modules/lucide-react/dist/esm/shared/src/utils.js
7
7
  var T = (e) => e.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), E = (e) => e.replace(/^([A-Z])|[\s-_]+(\w)/g, (e, t, n) => n ? n.toUpperCase() : t.toLowerCase()), D = (e) => {
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.83",
21
+ version: "1.2.84",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_stream_broker_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.2.57",
36
+ version: "1.2.58",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",