@camstack/addon-pipeline 1.2.63 → 1.2.64

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 (27) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +2 -2
  4. package/dist/detection-pipeline/index.mjs +2 -2
  5. package/dist/{dist-DTvDedJV.mjs → dist-B9_C-QmC.mjs} +323 -38
  6. package/dist/{dist-BNXiD8pH.js → dist-DKrqeIfS.js} +323 -38
  7. package/dist/{event-loop-stall-monitor-RLis0vRd.mjs → event-loop-stall-monitor-BucTbe9v.mjs} +1 -1
  8. package/dist/{event-loop-stall-monitor-DmscRH8r.js → event-loop-stall-monitor-CnJ5JGA2.js} +1 -1
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +3 -3
  12. package/dist/pipeline-runner/index.mjs +3 -3
  13. package/dist/recorder/index.js +211 -41
  14. package/dist/recorder/index.mjs +211 -41
  15. package/dist/session-decode/decode-worker-child.js +1 -1
  16. package/dist/session-decode/decode-worker-child.mjs +1 -1
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CFoXGKXX.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DkDAcqBW.mjs} +3 -3
  19. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cp3o2Ve6.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C2tu4c14.mjs} +1 -1
  20. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Bw7zJGAQ.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CJAUKSrT.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-DqexQ1ED.mjs → hostInit-C-peB-6a.mjs} +3 -3
  22. package/dist/stream-broker/index.js +1 -1
  23. package/dist/stream-broker/index.mjs +1 -1
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-D8ZsVii1.js → worker-protocol-CDkbdSuM.js} +1 -1
  26. package/dist/{worker-protocol-C3pPkCGe.mjs → worker-protocol-CQHY2NeB.mjs} +1 -1
  27. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { K as recordingCapability, M as deriveRecordingMode, Ot as record, X as storageEvictableCapability, _ as OpsLogEntrySchema, b as RecordingConfigSchema, c as DEFAULT_EVENTS_BAND_BUFFER_SEC, d as EVENT_PAD_MS, dt as hydrateSchema, et as errMsg, ht as nodePin, jt as EventCategory, kt as string, lt as DeviceType, ot as BaseAddon, p as ExportRecordSchema, q as recordingExportCapability, vt as selectAssignedProfileSlots, xt as array } from "../dist-DTvDedJV.mjs";
1
+ import { K as recordingCapability, M as deriveRecordingMode, Ot as record, X as storageEvictableCapability, _ as OpsLogEntrySchema, b as RecordingConfigSchema, c as DEFAULT_EVENTS_BAND_BUFFER_SEC, d as EVENT_PAD_MS, dt as hydrateSchema, et as errMsg, ht as nodePin, jt as EventCategory, kt as string, lt as DeviceType, ot as BaseAddon, p as ExportRecordSchema, q as recordingExportCapability, vt as selectAssignedProfileSlots, xt as array } from "../dist-B9_C-QmC.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-CZ2xo67g.mjs";
4
4
  import { randomUUID } from "node:crypto";
@@ -2771,12 +2771,28 @@ function buildRecordingProvider(deps) {
2771
2771
  if (!deps.gifRender) throw new Error("gif rendering unavailable on this node");
2772
2772
  return deps.gifRender(input);
2773
2773
  },
2774
- relocateFootage: async (input) => {
2774
+ pauseForStorageMigration: async ({ leaseId }) => {
2775
+ if (!deps.pauseForStorageMigration) throw new Error("storage migration pause unavailable");
2776
+ await deps.pauseForStorageMigration(leaseId);
2777
+ return { paused: true };
2778
+ },
2779
+ resumeForStorageMigration: async ({ leaseId }) => {
2780
+ if (!deps.resumeForStorageMigration) throw new Error("storage migration resume unavailable");
2781
+ await deps.resumeForStorageMigration(leaseId);
2782
+ return { resumed: true };
2783
+ },
2784
+ refreshStorageLocationsForMigration: async ({ leaseId }) => {
2785
+ if (!deps.refreshStorageLocationsForMigration) throw new Error("storage migration location refresh unavailable");
2786
+ await deps.refreshStorageLocationsForMigration(leaseId);
2787
+ return { refreshed: true };
2788
+ },
2789
+ startStorageMigrationMove: async (input) => {
2775
2790
  if (!deps.relocate) throw new Error("relocation unavailable on this node");
2791
+ deps.assertStorageMigrationLease?.(input.leaseId);
2776
2792
  return { jobId: deps.relocate.start(input) };
2777
2793
  },
2778
- getRelocateStatus: async () => deps.relocate?.list() ?? [],
2779
- cancelRelocate: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
2794
+ getStorageMigrationMoveStatus: async ({ jobId }) => deps.relocate?.list().find((job) => job.jobId === jobId) ?? null,
2795
+ cancelStorageMigrationMove: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
2780
2796
  getDeviceSettingsContribution: async ({ deviceId }) => {
2781
2797
  if (deps.isCameraDevice && !await deps.isCameraDevice(deviceId)) return null;
2782
2798
  return hydrateSchema(buildRecordingDeviceSchema(), {});
@@ -3388,7 +3404,31 @@ function resolvePlacement(locations, profile) {
3388
3404
  const writable = locations.filter((l) => !l.readOnly);
3389
3405
  if (writable.length === 0) throw new Error("recorder controller: every recordings location is read-only — nowhere to write");
3390
3406
  const preferType = profile === "low" ? "recordingsLow" : "recordings";
3391
- return writable.find((l) => l.type === preferType) ?? writable[0];
3407
+ const preferred = writable.filter((l) => l.type === preferType);
3408
+ return preferred.find((l) => l.isDefault) ?? preferred[0] ?? writable.find((l) => l.isDefault) ?? writable[0];
3409
+ }
3410
+ /**
3411
+ * Attribute a discovered segment on an aliased physical root by its profile,
3412
+ * not by whichever logical location happened to be hydrated last. Stock
3413
+ * `recordings` and `recordingsLow` deliberately share a root: high/mid belong
3414
+ * to `recordings`, while low belongs to `recordingsLow`.
3415
+ */
3416
+ function locationForHydratedProfile(aliases, profile) {
3417
+ const preferredType = profile === "low" ? "recordingsLow" : "recordings";
3418
+ const preferred = aliases.filter((location) => location.type === preferredType).toSorted((a, b) => a.id.localeCompare(b.id))[0];
3419
+ const fallback = aliases.toSorted((a, b) => a.id.localeCompare(b.id))[0];
3420
+ if (preferred) return preferred;
3421
+ if (fallback) return fallback;
3422
+ throw new Error("recorder: no location aliases to attribute hydrated profile");
3423
+ }
3424
+ function aliasesByRoot(locations) {
3425
+ const grouped = /* @__PURE__ */ new Map();
3426
+ for (const location of locations) {
3427
+ const aliases = grouped.get(location.root);
3428
+ if (aliases) aliases.push(location);
3429
+ else grouped.set(location.root, [location]);
3430
+ }
3431
+ return grouped;
3392
3432
  }
3393
3433
  /**
3394
3434
  * Resolve every recordings storage location (`recordings` + `recordingsLow`)
@@ -3405,7 +3445,8 @@ async function resolveRecordingsLocations(api, logger) {
3405
3445
  location: l.id,
3406
3446
  relativePath: ""
3407
3447
  }),
3408
- readOnly: l.config["readOnly"] === true
3448
+ readOnly: l.config["readOnly"] === true,
3449
+ isDefault: l.isDefault
3409
3450
  })));
3410
3451
  } catch (err) {
3411
3452
  logger.warn("recorder: listLocations failed — no recordings locations resolved", { meta: { error: errMsg(err) } });
@@ -3421,8 +3462,8 @@ async function resolveRecordingsLocations(api, logger) {
3421
3462
  * (best-effort), so one bad volume never blocks the rest of the hydrate.
3422
3463
  */
3423
3464
  async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger) {
3424
- for (const loc of locations) {
3425
- const deviceDir = path.join(loc.root, String(deviceId));
3465
+ for (const [root, aliases] of aliasesByRoot(locations)) {
3466
+ const deviceDir = path.join(root, String(deviceId));
3426
3467
  let entries;
3427
3468
  try {
3428
3469
  entries = await promises.readdir(deviceDir, { recursive: true });
@@ -3430,14 +3471,21 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
3430
3471
  if (err.code !== "ENOENT") logger.warn("recorder: hydrateDevice walk failed for location", {
3431
3472
  tags: { deviceId },
3432
3473
  meta: {
3433
- locationId: loc.id,
3474
+ locationIds: aliases.map((location) => location.id),
3434
3475
  error: errMsg(err)
3435
3476
  }
3436
3477
  });
3437
3478
  continue;
3438
3479
  }
3439
- const relPaths = entries.filter((e) => e.endsWith(".m4s")).map((e) => `${deviceId}/${e.split(path.sep).join("/")}`);
3440
- index.hydrateDevice(deviceId, loc.id, relPaths);
3480
+ const pathsByLocation = /* @__PURE__ */ new Map();
3481
+ for (const entry of entries.filter((entry) => entry.endsWith(".m4s"))) {
3482
+ const relative = `${deviceId}/${entry.split(path.sep).join("/")}`;
3483
+ const location = locationForHydratedProfile(aliases, relative.split("/")[1] ?? "");
3484
+ const paths = pathsByLocation.get(location.id);
3485
+ if (paths) paths.push(relative);
3486
+ else pathsByLocation.set(location.id, [relative]);
3487
+ }
3488
+ for (const [locationId, relPaths] of pathsByLocation) index.hydrateDevice(deviceId, locationId, relPaths);
3441
3489
  }
3442
3490
  }
3443
3491
  /** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
@@ -3466,8 +3514,8 @@ var HOUR_MS = 36e5;
3466
3514
  */
3467
3515
  async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger) {
3468
3516
  if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
3469
- for (const loc of locations) {
3470
- const deviceDir = path.join(loc.root, String(deviceId));
3517
+ for (const [root, aliases] of aliasesByRoot(locations)) {
3518
+ const deviceDir = path.join(root, String(deviceId));
3471
3519
  let profiles;
3472
3520
  try {
3473
3521
  profiles = (await promises.readdir(deviceDir)).filter((e) => e === "high" || e === "mid" || e === "low");
@@ -3475,7 +3523,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
3475
3523
  if (err.code !== "ENOENT") logger.warn("recorder: hydrateWindow profile listing failed", {
3476
3524
  tags: { deviceId },
3477
3525
  meta: {
3478
- locationId: loc.id,
3526
+ locationIds: aliases.map((location) => location.id),
3479
3527
  error: errMsg(err)
3480
3528
  }
3481
3529
  });
@@ -3486,11 +3534,11 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
3486
3534
  const rel = hourDirRelPath(deviceId, profile, hour);
3487
3535
  let files;
3488
3536
  try {
3489
- files = await promises.readdir(path.join(loc.root, rel));
3537
+ files = await promises.readdir(path.join(root, rel));
3490
3538
  } catch {
3491
3539
  continue;
3492
3540
  }
3493
- index.hydrateHour(deviceId, loc.id, hour, files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`));
3541
+ index.hydrateHour(deviceId, locationForHydratedProfile(aliases, profile).id, hour, files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`));
3494
3542
  }
3495
3543
  index.markHydrated(deviceId, fromMs, toMs);
3496
3544
  }
@@ -3773,7 +3821,7 @@ async function handleSegmentEntry(outDir, entry, durMs, onFinalized, logger) {
3773
3821
  logger.debug("segment flat file not on disk yet — will retry", { meta: { path: flatAbsPath } });
3774
3822
  return "pending";
3775
3823
  }
3776
- onFinalized(startMs, durMs, flatAbsPath);
3824
+ await onFinalized(startMs, durMs, flatAbsPath);
3777
3825
  return "retained";
3778
3826
  }
3779
3827
  /**
@@ -3786,11 +3834,11 @@ async function handleSegmentEntry(outDir, entry, durMs, onFinalized, logger) {
3786
3834
  * inflated by the initial PTS offset). Once `#EXT-X-ENDLIST` appears the tail
3787
3835
  * is flushed too — by then it is never the unreliable first segment.
3788
3836
  */
3789
- function planFinalizations(body, processed) {
3837
+ function planFinalizations(body, processed, forceFinal = false) {
3790
3838
  const entries = parseLivePlaylist(body);
3791
3839
  const endlist = body.includes("#EXT-X-ENDLIST");
3792
3840
  const hw = entries.length < processed ? 0 : processed;
3793
- const finalizable = endlist ? entries.length : Math.max(0, entries.length - 1);
3841
+ const finalizable = endlist || forceFinal ? entries.length : Math.max(0, entries.length - 1);
3794
3842
  const toHandle = [];
3795
3843
  for (let i = hw; i < finalizable; i++) {
3796
3844
  const entry = entries[i];
@@ -3818,9 +3866,10 @@ function startSegmentWatcher(deps) {
3818
3866
  let processed = 0;
3819
3867
  let stopped = false;
3820
3868
  let ticking = false;
3869
+ let currentTick = null;
3821
3870
  let pendingIndex = -1;
3822
3871
  let pendingTicks = 0;
3823
- const tick = async () => {
3872
+ const tick = async (forceFinal = false) => {
3824
3873
  if (stopped || ticking) return;
3825
3874
  ticking = true;
3826
3875
  try {
@@ -3830,7 +3879,7 @@ function startSegmentWatcher(deps) {
3830
3879
  } catch {
3831
3880
  return;
3832
3881
  }
3833
- const plan = planFinalizations(body, processed);
3882
+ const plan = planFinalizations(body, processed, forceFinal);
3834
3883
  let index = plan.from;
3835
3884
  for (const { entry, durMs } of plan.toHandle) {
3836
3885
  if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger) === "pending") {
@@ -3860,14 +3909,31 @@ function startSegmentWatcher(deps) {
3860
3909
  ticking = false;
3861
3910
  }
3862
3911
  };
3912
+ const startTick = (forceFinal = false) => {
3913
+ if (currentTick !== null) return currentTick;
3914
+ const running = tick(forceFinal);
3915
+ currentTick = running;
3916
+ running.then(() => {
3917
+ if (currentTick === running) currentTick = null;
3918
+ }, () => {
3919
+ if (currentTick === running) currentTick = null;
3920
+ });
3921
+ return running;
3922
+ };
3863
3923
  const timer = setInterval(() => {
3864
- tick();
3924
+ startTick();
3865
3925
  }, deps.intervalMs);
3866
3926
  timer.unref?.();
3867
- return { stop() {
3868
- stopped = true;
3869
- clearInterval(timer);
3870
- } };
3927
+ return {
3928
+ stop() {
3929
+ stopped = true;
3930
+ clearInterval(timer);
3931
+ },
3932
+ flush: async () => {
3933
+ await currentTick;
3934
+ await startTick(true);
3935
+ }
3936
+ };
3871
3937
  }
3872
3938
  //#endregion
3873
3939
  //#region src/recorder/addon/ffmpeg-args.ts
@@ -3935,6 +4001,8 @@ var SegmentWriter = class {
3935
4001
  restarts = 0;
3936
4002
  restartTimer = null;
3937
4003
  startedAt = 0;
4004
+ exitWait = null;
4005
+ resolveExit = null;
3938
4006
  constructor(cfg, deps) {
3939
4007
  this.cfg = cfg;
3940
4008
  this.deps = deps;
@@ -3944,6 +4012,9 @@ var SegmentWriter = class {
3944
4012
  const args = buildPassthroughArgs(this.cfg);
3945
4013
  const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
3946
4014
  this.proc = proc;
4015
+ this.exitWait = new Promise((resolve) => {
4016
+ this.resolveExit = resolve;
4017
+ });
3947
4018
  this.startedAt = Date.now();
3948
4019
  const stderrTail = [];
3949
4020
  proc.stderr?.on("data", (chunk) => {
@@ -3956,6 +4027,8 @@ var SegmentWriter = class {
3956
4027
  }
3957
4028
  });
3958
4029
  proc.on("exit", (code) => {
4030
+ this.resolveExit?.();
4031
+ this.resolveExit = null;
3959
4032
  this.proc = null;
3960
4033
  if (this.stopped) return;
3961
4034
  const ranMs = Date.now() - this.startedAt;
@@ -3985,12 +4058,22 @@ var SegmentWriter = class {
3985
4058
  });
3986
4059
  }
3987
4060
  stop() {
4061
+ this.stopAndWait();
4062
+ }
4063
+ /**
4064
+ * Stop ffmpeg and wait for its exit when the process exposes real child
4065
+ * liveness fields. Test fakes often omit them, in which case stop remains
4066
+ * deliberately synchronous. The recorder keeps its watcher alive until this
4067
+ * promise settles, so a final ENDLIST segment is indexed before migration.
4068
+ */
4069
+ async stopAndWait() {
3988
4070
  this.stopped = true;
3989
4071
  if (this.restartTimer) {
3990
4072
  clearTimeout(this.restartTimer);
3991
4073
  this.restartTimer = null;
3992
4074
  }
3993
4075
  const proc = this.proc;
4076
+ const exitWait = this.exitWait;
3994
4077
  this.proc = null;
3995
4078
  if (!proc) return;
3996
4079
  try {
@@ -4001,6 +4084,8 @@ var SegmentWriter = class {
4001
4084
  proc.kill("SIGKILL");
4002
4085
  } catch {}
4003
4086
  }, KILL_GRACE_MS).unref?.();
4087
+ if (proc.exitCode === void 0 && proc.signalCode === void 0) return;
4088
+ await exitWait;
4004
4089
  }
4005
4090
  };
4006
4091
  //#endregion
@@ -4117,6 +4202,9 @@ var RecordingController = class {
4117
4202
  * a `finally`. Mirrors the decoder-ffmpeg session-creation coalescing guard.
4118
4203
  */
4119
4204
  attaching = /* @__PURE__ */ new Set();
4205
+ /** Every attach admitted before a maintenance pause. The pause waits these
4206
+ * promises before it snapshots/drains active writers. */
4207
+ attachingTasks = /* @__PURE__ */ new Map();
4120
4208
  /** Latest qualifying trigger timestamps per device — drives `events`-band gating. */
4121
4209
  triggers = /* @__PURE__ */ new Map();
4122
4210
  /**
@@ -4136,6 +4224,9 @@ var RecordingController = class {
4136
4224
  restore = null;
4137
4225
  tickTimer = null;
4138
4226
  stopped = false;
4227
+ /** A reversible maintenance lease. Unlike `stopped`, it never changes
4228
+ * persisted recording intent and `resume()` restarts normal convergence. */
4229
+ paused = false;
4139
4230
  constructor(deps) {
4140
4231
  this.deps = deps;
4141
4232
  }
@@ -4200,7 +4291,7 @@ var RecordingController = class {
4200
4291
  * has already been written by the time this runs.
4201
4292
  */
4202
4293
  async onConfigChanged(deviceId) {
4203
- if (this.stopped) return;
4294
+ if (this.stopped || this.paused) return;
4204
4295
  await this.evaluateDevice(deviceId);
4205
4296
  }
4206
4297
  /**
@@ -4306,8 +4397,9 @@ var RecordingController = class {
4306
4397
  * call throws → `ReadinessRestore.attempt` defers → retried on next ready).
4307
4398
  */
4308
4399
  async evaluateDevice(deviceId) {
4309
- if (this.stopped) return;
4400
+ if (this.stopped || this.paused) return;
4310
4401
  const config = await this.deps.loadConfig(deviceId);
4402
+ if (this.stopped || this.paused) return;
4311
4403
  if (!shouldRecordAt(config, this.triggersFor(deviceId), new Date(this.now()))) {
4312
4404
  this.reportUnrecordable(deviceId, config);
4313
4405
  await this.detachDevice(deviceId);
@@ -4318,9 +4410,12 @@ var RecordingController = class {
4318
4410
  if (this.active.has(deviceId)) return;
4319
4411
  if (this.attaching.has(deviceId)) return;
4320
4412
  this.attaching.add(deviceId);
4413
+ const attaching = this.performAttach(deviceId, config);
4414
+ this.attachingTasks.set(deviceId, attaching);
4321
4415
  try {
4322
- await this.performAttach(deviceId, config);
4416
+ await attaching;
4323
4417
  } finally {
4418
+ this.attachingTasks.delete(deviceId);
4324
4419
  this.attaching.delete(deviceId);
4325
4420
  }
4326
4421
  }
@@ -4378,6 +4473,10 @@ var RecordingController = class {
4378
4473
  for (const r of recordings) await this.teardownProfile(deviceId, r);
4379
4474
  throw err;
4380
4475
  }
4476
+ if (this.paused) {
4477
+ await Promise.all(recordings.map((recording) => this.teardownProfile(deviceId, recording)));
4478
+ return;
4479
+ }
4381
4480
  this.active.set(deviceId, recordings);
4382
4481
  this.deps.logger.info("recorder pinned broker source(s) for recording", {
4383
4482
  tags: { deviceId },
@@ -4440,9 +4539,9 @@ var RecordingController = class {
4440
4539
  watcher: startSegmentWatcher({
4441
4540
  outDir,
4442
4541
  intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
4443
- onFinalized: (startMs, durMs, flatAbsPath) => {
4542
+ onFinalized: async (startMs, durMs, flatAbsPath) => {
4444
4543
  this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
4445
- this.handleFinalizedSegment(deviceId, profile, placement, startMs, durMs, flatAbsPath);
4544
+ await this.handleFinalizedSegment(deviceId, profile, placement, startMs, durMs, flatAbsPath);
4446
4545
  },
4447
4546
  logger: deviceLog
4448
4547
  }),
@@ -4497,10 +4596,13 @@ var RecordingController = class {
4497
4596
  /** Stop one profile's writer + watcher and release its broker lease. */
4498
4597
  async teardownProfile(deviceId, r) {
4499
4598
  try {
4500
- r.watcher.stop();
4599
+ await r.writer.stopAndWait();
4501
4600
  } catch {}
4502
4601
  try {
4503
- r.writer.stop();
4602
+ await r.watcher.flush?.();
4603
+ } catch {}
4604
+ try {
4605
+ r.watcher.stop();
4504
4606
  } catch {}
4505
4607
  try {
4506
4608
  await withDeadline(this.deps.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey: r.pipelineKey }, nodePin(this.deps.ownerNodeId)), RELEASE_RPC_TIMEOUT_MS, "releaseStreamWithCodec");
@@ -4527,6 +4629,25 @@ var RecordingController = class {
4527
4629
  meta: { pipelineKeys: recordings.map((r) => r.pipelineKey) }
4528
4630
  });
4529
4631
  }
4632
+ /** Acquire a reversible maintenance pause without mutating camera settings. */
4633
+ async pause() {
4634
+ if (this.stopped || this.paused) return;
4635
+ this.paused = true;
4636
+ while (this.attachingTasks.size > 0) await Promise.allSettled([...this.attachingTasks.values()]);
4637
+ await Promise.all(Array.from(this.active.keys()).map((deviceId) => this.detachDevice(deviceId)));
4638
+ }
4639
+ /** Release the maintenance pause and converge persisted recording intent. */
4640
+ async resume() {
4641
+ if (this.stopped || !this.paused) return;
4642
+ this.paused = false;
4643
+ let ids = [];
4644
+ try {
4645
+ ids = await this.deps.enabledDeviceIds();
4646
+ } catch (err) {
4647
+ this.deps.logger.warn("recorder: resume could not enumerate configured devices", { meta: { error: errMsg(err) } });
4648
+ }
4649
+ for (const deviceId of ids) await this.evaluateDevice(deviceId);
4650
+ }
4530
4651
  /** Tear EVERYTHING down: timer, restore subscription, every writer/watcher/lease. */
4531
4652
  async stop() {
4532
4653
  this.stopped = true;
@@ -4749,14 +4870,13 @@ var RelocateEngine = class {
4749
4870
  if (!from) throw new Error(`unknown source location: ${input.fromLocationId}`);
4750
4871
  if (!to) throw new Error(`unknown target location: ${input.toLocationId}`);
4751
4872
  if (from.id === to.id) throw new Error("source and target locations are identical");
4752
- if (from.root === to.root) throw new Error("source and target locations share one root — nothing to move");
4753
4873
  if (to.readOnly) throw new Error(`target location is read-only: ${to.id}`);
4754
4874
  const job = {
4755
4875
  jobId: this.deps.newId(),
4756
4876
  state: "running",
4757
4877
  fromLocationId: from.id,
4758
4878
  toLocationId: to.id,
4759
- deviceId: input.deviceId ?? null,
4879
+ deviceId: null,
4760
4880
  entities: input.entities ? [...input.entities] : ["segments"],
4761
4881
  filesMoved: 0,
4762
4882
  bytesMoved: 0,
@@ -4767,23 +4887,33 @@ var RelocateEngine = class {
4767
4887
  cancelRequested: false
4768
4888
  };
4769
4889
  this.jobs.set(job.jobId, job);
4770
- this.run(job, from, to, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS);
4890
+ this.run(job, from, to, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS, input.footageClass);
4771
4891
  return job.jobId;
4772
4892
  }
4773
- async run(job, from, to, throttleMbps) {
4893
+ async run(job, from, to, throttleMbps, footageClass) {
4774
4894
  const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
4775
4895
  const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
4776
4896
  try {
4777
- const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).sort((a, b) => a.startMs - b.startMs);
4897
+ const samePhysicalRoot = await rootsPhysicallySame(from.root, to.root);
4898
+ const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => profileBelongsToClass(r.profile, footageClass)).sort((a, b) => a.startMs - b.startMs);
4778
4899
  job.filesTotal = segRows.length;
4779
4900
  for (const row of segRows) {
4780
4901
  if (job.cancelRequested) break;
4781
- await this.moveSegment(row, from, to);
4902
+ await this.moveSegment(row, from, to, samePhysicalRoot);
4782
4903
  job.filesMoved++;
4783
4904
  job.bytesMoved += row.bytes;
4784
4905
  if (job.filesMoved % PROGRESS_EVERY_FILES === 0) this.deps.onProgress?.(snapshot(job));
4785
4906
  await sleep(row.bytes / bytesPerMs);
4786
4907
  }
4908
+ async function rootsPhysicallySame(from, to) {
4909
+ if (physicalRootKey(from) === physicalRootKey(to)) return true;
4910
+ try {
4911
+ const [a, b] = await Promise.all([promises.stat(from), promises.stat(to)]);
4912
+ return a.dev === b.dev && a.ino === b.ino;
4913
+ } catch {
4914
+ return false;
4915
+ }
4916
+ }
4787
4917
  job.state = job.cancelRequested ? "cancelled" : "done";
4788
4918
  } catch (err) {
4789
4919
  job.state = "failed";
@@ -4799,8 +4929,8 @@ var RelocateEngine = class {
4799
4929
  }
4800
4930
  /** Move one segment file and re-point its index row to the target location
4801
4931
  * (same location-relative path — the layout is identical on every root). */
4802
- async moveSegment(row, from, to) {
4803
- await moveFile(path.join(from.root, row.path), path.join(to.root, row.path), row.bytes);
4932
+ async moveSegment(row, from, to, samePhysicalRoot) {
4933
+ if (!samePhysicalRoot) await moveFile(path.join(from.root, row.path), path.join(to.root, row.path), row.bytes);
4804
4934
  this.deps.index.removeSegments([row.path]);
4805
4935
  this.deps.index.addSegment({
4806
4936
  ...row,
@@ -4808,6 +4938,12 @@ var RelocateEngine = class {
4808
4938
  });
4809
4939
  }
4810
4940
  };
4941
+ /** Logical migration classes are profile-derived, never location-derived:
4942
+ * high+mid are `recordings`; low is `recordingsLow`. */
4943
+ var profileBelongsToClass = (profile, footageClass) => {
4944
+ if (footageClass === void 0) return true;
4945
+ return footageClass === "recordingsLow" ? profile === "low" : profile === "high" || profile === "mid";
4946
+ };
4811
4947
  //#endregion
4812
4948
  //#region src/recorder/addon/footage-render.ts
4813
4949
  /**
@@ -5282,6 +5418,8 @@ var RecorderV2Addon = class extends BaseAddon {
5282
5418
  /** Periodic footage-retention sweep (owns its timer + single-flight guard).
5283
5419
  * Constructed + started in `onHubReachable`, stopped on shutdown. */
5284
5420
  retentionSweeper = null;
5421
+ /** Reversible migration pause lease; never reflected in RecordingConfig. */
5422
+ storageMigrationLeaseId = null;
5285
5423
  constructor() {
5286
5424
  super({ ...DEFAULT_CONFIG });
5287
5425
  }
@@ -5439,6 +5577,10 @@ var RecorderV2Addon = class extends BaseAddon {
5439
5577
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
5440
5578
  hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
5441
5579
  onConfigChanged: (deviceId) => this.controller?.onConfigChanged(deviceId) ?? Promise.resolve(),
5580
+ pauseForStorageMigration: (leaseId) => this.pauseForStorageMigration(leaseId),
5581
+ resumeForStorageMigration: (leaseId) => this.resumeForStorageMigration(leaseId),
5582
+ refreshStorageLocationsForMigration: (leaseId) => this.refreshStorageLocationsForMigration(leaseId),
5583
+ assertStorageMigrationLease: (leaseId) => this.assertStorageMigrationLease(leaseId),
5442
5584
  dataDir: this.ctx.dataDir,
5443
5585
  playbackBaseUrl: () => this.playbackBaseUrl,
5444
5586
  isCameraDevice: async (deviceId) => {
@@ -5827,6 +5969,34 @@ var RecorderV2Addon = class extends BaseAddon {
5827
5969
  this.resolvedLocations = await resolveRecordingsLocations(this.ctx.api, this.ctx.logger);
5828
5970
  return this.resolvedLocations;
5829
5971
  }
5972
+ async pauseForStorageMigration(leaseId) {
5973
+ if (this.storageMigrationLeaseId !== null && this.storageMigrationLeaseId !== leaseId) throw new Error("recorder is already paused by another storage migration");
5974
+ if (!this.controller) throw new Error("recorder controller is unavailable for storage migration");
5975
+ this.storageMigrationLeaseId = leaseId;
5976
+ try {
5977
+ await this.controller.pause();
5978
+ } catch (err) {
5979
+ this.storageMigrationLeaseId = null;
5980
+ throw err;
5981
+ }
5982
+ }
5983
+ async resumeForStorageMigration(leaseId) {
5984
+ if (this.storageMigrationLeaseId === null) return;
5985
+ if (this.storageMigrationLeaseId !== leaseId) throw new Error("recorder storage migration lease does not match");
5986
+ try {
5987
+ await this.controller?.resume();
5988
+ } finally {
5989
+ this.storageMigrationLeaseId = null;
5990
+ }
5991
+ }
5992
+ async refreshStorageLocationsForMigration(leaseId) {
5993
+ if (this.storageMigrationLeaseId !== leaseId) throw new Error("recorder storage migration lease does not match");
5994
+ await this.refreshLocations();
5995
+ this.calendar.invalidateAll();
5996
+ }
5997
+ assertStorageMigrationLease(leaseId) {
5998
+ if (this.storageMigrationLeaseId !== leaseId) throw new Error("recorder storage migration lease does not match");
5999
+ }
5830
6000
  /**
5831
6001
  * Resolve the cluster's camera-source owner. Graceful degrade: the
5832
6002
  * `pipeline-orchestrator` cap may not be mounted yet (pre-image-release),
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../chunk-emK7D4bc.js");
3
- const require_worker_protocol = require("../worker-protocol-D8ZsVii1.js");
3
+ const require_worker_protocol = require("../worker-protocol-CDkbdSuM.js");
4
4
  let node_url = require("node:url");
5
5
  let node_fs = require("node:fs");
6
6
  node_fs = require_chunk.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-C3pPkCGe.mjs";
1
+ import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-CQHY2NeB.mjs";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import * as fs from "node:fs";
4
4
  import * as path$1 from "node:path";
@@ -1,8 +1,8 @@
1
- import { a as e, c as t, d as n, i as r, l as i, n as a, o, r as s, s as c, t as l, u } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Bw7zJGAQ.mjs";
1
+ import { a as e, c as t, d as n, i as r, l as i, n as a, o, r as s, s as c, t as l, u } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CJAUKSrT.mjs";
2
2
  import { a as d, i as f, n as p, o as m, r as h, t as g } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.js-C9j-2lBe.mjs";
3
3
  import { n as _, r as v, t as y } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-XO0-Pyu6.mjs";
4
4
  import { n as b, t as x } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-BO7TIbJV.mjs";
5
- import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Cp3o2Ve6.mjs";
5
+ import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C2tu4c14.mjs";
6
6
  //#region ../../node_modules/lucide-react/dist/esm/shared/src/utils.js
7
7
  var C = (e) => e.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), w = (e) => e.replace(/^([A-Z])|[\s-_]+(\w)/g, (e, t, n) => n ? n.toUpperCase() : t.toLowerCase()), T = (e) => {
8
8
  let t = w(e);
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.2.13",
6
+ version: "1.2.14",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_stream_broker_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.57",
21
+ version: "1.2.59",
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.40",
36
+ version: "1.2.41",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",