@camstack/addon-pipeline 0.1.19 → 0.2.0

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 (96) hide show
  1. package/dist/audio-analyzer/index.js +736 -716
  2. package/dist/audio-analyzer/index.mjs +726 -676
  3. package/dist/audio-codec-nodeav/index.js +304 -461
  4. package/dist/audio-codec-nodeav/index.mjs +300 -462
  5. package/dist/chunk-BdkLduGY.mjs +5 -0
  6. package/dist/chunk-D6vf50IK.js +28 -0
  7. package/dist/codec-runtime-BOk-13PN.js +202 -0
  8. package/dist/codec-runtime-BsqlEjPi.mjs +197 -0
  9. package/dist/constants-B_b0a-6h.mjs +3119 -0
  10. package/dist/{index-D_cl0Qqb.js → constants-D65v6yp6.js} +3107 -2935
  11. package/dist/decoder-nodeav/index.js +1374 -1444
  12. package/dist/decoder-nodeav/index.mjs +1369 -1425
  13. package/dist/detection-pipeline/index.js +6462 -5613
  14. package/dist/detection-pipeline/index.mjs +6451 -5574
  15. package/dist/dist-7ewQjTle.js +22454 -0
  16. package/dist/dist-C5jnNl0n.mjs +22089 -0
  17. package/dist/motion-wasm/index.js +469 -467
  18. package/dist/motion-wasm/index.mjs +464 -446
  19. package/dist/pipeline-runner/index.js +2035 -1836
  20. package/dist/pipeline-runner/index.mjs +2031 -1820
  21. package/dist/recorder/index.js +2097 -0
  22. package/dist/recorder/index.mjs +2095 -0
  23. package/dist/stream-broker/_stub.js +1818 -734
  24. package/dist/stream-broker/_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D4-DHanK.mjs +156 -0
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Tf-HACFd.mjs +26 -0
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C9WX5HNw.mjs +26 -0
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-BO7TIbJV.mjs +26 -0
  28. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.js-C9j-2lBe.mjs +26 -0
  29. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-XO0-Pyu6.mjs +26 -0
  30. package/dist/stream-broker/dist-CYZr2fwk.mjs +2726 -0
  31. package/dist/stream-broker/hostInit-Di6vceAU.mjs +129 -0
  32. package/dist/stream-broker/index.js +17837 -12904
  33. package/dist/stream-broker/index.mjs +17826 -12896
  34. package/dist/stream-broker/remoteEntry.js +134 -2973
  35. package/dist/stream-broker/remoteEntry.ssr.js +33 -0
  36. package/dist/stream-broker/virtualExposes-dYNvIwoR.mjs +27 -0
  37. package/dist/stream-broker/virtual_mf-exposes-ssr___mfe_internal__addon_stream_broker_widgets__remoteEntry_js-Cmqfp4i_.mjs +10 -0
  38. package/embed-dist/assets/index-B8VlSD0-.js +150 -0
  39. package/embed-dist/assets/index-ZhDdp1Nd.css +2 -0
  40. package/embed-dist/index.html +13 -0
  41. package/package.json +75 -9
  42. package/wasm/assembly/index.ts +41 -16
  43. package/dist/audio-analyzer/index.js.map +0 -1
  44. package/dist/audio-analyzer/index.mjs.map +0 -1
  45. package/dist/audio-codec-nodeav/index.js.map +0 -1
  46. package/dist/audio-codec-nodeav/index.mjs.map +0 -1
  47. package/dist/decoder-nodeav/index.js.map +0 -1
  48. package/dist/decoder-nodeav/index.mjs.map +0 -1
  49. package/dist/detection-pipeline/index.js.map +0 -1
  50. package/dist/detection-pipeline/index.mjs.map +0 -1
  51. package/dist/index-BbPPvoCx.js +0 -14682
  52. package/dist/index-BbPPvoCx.js.map +0 -1
  53. package/dist/index-Bmlkm0Fd.mjs +0 -14683
  54. package/dist/index-Bmlkm0Fd.mjs.map +0 -1
  55. package/dist/index-D_cl0Qqb.js.map +0 -1
  56. package/dist/index-UbcdLS7a.mjs +0 -5790
  57. package/dist/index-UbcdLS7a.mjs.map +0 -1
  58. package/dist/motion-wasm/index.js.map +0 -1
  59. package/dist/motion-wasm/index.mjs.map +0 -1
  60. package/dist/pipeline-runner/index.js.map +0 -1
  61. package/dist/pipeline-runner/index.mjs.map +0 -1
  62. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/StreamBrokerPanel.d.ts +0 -21
  63. package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/index.d.ts +0 -13
  64. package/dist/stream-broker/@mf-types/widgets.d.ts +0 -2
  65. package/dist/stream-broker/@mf-types.d.ts +0 -3
  66. package/dist/stream-broker/@mf-types.zip +0 -0
  67. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-h5aXOPSA.mjs +0 -12
  68. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-NjF4kxzW.mjs +0 -19
  69. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-BAv_5ISf.mjs +0 -20
  70. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.mjs-U1EUeEPs.mjs +0 -104
  71. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_trpc_mf_1_client__loadShare__.mjs-DeouEaSs.mjs +0 -85
  72. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.mjs-DHUwjbb9.mjs +0 -62
  73. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-BsB2G7oY.mjs +0 -88
  74. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-xrRiPUpA.mjs +0 -29
  75. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-gBEZsQrp.mjs +0 -36
  76. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs-DYEKzzY-.mjs +0 -45
  77. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-C0E2yCzO.mjs +0 -6
  78. package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-DICOtMTl.mjs +0 -34
  79. package/dist/stream-broker/_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CupRlwqG.mjs +0 -156
  80. package/dist/stream-broker/client-NPZqorv9.mjs +0 -9836
  81. package/dist/stream-broker/getErrorShape-BPSzUA7W-TlK8ipWe.mjs +0 -211
  82. package/dist/stream-broker/hostInit-Bh4w7o5_.mjs +0 -168
  83. package/dist/stream-broker/index-2Qp8vT3w.mjs +0 -185
  84. package/dist/stream-broker/index-BBcZvb5t.mjs +0 -435
  85. package/dist/stream-broker/index-CIJue-4t.mjs +0 -37880
  86. package/dist/stream-broker/index-CWkKuNLr.mjs +0 -232
  87. package/dist/stream-broker/index-Cc6QBqMk.mjs +0 -1655
  88. package/dist/stream-broker/index-D_1p2K9B.mjs +0 -2603
  89. package/dist/stream-broker/index-Dy2V7VOm.mjs +0 -14379
  90. package/dist/stream-broker/index-mX3Kgiv1.mjs +0 -725
  91. package/dist/stream-broker/index-xncRG7-x.mjs +0 -2713
  92. package/dist/stream-broker/index.js.map +0 -1
  93. package/dist/stream-broker/index.mjs.map +0 -1
  94. package/dist/stream-broker/jsx-runtime-lb0mH5st.mjs +0 -55
  95. package/dist/stream-broker/schemas-ClCuS4qa.mjs +0 -3594
  96. package/dist/stream-broker/virtualExposes-pCd777Rp.mjs +0 -42
@@ -0,0 +1,2097 @@
1
+ const require_chunk = require("../chunk-D6vf50IK.js");
2
+ const require_dist = require("../dist-7ewQjTle.js");
3
+ let node_fs = require("node:fs");
4
+ let node_path = require("node:path");
5
+ node_path = require_chunk.__toESM(node_path);
6
+ let _camstack_core = require("@camstack/core");
7
+ let node_child_process = require("node:child_process");
8
+ //#region src/recorder/segment-path.ts
9
+ function segmentRelPath(deviceId, profile, startMs, durMs, bytes) {
10
+ if (profile.includes("/")) throw new Error(`segmentRelPath: profile must not contain '/': ${profile}`);
11
+ const d = new Date(startMs);
12
+ const p2 = (n) => String(n).padStart(2, "0");
13
+ return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}/${startMs}-${durMs}-${bytes}.m4s`;
14
+ }
15
+ var SEG_RE = /^(\d+)\/([^/]+)\/\d{4}\/\d{2}\/\d{2}\/\d{2}\/(\d+)-(\d+)-(\d+)\.m4s$/;
16
+ function parseSegmentPath(relPath) {
17
+ const m = SEG_RE.exec(relPath);
18
+ if (!m) return null;
19
+ return {
20
+ deviceId: Number(m[1]),
21
+ profile: m[2],
22
+ startMs: Number(m[3]),
23
+ durMs: Number(m[4]),
24
+ bytes: Number(m[5])
25
+ };
26
+ }
27
+ //#endregion
28
+ //#region src/recorder/merge-ranges.ts
29
+ function mergeRanges(segments, gapToleranceMs) {
30
+ if (segments.length === 0) return [];
31
+ const sorted = [...segments].toSorted((a, b) => a.startMs - b.startMs);
32
+ const out = [];
33
+ let curStart = sorted[0].startMs;
34
+ let curEnd = sorted[0].startMs + sorted[0].durMs;
35
+ for (let i = 1; i < sorted.length; i++) {
36
+ const s = sorted[i];
37
+ if (s.startMs <= curEnd + gapToleranceMs) curEnd = Math.max(curEnd, s.startMs + s.durMs);
38
+ else {
39
+ out.push({
40
+ startMs: curStart,
41
+ endMs: curEnd
42
+ });
43
+ curStart = s.startMs;
44
+ curEnd = s.startMs + s.durMs;
45
+ }
46
+ }
47
+ out.push({
48
+ startMs: curStart,
49
+ endMs: curEnd
50
+ });
51
+ return out;
52
+ }
53
+ //#endregion
54
+ //#region src/recorder/recording-index.ts
55
+ /**
56
+ * In-memory footage index, keyed by deviceId → (relativePath → SegmentRow).
57
+ * The framework `storage` cap's `list()` is the ground truth: `hydrateDevice`
58
+ * rebuilds a camera's set from a `list()` result, and `addSegment`/`removeSegments`
59
+ * keep it write-through on finalize/evict. Because every row is derived from a
60
+ * segment path (which encodes start/dur/bytes), the index can never drift from
61
+ * disk. No SQLite, no persistence of its own.
62
+ */
63
+ var RecordingIndex = class {
64
+ byDevice = /* @__PURE__ */ new Map();
65
+ mapFor(deviceId) {
66
+ let m = this.byDevice.get(deviceId);
67
+ if (!m) {
68
+ m = /* @__PURE__ */ new Map();
69
+ this.byDevice.set(deviceId, m);
70
+ }
71
+ return m;
72
+ }
73
+ /**
74
+ * Replace a device's segments FOR ONE LOCATION from a `storage.list` result (paths relative to that
75
+ * location root). Rows on other locations are preserved. Non-segment paths and other devices are ignored.
76
+ */
77
+ hydrateDevice(deviceId, locationId, relPaths) {
78
+ const m = this.mapFor(deviceId);
79
+ for (const [path, existing] of m) if (existing.locationId === locationId) m.delete(path);
80
+ for (const path of relPaths) {
81
+ const p = parseSegmentPath(path);
82
+ if (!p || p.deviceId !== deviceId) continue;
83
+ m.set(path, {
84
+ deviceId: p.deviceId,
85
+ profile: p.profile,
86
+ startMs: p.startMs,
87
+ durMs: p.durMs,
88
+ bytes: p.bytes,
89
+ path,
90
+ locationId
91
+ });
92
+ }
93
+ }
94
+ /** Insert/replace one finalized segment (idempotent by path). */
95
+ addSegment(s) {
96
+ this.mapFor(s.deviceId).set(s.path, s);
97
+ }
98
+ /** Remove segments by relative path, routing each path to the correct device map via its encoded deviceId. */
99
+ removeSegments(paths) {
100
+ for (const p of paths) {
101
+ const parsed = parseSegmentPath(p);
102
+ if (!parsed) continue;
103
+ this.byDevice.get(parsed.deviceId)?.delete(p);
104
+ }
105
+ }
106
+ /** Rows for a device (optionally one profile), sorted by start. */
107
+ segments(deviceId, profile) {
108
+ const m = this.byDevice.get(deviceId);
109
+ if (!m) return [];
110
+ return [...m.values()].filter((s) => profile == null || s.profile === profile).toSorted((a, b) => a.startMs - b.startMs);
111
+ }
112
+ /** Aggregate accounting for a device across ALL its locations (use `accountingForLocation` for one location). */
113
+ accounting(deviceId) {
114
+ const m = this.byDevice.get(deviceId);
115
+ if (!m || m.size === 0) return {
116
+ bytes: 0,
117
+ count: 0,
118
+ oldestMs: null,
119
+ newestMs: null
120
+ };
121
+ let bytes = 0;
122
+ let oldestMs = Infinity;
123
+ let newestMs = -Infinity;
124
+ for (const s of m.values()) {
125
+ bytes += s.bytes;
126
+ if (s.startMs < oldestMs) oldestMs = s.startMs;
127
+ if (s.startMs > newestMs) newestMs = s.startMs;
128
+ }
129
+ return {
130
+ bytes,
131
+ count: m.size,
132
+ oldestMs,
133
+ newestMs
134
+ };
135
+ }
136
+ /** All segments on a storage location across every device, oldest-first. */
137
+ segmentsOnLocation(locationId) {
138
+ const out = [];
139
+ for (const m of this.byDevice.values()) for (const s of m.values()) if (s.locationId === locationId) out.push(s);
140
+ return out.toSorted((a, b) => a.startMs - b.startMs);
141
+ }
142
+ /** Aggregate accounting for a storage location across every device. */
143
+ accountingForLocation(locationId) {
144
+ let bytes = 0;
145
+ let count = 0;
146
+ let oldestMs = Infinity;
147
+ let newestMs = -Infinity;
148
+ for (const m of this.byDevice.values()) for (const s of m.values()) {
149
+ if (s.locationId !== locationId) continue;
150
+ bytes += s.bytes;
151
+ count += 1;
152
+ if (s.startMs < oldestMs) oldestMs = s.startMs;
153
+ if (s.startMs > newestMs) newestMs = s.startMs;
154
+ }
155
+ if (count === 0) return {
156
+ bytes: 0,
157
+ count: 0,
158
+ oldestMs: null,
159
+ newestMs: null
160
+ };
161
+ return {
162
+ bytes,
163
+ count,
164
+ oldestMs,
165
+ newestMs
166
+ };
167
+ }
168
+ /** Binary-search a start-sorted slice for the segment containing epochMs. */
169
+ segmentAtIn(segs, epochMs) {
170
+ if (segs.length === 0) return null;
171
+ let lo = 0;
172
+ let hi = segs.length - 1;
173
+ let cand = -1;
174
+ while (lo <= hi) {
175
+ const mid = lo + hi >> 1;
176
+ if (segs[mid].startMs <= epochMs) {
177
+ cand = mid;
178
+ lo = mid + 1;
179
+ } else hi = mid - 1;
180
+ }
181
+ if (cand < 0) return null;
182
+ const seg = segs[cand];
183
+ return epochMs < seg.startMs + seg.durMs ? seg : null;
184
+ }
185
+ /** The segment whose [startMs, startMs+durMs) window contains `epochMs`, or null if `epochMs` falls in a gap / there is no footage. */
186
+ segmentAt(deviceId, profile, epochMs) {
187
+ return this.segmentAtIn(this.segments(deviceId, profile), epochMs);
188
+ }
189
+ /**
190
+ * If epochMs is covered, returns it unchanged. Otherwise returns the start of the
191
+ * next covered segment forward. Returns null if there is no forward coverage
192
+ * (epoch past all footage, or no footage at all). Only snaps forward — never backward.
193
+ */
194
+ nearestCoveredEdge(deviceId, profile, epochMs) {
195
+ const segs = this.segments(deviceId, profile);
196
+ if (segs.length === 0) return null;
197
+ if (this.segmentAtIn(segs, epochMs) !== null) return epochMs;
198
+ const next = segs.find((s) => s.startMs >= epochMs);
199
+ return next ? next.startMs : null;
200
+ }
201
+ ranges(deviceId, profile, fromMs, toMs, gapToleranceMs) {
202
+ return mergeRanges(this.segments(deviceId, profile).filter((s) => s.startMs + s.durMs > fromMs && s.startMs < toMs), gapToleranceMs).map((r) => ({
203
+ startMs: Math.max(r.startMs, fromMs),
204
+ endMs: Math.min(r.endMs, toMs)
205
+ }));
206
+ }
207
+ };
208
+ //#endregion
209
+ //#region src/recorder/segment-store.ts
210
+ var SegmentStore = class {
211
+ deps;
212
+ constructor(deps) {
213
+ this.deps = deps;
214
+ }
215
+ async hydrate(deviceId, location) {
216
+ const paths = await this.deps.list({
217
+ location,
218
+ prefix: `${deviceId}/`
219
+ });
220
+ this.deps.index.hydrateDevice(deviceId, location, paths);
221
+ }
222
+ async onFinalized(input) {
223
+ let bytes;
224
+ try {
225
+ bytes = await this.deps.statBytes(input.flatAbsPath);
226
+ } catch (err) {
227
+ this.deps.logger.warn("segment stat failed", err);
228
+ return null;
229
+ }
230
+ if (bytes <= 0) return null;
231
+ const relPath = segmentRelPath(input.deviceId, input.profile, input.startMs, input.durMs, bytes);
232
+ const toAbs = `${input.locationRoot}/${relPath}`;
233
+ try {
234
+ await this.deps.moveFile(input.flatAbsPath, toAbs);
235
+ } catch (err) {
236
+ this.deps.logger.warn("segment relocate failed", err);
237
+ return null;
238
+ }
239
+ const row = {
240
+ deviceId: input.deviceId,
241
+ profile: input.profile,
242
+ startMs: input.startMs,
243
+ durMs: input.durMs,
244
+ bytes,
245
+ path: relPath,
246
+ locationId: input.locationId
247
+ };
248
+ this.deps.index.addSegment(row);
249
+ return row;
250
+ }
251
+ /**
252
+ * Delete `rows` via the storage cap under `location` and de-index the ones that delete
253
+ * successfully; returns the reclaimed byte total. Precondition: every row must belong to
254
+ * `location` (`row.locationId === location`) — the caller guarantees this (e.g. the evictable
255
+ * provider feeds rows from `segmentsOnLocation(location)`). Mixed-location rows would be deleted
256
+ * against the wrong location.
257
+ */
258
+ async evict(location, rows) {
259
+ let reclaimed = 0;
260
+ const removed = [];
261
+ for (const r of rows) try {
262
+ await this.deps.deletePath({
263
+ location,
264
+ relativePath: r.path
265
+ });
266
+ removed.push(r.path);
267
+ reclaimed += r.bytes;
268
+ } catch (err) {
269
+ this.deps.logger.warn("segment delete failed", err);
270
+ }
271
+ if (removed.length > 0) this.deps.index.removeSegments(removed);
272
+ return reclaimed;
273
+ }
274
+ };
275
+ //#endregion
276
+ //#region src/recorder/evict-select.ts
277
+ var DAY_MS$2 = 1440 * 60 * 1e3;
278
+ var GB = 1e9;
279
+ function selectEvictions(segments, limits) {
280
+ const oldestFirst = [...segments].toSorted((a, b) => a.startMs - b.startMs);
281
+ const selected = /* @__PURE__ */ new Set();
282
+ const out = [];
283
+ const select = (s) => {
284
+ if (selected.has(s.path)) return;
285
+ selected.add(s.path);
286
+ out.push(s);
287
+ };
288
+ if (limits.maxAgeDays != null && limits.maxAgeDays > 0) {
289
+ const cutoff = limits.nowMs - limits.maxAgeDays * DAY_MS$2;
290
+ for (const s of oldestFirst) if (s.startMs < cutoff) select(s);
291
+ }
292
+ if (limits.maxSizeGb != null && limits.maxSizeGb > 0) {
293
+ const cap = limits.maxSizeGb * GB;
294
+ let remaining = 0;
295
+ for (const s of oldestFirst) if (!selected.has(s.path)) remaining += s.bytes;
296
+ for (const s of oldestFirst) {
297
+ if (remaining <= cap) break;
298
+ if (selected.has(s.path)) continue;
299
+ select(s);
300
+ remaining -= s.bytes;
301
+ }
302
+ }
303
+ return out;
304
+ }
305
+ /**
306
+ * Pick the oldest segments whose cumulative bytes reach `targetBytes` (free ≈ targetBytes).
307
+ * Oldest-first, no pinning. Returns all segments if the target exceeds the total; empty for a non-positive target.
308
+ *
309
+ * Selection is segment-granular: the actual reclaimable bytes may EXCEED `targetBytes` by up to
310
+ * the size of the last selected segment (a 1-byte target still evicts one whole segment). Callers
311
+ * sizing a reclaim share (e.g. StoragePressureManager) should expect this overshoot.
312
+ */
313
+ function selectEvictionsByBytes(segments, targetBytes) {
314
+ if (targetBytes <= 0) return [];
315
+ const oldestFirst = [...segments].toSorted((a, b) => a.startMs - b.startMs);
316
+ const out = [];
317
+ let freed = 0;
318
+ for (const s of oldestFirst) {
319
+ if (freed >= targetBytes) break;
320
+ out.push(s);
321
+ freed += s.bytes;
322
+ }
323
+ return out;
324
+ }
325
+ //#endregion
326
+ //#region src/recorder/storage-evictable-provider.ts
327
+ /**
328
+ * Implements the `storage-evictable` cap surface for recording footage. The core
329
+ * StoragePressureManager owns the trigger (per-location free-space monitoring); this
330
+ * provider decides WHAT footage is expendable (oldest-first, no pinning) and deletes it
331
+ * via `SegmentStore.evict`, which removes the file through the `storage` cap and de-indexes.
332
+ *
333
+ * The cap's `locationId` is used directly as the storage `location` (identity); see Plan 2d
334
+ * notes. Wiring this class to the cap + the SegmentStore happens in Plan 2e.
335
+ */
336
+ var StorageEvictableProvider = class {
337
+ deps;
338
+ constructor(deps) {
339
+ this.deps = deps;
340
+ }
341
+ async getEvictableUsage(input) {
342
+ return { bytes: this.deps.index.accountingForLocation(input.locationId).bytes };
343
+ }
344
+ async evict(input) {
345
+ const onLocation = this.deps.index.segmentsOnLocation(input.locationId);
346
+ const victims = selectEvictionsByBytes(onLocation, input.targetBytes);
347
+ return {
348
+ reclaimedBytes: await this.deps.store.evict(input.locationId, victims),
349
+ exhausted: victims.length >= onLocation.length
350
+ };
351
+ }
352
+ };
353
+ //#endregion
354
+ //#region src/recorder/redundant-segments.ts
355
+ /**
356
+ * Return the indices (into `segs`) of segments safe to delete: those fully
357
+ * contained within a kept segment `[coverStart, coverEnd]`. Sorting by start
358
+ * ascending, then by end DESCENDING, guarantees the containing (longer) segment
359
+ * is seen first and kept, and any shorter segment nested inside it is removed.
360
+ */
361
+ function selectRedundantSegments(segs) {
362
+ if (segs.length < 2) return [];
363
+ const order = segs.map((s, i) => ({
364
+ start: s.startMs,
365
+ end: s.startMs + s.durMs,
366
+ i
367
+ })).toSorted((a, b) => a.start - b.start || b.end - a.end);
368
+ const remove = [];
369
+ let coverStart = Number.NEGATIVE_INFINITY;
370
+ let coverEnd = Number.NEGATIVE_INFINITY;
371
+ for (const seg of order) if (seg.start >= coverStart && seg.end <= coverEnd && seg.end > seg.start) remove.push(seg.i);
372
+ else {
373
+ coverStart = seg.start;
374
+ coverEnd = seg.end;
375
+ }
376
+ return remove;
377
+ }
378
+ //#endregion
379
+ //#region src/recorder/janitor.ts
380
+ /** Group an array by a string key. */
381
+ function groupBy(items, key) {
382
+ const out = /* @__PURE__ */ new Map();
383
+ for (const it of items) {
384
+ const k = key(it);
385
+ const arr = out.get(k);
386
+ if (arr) arr.push(it);
387
+ else out.set(k, [it]);
388
+ }
389
+ return out;
390
+ }
391
+ async function runRedundancyJanitor(deps) {
392
+ let files = 0;
393
+ let bytes = 0;
394
+ for (const deviceId of deps.deviceIds) {
395
+ const byProfile = groupBy(deps.segmentsFor(deviceId), (r) => r.profile);
396
+ for (const [profile, rows] of byProfile) {
397
+ const sorted = [...rows].toSorted((a, b) => a.startMs - b.startMs);
398
+ const redundant = selectRedundantSegments(sorted);
399
+ if (redundant.length === 0) continue;
400
+ const victims = redundant.map((i) => sorted[i]);
401
+ for (const [location, vrows] of groupBy(victims, (v) => v.locationId)) try {
402
+ const reclaimed = await deps.evict(location, vrows);
403
+ files += vrows.length;
404
+ bytes += reclaimed;
405
+ } catch (err) {
406
+ deps.logger.warn("janitor: evict failed (skipping)", { meta: {
407
+ deviceId,
408
+ profile,
409
+ location,
410
+ count: vrows.length,
411
+ error: err instanceof Error ? err.message : String(err)
412
+ } });
413
+ }
414
+ }
415
+ }
416
+ if (files > 0) deps.logger.info("janitor: removed redundant recording segments", { meta: {
417
+ files,
418
+ bytes
419
+ } });
420
+ return {
421
+ files,
422
+ bytes
423
+ };
424
+ }
425
+ //#endregion
426
+ //#region src/recorder/event-map.ts
427
+ var EventMap = class {
428
+ byDevice = /* @__PURE__ */ new Map();
429
+ record(deviceId, marker) {
430
+ const list = this.byDevice.get(deviceId) ?? [];
431
+ list.push({ ...marker });
432
+ this.byDevice.set(deviceId, list);
433
+ }
434
+ events(deviceId, fromMs, toMs) {
435
+ const list = this.byDevice.get(deviceId);
436
+ if (!list) return [];
437
+ return list.filter((e) => e.t >= fromMs && e.t <= toMs).toSorted((a, b) => a.t - b.t);
438
+ }
439
+ prune(nowMs, maxAgeMs) {
440
+ const cutoff = nowMs - maxAgeMs;
441
+ let dropped = 0;
442
+ for (const [deviceId, list] of this.byDevice) {
443
+ const kept = list.filter((e) => e.t >= cutoff);
444
+ dropped += list.length - kept.length;
445
+ this.byDevice.set(deviceId, kept);
446
+ }
447
+ return dropped;
448
+ }
449
+ };
450
+ //#endregion
451
+ //#region src/recorder/event-capture.ts
452
+ function markerFromMotion(data, pad) {
453
+ if (!data.detected) return null;
454
+ return {
455
+ t: data.timestamp,
456
+ startMs: data.timestamp - pad.padPreMs,
457
+ endMs: data.timestamp + pad.padPostMs,
458
+ source: "motion",
459
+ ...data.regions !== void 0 ? { metadata: { regions: data.regions } } : {}
460
+ };
461
+ }
462
+ function subscribeEventCapture(deps) {
463
+ const { eventBus, map, padPreMs, padPostMs, onMotion } = deps;
464
+ return eventBus.subscribe({ category: require_dist.EventCategory.MotionOnMotionChanged }, (event) => {
465
+ const mk = markerFromMotion(event.data, {
466
+ padPreMs,
467
+ padPostMs
468
+ });
469
+ if (!mk) return;
470
+ map.record(event.data.deviceId, mk);
471
+ onMotion?.(event.data.deviceId, event.data.timestamp);
472
+ });
473
+ }
474
+ //#endregion
475
+ //#region src/recorder/day-bucket.ts
476
+ /** Pure helpers to bucket recording segment starts into local calendar days. */
477
+ var DAY_MS$1 = 1440 * 60 * 1e3;
478
+ /**
479
+ * Epoch (UTC ms) of the local midnight that starts the day containing `ms`.
480
+ * `tzOffsetMinutes` is minutes to ADD to UTC to get local time — i.e. the client
481
+ * sends `-new Date().getTimezoneOffset()`. Returning a UTC epoch (not a date
482
+ * string) lets the viewer compare directly against the epoch it computes per
483
+ * calendar cell, so markers line up regardless of timezone.
484
+ */
485
+ function localMidnightEpoch(ms, tzOffsetMinutes) {
486
+ const off = tzOffsetMinutes * 6e4;
487
+ const local = ms + off;
488
+ return local - (local % DAY_MS$1 + DAY_MS$1) % DAY_MS$1 - off;
489
+ }
490
+ /**
491
+ * Sorted, distinct local-midnight epochs for every segment start in [fromMs, toMs).
492
+ */
493
+ function distinctRecordingDays(startsMs, fromMs, toMs, tzOffsetMinutes) {
494
+ const days = /* @__PURE__ */ new Set();
495
+ for (const s of startsMs) {
496
+ if (s < fromMs || s >= toMs) continue;
497
+ days.add(localMidnightEpoch(s, tzOffsetMinutes));
498
+ }
499
+ return [...days].toSorted((a, b) => a - b);
500
+ }
501
+ //#endregion
502
+ //#region src/recorder/playlist.ts
503
+ /** A VOD variant playlist with an absolute wall-clock anchor per segment. */
504
+ function buildVariantPlaylist(segments) {
505
+ const maxDur = segments.reduce((m, s) => Math.max(m, s.durMs), 0);
506
+ const lines = [
507
+ "#EXTM3U",
508
+ "#EXT-X-VERSION:7",
509
+ "#EXT-X-PLAYLIST-TYPE:VOD",
510
+ `#EXT-X-TARGETDURATION:${Math.ceil(maxDur / 1e3)}`,
511
+ "#EXT-X-MEDIA-SEQUENCE:0"
512
+ ];
513
+ segments.forEach((s, i) => {
514
+ if (i > 0) lines.push("#EXT-X-DISCONTINUITY");
515
+ lines.push(`#EXT-X-PROGRAM-DATE-TIME:${new Date(s.startMs).toISOString()}`);
516
+ lines.push(`#EXTINF:${(s.durMs / 1e3).toFixed(3)},`);
517
+ lines.push(s.uri);
518
+ });
519
+ lines.push("#EXT-X-ENDLIST");
520
+ return lines.join("\n") + "\n";
521
+ }
522
+ /** A master (multi-variant) playlist enabling client ABR / profile selection. */
523
+ function buildMasterPlaylist(variants) {
524
+ const lines = ["#EXTM3U", "#EXT-X-VERSION:7"];
525
+ for (const v of variants) {
526
+ const attrs = [`BANDWIDTH=${v.bandwidth}`];
527
+ if (v.resolution) attrs.push(`RESOLUTION=${v.resolution.width}x${v.resolution.height}`);
528
+ if (v.codecs) attrs.push(`CODECS="${v.codecs}"`);
529
+ attrs.push(`NAME="${v.profile}"`);
530
+ lines.push(`#EXT-X-STREAM-INF:${attrs.join(",")}`);
531
+ lines.push(v.uri);
532
+ }
533
+ return lines.join("\n") + "\n";
534
+ }
535
+ //#endregion
536
+ //#region src/recorder/recording-device-settings.ts
537
+ function buildRecordingDeviceSchema() {
538
+ return { sections: [{
539
+ id: "recording-playback",
540
+ tab: "recording",
541
+ location: "top-tab",
542
+ title: "Recording",
543
+ order: 10,
544
+ fields: [{
545
+ type: "widget",
546
+ key: "_recordingPanel",
547
+ label: "",
548
+ widgetId: "host/recording-panel"
549
+ }]
550
+ }] };
551
+ }
552
+ //#endregion
553
+ //#region src/recorder/addon/config-store.ts
554
+ /**
555
+ * Per-device recording-config persistence for the recorder addon.
556
+ *
557
+ * Every camera's `RecordingConfig` lives in ONE addon-store blob keyed by
558
+ * (stringified) numeric deviceId — enumerable for boot hydrate (so the addon
559
+ * knows which devices have persisted footage to index without scanning every
560
+ * device store). The whole Zod-validated blob round-trips on every read/write
561
+ * via a {@link createDurableState} handle, so no per-device field can be
562
+ * silently dropped on persist. A corrupt blob reads back as EMPTY rather than
563
+ * crashing boot.
564
+ *
565
+ * On read the legacy `mode`/`schedules`/`triggers` fields are derived once into
566
+ * the authoritative `bands` via `migrateConfigToBands`, then persisted, so the
567
+ * UI reads `bands` directly forever after.
568
+ */
569
+ /** The addon-store key holding the deviceId → RecordingConfig map. */
570
+ var RECORDING_CONFIGS_KEY = "recordingConfigs";
571
+ /** Persisted shape: stringified numeric deviceId → full RecordingConfig. */
572
+ var RecordingConfigsBlobSchema = require_dist.record(require_dist.string(), require_dist.RecordingConfigSchema);
573
+ /** Cold-start default for a camera that has never been configured: disabled, no bands. */
574
+ var DEFAULT_DEVICE_CONFIG = {
575
+ enabled: false,
576
+ bands: []
577
+ };
578
+ function blobState(store) {
579
+ return require_dist.createDurableState({
580
+ key: RECORDING_CONFIGS_KEY,
581
+ schema: RecordingConfigsBlobSchema,
582
+ fallback: {},
583
+ read: () => store.readAddonStore(),
584
+ write: (patch) => store.writeAddonStore(patch)
585
+ });
586
+ }
587
+ function serialize(map) {
588
+ const out = {};
589
+ for (const [id, config] of map) out[String(id)] = config;
590
+ return out;
591
+ }
592
+ /** Read every persisted device config, keyed by numeric deviceId. */
593
+ async function readDeviceConfigs(store) {
594
+ const blob = await blobState(store).get();
595
+ const map = /* @__PURE__ */ new Map();
596
+ for (const [key, config] of Object.entries(blob)) {
597
+ const id = Number(key);
598
+ if (Number.isInteger(id)) map.set(id, config);
599
+ }
600
+ return map;
601
+ }
602
+ /**
603
+ * The persisted config for a device with `bands` guaranteed materialized.
604
+ * Legacy blobs (mode/schedules/triggers, no bands) are migrated into bands and
605
+ * re-persisted so the stored shape converges on the authoritative model. An
606
+ * unconfigured device returns {@link DEFAULT_DEVICE_CONFIG}.
607
+ */
608
+ async function loadDeviceConfig(store, deviceId) {
609
+ const stored = (await readDeviceConfigs(store)).get(deviceId);
610
+ if (!stored) return DEFAULT_DEVICE_CONFIG;
611
+ if (stored.bands) return stored;
612
+ const migrated = {
613
+ ...stored,
614
+ bands: require_dist.migrateConfigToBands(stored)
615
+ };
616
+ await saveDeviceConfig(store, deviceId, migrated);
617
+ return migrated;
618
+ }
619
+ /**
620
+ * Persist (insert or replace) a device's config. Validates against
621
+ * `RecordingConfigSchema` at the boundary (untrusted cap input). Returns the
622
+ * validated config that was persisted.
623
+ */
624
+ async function saveDeviceConfig(store, deviceId, config) {
625
+ const validated = require_dist.RecordingConfigSchema.parse(config);
626
+ const map = await readDeviceConfigs(store);
627
+ map.set(deviceId, validated);
628
+ await blobState(store).set(serialize(map));
629
+ return validated;
630
+ }
631
+ /**
632
+ * Determine the active recording mode for a config's bands — the value the
633
+ * `recording` cap status reports. `continuous` wins when any band records
634
+ * continuously, else `events` when any band exists, else `off`.
635
+ */
636
+ function activeModeForConfig(config) {
637
+ const bands = config.bands ?? [];
638
+ if (!config.enabled || bands.length === 0) return "off";
639
+ if (bands.some((b) => b.mode === "continuous")) return "continuous";
640
+ return "events";
641
+ }
642
+ //#endregion
643
+ //#region src/recorder/addon/recording-provider.ts
644
+ /**
645
+ * Builds the `recording` cap provider (`IRecordingProvider`) on the recorder
646
+ * core. NO ffmpeg / segment writing here — that arrives in B2/B3. This shell
647
+ * answers reads from the v2 `RecordingIndex` (hydrated from `storage.list`),
648
+ * persists per-device config via durable-state, and prunes footage through
649
+ * `selectEvictions` + `SegmentStore.evict`.
650
+ */
651
+ /** Bandwidth hint per profile for the master playlist (real bitrate is a later refinement). */
652
+ var PROFILE_BANDWIDTH = {
653
+ high: 4e6,
654
+ mid: 15e5,
655
+ low: 5e5
656
+ };
657
+ var DEFAULT_BANDWIDTH = 1e6;
658
+ /**
659
+ * Max gap (ms) between consecutive segments still treated as ONE continuous
660
+ * availability range. Absorbs the few-ms EXTINF-rounding jitter at every segment
661
+ * boundary and a single dropped/late ~4s segment; a larger gap (recorder off,
662
+ * multiple missing segments) splits the ranges so real discontinuities show.
663
+ */
664
+ var RANGE_MERGE_GAP_MS = 5e3;
665
+ /** Empty "no playback" manifest. */
666
+ function noPlayback(deviceId) {
667
+ return {
668
+ deviceId,
669
+ localMasterPath: null,
670
+ playbackUrl: null,
671
+ playbackEndpoints: []
672
+ };
673
+ }
674
+ /** Absolute dir the playback playlists for a device are written to (served root = `<dataDir>/playback`). */
675
+ function playbackDirFor(dataDir, deviceId) {
676
+ return node_path.default.join(dataDir, "playback", String(deviceId));
677
+ }
678
+ /**
679
+ * Build a master + per-profile variant HLS playlist on disk for the requested
680
+ * window and return a RELATIVE playback URL through the hub's data-plane
681
+ * reverse-proxy. Only segments actually present on disk are referenced (a
682
+ * missing file would 404 mid-playlist). Returns "no playback" when the device
683
+ * has no footage in range or the data-plane isn't served yet. Variant/segment
684
+ * URIs are relative so the master + variants + segments form a self-contained
685
+ * tree the player resolves against the master URL.
686
+ */
687
+ async function buildPlaybackManifest(deps, deviceId, fromMs, toMs) {
688
+ const base = deps.playbackBaseUrl();
689
+ if (base === null) return noPlayback(deviceId);
690
+ const rootById = new Map(deps.locations().map((l) => [l.id, l.root]));
691
+ const playbackDir = playbackDirFor(deps.dataDir, deviceId);
692
+ const profiles = [...new Set(deps.index.segments(deviceId).map((s) => s.profile))];
693
+ const variants = [];
694
+ for (const profile of profiles) {
695
+ const segs = deps.index.segments(deviceId, profile).filter((s) => s.startMs < toMs && s.startMs + s.durMs > fromMs).toSorted((a, b) => a.startMs - b.startMs);
696
+ if (segs.length === 0) continue;
697
+ const variantSegments = [];
698
+ for (const s of segs) {
699
+ const rel = segmentRelPath(deviceId, profile, s.startMs, s.durMs, s.bytes);
700
+ const root = rootById.get(s.locationId);
701
+ if (root === void 0) continue;
702
+ try {
703
+ await node_fs.promises.stat(node_path.default.join(root, rel));
704
+ } catch {
705
+ continue;
706
+ }
707
+ variantSegments.push({
708
+ uri: rel.split("/").slice(1).join("/"),
709
+ startMs: s.startMs,
710
+ durMs: s.durMs
711
+ });
712
+ }
713
+ if (variantSegments.length === 0) continue;
714
+ await node_fs.promises.mkdir(playbackDir, { recursive: true });
715
+ await node_fs.promises.writeFile(node_path.default.join(playbackDir, `${profile}.m3u8`), buildVariantPlaylist(variantSegments), "utf8");
716
+ variants.push({
717
+ profile,
718
+ bandwidth: PROFILE_BANDWIDTH[profile] ?? DEFAULT_BANDWIDTH,
719
+ uri: `${profile}.m3u8`
720
+ });
721
+ }
722
+ if (variants.length === 0) return noPlayback(deviceId);
723
+ await node_fs.promises.mkdir(playbackDir, { recursive: true });
724
+ const masterPath = node_path.default.join(playbackDir, "master.m3u8");
725
+ await node_fs.promises.writeFile(masterPath, buildMasterPlaylist(variants), "utf8");
726
+ const playbackUrl = `${base}/${deviceId}/master.m3u8?from=${fromMs}&to=${toMs}`;
727
+ return {
728
+ deviceId,
729
+ localMasterPath: masterPath,
730
+ playbackUrl,
731
+ playbackEndpoints: [playbackUrl]
732
+ };
733
+ }
734
+ /** Number of days in ms — for the retention floor returned by `pruneFootage`. */
735
+ var DAY_MS = 1440 * 60 * 1e3;
736
+ function statusFor(deps, deviceId, config) {
737
+ return {
738
+ deviceId,
739
+ enabled: config.enabled,
740
+ activeMode: activeModeForConfig(config),
741
+ nodeId: deps.nodeId,
742
+ storageBytes: deps.index.accounting(deviceId).bytes
743
+ };
744
+ }
745
+ /** Group a device's segments by their storage location id. */
746
+ function segmentsByLocation(rows) {
747
+ const out = /* @__PURE__ */ new Map();
748
+ for (const r of rows) {
749
+ const list = out.get(r.locationId);
750
+ if (list) list.push(r);
751
+ else out.set(r.locationId, [r]);
752
+ }
753
+ return out;
754
+ }
755
+ /**
756
+ * Build the `IRecordingProvider`. The provider is a thin façade over the v2
757
+ * core; all storage I/O flows through the injected deps.
758
+ */
759
+ function buildRecordingProvider(deps) {
760
+ const readFile = deps.readFile ?? ((absPath) => node_fs.promises.readFile(absPath));
761
+ return {
762
+ getStatus: async ({ deviceId }) => {
763
+ return statusFor(deps, deviceId, await loadDeviceConfig(deps.configStore, deviceId));
764
+ },
765
+ getAvailability: async ({ deviceId, fromMs, toMs }) => {
766
+ return {
767
+ deviceId,
768
+ ranges: [...new Set(deps.index.segments(deviceId).map((s) => s.profile))].flatMap((profile) => deps.index.ranges(deviceId, profile, fromMs, toMs, RANGE_MERGE_GAP_MS).map((r) => ({
769
+ profile,
770
+ startMs: r.startMs,
771
+ endMs: r.endMs
772
+ })))
773
+ };
774
+ },
775
+ getDaysWithRecordings: async ({ deviceId, fromMs, toMs, tzOffsetMinutes }) => {
776
+ return {
777
+ deviceId,
778
+ days: distinctRecordingDays(deps.index.segments(deviceId).map((s) => s.startMs), fromMs, toMs, tzOffsetMinutes)
779
+ };
780
+ },
781
+ getPlaybackManifest: ({ deviceId, fromMs, toMs }) => buildPlaybackManifest(deps, deviceId, fromMs, toMs),
782
+ getStorageUsage: async () => {
783
+ const locations = deps.locations();
784
+ const devices = [];
785
+ const seenDevices = /* @__PURE__ */ new Set();
786
+ for (const loc of locations) for (const row of deps.index.segmentsOnLocation(loc.id)) seenDevices.add(row.deviceId);
787
+ let totalUsedBytes = 0;
788
+ for (const deviceId of seenDevices) {
789
+ const usedBytes = deps.index.accounting(deviceId).bytes;
790
+ totalUsedBytes += usedBytes;
791
+ devices.push({
792
+ deviceId,
793
+ usedBytes
794
+ });
795
+ }
796
+ const locationUsages = await Promise.all(locations.map(async (loc) => {
797
+ const cap = await deps.capacity(loc.root);
798
+ return {
799
+ locationId: loc.id,
800
+ usedBytes: deps.index.accountingForLocation(loc.id).bytes,
801
+ availableBytes: cap.availableBytes,
802
+ totalBytes: cap.totalBytes
803
+ };
804
+ }));
805
+ devices.sort((a, b) => b.usedBytes - a.usedBytes);
806
+ return {
807
+ nodeId: deps.nodeId,
808
+ totalUsedBytes,
809
+ devices,
810
+ locations: locationUsages
811
+ };
812
+ },
813
+ getDeviceConfig: async ({ deviceId }) => loadDeviceConfig(deps.configStore, deviceId),
814
+ locateSegment: async ({ deviceId, profile, epochMs }) => {
815
+ const seg = deps.index.segmentAt(deviceId, profile, epochMs);
816
+ if (seg) return {
817
+ kind: "segment",
818
+ startMs: seg.startMs,
819
+ durMs: seg.durMs,
820
+ bytes: seg.bytes
821
+ };
822
+ return {
823
+ kind: "gap",
824
+ nearestEdgeMs: deps.index.nearestCoveredEdge(deviceId, profile, epochMs)
825
+ };
826
+ },
827
+ readSegmentBytes: async ({ deviceId, profile, startMs }) => {
828
+ const seg = deps.index.segments(deviceId, profile).find((s) => s.startMs === startMs);
829
+ if (!seg) throw new Error(`recording: no segment at start ${startMs} for ${deviceId}/${profile}`);
830
+ const loc = deps.locations().find((l) => l.id === seg.locationId);
831
+ if (!loc) throw new Error(`recording: unknown location ${seg.locationId}`);
832
+ const data = await readFile(node_path.default.join(loc.root, seg.path));
833
+ return { data: Uint8Array.from(data) };
834
+ },
835
+ setDeviceConfig: async ({ deviceId, config }) => {
836
+ const saved = await saveDeviceConfig(deps.configStore, deviceId, config);
837
+ if (deps.onConfigChanged) try {
838
+ await deps.onConfigChanged(deviceId);
839
+ } catch (err) {
840
+ deps.logger.warn("recorder: re-evaluate after setDeviceConfig failed", {
841
+ tags: { deviceId },
842
+ meta: { error: require_dist.errMsg(err) }
843
+ });
844
+ }
845
+ return saved;
846
+ },
847
+ rescanStorage: async ({ deviceId }) => {
848
+ const locations = await deps.refreshLocations();
849
+ await deps.hydrateDevice(deviceId, locations);
850
+ return statusFor(deps, deviceId, await loadDeviceConfig(deps.configStore, deviceId));
851
+ },
852
+ pruneFootage: async ({ deviceId }) => {
853
+ const retention = (await loadDeviceConfig(deps.configStore, deviceId)).retention;
854
+ const nowMs = Date.now();
855
+ const victims = selectEvictions(deps.index.segments(deviceId), {
856
+ maxAgeDays: retention?.maxAgeDays,
857
+ maxSizeGb: retention?.maxSizeGb,
858
+ nowMs
859
+ });
860
+ let reclaimedBytes = 0;
861
+ let deletedBuckets = 0;
862
+ for (const [locationId, locVictims] of segmentsByLocation(victims)) try {
863
+ const freed = await deps.segmentStore.evict(locationId, locVictims);
864
+ reclaimedBytes += freed;
865
+ if (freed > 0) deletedBuckets += locVictims.length;
866
+ } catch (err) {
867
+ deps.logger.warn("recorder pruneFootage evict failed for location", {
868
+ tags: { deviceId },
869
+ meta: {
870
+ locationId,
871
+ error: require_dist.errMsg(err)
872
+ }
873
+ });
874
+ }
875
+ return {
876
+ floorMs: deps.index.accounting(deviceId).oldestMs ?? (retention?.maxAgeDays ? nowMs - retention.maxAgeDays * DAY_MS : null),
877
+ deletedBuckets,
878
+ reclaimedBytes
879
+ };
880
+ },
881
+ getDeviceSettingsContribution: async () => require_dist.hydrateSchema(buildRecordingDeviceSchema(), {}),
882
+ getDeviceLiveContribution: async () => null,
883
+ applyDeviceSettingsPatch: async ({ patch }) => {
884
+ const keys = Object.keys(patch ?? {});
885
+ if (keys.length > 0) deps.logger.debug("recorder: ignoring legacy settings patch (widget owns settings)", { meta: { keys } });
886
+ return { success: true };
887
+ }
888
+ };
889
+ }
890
+ //#endregion
891
+ //#region src/recorder/addon/roots.ts
892
+ /**
893
+ * Storage-root resolution + boot hydrate for the recorder addon.
894
+ *
895
+ * Recordings live in the managed `recordings` / `recordingsLow` storage
896
+ * locations (operator-configurable, multi-disk-ready), resolved through the
897
+ * `storage` cap. The v2 `RecordingIndex` is hydrated by walking each location
898
+ * root's `<deviceId>/` subtree on disk — segment paths encode start/dur/bytes,
899
+ * so the in-memory index can never drift from disk. (We walk the filesystem
900
+ * directly instead of `storage.list` because that cap lists only a directory's
901
+ * IMMEDIATE children, not the `<deviceId>/<profile>/Y/M/D/H/*.m4s` tree — using
902
+ * it dropped the entire index to ~empty on every restart/rescan; the recorder
903
+ * already does local fs I/O for stat/move on these same roots.)
904
+ */
905
+ /** The two recordings location types the recorder addon writes/reads. */
906
+ var RECORDING_LOCATION_TYPES = new Set(["recordings", "recordingsLow"]);
907
+ /**
908
+ * Resolve every recordings storage location (`recordings` + `recordingsLow`)
909
+ * to `{ id, root }`. Returns an empty list when the storage cap is unreachable
910
+ * or no recordings location exists — callers degrade rather than crash.
911
+ */
912
+ async function resolveRecordingsLocations(api, logger) {
913
+ try {
914
+ const recordings = (await api.storage.listLocations.query({})).filter((l) => RECORDING_LOCATION_TYPES.has(l.type));
915
+ return Promise.all(recordings.map(async (l) => ({
916
+ id: l.id,
917
+ root: await api.storage.resolve.query({
918
+ location: l.id,
919
+ relativePath: ""
920
+ })
921
+ })));
922
+ } catch (err) {
923
+ logger.warn("recorder: listLocations failed — no recordings locations resolved", { meta: { error: require_dist.errMsg(err) } });
924
+ return [];
925
+ }
926
+ }
927
+ /**
928
+ * Hydrate the v2 `RecordingIndex` for one device from every recordings location
929
+ * by recursively walking `<root>/<deviceId>/` for `*.m4s` segments and feeding
930
+ * the location-relative paths (`<deviceId>/<profile>/Y/M/D/H/<file>.m4s`, which
931
+ * `parseSegmentPath` understands) to `index.hydrateDevice`. A missing device dir
932
+ * (no footage yet) and any per-location failure are warned + skipped
933
+ * (best-effort), so one bad volume never blocks the rest of the hydrate.
934
+ */
935
+ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger) {
936
+ for (const loc of locations) {
937
+ const deviceDir = node_path.default.join(loc.root, String(deviceId));
938
+ let entries;
939
+ try {
940
+ entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
941
+ } catch (err) {
942
+ if (err.code !== "ENOENT") logger.warn("recorder: hydrateDevice walk failed for location", {
943
+ tags: { deviceId },
944
+ meta: {
945
+ locationId: loc.id,
946
+ error: require_dist.errMsg(err)
947
+ }
948
+ });
949
+ continue;
950
+ }
951
+ const relPaths = entries.filter((e) => e.endsWith(".m4s")).map((e) => `${deviceId}/${e.split(node_path.default.sep).join("/")}`);
952
+ index.hydrateDevice(deviceId, loc.id, relPaths);
953
+ }
954
+ }
955
+ //#endregion
956
+ //#region src/recorder/addon/ffmpeg-args.ts
957
+ /** ffmpeg argv: pull RTSP (TCP), copy video / transcode audio to AAC, write
958
+ * fragmented-mp4 segments named by epoch seconds FLAT under `outDir`, plus a
959
+ * rolling `live.m3u8` playlist. The recorder segment-watcher tails the
960
+ * playlist and hands each finalized flat file to `SegmentStore.onFinalized`,
961
+ * which stats + relocates it into the bucket tree (`segmentRelPath`). */
962
+ function buildPassthroughArgs(a) {
963
+ return [
964
+ "-rtsp_transport",
965
+ "tcp",
966
+ "-i",
967
+ a.rtspUrl,
968
+ "-map",
969
+ "0",
970
+ "-c:v",
971
+ "copy",
972
+ "-c:a",
973
+ "aac",
974
+ "-f",
975
+ "segment",
976
+ "-segment_time",
977
+ String(a.segmentSeconds),
978
+ "-segment_format",
979
+ "mp4",
980
+ "-segment_format_options",
981
+ "movflags=+frag_keyframe+empty_moov+default_base_moof",
982
+ "-reset_timestamps",
983
+ "1",
984
+ "-strftime",
985
+ "1",
986
+ "-segment_list",
987
+ `${a.outDir}/live.m3u8`,
988
+ "-segment_list_type",
989
+ "m3u8",
990
+ `${a.outDir}/%s.m4s`
991
+ ];
992
+ }
993
+ //#endregion
994
+ //#region src/recorder/addon/segment-writer.ts
995
+ /** Max CONSECUTIVE rapid restarts before giving up (reset by a stable run). */
996
+ var MAX_RESTARTS = 10;
997
+ /** How many trailing ffmpeg stderr lines to keep for the give-up diagnostic. */
998
+ var STDERR_TAIL_LINES = 12;
999
+ /** Restart backoff: base delay, doubled per consecutive attempt, capped. */
1000
+ var RESTART_BASE_MS = 500;
1001
+ var RESTART_MAX_MS = 1e4;
1002
+ /** A process that ran at least this long is "stable" → reset the restart count,
1003
+ * so sporadic blips over a long lifetime never accumulate into a give-up. */
1004
+ var STABLE_RUN_MS = 3e4;
1005
+ /** Supervises one passthrough ffmpeg for a single (camera, profile). */
1006
+ var SegmentWriter = class {
1007
+ cfg;
1008
+ deps;
1009
+ proc = null;
1010
+ stopped = false;
1011
+ restarts = 0;
1012
+ restartTimer = null;
1013
+ startedAt = 0;
1014
+ constructor(cfg, deps) {
1015
+ this.cfg = cfg;
1016
+ this.deps = deps;
1017
+ }
1018
+ start() {
1019
+ if (this.stopped || this.proc) return;
1020
+ const args = buildPassthroughArgs(this.cfg);
1021
+ const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
1022
+ this.proc = proc;
1023
+ this.startedAt = Date.now();
1024
+ const stderrTail = [];
1025
+ proc.stderr?.on("data", (chunk) => {
1026
+ const text = chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk);
1027
+ for (const line of text.split("\n")) {
1028
+ const trimmed = line.trim();
1029
+ if (!trimmed) continue;
1030
+ stderrTail.push(trimmed);
1031
+ if (stderrTail.length > STDERR_TAIL_LINES) stderrTail.shift();
1032
+ }
1033
+ });
1034
+ proc.on("exit", (code) => {
1035
+ this.proc = null;
1036
+ if (this.stopped) return;
1037
+ const ranMs = Date.now() - this.startedAt;
1038
+ if (ranMs >= STABLE_RUN_MS) this.restarts = 0;
1039
+ if (this.restarts >= MAX_RESTARTS) {
1040
+ this.deps.logger.warn("SegmentWriter giving up after max restarts", { meta: {
1041
+ outDir: this.cfg.outDir,
1042
+ code,
1043
+ stderrTail: stderrTail.join(" | ")
1044
+ } });
1045
+ this.stopped = true;
1046
+ this.deps.onGaveUp?.();
1047
+ return;
1048
+ }
1049
+ this.restarts++;
1050
+ const delayMs = Math.min(RESTART_BASE_MS * 2 ** (this.restarts - 1), RESTART_MAX_MS);
1051
+ this.deps.logger.info("SegmentWriter restarting", { meta: {
1052
+ outDir: this.cfg.outDir,
1053
+ attempt: this.restarts,
1054
+ delayMs,
1055
+ ranMs
1056
+ } });
1057
+ this.restartTimer = setTimeout(() => {
1058
+ this.restartTimer = null;
1059
+ this.start();
1060
+ }, delayMs);
1061
+ });
1062
+ }
1063
+ stop() {
1064
+ this.stopped = true;
1065
+ if (this.restartTimer) {
1066
+ clearTimeout(this.restartTimer);
1067
+ this.restartTimer = null;
1068
+ }
1069
+ this.proc?.kill();
1070
+ this.proc = null;
1071
+ }
1072
+ };
1073
+ //#endregion
1074
+ //#region src/recorder/addon/segment-watcher.ts
1075
+ /**
1076
+ * Poll-based tail of an ffmpeg `-segment_list` (`live.m3u8`) for one
1077
+ * (camera, profile) output dir — recorder variant.
1078
+ *
1079
+ * Adapted from the old recorder (`addon-pipeline/src/recorder/segment-watcher.ts`).
1080
+ * The robust playlist-tailing core is unchanged: it polls `live.m3u8`, derives
1081
+ * `startMs` from the flat `<epochSec>.m4s` filename, corrects the inflated
1082
+ * first-segment EXTINF duration against the successor's start, and handles
1083
+ * pending/phantom (file-not-yet-flushed) entries with a bounded retry.
1084
+ *
1085
+ * KEY ADAPTATION vs the old watcher: this watcher does NOT relocate the flat
1086
+ * file, does NOT classify a continuous/events subtree, and does NOT touch a RAM
1087
+ * index. For each finalized flat segment it simply calls
1088
+ * `onFinalized(startMs, durMs, flatAbsPath)` and lets the v2 `SegmentStore`
1089
+ * (`onFinalized`) do the stat + relocate-into-bucket + index. The watcher only
1090
+ * verifies the flat file is on disk (so `onFinalized` does not race the flush)
1091
+ * before handing it off; the SegmentStore re-stats for the authoritative byte
1092
+ * count that goes into the path. B2 records CONTINUOUS bands only, so there is
1093
+ * no subtree to classify.
1094
+ */
1095
+ var EPOCH_NAME_RE = /^(\d+)\.m4s$/;
1096
+ /** How many consecutive ticks to wait for a finalized segment's flat file to
1097
+ * land before giving up on it (a genuine phantom). At a 2s watch interval this
1098
+ * is ~16s — far longer than ffmpeg's slow-first-segment flush. */
1099
+ var MAX_PENDING_TICKS = 8;
1100
+ /**
1101
+ * Derive `startMs` (epoch milliseconds) from a flat ffmpeg segment path
1102
+ * (`<epochSec>.m4s`, possibly absolute). Returns null for any non-epoch path.
1103
+ */
1104
+ function parseEpochStartMs(segPath) {
1105
+ const m = EPOCH_NAME_RE.exec(node_path.default.basename(segPath));
1106
+ if (!m) return null;
1107
+ const epochSec = Number(m[1]);
1108
+ return Number.isFinite(epochSec) ? epochSec * 1e3 : null;
1109
+ }
1110
+ /**
1111
+ * Correct a finalized segment's duration. ffmpeg's first-segment `#EXTINF` is
1112
+ * unreliable under `-c copy` from RTSP — it is inflated by the initial PTS
1113
+ * offset (observed: 87.961s for a segment whose successor starts only 12s
1114
+ * later). A segment physically cannot outlast the start of the next segment, so
1115
+ * when a successor exists we clamp the rounded EXTINF to the inter-segment gap.
1116
+ * For normal segments EXTINF (millisecond-accurate) is below the
1117
+ * integer-second start gap and wins, preserving sub-second precision. When
1118
+ * there is no successor (genuine last segment) or the gap is non-positive
1119
+ * (restart / same-second), we trust EXTINF.
1120
+ */
1121
+ function correctedDurMs(durSeconds, startMs, nextStartMs) {
1122
+ const extinfMs = Math.round(durSeconds * 1e3);
1123
+ if (nextStartMs === null) return extinfMs;
1124
+ const gapMs = nextStartMs - startMs;
1125
+ if (gapMs <= 0) return extinfMs;
1126
+ return Math.min(extinfMs, gapMs);
1127
+ }
1128
+ /** Parse a `live.m3u8` body into ordered `#EXTINF` → segment-path pairs. */
1129
+ function parseLivePlaylist(body) {
1130
+ const lines = body.split("\n");
1131
+ const out = [];
1132
+ let pendingDur = null;
1133
+ for (const raw of lines) {
1134
+ const line = raw.trim();
1135
+ if (line.length === 0) continue;
1136
+ if (line.startsWith("#EXTINF:")) {
1137
+ const v = line.slice(8).replace(/,.*$/, "");
1138
+ const dur = Number(v);
1139
+ pendingDur = Number.isFinite(dur) ? dur : null;
1140
+ continue;
1141
+ }
1142
+ if (line.startsWith("#")) continue;
1143
+ if (pendingDur !== null) {
1144
+ out.push({
1145
+ durSeconds: pendingDur,
1146
+ segPath: line
1147
+ });
1148
+ pendingDur = null;
1149
+ }
1150
+ }
1151
+ return out;
1152
+ }
1153
+ /** `fs.stat` size, or null when the path does not exist. */
1154
+ async function statSize(p) {
1155
+ try {
1156
+ return (await node_fs.promises.stat(p)).size;
1157
+ } catch {
1158
+ return null;
1159
+ }
1160
+ }
1161
+ /**
1162
+ * Process ONE finalized playlist entry: verify the flat file is on disk and, if
1163
+ * so, hand it to `onFinalized` (the v2 SegmentStore stats + relocates + indexes
1164
+ * it). Returns:
1165
+ * - `'retained'` — the flat file is on disk and was handed off;
1166
+ * - `'skipped'` — not a relocatable epoch-named segment (caller advances past it);
1167
+ * - `'pending'` — a valid segment whose flat file has NOT landed on disk yet.
1168
+ * ffmpeg's first segment is slow to flush, so the caller must RETRY this
1169
+ * entry on a later tick instead of orphaning it.
1170
+ *
1171
+ * Unlike the old recorder, this watcher does NOT relocate or re-stat: the
1172
+ * SegmentStore owns the move + the authoritative byte count.
1173
+ */
1174
+ async function handleSegmentEntry(outDir, entry, durMs, onFinalized, logger) {
1175
+ const flatAbsPath = node_path.default.isAbsolute(entry.segPath) ? entry.segPath : node_path.default.join(outDir, entry.segPath);
1176
+ const startMs = parseEpochStartMs(flatAbsPath);
1177
+ if (startMs === null) return "skipped";
1178
+ if (await statSize(flatAbsPath) === null) {
1179
+ logger.debug("segment flat file not on disk yet — will retry", { meta: { path: flatAbsPath } });
1180
+ return "pending";
1181
+ }
1182
+ onFinalized(startMs, durMs, flatAbsPath);
1183
+ return "retained";
1184
+ }
1185
+ /**
1186
+ * Decide which playlist entries are FINALIZABLE this tick and their corrected
1187
+ * durations, given the previously-processed high-water mark. Pure (no I/O) so
1188
+ * the tick loop is just: read file → plan → hand each off → advance mark.
1189
+ *
1190
+ * The live tail entry is held back while recording continues: a segment's true
1191
+ * duration needs its SUCCESSOR's start (ffmpeg's first-segment EXTINF is
1192
+ * inflated by the initial PTS offset). Once `#EXT-X-ENDLIST` appears the tail
1193
+ * is flushed too — by then it is never the unreliable first segment.
1194
+ */
1195
+ function planFinalizations(body, processed) {
1196
+ const entries = parseLivePlaylist(body);
1197
+ const endlist = body.includes("#EXT-X-ENDLIST");
1198
+ const hw = entries.length < processed ? 0 : processed;
1199
+ const finalizable = endlist ? entries.length : Math.max(0, entries.length - 1);
1200
+ const toHandle = [];
1201
+ for (let i = hw; i < finalizable; i++) {
1202
+ const entry = entries[i];
1203
+ const startMs = parseEpochStartMs(entry.segPath);
1204
+ const nextStartMs = i + 1 < entries.length ? parseEpochStartMs(entries[i + 1].segPath) : null;
1205
+ const durMs = startMs === null ? Math.round(entry.durSeconds * 1e3) : correctedDurMs(entry.durSeconds, startMs, nextStartMs);
1206
+ toHandle.push({
1207
+ entry,
1208
+ durMs
1209
+ });
1210
+ }
1211
+ return {
1212
+ toHandle,
1213
+ from: hw,
1214
+ processed: Math.max(hw, finalizable)
1215
+ };
1216
+ }
1217
+ /**
1218
+ * Start polling `<outDir>/live.m3u8`. Returns a handle whose `stop()`
1219
+ * cancels the timer. Errors during a tick are swallowed (logged at debug)
1220
+ * — a transient read race self-corrects on the next interval.
1221
+ */
1222
+ function startSegmentWatcher(deps) {
1223
+ const playlistPath = node_path.default.join(deps.outDir, "live.m3u8");
1224
+ let processed = 0;
1225
+ let stopped = false;
1226
+ let ticking = false;
1227
+ let pendingIndex = -1;
1228
+ let pendingTicks = 0;
1229
+ const tick = async () => {
1230
+ if (stopped || ticking) return;
1231
+ ticking = true;
1232
+ try {
1233
+ let body;
1234
+ try {
1235
+ body = await node_fs.promises.readFile(playlistPath, "utf8");
1236
+ } catch {
1237
+ return;
1238
+ }
1239
+ const plan = planFinalizations(body, processed);
1240
+ let index = plan.from;
1241
+ for (const { entry, durMs } of plan.toHandle) {
1242
+ if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger) === "pending") {
1243
+ if (pendingIndex === index) {
1244
+ pendingTicks++;
1245
+ if (pendingTicks >= MAX_PENDING_TICKS) {
1246
+ deps.logger.warn("segment file never landed — skipping", { meta: { seg: entry.segPath } });
1247
+ pendingIndex = -1;
1248
+ pendingTicks = 0;
1249
+ index++;
1250
+ continue;
1251
+ }
1252
+ } else {
1253
+ pendingIndex = index;
1254
+ pendingTicks = 1;
1255
+ }
1256
+ break;
1257
+ }
1258
+ index++;
1259
+ }
1260
+ if (pendingIndex !== index) {
1261
+ pendingIndex = -1;
1262
+ pendingTicks = 0;
1263
+ }
1264
+ processed = index;
1265
+ } finally {
1266
+ ticking = false;
1267
+ }
1268
+ };
1269
+ const timer = setInterval(() => {
1270
+ tick();
1271
+ }, deps.intervalMs);
1272
+ timer.unref?.();
1273
+ return { stop() {
1274
+ stopped = true;
1275
+ clearInterval(timer);
1276
+ } };
1277
+ }
1278
+ //#endregion
1279
+ //#region src/recorder/addon/readiness-restore.ts
1280
+ var ReadinessRestore = class {
1281
+ deps;
1282
+ pending = /* @__PURE__ */ new Map();
1283
+ draining = false;
1284
+ rerun = false;
1285
+ unsub = null;
1286
+ constructor(deps) {
1287
+ this.deps = deps;
1288
+ }
1289
+ /** Seed the pending set, subscribe to readiness, and drain once immediately
1290
+ * (covers the already-ready case). Returns when the first drain settles. */
1291
+ async start(items) {
1292
+ for (const [k, v] of items) this.pending.set(k, v);
1293
+ if (this.unsub === null) this.unsub = this.deps.subscribeReady(() => {
1294
+ this.drain();
1295
+ });
1296
+ await this.drain();
1297
+ }
1298
+ /** Add (or replace) one pending item without re-subscribing. Does NOT drain. */
1299
+ add(key, value) {
1300
+ this.pending.set(key, value);
1301
+ }
1302
+ /** Remove one pending item (e.g. the camera was disabled before it attached). */
1303
+ remove(key) {
1304
+ this.pending.delete(key);
1305
+ }
1306
+ /** Unsubscribe from readiness. Call on shutdown. */
1307
+ stop() {
1308
+ this.unsub?.();
1309
+ this.unsub = null;
1310
+ this.pending.clear();
1311
+ }
1312
+ get pendingCount() {
1313
+ return this.pending.size;
1314
+ }
1315
+ hasPending(key) {
1316
+ return this.pending.has(key);
1317
+ }
1318
+ /** Attempt every still-pending item; repeat once if a readiness signal arrived
1319
+ * mid-flight. Re-entrant and self-coalescing. */
1320
+ async drain() {
1321
+ if (this.draining) {
1322
+ this.rerun = true;
1323
+ return;
1324
+ }
1325
+ if (this.pending.size === 0) return;
1326
+ this.draining = true;
1327
+ try {
1328
+ do {
1329
+ this.rerun = false;
1330
+ for (const [key, value] of [...this.pending]) try {
1331
+ await this.deps.attempt(key, value);
1332
+ this.pending.delete(key);
1333
+ } catch (err) {
1334
+ this.deps.onDefer?.(key, value, err);
1335
+ }
1336
+ } while (this.rerun && this.pending.size > 0);
1337
+ } finally {
1338
+ this.draining = false;
1339
+ }
1340
+ }
1341
+ };
1342
+ //#endregion
1343
+ //#region src/recorder/band-engine.ts
1344
+ function toMin(hhmm) {
1345
+ const [h, m] = hhmm.split(":");
1346
+ return Number(h) * 60 + Number(m);
1347
+ }
1348
+ function bandActiveAt(band, date) {
1349
+ const wd = date.getDay();
1350
+ const prevWd = (wd + 6) % 7;
1351
+ const t = date.getHours() * 60 + date.getMinutes();
1352
+ const s = toMin(band.start);
1353
+ const e = toMin(band.end);
1354
+ const applies = (d) => band.days.length === 0 || band.days.includes(d);
1355
+ if (s < e) return applies(wd) && t >= s && t < e;
1356
+ if (s > e) return applies(wd) && t >= s || applies(prevWd) && t < e;
1357
+ return applies(wd);
1358
+ }
1359
+ function activeBandAt(config, date) {
1360
+ return config.bands.find((b) => bandActiveAt(b, date)) ?? null;
1361
+ }
1362
+ //#endregion
1363
+ //#region src/recorder/addon/band-decision.ts
1364
+ /**
1365
+ * Is an `events` band's live demand window open at `atMs`? True iff a trigger
1366
+ * the band listens for fired within `postBufferSec` of now. `preBufferSec` is
1367
+ * deliberately NOT consulted here (see file header).
1368
+ */
1369
+ function eventsBandDemanded(band, triggers, atMs) {
1370
+ const postMs = (band.postBufferSec ?? 0) * 1e3;
1371
+ const fresh = (lastMs) => lastMs != null && atMs - lastMs <= postMs;
1372
+ if (band.triggers?.motion && fresh(triggers.lastMotionMs)) return true;
1373
+ if (band.triggers?.audioThresholdDbfs != null && fresh(triggers.lastAudioMs)) return true;
1374
+ return false;
1375
+ }
1376
+ /**
1377
+ * Should recording (any mode) be active for this device at `at`? The live
1378
+ * attach/detach gate: enabled + a band covers `at` + that band demands now
1379
+ * (continuous always; events within `postBufferSec` of a qualifying trigger).
1380
+ */
1381
+ function shouldRecordAt(config, triggers, at) {
1382
+ if (!config.enabled) return false;
1383
+ const bands = config.bands;
1384
+ if (!bands || bands.length === 0) return false;
1385
+ const band = activeBandAt({ bands }, at);
1386
+ if (!band) return false;
1387
+ if (band.mode === "continuous") return true;
1388
+ return eventsBandDemanded(band, triggers, at.getTime());
1389
+ }
1390
+ /**
1391
+ * Should a finalized segment `[segStartMs, segEndMs]` recorded under an
1392
+ * `events` band be KEPT (else discarded)? Kept iff it overlaps any qualifying
1393
+ * trigger's full window `[trigger - preBufferSec, trigger + postBufferSec]`.
1394
+ * This is where `preBufferSec` retroactively retains pre-trigger footage.
1395
+ */
1396
+ function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers) {
1397
+ const preMs = (band.preBufferSec ?? 0) * 1e3;
1398
+ const postMs = (band.postBufferSec ?? 0) * 1e3;
1399
+ const overlaps = (lastMs) => lastMs != null && segStartMs <= lastMs + postMs && segEndMs >= lastMs - preMs;
1400
+ if (band.triggers?.motion && overlaps(triggers.lastMotionMs)) return true;
1401
+ if (band.triggers?.audioThresholdDbfs != null && overlaps(triggers.lastAudioMs)) return true;
1402
+ return false;
1403
+ }
1404
+ //#endregion
1405
+ //#region src/recorder/addon/recording-controller.ts
1406
+ /**
1407
+ * recorder recording controller — the B2 continuous-band write path.
1408
+ *
1409
+ * Owns, per (deviceId, profile), the lifecycle of:
1410
+ * - a broker stream lease (`getStreamWithCodec`, released on detach),
1411
+ * - an ffmpeg `SegmentWriter` (passthrough segmenter into a temp outDir),
1412
+ * - a `live.m3u8` `SegmentWatcher` that hands each finalized flat segment to
1413
+ * the v2 `SegmentStore.onFinalized` (stat + relocate + index).
1414
+ *
1415
+ * Per device it decides whether a CONTINUOUS band is active right now
1416
+ * (`shouldRecordContinuousAt`) and ensures the writer set is running iff so.
1417
+ * It re-evaluates on three triggers:
1418
+ * 1. `setDeviceConfig` (operator changed the bands / enable),
1419
+ * 2. a periodic tick (catches band boundaries crossed with no config change),
1420
+ * 3. a `stream-broker` ready transition (boot restore — via `ReadinessRestore`).
1421
+ *
1422
+ * EVENTS bands are treated as "not recording continuously" in B2 — B3 adds
1423
+ * trigger-gating. The enable intent is persisted in durable-state by the
1424
+ * config-store, so `restoreEnabledDevices` on boot re-attaches every device
1425
+ * whose persisted config records continuously now.
1426
+ *
1427
+ * Source acquisition rides the broker's single source dial: the controller
1428
+ * gates each broker call on the broker's readiness via the cached capability
1429
+ * handle (`useCapability(...).call(fn)`) — no bespoke retry/backoff, the
1430
+ * `ReadinessRegistry` is the single source of truth.
1431
+ */
1432
+ var DEFAULT_WATCH_INTERVAL_MS = 2e3;
1433
+ /** Periodic re-evaluation to catch band boundaries crossed with no config change. */
1434
+ var TICK_MS = 3e4;
1435
+ var RecordingController = class {
1436
+ deps;
1437
+ active = /* @__PURE__ */ new Map();
1438
+ /** Latest qualifying trigger timestamps per device — drives `events`-band gating. */
1439
+ triggers = /* @__PURE__ */ new Map();
1440
+ restore = null;
1441
+ tickTimer = null;
1442
+ stopped = false;
1443
+ constructor(deps) {
1444
+ this.deps = deps;
1445
+ }
1446
+ now() {
1447
+ return this.deps.now?.() ?? Date.now();
1448
+ }
1449
+ /**
1450
+ * Boot restore: seed the readiness-gated queue with every persisted device and
1451
+ * (re)evaluate each on every `stream-broker` ready transition. Also arms the
1452
+ * periodic tick. Idempotent — safe to call once after the index is hydrated.
1453
+ */
1454
+ async start() {
1455
+ if (this.stopped) return;
1456
+ let ids;
1457
+ try {
1458
+ ids = await this.deps.enabledDeviceIds();
1459
+ } catch (err) {
1460
+ this.deps.logger.warn("recorder controller: reading persisted devices failed", { meta: { error: require_dist.errMsg(err) } });
1461
+ ids = [];
1462
+ }
1463
+ this.restore = new ReadinessRestore({
1464
+ subscribeReady: (handler) => this.deps.onBrokerReady(handler),
1465
+ attempt: async (deviceId) => {
1466
+ await this.evaluateDevice(deviceId);
1467
+ },
1468
+ onDefer: (deviceId, _v, err) => this.deps.logger.warn("recorder controller: device evaluate deferred (retries on next broker ready)", {
1469
+ tags: { deviceId },
1470
+ meta: { error: require_dist.errMsg(err) }
1471
+ })
1472
+ });
1473
+ if (this.tickTimer === null) {
1474
+ this.tickTimer = setInterval(() => {
1475
+ this.tick();
1476
+ }, TICK_MS);
1477
+ this.tickTimer.unref?.();
1478
+ }
1479
+ await this.restore.start(ids.map((id) => [id, true]));
1480
+ }
1481
+ /** Re-evaluate every currently-tracked OR active device on the periodic tick. */
1482
+ async tick() {
1483
+ if (this.stopped) return;
1484
+ const ids = new Set(this.active.keys());
1485
+ let persisted = [];
1486
+ try {
1487
+ persisted = await this.deps.enabledDeviceIds();
1488
+ } catch {}
1489
+ for (const id of persisted) ids.add(id);
1490
+ for (const id of ids) try {
1491
+ await this.evaluateDevice(id);
1492
+ } catch (err) {
1493
+ this.deps.logger.warn("recorder controller: tick evaluate failed", {
1494
+ tags: { deviceId: id },
1495
+ meta: { error: require_dist.errMsg(err) }
1496
+ });
1497
+ }
1498
+ }
1499
+ /**
1500
+ * Re-evaluate one device after a config change (called from `setDeviceConfig`).
1501
+ * Failures propagate so the caller can surface them, but the persisted config
1502
+ * has already been written by the time this runs.
1503
+ */
1504
+ async onConfigChanged(deviceId) {
1505
+ if (this.stopped) return;
1506
+ await this.evaluateDevice(deviceId);
1507
+ }
1508
+ /**
1509
+ * One profile's ffmpeg writer exhausted its bounded inner restarts and gave
1510
+ * up. The device is still in `active` with a dead writer, so `evaluateDevice`
1511
+ * would short-circuit at "already recording — converged" and never recover.
1512
+ * Detach the WHOLE device (releases every broker lease + stops watchers, so the
1513
+ * idle source can re-dial cleanly), then re-evaluate: if the band still demands
1514
+ * recording, `evaluateDevice` re-queues + re-attaches a fresh writer set. If
1515
+ * the broker is not ready, the re-attach throws and the device stays on the
1516
+ * readiness queue (retried on the next broker ready) — and the periodic tick is
1517
+ * the backstop. Bounded OUTER retry (TICK_MS cadence), never a tight loop.
1518
+ */
1519
+ async recoverWriterGaveUp(deviceId) {
1520
+ if (this.stopped) return;
1521
+ this.deps.logger.warn("recorder: writer gave up — detaching + re-evaluating device", { tags: { deviceId } });
1522
+ try {
1523
+ await this.detachDevice(deviceId);
1524
+ await this.evaluateDevice(deviceId);
1525
+ } catch (err) {
1526
+ this.restore?.add(deviceId, true);
1527
+ this.deps.logger.warn("recorder: writer give-up recovery deferred (retries on next broker ready/tick)", {
1528
+ tags: { deviceId },
1529
+ meta: { error: require_dist.errMsg(err) }
1530
+ });
1531
+ }
1532
+ }
1533
+ /** Current trigger state for a device (cold default = no triggers seen). */
1534
+ triggersFor(deviceId) {
1535
+ return this.triggers.get(deviceId) ?? {
1536
+ lastMotionMs: null,
1537
+ lastAudioMs: null
1538
+ };
1539
+ }
1540
+ /**
1541
+ * Record a qualifying trigger (motion/audio) for a device and converge: an
1542
+ * `events` band whose window this opens attaches now; the periodic tick
1543
+ * detaches it once `postBufferSec` elapses. Called from the addon's
1544
+ * EventCapture subscription. The qualification test (motion detected / audio
1545
+ * over threshold) is done upstream — by the time we're here, it qualified.
1546
+ */
1547
+ async onTrigger(deviceId, source, atMs) {
1548
+ if (this.stopped) return;
1549
+ const prev = this.triggersFor(deviceId);
1550
+ this.triggers.set(deviceId, {
1551
+ lastMotionMs: source === "motion" ? atMs : prev.lastMotionMs,
1552
+ lastAudioMs: source === "audio" ? atMs : prev.lastAudioMs
1553
+ });
1554
+ await this.evaluateDevice(deviceId);
1555
+ }
1556
+ /**
1557
+ * Decide-and-converge for one device: if its active band demands recording now
1558
+ * (continuous always; events within `postBufferSec` of a trigger) and the
1559
+ * device is enabled, ensure its writer set is running; otherwise stop it.
1560
+ * Re-attaches via the readiness queue if the broker is not ready (the broker
1561
+ * call throws → `ReadinessRestore.attempt` defers → retried on next ready).
1562
+ */
1563
+ async evaluateDevice(deviceId) {
1564
+ if (this.stopped) return;
1565
+ const config = await this.deps.loadConfig(deviceId);
1566
+ if (!shouldRecordAt(config, this.triggersFor(deviceId), new Date(this.now()))) {
1567
+ await this.detachDevice(deviceId);
1568
+ this.restore?.remove(deviceId);
1569
+ return;
1570
+ }
1571
+ if (this.active.has(deviceId)) return;
1572
+ this.restore?.add(deviceId, true);
1573
+ const profiles = await this.resolveProfiles(deviceId, config.profiles);
1574
+ if (profiles.length === 0) throw new Error(`recorder controller: no assigned broker sources for device ${deviceId}`);
1575
+ const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
1576
+ const recordings = [];
1577
+ try {
1578
+ for (const profile of profiles) recordings.push(await this.attachProfile(deviceId, profile, segmentSeconds));
1579
+ } catch (err) {
1580
+ for (const r of recordings) await this.teardownProfile(deviceId, r);
1581
+ throw err;
1582
+ }
1583
+ this.active.set(deviceId, recordings);
1584
+ this.deps.logger.info("recorder pinned broker source(s) for recording", {
1585
+ tags: { deviceId },
1586
+ meta: {
1587
+ profiles,
1588
+ segmentSeconds,
1589
+ pipelineKeys: recordings.map((r) => r.pipelineKey)
1590
+ }
1591
+ });
1592
+ }
1593
+ /**
1594
+ * Pick which profiles to record. The broker enumerates assigned profile slots,
1595
+ * deduped by physical source (`selectAssignedProfileSlots`) so the same camera
1596
+ * encoder is never recorded twice. An operator `override` DISABLES the
1597
+ * unselected profiles (assigned ∩ selection); a selection matching none of the
1598
+ * assigned sources is ignored (record every assigned source — minimum of 1).
1599
+ */
1600
+ async resolveProfiles(deviceId, override) {
1601
+ const assigned = require_dist.selectAssignedProfileSlots(await this.deps.brokerCall(() => this.deps.api.streamBroker.listAllProfileSlots.query()), deviceId).map((slot) => slot.profile);
1602
+ if (!override || override.length === 0) return assigned;
1603
+ const selected = assigned.filter((p) => override.includes(p));
1604
+ return selected.length > 0 ? selected : assigned;
1605
+ }
1606
+ /**
1607
+ * Attach one (device, profile): acquire the broker source, mkdir the temp
1608
+ * outDir, spawn the SegmentWriter, and start the watcher that feeds finalized
1609
+ * flat segments into `SegmentStore.onFinalized`.
1610
+ */
1611
+ async attachProfile(deviceId, profile, segmentSeconds) {
1612
+ const source = await this.deps.brokerCall(() => this.deps.api.streamBroker.getStreamWithCodec.mutate({
1613
+ deviceId,
1614
+ video: "copy",
1615
+ audio: "aac",
1616
+ profile,
1617
+ ...this.deps.consumerHostname !== void 0 ? { hostname: this.deps.consumerHostname } : {},
1618
+ tag: `recorder:${deviceId}/${profile}`
1619
+ }));
1620
+ const outDir = node_path.default.join(this.deps.tmpRoot, String(deviceId), profile);
1621
+ await node_fs.promises.mkdir(outDir, { recursive: true });
1622
+ const writer = new SegmentWriter({
1623
+ rtspUrl: source.url,
1624
+ outDir,
1625
+ segmentSeconds
1626
+ }, {
1627
+ spawn: this.deps.spawn,
1628
+ logger: this.deps.logger,
1629
+ onGaveUp: () => {
1630
+ this.recoverWriterGaveUp(deviceId);
1631
+ }
1632
+ });
1633
+ writer.start();
1634
+ const placement = this.resolvePlacement(profile);
1635
+ return {
1636
+ profile,
1637
+ writer,
1638
+ watcher: startSegmentWatcher({
1639
+ outDir,
1640
+ intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
1641
+ onFinalized: (startMs, durMs, flatAbsPath) => {
1642
+ this.handleFinalizedSegment(deviceId, profile, placement, startMs, durMs, flatAbsPath);
1643
+ },
1644
+ logger: this.deps.logger
1645
+ }),
1646
+ pipelineKey: source.pipelineKey,
1647
+ outDir
1648
+ };
1649
+ }
1650
+ /**
1651
+ * Index a finalized segment — unless it was recorded under an `events` band
1652
+ * and falls OUTSIDE every trigger's `[trigger-preBufferSec, trigger+postBufferSec]`
1653
+ * window, in which case it is discarded (deleted, never indexed). Continuous
1654
+ * footage (or any segment with no active events band) is always indexed: the
1655
+ * keep/discard gate touches `events`-mode segments only, so the continuous
1656
+ * path is unchanged.
1657
+ */
1658
+ async handleFinalizedSegment(deviceId, profile, placement, startMs, durMs, flatAbsPath) {
1659
+ try {
1660
+ const band = activeBandAt({ bands: (await this.deps.loadConfig(deviceId)).bands ?? [] }, new Date(startMs + durMs));
1661
+ if (band?.mode === "events" && !segmentRetainedForEvents(startMs, startMs + durMs, band, this.triggersFor(deviceId))) {
1662
+ await node_fs.promises.unlink(flatAbsPath).catch(() => {});
1663
+ return;
1664
+ }
1665
+ await this.deps.segmentStore.onFinalized({
1666
+ deviceId,
1667
+ profile,
1668
+ locationId: placement.id,
1669
+ locationRoot: placement.root,
1670
+ flatAbsPath,
1671
+ startMs,
1672
+ durMs
1673
+ });
1674
+ } catch (err) {
1675
+ this.deps.logger.warn("recorder: onFinalized failed", {
1676
+ tags: { deviceId },
1677
+ meta: {
1678
+ profile,
1679
+ error: require_dist.errMsg(err)
1680
+ }
1681
+ });
1682
+ }
1683
+ }
1684
+ /**
1685
+ * Resolve the storage location a profile writes to: `low` → the recordingsLow
1686
+ * location when present, every other profile → the recordings location. Falls
1687
+ * back to the first resolved location when the preferred type is absent — both
1688
+ * types resolve to the same path today, so the split is free-and-future-proof.
1689
+ */
1690
+ resolvePlacement(profile) {
1691
+ const locations = this.deps.locations();
1692
+ if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
1693
+ const preferLow = profile === "low";
1694
+ return locations.find((l) => preferLow ? l.id === "recordingsLow" : l.id === "recordings") ?? locations[0];
1695
+ }
1696
+ /** Stop one profile's writer + watcher and release its broker lease. */
1697
+ async teardownProfile(deviceId, r) {
1698
+ try {
1699
+ r.watcher.stop();
1700
+ } catch {}
1701
+ try {
1702
+ r.writer.stop();
1703
+ } catch {}
1704
+ try {
1705
+ await this.deps.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey: r.pipelineKey });
1706
+ } catch (err) {
1707
+ this.deps.logger.warn("recorder: releaseStreamWithCodec failed", {
1708
+ tags: { deviceId },
1709
+ meta: {
1710
+ profile: r.profile,
1711
+ pipelineKey: r.pipelineKey,
1712
+ error: require_dist.errMsg(err)
1713
+ }
1714
+ });
1715
+ }
1716
+ }
1717
+ /** Stop every profile recording for a device (no-op when not recording). */
1718
+ async detachDevice(deviceId) {
1719
+ const recordings = this.active.get(deviceId);
1720
+ if (!recordings) return;
1721
+ this.active.delete(deviceId);
1722
+ for (const r of recordings) await this.teardownProfile(deviceId, r);
1723
+ this.deps.logger.info("recorder unpinned broker source(s) — recording stopped", {
1724
+ tags: { deviceId },
1725
+ meta: { pipelineKeys: recordings.map((r) => r.pipelineKey) }
1726
+ });
1727
+ }
1728
+ /** Tear EVERYTHING down: timer, restore subscription, every writer/watcher/lease. */
1729
+ async stop() {
1730
+ this.stopped = true;
1731
+ if (this.tickTimer !== null) {
1732
+ clearInterval(this.tickTimer);
1733
+ this.tickTimer = null;
1734
+ }
1735
+ this.restore?.stop();
1736
+ this.restore = null;
1737
+ for (const deviceId of [...this.active.keys()]) await this.detachDevice(deviceId);
1738
+ }
1739
+ };
1740
+ /** Build a `stream-broker` readiness scope for one node. */
1741
+ function brokerScope(nodeId) {
1742
+ return {
1743
+ type: "node",
1744
+ nodeId
1745
+ };
1746
+ }
1747
+ /** Default broker readiness gate timeout (ms) — matches the old recorder. */
1748
+ var BROKER_READY_TIMEOUT_MS = 1e4;
1749
+ /** Wrap a cached capability handle into the `brokerCall` gate the controller needs. */
1750
+ function makeBrokerCall(handle) {
1751
+ return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
1752
+ }
1753
+ //#endregion
1754
+ //#region src/recorder/addon/recording-hub-host.ts
1755
+ /**
1756
+ * Resolve the host a REMOTE recording node should use to dial the hub-only
1757
+ * stream-broker's restream port.
1758
+ *
1759
+ * The broker mints `127.0.0.1` restream URLs (hub-local). When the operator
1760
+ * designates an AGENT as the recording node, that agent's recorder must pull
1761
+ * from the hub, so it needs the hub's cluster-resolvable host. Resolution
1762
+ * order:
1763
+ *
1764
+ * 1. `override` — the `recordingHubHostname` global setting, when the
1765
+ * operator set one explicitly (e.g. the auto-derived host isn't reachable
1766
+ * for RTSP on the recording interface).
1767
+ * 2. `hubUrl` — derived from the agent's `CAMSTACK_HUB_URL` (the address it
1768
+ * already uses to reach the hub). The RTSP restream port differs from the
1769
+ * hub API port, but the HOST is the same — only the host is extracted.
1770
+ *
1771
+ * Returns `undefined` when neither yields a host (the caller logs and the pull
1772
+ * stays hub-local, which simply won't reach a remote node — never a crash).
1773
+ *
1774
+ * Pure: the env string is passed in (not read here) so it can be unit-tested.
1775
+ */
1776
+ function resolveRecordingHubHostname(override, hubUrl) {
1777
+ const trimmedOverride = override?.trim();
1778
+ if (trimmedOverride !== void 0 && trimmedOverride.length > 0) return trimmedOverride;
1779
+ return extractHost(hubUrl);
1780
+ }
1781
+ /** Extract the bare host from a URL/authority string, scheme + port optional. */
1782
+ function extractHost(raw) {
1783
+ const value = raw?.trim();
1784
+ if (value === void 0 || value.length === 0) return void 0;
1785
+ try {
1786
+ const parsed = new URL(value);
1787
+ if (parsed.hostname.length > 0) return parsed.hostname;
1788
+ } catch {}
1789
+ const authority = value.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "").split("/")[0] ?? "";
1790
+ if (authority.startsWith("[")) {
1791
+ const end = authority.indexOf("]");
1792
+ if (end > 0) return authority.slice(0, end + 1);
1793
+ }
1794
+ const host = authority.split(":")[0] ?? "";
1795
+ return host.length > 0 ? host : void 0;
1796
+ }
1797
+ //#endregion
1798
+ //#region src/recorder/addon/index.ts
1799
+ /**
1800
+ * recorder addon SHELL — recording + storage-evictable providers on the
1801
+ * unit-tested recorder core (`recorder/*.ts`).
1802
+ *
1803
+ * B1 scope (THIS file): the addon lifecycle + provider wiring with NO ffmpeg
1804
+ * and NO live recording. It resolves the `recordings` / `recordingsLow`
1805
+ * storage roots via the `storage` cap, hydrates the v2 `RecordingIndex` from
1806
+ * `storage.list` for every persisted-config device on boot, persists per-device
1807
+ * band config via durable-state, answers the `recording` cap reads from the
1808
+ * index, prunes footage via `selectEvictions` + `SegmentStore.evict`, and
1809
+ * mounts the v2 `StorageEvictableProvider` for disk-pressure eviction.
1810
+ *
1811
+ * DEFERRED to B2/B3 (search "B2/B3 HOOK" below for the exact insertion points):
1812
+ * - ffmpeg `SegmentWriter` (per-profile passthrough segmenter)
1813
+ * - segment watcher → `SegmentStore.onFinalized` (stat + relocate + index)
1814
+ * - `BandEngine` attach/detach on enable/disable (`activeBandAt`/`bandActiveAt`)
1815
+ * - `EventCapture` (motion/audio markers feeding events-mode bands)
1816
+ * - playback data-plane (getPlaybackManifest currently STUBs null)
1817
+ *
1818
+ * The old recorder (`addon-pipeline/src/recorder`) stays the live `recording`
1819
+ * provider until the controller swaps the manifest entry in a later step — this
1820
+ * file ships dormant (separate tree, not wired into the manifest yet).
1821
+ */
1822
+ var DEFAULT_CONFIG = {
1823
+ segmentSeconds: 4,
1824
+ watchIntervalMs: 2e3
1825
+ };
1826
+ /** Playback-marker window padding around a motion event (EventMap only; the
1827
+ * controller's events-band gating uses the raw trigger ms, not this pad). */
1828
+ var MARKER_PAD_PRE_MS = 2e3;
1829
+ var MARKER_PAD_POST_MS = 5e3;
1830
+ /** This addon's manifest id + the playback data-plane prefix — together they
1831
+ * form the hub reverse-proxy client path `/addon/recorder/playback`. */
1832
+ var RECORDER_ADDON_ID = "recorder";
1833
+ var PLAYBACK_PREFIX = "playback";
1834
+ /** Default designated recording node (`recordingNodeId` global setting). The
1835
+ * stream-broker is hub-only and recording is a single-node global function, so
1836
+ * the hub is the safe default — mirrors pipeline-analytics' post-processing
1837
+ * node gate. */
1838
+ var RECORDING_NODE_DEFAULT = "hub";
1839
+ /** Node the hub-only `stream-broker` runs on. Every recorder — on the hub or on
1840
+ * a designated agent — gates on, and pulls from, THIS node's broker. */
1841
+ var BROKER_HOST_NODE_ID = "hub";
1842
+ var RecorderV2Addon = class extends require_dist.BaseAddon {
1843
+ nodeId = "hub";
1844
+ /** True when THIS node is the designated recording node (`recordingNodeId`).
1845
+ * Every other node stays fully inert — registers no providers, serves no
1846
+ * playback, runs no controller. Resolved in `onInitialize`. */
1847
+ isRecordingNode = true;
1848
+ index = new RecordingIndex();
1849
+ segmentStore = null;
1850
+ /** Continuous + events band write-path controller (ffmpeg writers + watchers). */
1851
+ controller = null;
1852
+ /** Per-device motion/audio markers for playback (B3 events feed the controller). */
1853
+ eventMap = new EventMap();
1854
+ /** Unsubscribe handle for the EventCapture motion subscription. */
1855
+ eventUnsub = null;
1856
+ /** Resolved recordings locations (id + root). Refreshed on boot + rescan. */
1857
+ resolvedLocations = [];
1858
+ /** Base URL of the playback HTTP data-plane, null until served in onHubReachable. */
1859
+ playbackBaseUrl = null;
1860
+ constructor() {
1861
+ super({ ...DEFAULT_CONFIG });
1862
+ }
1863
+ async onInitialize() {
1864
+ const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
1865
+ this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
1866
+ const store = await this.ctx.settings?.readAddonStore() ?? {};
1867
+ const rawNode = store["recordingNodeId"];
1868
+ const designated = typeof rawNode === "string" && rawNode.length > 0 ? rawNode : RECORDING_NODE_DEFAULT;
1869
+ this.isRecordingNode = this.nodeId === designated;
1870
+ if (!this.isRecordingNode) {
1871
+ this.ctx.logger.info("recorder INERT on this node — recording assigned elsewhere", { meta: {
1872
+ ownNodeId: this.nodeId,
1873
+ designatedNode: designated
1874
+ } });
1875
+ return { providers: [] };
1876
+ }
1877
+ const rawHubHostname = store["recordingHubHostname"];
1878
+ const consumerHostname = this.nodeId === BROKER_HOST_NODE_ID ? void 0 : resolveRecordingHubHostname(typeof rawHubHostname === "string" ? rawHubHostname : void 0, process.env["CAMSTACK_HUB_URL"]);
1879
+ if (this.nodeId !== BROKER_HOST_NODE_ID) {
1880
+ this.ctx.logger.info("recorder is a REMOTE recording node — pulling broker streams cross-node", { meta: {
1881
+ ownNodeId: this.nodeId,
1882
+ brokerHost: BROKER_HOST_NODE_ID,
1883
+ consumerHostname: consumerHostname ?? "(unresolved)"
1884
+ } });
1885
+ if (consumerHostname === void 0) this.ctx.logger.warn("recorder: could not resolve the hub host for cross-node pull — set the `recordingHubHostname` global setting or CAMSTACK_HUB_URL; recording will not reach this node", { meta: { ownNodeId: this.nodeId } });
1886
+ }
1887
+ const segmentStoreDeps = {
1888
+ index: this.index,
1889
+ list: (input) => this.ctx.api.storage.list.query(input),
1890
+ deletePath: (input) => this.ctx.api.storage.delete.mutate(input),
1891
+ statBytes: async (absPath) => (await node_fs.promises.stat(absPath)).size,
1892
+ moveFile: async (fromAbs, toAbs) => {
1893
+ await node_fs.promises.mkdir(node_path.default.dirname(toAbs), { recursive: true });
1894
+ await node_fs.promises.rename(fromAbs, toAbs);
1895
+ },
1896
+ logger: { warn: (message, extras) => this.ctx.logger.warn(message, { meta: { extras } }) }
1897
+ };
1898
+ this.segmentStore = new SegmentStore(segmentStoreDeps);
1899
+ const tmpRoot = node_path.default.join(this.ctx.dataDir, "rec-tmp");
1900
+ const brokerHandle = this.ctx.useCapability("stream-broker", brokerScope(BROKER_HOST_NODE_ID));
1901
+ this.controller = new RecordingController({
1902
+ api: this.ctx.api,
1903
+ logger: this.ctx.logger,
1904
+ nodeId: this.nodeId,
1905
+ segmentStore: this.segmentStore,
1906
+ loadConfig: (deviceId) => loadDeviceConfig(this.configStore(), deviceId),
1907
+ enabledDeviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
1908
+ locations: () => this.resolvedLocations,
1909
+ tmpRoot,
1910
+ spawn: node_child_process.spawn,
1911
+ segmentSeconds: this.config.segmentSeconds,
1912
+ watchIntervalMs: this.config.watchIntervalMs,
1913
+ ...consumerHostname !== void 0 ? { consumerHostname } : {},
1914
+ onBrokerReady: (handler) => this.ctx.onCapabilityStateChange("stream-broker", brokerScope(BROKER_HOST_NODE_ID), (state) => {
1915
+ if (state === "ready") handler();
1916
+ }),
1917
+ brokerCall: makeBrokerCall(brokerHandle)
1918
+ });
1919
+ const recordingProvider = buildRecordingProvider({
1920
+ index: this.index,
1921
+ segmentStore: this.segmentStore,
1922
+ nodeId: this.nodeId,
1923
+ logger: this.ctx.logger,
1924
+ configStore: this.configStore(),
1925
+ locations: () => this.resolvedLocations,
1926
+ refreshLocations: () => this.refreshLocations(),
1927
+ capacity: (root) => this.locationCapacity(root),
1928
+ hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
1929
+ onConfigChanged: (deviceId) => this.controller?.onConfigChanged(deviceId) ?? Promise.resolve(),
1930
+ dataDir: this.ctx.dataDir,
1931
+ playbackBaseUrl: () => this.playbackBaseUrl
1932
+ });
1933
+ const evictableProvider = new StorageEvictableProvider({
1934
+ index: this.index,
1935
+ store: this.segmentStore
1936
+ });
1937
+ try {
1938
+ const handler = (0, _camstack_core.createFileDataPlaneHandler)({ getRoots: () => this.playbackRoots() });
1939
+ const served = await this.ctx.dataPlane?.serve({
1940
+ prefix: PLAYBACK_PREFIX,
1941
+ access: "authenticated",
1942
+ handler
1943
+ });
1944
+ this.playbackBaseUrl = served ? `/addon/${RECORDER_ADDON_ID}/${PLAYBACK_PREFIX}` : null;
1945
+ this.ctx.logger.info("recorder playback data-plane served", { meta: {
1946
+ clientBase: this.playbackBaseUrl ?? "(no dataPlane facility)",
1947
+ internal: served?.baseUrl
1948
+ } });
1949
+ } catch (err) {
1950
+ this.ctx.logger.warn("recorder playback data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
1951
+ }
1952
+ this.ctx.logger.info("recorder addon started (continuous write path active)", {
1953
+ tags: { nodeId: this.nodeId },
1954
+ meta: {
1955
+ segmentSeconds: this.config.segmentSeconds,
1956
+ watchIntervalMs: this.config.watchIntervalMs
1957
+ }
1958
+ });
1959
+ return { providers: [{
1960
+ capability: require_dist.recordingCapability,
1961
+ provider: recordingProvider
1962
+ }, {
1963
+ capability: require_dist.storageEvictableCapability,
1964
+ provider: evictableProvider
1965
+ }] };
1966
+ }
1967
+ async onShutdown() {
1968
+ this.eventUnsub?.();
1969
+ this.eventUnsub = null;
1970
+ await this.controller?.stop();
1971
+ this.controller = null;
1972
+ this.segmentStore = null;
1973
+ }
1974
+ /**
1975
+ * Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
1976
+ * recordings locations and rebuild the in-memory index from `storage.list`
1977
+ * for every device that has a persisted config. The list result is the
1978
+ * ground truth — segment paths encode start/dur/bytes, so the index can never
1979
+ * drift from disk.
1980
+ */
1981
+ async onHubReachable() {
1982
+ if (!this.isRecordingNode) return;
1983
+ this.resolvedLocations = await resolveRecordingsLocations(this.ctx.api, this.ctx.logger);
1984
+ if (this.resolvedLocations.length === 0) {
1985
+ this.ctx.logger.warn("recorder: no recordings storage locations resolved — index not hydrated");
1986
+ return;
1987
+ }
1988
+ let configs;
1989
+ try {
1990
+ configs = await readDeviceConfigs(this.configStore());
1991
+ } catch (err) {
1992
+ this.ctx.logger.warn("recorder: reading persisted device configs failed — skipping hydrate", { meta: { error: require_dist.errMsg(err) } });
1993
+ return;
1994
+ }
1995
+ for (const deviceId of configs.keys()) await hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, this.resolvedLocations, this.ctx.logger);
1996
+ this.ctx.logger.info("recorder: hydrated index from storage", { meta: {
1997
+ deviceCount: configs.size,
1998
+ locationCount: this.resolvedLocations.length
1999
+ } });
2000
+ const store = this.segmentStore;
2001
+ if (store) try {
2002
+ await runRedundancyJanitor({
2003
+ deviceIds: [...configs.keys()],
2004
+ segmentsFor: (deviceId, profile) => this.index.segments(deviceId, profile),
2005
+ evict: (location, rows) => store.evict(location, rows),
2006
+ logger: this.ctx.logger
2007
+ });
2008
+ } catch (err) {
2009
+ this.ctx.logger.warn("recorder: redundancy janitor failed", { meta: { error: require_dist.errMsg(err) } });
2010
+ }
2011
+ try {
2012
+ await this.controller?.start();
2013
+ } catch (err) {
2014
+ this.ctx.logger.warn("recorder: controller start failed", { meta: { error: require_dist.errMsg(err) } });
2015
+ }
2016
+ if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
2017
+ eventBus: this.ctx.eventBus,
2018
+ map: this.eventMap,
2019
+ padPreMs: MARKER_PAD_PRE_MS,
2020
+ padPostMs: MARKER_PAD_POST_MS,
2021
+ onMotion: (deviceId, atMs) => {
2022
+ this.controller?.onTrigger(deviceId, "motion", atMs);
2023
+ }
2024
+ });
2025
+ }
2026
+ /** Re-resolve recordings locations (rescanStorage picks up newly-added disks). */
2027
+ async refreshLocations() {
2028
+ this.resolvedLocations = await resolveRecordingsLocations(this.ctx.api, this.ctx.logger);
2029
+ return this.resolvedLocations;
2030
+ }
2031
+ /**
2032
+ * Roots the playback data-plane handler searches, IN ORDER: `<dataDir>/playback`
2033
+ * FIRST (the freshly-written master/variant `.m3u8` playlists), then every
2034
+ * recordings location root (the segment `.m4s` files at
2035
+ * `<root>/<deviceId>/<profile>/Y/M/D/H/<file>`). Playlists-first matters: the
2036
+ * OLD recorder left stale `<recordingsRoot>/<deviceId>/master.m3u8` playlists
2037
+ * that would otherwise shadow ours; segments never collide (only `.m4s` live in
2038
+ * the location roots, only `.m3u8` in the playback dir), so the two form one
2039
+ * served tree with our playlists authoritative.
2040
+ */
2041
+ playbackRoots() {
2042
+ return [node_path.default.join(this.ctx.dataDir, "playback"), ...this.resolvedLocations.map((l) => l.root)];
2043
+ }
2044
+ /** Free + total bytes on a location's volume (`statfs`); null when unstattable. */
2045
+ async locationCapacity(root) {
2046
+ try {
2047
+ const st = await node_fs.promises.statfs(root);
2048
+ return {
2049
+ availableBytes: st.bavail * st.bsize,
2050
+ totalBytes: st.blocks * st.bsize
2051
+ };
2052
+ } catch {
2053
+ return {
2054
+ availableBytes: null,
2055
+ totalBytes: null
2056
+ };
2057
+ }
2058
+ }
2059
+ /**
2060
+ * Global settings surfaced in the admin UI (and persisted to the addon
2061
+ * store). The cluster Recording tab drives `recordingNodeId` via a node
2062
+ * picker; `recordingHubHostname` is an advanced override for cross-node
2063
+ * pulls. Both take effect on addon restart (read once in `onInitialize`).
2064
+ */
2065
+ globalSettingsSchema() {
2066
+ return this.schema({ sections: [{
2067
+ id: "cluster-recording",
2068
+ title: "Cluster recording",
2069
+ description: "Which node runs recording (continuous + events write path). A SINGLE node — no multi-node balancing; every other node stays fully inert. Default: hub.",
2070
+ columns: 1,
2071
+ fields: [{
2072
+ type: "text",
2073
+ key: "recordingNodeId",
2074
+ label: "Recording node",
2075
+ description: "Node id that records + serves playback (e.g. \"hub\"). Leave \"hub\" unless you intentionally move recording to an agent. Takes effect on addon restart.",
2076
+ default: RECORDING_NODE_DEFAULT
2077
+ }, {
2078
+ type: "text",
2079
+ key: "recordingHubHostname",
2080
+ label: "Hub host (cross-node)",
2081
+ description: "Advanced: only used when the recording node is an AGENT. The hub's host the agent dials to pull broker streams (the RTSP restream port must be reachable there). Leave empty to auto-derive from the agent's hub connection.",
2082
+ default: ""
2083
+ }]
2084
+ }] });
2085
+ }
2086
+ /** The addon-store slice config-store reads/writes the deviceId→config blob on. */
2087
+ configStore() {
2088
+ const settings = this.ctx.settings;
2089
+ if (!settings) throw new Error("RecorderV2Addon: ctx.settings unavailable — cannot persist recording config");
2090
+ return {
2091
+ readAddonStore: () => settings.readAddonStore(),
2092
+ writeAddonStore: (patch) => settings.writeAddonStore(patch)
2093
+ };
2094
+ }
2095
+ };
2096
+ //#endregion
2097
+ module.exports = RecorderV2Addon;