@camstack/addon-pipeline 1.2.155 → 1.2.159

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 (36) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +5 -5
  4. package/dist/detection-pipeline/index.mjs +4 -4
  5. package/dist/{dist-CmrtJ8RB.js → dist-bBqjZ7fo.js} +233 -7
  6. package/dist/{dist-CBRky_dz.mjs → dist-p8ruahJy.mjs} +233 -7
  7. package/dist/{lazy-sharp-BODx7w1t.js → lazy-sharp-Ct1Ow2zO.js} +1 -1
  8. package/dist/{local-frame-registry-BRG0GTmK.js → local-frame-registry-Bua7Qg7A.js} +1 -1
  9. package/dist/{local-frame-registry-ByKcWWPU.mjs → local-frame-registry-Ce3h-QcZ.mjs} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-BSeMUwxJ.js → node-DRc0Vqkz.js} +1 -1
  13. package/dist/{node-CQzPpwIR.mjs → node-kVYpLOax.mjs} +1 -1
  14. package/dist/pipeline-runner/index.js +5 -5
  15. package/dist/pipeline-runner/index.mjs +4 -4
  16. package/dist/{process-memory-DVmHBraW.js → process-memory-B4Id9YAE.js} +1 -1
  17. package/dist/{process-memory-DQX-DbHx.mjs → process-memory-D9s145SO.mjs} +1 -1
  18. package/dist/recorder/index.js +536 -27
  19. package/dist/recorder/index.mjs +533 -29
  20. package/dist/{segment-demux-js-DmqnujVx.js → segment-demux-js-DAgunuXf.js} +1 -1
  21. package/dist/{segment-demux-js-DGX1iGLL.mjs → segment-demux-js-VrM03R0O.mjs} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +2 -2
  23. package/dist/session-decode/decode-worker-child.mjs +1 -1
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DvfM0ofY.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B-FG8Phz.mjs} +2 -2
  26. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-lMPlG5w5.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-OnimCf2v.mjs} +1 -1
  27. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CSNuIp4J.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-_e_Px_mR.mjs} +1 -1
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-DQGeMfoN.mjs → hostInit-DePWOWOH.mjs} +2 -2
  31. package/dist/stream-broker/index.js +3 -3
  32. package/dist/stream-broker/index.mjs +3 -3
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/dist/{worker-protocol-C7jOjCsc.mjs → worker-protocol-BN0Q-eeu.mjs} +1 -1
  35. package/dist/{worker-protocol-S2nAdf6B.js → worker-protocol-CjWhvUMn.js} +1 -1
  36. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
- import { $ as isFirstLevelMacroClass, $t as record, B as defineCustomActions, Bt as nodePin, E as RecordingConfigSchema, It as hydrateSchema, J as evaluateSensorEdge, Kt as array, L as customAction, N as batteryCapability, Nt as DeviceFeature, Pt as DeviceType, Qt as object, T as RECORDING_EXPORT_MAX_READ_BYTES, U as deriveRecordingMode, Ut as selectAssignedProfileSlots, V as deriveBatteryPresence, Wt as sleep, Zt as number, bt as storageEvictableCapability, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, en as string, et as loadContributionCapability, g as ExportRecordSchema, i as BatteryStatusSchema, jt as BaseAddon, mt as recordingExportCapability, nn as EventCategory, pt as recordingCapability, qt as boolean, vt as resolveRecordingProfiles, wt as errMsg, x as OpsLogEntrySchema } from "../dist-CBRky_dz.mjs";
2
- import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-CQzPpwIR.mjs";
1
+ import { $ as isFirstLevelMacroClass, $t as record, B as defineCustomActions, Bt as nodePin, E as RecordingConfigSchema, Gt as _enum, It as hydrateSchema, J as evaluateSensorEdge, Kt as array, L as customAction, N as batteryCapability, Nt as DeviceFeature, Pt as DeviceType, Qt as object, T as RECORDING_EXPORT_MAX_READ_BYTES, U as deriveRecordingMode, Ut as selectAssignedProfileSlots, V as deriveBatteryPresence, Wt as sleep$1, Zt as number, bt as storageEvictableCapability, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, en as string, et as loadContributionCapability, g as ExportRecordSchema, i as BatteryStatusSchema, jt as BaseAddon, mt as recordingExportCapability, nn as EventCategory, pt as recordingCapability, qt as boolean, vt as resolveRecordingProfiles, wt as errMsg, x as OpsLogEntrySchema } from "../dist-p8ruahJy.mjs";
2
+ import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-kVYpLOax.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { r as withRecordingIntent } from "../restream-intent-B4BXZra7.mjs";
5
5
  import { n as startEventLoopStallMonitor } from "../event-loop-stall-monitor-DXLNBMkY.mjs";
@@ -4209,6 +4209,377 @@ function sendDirectory(res, payload) {
4209
4209
  res.end(Buffer.from(body));
4210
4210
  }
4211
4211
  //#endregion
4212
+ //#region src/recorder/addon/location-discard-actions.ts
4213
+ var DiscardStartInput = object({ locationId: string().min(1) });
4214
+ var DiscardStarted = object({
4215
+ started: boolean(),
4216
+ alreadyRunning: boolean(),
4217
+ jobId: string().nullable()
4218
+ });
4219
+ var DiscardStatusSchema = object({
4220
+ running: boolean(),
4221
+ locationId: string().nullable(),
4222
+ jobId: string().nullable(),
4223
+ phase: _enum([
4224
+ "idle",
4225
+ "scanning",
4226
+ "wiping-fs",
4227
+ "wiping-db",
4228
+ "cascading",
4229
+ "complete",
4230
+ "cancelled",
4231
+ "error"
4232
+ ]),
4233
+ camerasTotal: number(),
4234
+ camerasDone: number(),
4235
+ foldersRemoved: number(),
4236
+ dbRowsDeleted: number(),
4237
+ ownersCascaded: number(),
4238
+ detail: string(),
4239
+ startedAtMs: number().nullable(),
4240
+ finishedAtMs: number().nullable(),
4241
+ error: string().nullable()
4242
+ });
4243
+ var DiscardCancel = object({ requested: boolean() });
4244
+ var locationDiscardActions = defineCustomActions({
4245
+ "storage.discardLocation": customAction(DiscardStartInput, DiscardStarted, { auth: "admin" }),
4246
+ "storage.discardLocationStatus": customAction(object({}), DiscardStatusSchema, { auth: "admin" }),
4247
+ "storage.discardLocationCancel": customAction(object({}), DiscardCancel, { auth: "admin" })
4248
+ });
4249
+ //#endregion
4250
+ //#region src/recorder/addon/location-class-wipe.ts
4251
+ /**
4252
+ * Class-subtree wipe — the filesystem half of discarding a frozen location
4253
+ * that SHARES its root with another class (today: Toshiba `/recordings`
4254
+ * holds events + low + high).
4255
+ *
4256
+ * The only directories this module will `rm` are
4257
+ * `<root>/<camera>/<subtree>` for `subtree` in the class map. It refuses
4258
+ * a path that is not exactly two levels under the root, a symlink, or a
4259
+ * subtree the class did not name. `rm` of the location root is not a
4260
+ * code path.
4261
+ */
4262
+ var DISCARD_SUBTREES$1 = {
4263
+ eventMedia: ["events"],
4264
+ recordingsLow: ["low"],
4265
+ recordings: ["high", "mid"]
4266
+ };
4267
+ function discardSubtreesForType(type) {
4268
+ return DISCARD_SUBTREES$1[type] ?? null;
4269
+ }
4270
+ var DEFAULT_FS = {
4271
+ readdir: (dir) => promises.readdir(dir, { withFileTypes: true }),
4272
+ lstat: (target) => promises.lstat(target),
4273
+ rm: (target) => promises.rm(target, {
4274
+ recursive: true,
4275
+ force: true
4276
+ })
4277
+ };
4278
+ /**
4279
+ * Resolve `<root>/<camera>/<subtree>` and refuse anything that is not
4280
+ * exactly that shape under `root`. The check is lexical (after
4281
+ * `path.resolve`) so `../high` in a camera name cannot escape.
4282
+ */
4283
+ function resolveClassDir(root, cameraId, subtree) {
4284
+ if (cameraId.length === 0 || cameraId === "." || cameraId === "..") throw new Error(`discard: illegal camera id ${cameraId}`);
4285
+ if (subtree.includes("/") || subtree.includes("\\") || subtree === ".." || subtree === ".") throw new Error(`discard: illegal subtree ${subtree}`);
4286
+ const rootResolved = path.resolve(root);
4287
+ const target = path.resolve(rootResolved, cameraId, subtree);
4288
+ const rel = path.relative(rootResolved, target);
4289
+ if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`discard: path escaped root: ${target}`);
4290
+ const parts = rel.split(path.sep).filter((p) => p.length > 0);
4291
+ if (parts.length !== 2 || parts[0] !== cameraId || parts[1] !== subtree) throw new Error(`discard: path is not a class subtree: ${target}`);
4292
+ return target;
4293
+ }
4294
+ async function wipeClassSubtrees(input) {
4295
+ const subtrees = discardSubtreesForType(input.type);
4296
+ if (subtrees === null) throw new Error(`discard: unknown location type ${input.type}`);
4297
+ const io = input.io ?? DEFAULT_FS;
4298
+ const rootResolved = path.resolve(input.root);
4299
+ let cameras;
4300
+ try {
4301
+ cameras = await io.readdir(rootResolved);
4302
+ } catch (err) {
4303
+ if (err.code === "ENOENT") return {
4304
+ camerasDone: 0,
4305
+ foldersRemoved: 0,
4306
+ cancelled: false
4307
+ };
4308
+ throw err;
4309
+ }
4310
+ const dirs = cameras.filter((e) => e.isDirectory() && e.name !== "." && e.name !== "..");
4311
+ let camerasDone = 0;
4312
+ let foldersRemoved = 0;
4313
+ for (const dir of dirs) {
4314
+ if (input.stop()) return {
4315
+ camerasDone,
4316
+ foldersRemoved,
4317
+ cancelled: true
4318
+ };
4319
+ for (const subtree of subtrees) {
4320
+ const target = resolveClassDir(rootResolved, dir.name, subtree);
4321
+ let st;
4322
+ try {
4323
+ st = await io.lstat(target);
4324
+ } catch (err) {
4325
+ if (err.code === "ENOENT") continue;
4326
+ throw err;
4327
+ }
4328
+ if (st.isSymbolicLink()) throw new Error(`discard: refusing to wipe symlink ${target}`);
4329
+ if (!st.isDirectory()) continue;
4330
+ await io.rm(target);
4331
+ foldersRemoved += 1;
4332
+ }
4333
+ camerasDone += 1;
4334
+ input.onProgress({
4335
+ camerasTotal: dirs.length,
4336
+ camerasDone,
4337
+ cameraId: dir.name,
4338
+ foldersRemoved
4339
+ });
4340
+ if (input.pace) await input.pace();
4341
+ }
4342
+ return {
4343
+ camerasDone,
4344
+ foldersRemoved,
4345
+ cancelled: false
4346
+ };
4347
+ }
4348
+ //#endregion
4349
+ //#region src/recorder/addon/location-discard-policy.ts
4350
+ /**
4351
+ * Server-side eligibility for discarding a frozen location's leftover
4352
+ * class subtree. Same rules as the admin-ui card: frozen, known class,
4353
+ * writable sibling of the same type. The last writable of a type is
4354
+ * refused here even if the UI is bypassed.
4355
+ */
4356
+ var DISCARD_SUBTREES = {
4357
+ eventMedia: ["events"],
4358
+ recordingsLow: ["low"],
4359
+ recordings: ["high", "mid"]
4360
+ };
4361
+ function locationIsFrozen(location) {
4362
+ const enabled = location.enabled !== false;
4363
+ const readOnly = location.config["readOnly"] === true;
4364
+ return !enabled || readOnly;
4365
+ }
4366
+ function discardEligible(location, siblings) {
4367
+ if (!(location.type in DISCARD_SUBTREES)) return false;
4368
+ if (!locationIsFrozen(location)) return false;
4369
+ return siblings.some((s) => {
4370
+ if (s.id === location.id) return false;
4371
+ if (s.type !== location.type) return false;
4372
+ const enabled = s.enabled !== false;
4373
+ const readOnly = s.config["readOnly"] === true;
4374
+ return enabled && !readOnly;
4375
+ });
4376
+ }
4377
+ function assertDiscardAllowed(location, all, localNodeId) {
4378
+ if (!discardEligible(location, all.filter((s) => s.type === location.type))) throw new Error(`discard refused: ${location.id} is not a frozen location with a writable sibling of type ${location.type}`);
4379
+ const nodeId = location.nodeId ?? "hub";
4380
+ if (nodeId !== localNodeId) throw new Error(`discard refused: ${location.id} lives on node ${nodeId}, this process is ${localNodeId}`);
4381
+ }
4382
+ function locationBasePath(location) {
4383
+ const basePath = location.config["basePath"];
4384
+ if (typeof basePath !== "string" || basePath.length === 0) throw new Error(`discard refused: ${location.id} has no basePath`);
4385
+ return basePath;
4386
+ }
4387
+ //#endregion
4388
+ //#region src/recorder/addon/location-discard-job.ts
4389
+ /**
4390
+ * Footage discard job: wipe `<camera>/low/` (or `high/`+`mid/` for the
4391
+ * recordings class) on a frozen location, then forget the hour rows
4392
+ * stamped with that `locationId` and drop them from the RAM index.
4393
+ *
4394
+ * The class-subtree wipe is the load-bearing safety: `recordingsLow:default`
4395
+ * shares `/recordings` with `recordings:default`. This job never `rm`s the
4396
+ * location root. Profiles passed to the ledger forget are the class's own
4397
+ * (`low` vs `high`/`mid`) so a mis-stamped hour of the other class is not
4398
+ * dropped.
4399
+ *
4400
+ * Start returns immediately. Progress is the status snapshot.
4401
+ */
4402
+ var IDLE_DISCARD = {
4403
+ running: false,
4404
+ locationId: null,
4405
+ jobId: null,
4406
+ phase: "idle",
4407
+ camerasTotal: 0,
4408
+ camerasDone: 0,
4409
+ foldersRemoved: 0,
4410
+ dbRowsDeleted: 0,
4411
+ ownersCascaded: 0,
4412
+ detail: "",
4413
+ startedAtMs: null,
4414
+ finishedAtMs: null,
4415
+ error: null
4416
+ };
4417
+ var USB_PACE_MS = 80;
4418
+ var FOOTAGE_TYPES = new Set(["recordings", "recordingsLow"]);
4419
+ function sleep(ms) {
4420
+ return new Promise((resolve) => setTimeout(resolve, ms));
4421
+ }
4422
+ function profilesForType(type) {
4423
+ return type === "recordingsLow" ? ["low"] : ["high", "mid"];
4424
+ }
4425
+ function createFootageDiscardJob(deps) {
4426
+ let state = { ...IDLE_DISCARD };
4427
+ let cancelRequested = false;
4428
+ let inflight = null;
4429
+ const report = (patch) => {
4430
+ state = {
4431
+ ...state,
4432
+ ...patch
4433
+ };
4434
+ };
4435
+ const status = () => ({ ...state });
4436
+ const cancel = () => {
4437
+ if (!state.running) return { requested: false };
4438
+ cancelRequested = true;
4439
+ report({ detail: "cancel requested — finishing current camera" });
4440
+ return { requested: true };
4441
+ };
4442
+ const start = async (input) => {
4443
+ if (state.running) return {
4444
+ started: false,
4445
+ alreadyRunning: true,
4446
+ jobId: state.jobId
4447
+ };
4448
+ const jobId = randomUUID();
4449
+ cancelRequested = false;
4450
+ report({
4451
+ ...IDLE_DISCARD,
4452
+ running: true,
4453
+ locationId: input.locationId,
4454
+ jobId,
4455
+ phase: "scanning",
4456
+ startedAtMs: deps.now(),
4457
+ detail: "starting"
4458
+ });
4459
+ try {
4460
+ const all = await deps.listLocations();
4461
+ const location = all.find((row) => row.id === input.locationId);
4462
+ if (!location) throw new Error(`discard refused: unknown location ${input.locationId}`);
4463
+ if (!FOOTAGE_TYPES.has(location.type)) throw new Error(`discard refused: recorder handles footage, not ${location.type}`);
4464
+ assertDiscardAllowed(location, all, deps.localNodeId);
4465
+ const root = locationBasePath(location);
4466
+ report({ detail: `scanning ${root}` });
4467
+ inflight = run(location, root).finally(() => {
4468
+ inflight = null;
4469
+ });
4470
+ return {
4471
+ started: true,
4472
+ alreadyRunning: false,
4473
+ jobId
4474
+ };
4475
+ } catch (err) {
4476
+ report({
4477
+ ...IDLE_DISCARD,
4478
+ phase: "error",
4479
+ error: err instanceof Error ? err.message : String(err),
4480
+ finishedAtMs: deps.now()
4481
+ });
4482
+ throw err;
4483
+ }
4484
+ };
4485
+ const run = async (location, root) => {
4486
+ const wipe = deps.wipe ?? wipeClassSubtrees;
4487
+ const paceMs = deps.paceMs ?? USB_PACE_MS;
4488
+ try {
4489
+ report({
4490
+ phase: "wiping-fs",
4491
+ detail: `wiping ${profilesForType(location.type).join("/")} under ${root}`
4492
+ });
4493
+ const fsResult = await wipe({
4494
+ root,
4495
+ type: location.type,
4496
+ stop: () => cancelRequested,
4497
+ onProgress: (p) => {
4498
+ report({
4499
+ phase: "wiping-fs",
4500
+ camerasTotal: p.camerasTotal,
4501
+ camerasDone: p.camerasDone,
4502
+ foldersRemoved: p.foldersRemoved,
4503
+ detail: `camera ${p.cameraId} (${p.camerasDone}/${p.camerasTotal})`
4504
+ });
4505
+ },
4506
+ pace: () => sleep(paceMs)
4507
+ });
4508
+ if (fsResult.cancelled || cancelRequested) {
4509
+ report({
4510
+ running: false,
4511
+ phase: "cancelled",
4512
+ camerasDone: fsResult.camerasDone,
4513
+ foldersRemoved: fsResult.foldersRemoved,
4514
+ finishedAtMs: deps.now(),
4515
+ detail: "cancelled"
4516
+ });
4517
+ return;
4518
+ }
4519
+ report({
4520
+ phase: "wiping-db",
4521
+ camerasDone: fsResult.camerasDone,
4522
+ foldersRemoved: fsResult.foldersRemoved,
4523
+ detail: "forgetting ledger hours"
4524
+ });
4525
+ const forgotten = await forgetLedger(location);
4526
+ if (cancelRequested) {
4527
+ report({
4528
+ running: false,
4529
+ phase: "cancelled",
4530
+ dbRowsDeleted: forgotten,
4531
+ finishedAtMs: deps.now(),
4532
+ detail: "cancelled"
4533
+ });
4534
+ return;
4535
+ }
4536
+ report({
4537
+ running: false,
4538
+ phase: "complete",
4539
+ dbRowsDeleted: forgotten,
4540
+ finishedAtMs: deps.now(),
4541
+ detail: `done — ${fsResult.foldersRemoved} folders, ${forgotten} ledger segments`,
4542
+ error: null
4543
+ });
4544
+ } catch (err) {
4545
+ report({
4546
+ running: false,
4547
+ phase: "error",
4548
+ finishedAtMs: deps.now(),
4549
+ error: err instanceof Error ? err.message : String(err),
4550
+ detail: "failed"
4551
+ });
4552
+ }
4553
+ };
4554
+ const forgetLedger = async (location) => {
4555
+ const ledger = deps.ledger;
4556
+ if (ledger === null) return 0;
4557
+ let forgotten = 0;
4558
+ const profiles = profilesForType(location.type);
4559
+ for await (const page of ledger.streamSegments({
4560
+ locationId: location.id,
4561
+ profiles
4562
+ })) {
4563
+ if (cancelRequested) return forgotten;
4564
+ deps.index.removeSegments(page.map((row) => row.path));
4565
+ await ledger.dropSegments(page);
4566
+ forgotten += page.length;
4567
+ report({
4568
+ dbRowsDeleted: forgotten,
4569
+ detail: `forgot ${forgotten} ledger segments`
4570
+ });
4571
+ await sleep(0);
4572
+ }
4573
+ return forgotten;
4574
+ };
4575
+ return {
4576
+ start,
4577
+ status,
4578
+ cancel,
4579
+ waitForIdle: () => inflight
4580
+ };
4581
+ }
4582
+ //#endregion
4212
4583
  //#region src/recorder/addon/export-dense-map.ts
4213
4584
  /**
4214
4585
  * Shortest clip worth keeping, in ms.
@@ -6337,7 +6708,10 @@ function buildRecordingProvider(deps) {
6337
6708
  }
6338
6709
  const locationUsages = await Promise.all([...byPhysical.values()].map(async (group) => {
6339
6710
  const first = group[0];
6340
- const cap = await deps.capacity(first.root);
6711
+ const cap = group.some((l) => l.enabled !== false) ? await deps.capacity(first.root) : {
6712
+ availableBytes: null,
6713
+ totalBytes: null
6714
+ };
6341
6715
  const ids = group.map((l) => l.id);
6342
6716
  if (cap.error !== void 0) deps.logger.warn("recorder: volume capacity unavailable (statfs failed)", { meta: {
6343
6717
  locationId: first.id,
@@ -6740,6 +7114,15 @@ function buildRecordingProvider(deps) {
6740
7114
  cancelRelocateJob: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
6741
7115
  planStorageRebalance: (input) => runRebalance(deps, input, false),
6742
7116
  startStorageRebalance: (input) => runRebalance(deps, input, true),
7117
+ getPlacement: async () => {
7118
+ if (!deps.placementView) throw new Error("placement is not active on this node");
7119
+ return deps.placementView();
7120
+ },
7121
+ setDevicePlacement: async ({ deviceId, locationId }) => {
7122
+ if (!deps.setDevicePlacement) throw new Error("placement is not active on this node");
7123
+ await deps.setDevicePlacement(deviceId, locationId);
7124
+ return { ok: true };
7125
+ },
6743
7126
  getDeviceSettingsContribution: async ({ deviceId }) => {
6744
7127
  if (deps.isCameraDevice && !await deps.isCameraDevice(deviceId)) return null;
6745
7128
  return hydrateSchema(buildRecordingDeviceSchema(), {});
@@ -9613,6 +9996,7 @@ Object.freeze({
9613
9996
  "getSensorEvents": { "limit": 1e3 },
9614
9997
  "listGroups": { "limit": 40 },
9615
9998
  "listRecentTracks": { "limit": 200 },
9999
+ "reclaimDebugMedia": { "mode": "report" },
9616
10000
  "searchObjectEvents": {
9617
10001
  "limit": 50,
9618
10002
  "minScore": .2
@@ -10700,6 +11084,11 @@ var RECORDING_PLACEMENT_KEY = "recordingPlacement";
10700
11084
  var RecordingPlacementBlobSchema = object({
10701
11085
  /** `<deviceId>:<profile>` → locationId. */
10702
11086
  assignments: record(string(), string()).default({}),
11087
+ /**
11088
+ * Operator pin: `String(deviceId)` → a `recordings:*` location id.
11089
+ * Absent = Auto. High and mid follow the pin; low is not pinned here.
11090
+ */
11091
+ pins: record(string(), string()).default({}),
10703
11092
  /** locationId → the volume id last verified at that location's root. */
10704
11093
  volumeIds: record(string(), string()).default({}),
10705
11094
  /**
@@ -10718,6 +11107,7 @@ var RecordingPlacementBlobSchema = object({
10718
11107
  /** Cold-start value: nothing assigned, nothing claimed, no authority recorded. */
10719
11108
  var EMPTY_PLACEMENT = {
10720
11109
  assignments: {},
11110
+ pins: {},
10721
11111
  volumeIds: {},
10722
11112
  defaultLocations: {}
10723
11113
  };
@@ -10803,6 +11193,25 @@ async function rememberVolumeId(state, locationId, volumeId) {
10803
11193
  updatedAt: Date.now()
10804
11194
  });
10805
11195
  }
11196
+ /** Operator pin: `locationId` null clears Auto. High/mid assignment keys follow. */
11197
+ async function writeDevicePin(state, deviceId, locationId) {
11198
+ const blob = await state.get();
11199
+ const pins = { ...blob.pins };
11200
+ const assignments = { ...blob.assignments };
11201
+ const key = String(deviceId);
11202
+ if (locationId === null) delete pins[key];
11203
+ else {
11204
+ pins[key] = locationId;
11205
+ assignments[assignmentKey(deviceId, "high")] = locationId;
11206
+ assignments[assignmentKey(deviceId, "mid")] = locationId;
11207
+ }
11208
+ await state.set({
11209
+ ...blob,
11210
+ pins,
11211
+ assignments,
11212
+ updatedAt: Date.now()
11213
+ });
11214
+ }
10806
11215
  //#endregion
10807
11216
  //#region src/recorder/addon/placement-volume.ts
10808
11217
  /**
@@ -11025,6 +11434,17 @@ function sameStamp(stamped, authority) {
11025
11434
  for (const [type, locationId] of authority) if (stamped.get(type) !== locationId) return false;
11026
11435
  return true;
11027
11436
  }
11437
+ /** Operator pins win over the planner for high/mid of the pinned camera. */
11438
+ function overlayPins(planned, pins) {
11439
+ const next = new Map(planned);
11440
+ for (const [id, locationId] of Object.entries(pins)) {
11441
+ const deviceId = Number(id);
11442
+ if (!Number.isFinite(deviceId)) continue;
11443
+ next.set(assignmentKey(deviceId, "high"), locationId);
11444
+ next.set(assignmentKey(deviceId, "mid"), locationId);
11445
+ }
11446
+ return next;
11447
+ }
11028
11448
  var PlacementService = class {
11029
11449
  deps;
11030
11450
  /** Locations whose volume has been probed since the last location change. */
@@ -11218,6 +11638,11 @@ var PlacementService = class {
11218
11638
  return null;
11219
11639
  }
11220
11640
  const assignedId = blob.assignments[assignmentKey(deviceId, profile)];
11641
+ const pinId = blob.pins[String(deviceId)];
11642
+ if (pinId !== void 0 && (profile === "high" || profile === "mid")) {
11643
+ const pinned = pool.find((l) => l.id === pinId && !l.readOnly) ?? null;
11644
+ if (pinned !== null) return pinned;
11645
+ }
11221
11646
  if (assignedId === void 0) return null;
11222
11647
  const location = pool.find((l) => l.id === assignedId && !l.readOnly) ?? null;
11223
11648
  if (location === null) return null;
@@ -11276,7 +11701,7 @@ var PlacementService = class {
11276
11701
  return NO_PLAN;
11277
11702
  }
11278
11703
  const stamped = new Map(Object.entries(blob.defaultLocations));
11279
- const repoints = this.detectRepoints(blob.assignments, stamped, authority);
11704
+ const repoints = this.detectRepoints(blob.assignments, stamped, authority, blob.pins);
11280
11705
  if (repoints.length === 0) this.repointAnnounced.clear();
11281
11706
  for (const repoint of repoints) this.announceRepoint(repoint);
11282
11707
  const repointed = /* @__PURE__ */ new Map();
@@ -11297,7 +11722,7 @@ var PlacementService = class {
11297
11722
  });
11298
11723
  const pass = {
11299
11724
  repointed,
11300
- planned: plan.assignments,
11725
+ planned: overlayPins(plan.assignments, blob.pins),
11301
11726
  defaultLocations: authority
11302
11727
  };
11303
11728
  this.reportDivergence(applyPlacementPass(blob.assignments, pass), authority);
@@ -11334,15 +11759,17 @@ var PlacementService = class {
11334
11759
  * from an absent stamp would throw away every deliberate assignment on the
11335
11760
  * first tick after a deploy.
11336
11761
  */
11337
- detectRepoints(stored, stamped, authority) {
11762
+ detectRepoints(stored, stamped, authority, pins) {
11338
11763
  const out = [];
11339
11764
  for (const [type, to] of authority) {
11340
11765
  const from = stamped.get(type);
11341
11766
  if (from === void 0 || from === to) continue;
11342
11767
  const keys = [];
11343
11768
  for (const [key, locationId] of Object.entries(stored)) {
11344
- if (placementTypeForProfile(key.split(":")[1] ?? "") !== type) continue;
11769
+ const [devicePart, profilePart] = key.split(":");
11770
+ if (placementTypeForProfile(profilePart ?? "") !== type) continue;
11345
11771
  if (locationId === to) continue;
11772
+ if (type === "recordings" && devicePart !== void 0 && pins[devicePart] !== void 0) continue;
11346
11773
  keys.push(key);
11347
11774
  }
11348
11775
  out.push({
@@ -11478,6 +11905,40 @@ var PlacementService = class {
11478
11905
  return /* @__PURE__ */ new Map();
11479
11906
  }
11480
11907
  }
11908
+ async placementView() {
11909
+ const blob = await readPlacementState(this.deps.state);
11910
+ return {
11911
+ assignments: Object.entries(blob.assignments).flatMap(([key, locationId]) => {
11912
+ const [idPart, profile] = key.split(":");
11913
+ const deviceId = Number(idPart);
11914
+ if (!Number.isFinite(deviceId) || profile === void 0) return [];
11915
+ return [{
11916
+ deviceId,
11917
+ profile,
11918
+ locationId
11919
+ }];
11920
+ }),
11921
+ pins: Object.entries(blob.pins).flatMap(([id, locationId]) => {
11922
+ const deviceId = Number(id);
11923
+ if (!Number.isFinite(deviceId)) return [];
11924
+ return [{
11925
+ deviceId,
11926
+ locationId
11927
+ }];
11928
+ }),
11929
+ defaultLocations: blob.defaultLocations
11930
+ };
11931
+ }
11932
+ async setDevicePin(deviceId, locationId) {
11933
+ if (locationId !== null) {
11934
+ if (!this.pool().find((l) => l.id === locationId && l.type === "recordings" && !l.readOnly)) throw new Error(`Cannot pin camera ${String(deviceId)} to "${locationId}" — it is not a writable recordings location`);
11935
+ }
11936
+ await writeDevicePin(this.deps.state, deviceId, locationId);
11937
+ this.deps.logger.info("recorder placement: operator pin updated", {
11938
+ tags: { deviceId },
11939
+ meta: { locationId }
11940
+ });
11941
+ }
11481
11942
  /**
11482
11943
  * Turn each pool location into a planner candidate. Headroom is
11483
11944
  * `min(free − minFree floor, maxUsedGb − used)`, clamped at zero: the first
@@ -15470,6 +15931,14 @@ function withDeviceTag(logger, deviceId) {
15470
15931
  * provider until the controller swaps the manifest entry in a later step — this
15471
15932
  * file ships dormant (separate tree, not wired into the manifest yet).
15472
15933
  */
15934
+ /**
15935
+ * Hub harvests this named export into CustomActionRegistry. Actions that
15936
+ * exist only on the onInitialize envelope stay invisible to `addons.custom`.
15937
+ */
15938
+ var customActions = {
15939
+ ...recorderDebugActions,
15940
+ ...locationDiscardActions
15941
+ };
15473
15942
  /** Default designated recording node (`recordingNodeId` global setting). The
15474
15943
  * stream-broker is hub-only and recording is a single-node global function, so
15475
15944
  * the hub is the safe default — mirrors pipeline-analytics' post-processing
@@ -15637,6 +16106,8 @@ var RecorderV2Addon = class extends BaseAddon {
15637
16106
  * deferred archive walk reconciles it against disk.
15638
16107
  */
15639
16108
  segmentHours = null;
16109
+ /** Frozen-location leftover wipe (`low/` or `high/`+`mid/`). Detached. */
16110
+ footageDiscard = null;
15640
16111
  /** Why the last placement pass planned nothing — latched so the line is one
15641
16112
  * per episode, not one per 30 s tick. `null` ⇒ the last pass had work. */
15642
16113
  placementWorkGap = null;
@@ -16063,6 +16534,16 @@ var RecorderV2Addon = class extends BaseAddon {
16063
16534
  refreshStorageLocationsForMigration: (leaseId) => this.refreshStorageLocationsForMigration(leaseId),
16064
16535
  assertStorageMigrationLease: (leaseId) => this.assertStorageMigrationLease(leaseId),
16065
16536
  placementSnapshot: () => this.placementSnapshot(),
16537
+ placementView: () => {
16538
+ const placement = this.placement;
16539
+ if (!placement) throw new Error("placement is not active on this node");
16540
+ return placement.placementView();
16541
+ },
16542
+ setDevicePlacement: (deviceId, locationId) => {
16543
+ const placement = this.placement;
16544
+ if (!placement) throw new Error("placement is not active on this node");
16545
+ return placement.setDevicePin(deviceId, locationId);
16546
+ },
16066
16547
  dataDir: this.ctx.dataDir,
16067
16548
  playbackBaseUrl: () => this.playbackBaseUrl,
16068
16549
  isCameraDevice: async (deviceId) => {
@@ -16225,27 +16706,50 @@ var RecorderV2Addon = class extends BaseAddon {
16225
16706
  }
16226
16707
  });
16227
16708
  warnLostBands(this.configStore(), this.ctx.logger);
16709
+ const providers = [
16710
+ {
16711
+ capability: recordingCapability,
16712
+ provider: recordingProvider
16713
+ },
16714
+ {
16715
+ capability: storageEvictableCapability,
16716
+ provider: evictableProvider
16717
+ },
16718
+ {
16719
+ capability: recordingExportCapability,
16720
+ provider: exportProvider
16721
+ },
16722
+ {
16723
+ capability: loadContributionCapability,
16724
+ provider: { list: () => this.costRegistry.contributions() }
16725
+ }
16726
+ ];
16727
+ this.footageDiscard = createFootageDiscardJob({
16728
+ listLocations: async () => this.ctx.api.storage.listLocations.query({}),
16729
+ localNodeId: this.nodeId,
16730
+ index: this.index,
16731
+ ledger: this.segmentHours === null ? null : {
16732
+ streamSegments: (scope) => this.segmentHours.streamSegments(scope),
16733
+ dropSegments: (rows) => this.segmentHours.dropSegments(rows)
16734
+ },
16735
+ now: () => Date.now()
16736
+ });
16228
16737
  return {
16229
- providers: [
16230
- {
16231
- capability: recordingCapability,
16232
- provider: recordingProvider
16233
- },
16234
- {
16235
- capability: storageEvictableCapability,
16236
- provider: evictableProvider
16237
- },
16238
- {
16239
- capability: recordingExportCapability,
16240
- provider: exportProvider
16738
+ providers,
16739
+ customActions: {
16740
+ ...recorderDebugActions,
16741
+ ...locationDiscardActions
16742
+ },
16743
+ actionHandlers: {
16744
+ "debug.storeCensus": async (input) => handleStoreCensusAction(this.readCensus, this.ctx.logger.child("Census"), input),
16745
+ "storage.discardLocation": async (input) => this.footageDiscard?.start(input) ?? {
16746
+ started: false,
16747
+ alreadyRunning: false,
16748
+ jobId: null
16241
16749
  },
16242
- {
16243
- capability: loadContributionCapability,
16244
- provider: { list: () => this.costRegistry.contributions() }
16245
- }
16246
- ],
16247
- customActions: recorderDebugActions,
16248
- actionHandlers: { "debug.storeCensus": async (input) => handleStoreCensusAction(this.readCensus, this.ctx.logger.child("Census"), input) }
16750
+ "storage.discardLocationStatus": async () => this.footageDiscard?.status() ?? IDLE_DISCARD,
16751
+ "storage.discardLocationCancel": async () => this.footageDiscard?.cancel() ?? { requested: false }
16752
+ }
16249
16753
  };
16250
16754
  }
16251
16755
  async onShutdown() {
@@ -16329,7 +16833,7 @@ var RecorderV2Addon = class extends BaseAddon {
16329
16833
  },
16330
16834
  logger: this.ctx.logger,
16331
16835
  now: () => Date.now(),
16332
- pace: () => sleep(LEDGER_WALK_PAUSE_MS)
16836
+ pace: () => sleep$1(LEDGER_WALK_PAUSE_MS)
16333
16837
  });
16334
16838
  }
16335
16839
  /**
@@ -16478,7 +16982,7 @@ var RecorderV2Addon = class extends BaseAddon {
16478
16982
  },
16479
16983
  logger: this.ctx.logger,
16480
16984
  now: () => Date.now(),
16481
- pace: () => sleep(GHOST_LOCATION_PROBE_PAUSE_MS)
16985
+ pace: () => sleep$1(GHOST_LOCATION_PROBE_PAUSE_MS)
16482
16986
  });
16483
16987
  this.ghostLocationPass = startPeriodicPass({
16484
16988
  name: "ghost-location-reconcile",
@@ -17252,4 +17756,4 @@ var RecorderV2Addon = class extends BaseAddon {
17252
17756
  }
17253
17757
  };
17254
17758
  //#endregion
17255
- export { RecorderV2Addon as default };
17759
+ export { customActions, RecorderV2Addon as default };