@camstack/addon-pipeline 0.1.20 → 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.
- package/dist/audio-analyzer/index.js +736 -719
- package/dist/audio-analyzer/index.mjs +726 -679
- package/dist/audio-codec-nodeav/index.js +304 -461
- package/dist/audio-codec-nodeav/index.mjs +300 -462
- package/dist/chunk-BdkLduGY.mjs +5 -0
- package/dist/chunk-D6vf50IK.js +28 -0
- package/dist/codec-runtime-BOk-13PN.js +202 -0
- package/dist/codec-runtime-BsqlEjPi.mjs +197 -0
- package/dist/constants-B_b0a-6h.mjs +3119 -0
- package/dist/{index-CMcx_k6Y.js → constants-D65v6yp6.js} +3107 -2935
- package/dist/decoder-nodeav/index.js +1374 -1444
- package/dist/decoder-nodeav/index.mjs +1369 -1425
- package/dist/detection-pipeline/index.js +6462 -5613
- package/dist/detection-pipeline/index.mjs +6451 -5574
- package/dist/dist-7ewQjTle.js +22454 -0
- package/dist/dist-C5jnNl0n.mjs +22089 -0
- package/dist/motion-wasm/index.js +469 -467
- package/dist/motion-wasm/index.mjs +464 -446
- package/dist/pipeline-runner/index.js +2029 -1827
- package/dist/pipeline-runner/index.mjs +2025 -1811
- package/dist/recorder/index.js +2045 -2157
- package/dist/recorder/index.mjs +2042 -2156
- package/dist/stream-broker/_stub.js +1806 -1352
- package/dist/stream-broker/_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D4-DHanK.mjs +156 -0
- 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
- 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
- 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
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.js-C9j-2lBe.mjs +26 -0
- 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
- package/dist/stream-broker/dist-CYZr2fwk.mjs +2726 -0
- package/dist/stream-broker/hostInit-Di6vceAU.mjs +129 -0
- package/dist/stream-broker/index.js +17778 -15470
- package/dist/stream-broker/index.mjs +17769 -15465
- package/dist/stream-broker/remoteEntry.js +134 -2973
- package/dist/stream-broker/remoteEntry.ssr.js +33 -0
- package/dist/stream-broker/virtualExposes-dYNvIwoR.mjs +27 -0
- package/dist/stream-broker/virtual_mf-exposes-ssr___mfe_internal__addon_stream_broker_widgets__remoteEntry_js-Cmqfp4i_.mjs +10 -0
- package/embed-dist/assets/index-B8VlSD0-.js +150 -0
- package/embed-dist/assets/index-ZhDdp1Nd.css +2 -0
- package/embed-dist/index.html +13 -0
- package/package.json +25 -7
- package/wasm/assembly/index.ts +41 -16
- package/dist/audio-analyzer/index.js.map +0 -1
- package/dist/audio-analyzer/index.mjs.map +0 -1
- package/dist/audio-codec-nodeav/index.js.map +0 -1
- package/dist/audio-codec-nodeav/index.mjs.map +0 -1
- package/dist/decoder-nodeav/index.js.map +0 -1
- package/dist/decoder-nodeav/index.mjs.map +0 -1
- package/dist/detection-pipeline/index.js.map +0 -1
- package/dist/detection-pipeline/index.mjs.map +0 -1
- package/dist/index-5aYef068.mjs +0 -17514
- package/dist/index-5aYef068.mjs.map +0 -1
- package/dist/index-B36NMAdu.js +0 -17513
- package/dist/index-B36NMAdu.js.map +0 -1
- package/dist/index-CMcx_k6Y.js.map +0 -1
- package/dist/index-CYb7cFrv.mjs +0 -5790
- package/dist/index-CYb7cFrv.mjs.map +0 -1
- package/dist/motion-wasm/index.js.map +0 -1
- package/dist/motion-wasm/index.mjs.map +0 -1
- package/dist/pipeline-runner/index.js.map +0 -1
- package/dist/pipeline-runner/index.mjs.map +0 -1
- package/dist/recorder/index.js.map +0 -1
- package/dist/recorder/index.mjs.map +0 -1
- package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/FfmpegParamsField.d.ts +0 -41
- package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/GeometryBuilder.d.ts +0 -54
- package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/StreamBrokerPanel.d.ts +0 -21
- package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/format-ua.d.ts +0 -13
- package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/index.d.ts +0 -15
- package/dist/stream-broker/@mf-types/widgets.d.ts +0 -2
- package/dist/stream-broker/@mf-types.d.ts +0 -3
- package/dist/stream-broker/@mf-types.zip +0 -0
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-lantnv8e.mjs +0 -12
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-DJ3UNg7O.mjs +0 -30
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-CYXy_bhS.mjs +0 -21
- 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
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_trpc_mf_1_client__loadShare__.mjs-DeouEaSs.mjs +0 -85
- 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
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-CaDEYBIU.mjs +0 -89
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-D6EROtlA.mjs +0 -29
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-x6pP3Ghk.mjs +0 -36
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs-DYEKzzY-.mjs +0 -45
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-CcnN6sbA.mjs +0 -6
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-DICOtMTl.mjs +0 -34
- package/dist/stream-broker/_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CL9DR49k.mjs +0 -156
- package/dist/stream-broker/client-BvTmMOQu.mjs +0 -9836
- package/dist/stream-broker/getErrorShape-BPSzUA7W-TlK8ipWe.mjs +0 -211
- package/dist/stream-broker/hostInit-ChmiMPS0.mjs +0 -168
- package/dist/stream-broker/index-BxsFuFmE.mjs +0 -2603
- package/dist/stream-broker/index-C-248uOU.mjs +0 -725
- package/dist/stream-broker/index-C05B6jqp.mjs +0 -185
- package/dist/stream-broker/index-CWkKuNLr.mjs +0 -232
- package/dist/stream-broker/index-DOJoSShD.mjs +0 -67784
- package/dist/stream-broker/index-DtOI1aTU.mjs +0 -18504
- package/dist/stream-broker/index-oMq6ilgR.mjs +0 -1641
- package/dist/stream-broker/index-vIWZQBIL.mjs +0 -435
- package/dist/stream-broker/index-xncRG7-x.mjs +0 -2713
- package/dist/stream-broker/index.js.map +0 -1
- package/dist/stream-broker/index.mjs.map +0 -1
- package/dist/stream-broker/jsx-runtime-BRT_HL0A.mjs +0 -55
- package/dist/stream-broker/schemas-B7L0qZtq.mjs +0 -3599
- package/dist/stream-broker/virtualExposes-pCd777Rp.mjs +0 -42
package/dist/recorder/index.js
CHANGED
|
@@ -1,2209 +1,2097 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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`;
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
};
|
|
16
26
|
}
|
|
17
|
-
|
|
18
|
-
|
|
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;
|
|
19
52
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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;
|
|
32
304
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
segs = [];
|
|
53
|
-
profiles.set(profile, segs);
|
|
54
|
-
}
|
|
55
|
-
if (segs.some((s) => s.startMs === startMs)) return;
|
|
56
|
-
segs.push({ startMs, durMs, bytes, locationId, subtree });
|
|
57
|
-
segs.sort((a, b) => a.startMs - b.startMs);
|
|
58
|
-
}
|
|
59
|
-
remove(camera, profile, startMs) {
|
|
60
|
-
const segs = this.byCam.get(camera)?.get(profile);
|
|
61
|
-
if (!segs) return;
|
|
62
|
-
const i = segs.findIndex((s) => s.startMs === startMs);
|
|
63
|
-
if (i >= 0) segs.splice(i, 1);
|
|
64
|
-
}
|
|
65
|
-
/** Profiles that have at least one segment for this camera. */
|
|
66
|
-
profiles(camera) {
|
|
67
|
-
return [...this.byCam.get(camera)?.keys() ?? []];
|
|
68
|
-
}
|
|
69
|
-
/** Camera keys with at least one indexed segment. */
|
|
70
|
-
cameras() {
|
|
71
|
-
return [...this.byCam.keys()];
|
|
72
|
-
}
|
|
73
|
-
totalBytes(camera) {
|
|
74
|
-
let total = 0;
|
|
75
|
-
for (const segs of this.byCam.get(camera)?.values() ?? []) {
|
|
76
|
-
for (const s of segs) total += s.bytes;
|
|
77
|
-
}
|
|
78
|
-
return total;
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Raw segments for a single (camera,profile) overlapping [fromMs,toMs],
|
|
82
|
-
* sorted ascending by start. Unlike `availability` (which merges
|
|
83
|
-
* contiguous segments into ranges) this returns the individual
|
|
84
|
-
* fragments — the input needed to build a VOD variant playlist.
|
|
85
|
-
*/
|
|
86
|
-
listSegments(camera, profile, fromMs, toMs) {
|
|
87
|
-
const segs = this.byCam.get(camera)?.get(profile);
|
|
88
|
-
if (!segs) return [];
|
|
89
|
-
const out = [];
|
|
90
|
-
for (const s of segs) {
|
|
91
|
-
const segEnd = s.startMs + s.durMs;
|
|
92
|
-
if (segEnd <= fromMs || s.startMs >= toMs) continue;
|
|
93
|
-
out.push({ startMs: s.startMs, durMs: s.durMs, locationId: s.locationId, subtree: s.subtree });
|
|
94
|
-
}
|
|
95
|
-
return out;
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Aggregate this camera's segments into UTC hour buckets — the unit
|
|
99
|
-
* retention deletes. Each bucket carries its dir rel path, hour-start ms,
|
|
100
|
-
* summed bytes (the RAM size counter the retention math prunes on), the
|
|
101
|
-
* storage `locationId` its segments live on, and the `subtree` (continuous vs
|
|
102
|
-
* events). Each distinct (subtree, hour, location) is a separate bucket — so
|
|
103
|
-
* mixed combinations split apart and each is `rm`'d from the right root under
|
|
104
|
-
* its own per-subtree retention policy. Buckets are per profile, ascending.
|
|
105
|
-
*/
|
|
106
|
-
hourBuckets(camera) {
|
|
107
|
-
const out = [];
|
|
108
|
-
for (const [profile, segs] of this.byCam.get(camera) ?? []) {
|
|
109
|
-
const byBucket = /* @__PURE__ */ new Map();
|
|
110
|
-
for (const s of segs) {
|
|
111
|
-
const hourStartMs = Math.floor(s.startMs / HOUR_MS$1) * HOUR_MS$1;
|
|
112
|
-
const key = `${s.subtree} ${hourStartMs} ${s.locationId ?? ""}`;
|
|
113
|
-
const cur = byBucket.get(key);
|
|
114
|
-
if (cur) {
|
|
115
|
-
cur.bytes += s.bytes;
|
|
116
|
-
cur.count += 1;
|
|
117
|
-
} else byBucket.set(key, { hourStartMs, locationId: s.locationId, subtree: s.subtree, bytes: s.bytes, count: 1 });
|
|
118
|
-
}
|
|
119
|
-
for (const b of byBucket.values()) {
|
|
120
|
-
out.push({
|
|
121
|
-
relPath: `${camera}/${profile}/${b.subtree}/${hourBucketRelPath(b.hourStartMs)}`,
|
|
122
|
-
hourStartMs: b.hourStartMs,
|
|
123
|
-
bytes: b.bytes,
|
|
124
|
-
segmentCount: b.count,
|
|
125
|
-
locationId: b.locationId,
|
|
126
|
-
subtree: b.subtree
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return out;
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* Drop every segment in the hour bucket named by `relPath`
|
|
134
|
-
* (`<camera>/<profile>/<subtree>/<Y>/<M>/<D>/<H>`) whose `locationId` matches
|
|
135
|
-
* (exact, incl. `undefined === undefined` for legacy single-root segments).
|
|
136
|
-
* Scoping by both subtree (in the path) and location keeps the hour's
|
|
137
|
-
* other-subtree / other-location segments intact when one bucket is `rm`'d.
|
|
138
|
-
* Returns the count removed; a non-bucket or unknown path is a no-op. The
|
|
139
|
-
* on-disk dir is removed by the sweeper — this keeps the size counter in sync.
|
|
140
|
-
*/
|
|
141
|
-
removeBucketByRelPath(relPath, locationId) {
|
|
142
|
-
const parts = relPath.split("/");
|
|
143
|
-
if (parts.length !== 7) return 0;
|
|
144
|
-
const [camera, profile, subtree, y, mo, d, h] = parts;
|
|
145
|
-
if (subtree !== "continuous" && subtree !== "events") return 0;
|
|
146
|
-
const hourStartMs = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h));
|
|
147
|
-
if (Number.isNaN(hourStartMs)) return 0;
|
|
148
|
-
const segs = this.byCam.get(camera)?.get(profile);
|
|
149
|
-
if (!segs) return 0;
|
|
150
|
-
let removed = 0;
|
|
151
|
-
for (let i = segs.length - 1; i >= 0; i--) {
|
|
152
|
-
const s = segs[i];
|
|
153
|
-
if (Math.floor(s.startMs / HOUR_MS$1) * HOUR_MS$1 === hourStartMs && s.locationId === locationId && s.subtree === subtree) {
|
|
154
|
-
segs.splice(i, 1);
|
|
155
|
-
removed++;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return removed;
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* Un-retained segments (the modes-engine policy reaper's work list). A
|
|
162
|
-
* segment stays a candidate until {@link markRetained} confirms it falls in a
|
|
163
|
-
* trigger window — then the reaper leaves it alone (only retention prunes it).
|
|
164
|
-
*/
|
|
165
|
-
policyCandidates(camera) {
|
|
166
|
-
const out = [];
|
|
167
|
-
for (const [profile, segs] of this.byCam.get(camera) ?? []) {
|
|
168
|
-
for (const s of segs) {
|
|
169
|
-
if (s.retained) continue;
|
|
170
|
-
out.push({ profile, startMs: s.startMs, durMs: s.durMs, endMs: s.startMs + s.durMs, locationId: s.locationId, subtree: s.subtree });
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return out;
|
|
174
|
-
}
|
|
175
|
-
/** Mark a segment as policy-retained so the reaper stops reconsidering it. */
|
|
176
|
-
markRetained(camera, profile, startMs) {
|
|
177
|
-
const seg = this.byCam.get(camera)?.get(profile)?.find((s) => s.startMs === startMs);
|
|
178
|
-
if (seg) seg.retained = true;
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* The earliest `startMs` of any segment for this camera across all profiles
|
|
182
|
-
* and subtrees, or `null` when the camera has no indexed segments.
|
|
183
|
-
*/
|
|
184
|
-
oldestStartMs(camera) {
|
|
185
|
-
let oldest = null;
|
|
186
|
-
for (const segs of this.byCam.get(camera)?.values() ?? []) {
|
|
187
|
-
for (const s of segs) {
|
|
188
|
-
if (oldest === null || s.startMs < oldest) oldest = s.startMs;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return oldest;
|
|
192
|
-
}
|
|
193
|
-
/** Merged availability ranges overlapping [fromMs,toMs], per profile. */
|
|
194
|
-
availability(camera, fromMs, toMs) {
|
|
195
|
-
const out = [];
|
|
196
|
-
for (const [profile, segs] of this.byCam.get(camera) ?? []) {
|
|
197
|
-
let cur = null;
|
|
198
|
-
for (const s of segs) {
|
|
199
|
-
const segEnd = s.startMs + s.durMs;
|
|
200
|
-
if (segEnd <= fromMs || s.startMs >= toMs) {
|
|
201
|
-
if (cur) {
|
|
202
|
-
out.push(cur);
|
|
203
|
-
cur = null;
|
|
204
|
-
}
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
if (cur && s.startMs === cur.endMs) {
|
|
208
|
-
cur.endMs = segEnd;
|
|
209
|
-
} else {
|
|
210
|
-
if (cur) out.push(cur);
|
|
211
|
-
cur = { profile, startMs: s.startMs, endMs: segEnd };
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (cur) out.push(cur);
|
|
215
|
-
}
|
|
216
|
-
return out;
|
|
217
|
-
}
|
|
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;
|
|
218
324
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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;
|
|
246
377
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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;
|
|
250
390
|
}
|
|
251
|
-
function
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
};
|
|
260
424
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
+
};
|
|
269
461
|
}
|
|
270
|
-
function
|
|
271
|
-
|
|
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
|
+
});
|
|
272
473
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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;
|
|
284
489
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
playbackIndex = new RamIndex();
|
|
296
|
-
/** Replace the playback index from a fresh disk scan (boot + periodic). */
|
|
297
|
-
setPlaybackIndex(entries) {
|
|
298
|
-
this.playbackIndex = RamIndex.fromScan(entries);
|
|
299
|
-
}
|
|
300
|
-
/** The index that answers queries for a camera: its live one if attached,
|
|
301
|
-
* else the scanned playback index. */
|
|
302
|
-
indexFor(deviceId) {
|
|
303
|
-
return this.cameras.get(deviceId)?.index ?? this.playbackIndex;
|
|
304
|
-
}
|
|
305
|
-
/**
|
|
306
|
-
* Start continuous recording for a camera: one passthrough writer per
|
|
307
|
-
* profile, each pulling its resolved RTSP url into a per-profile output
|
|
308
|
-
* directory. Idempotent-ish — re-attaching a live camera detaches first.
|
|
309
|
-
*/
|
|
310
|
-
async attach(input) {
|
|
311
|
-
if (input.profiles.length === 0) {
|
|
312
|
-
throw new Error(`RecorderCore.attach: no profiles for device ${input.deviceId}`);
|
|
313
|
-
}
|
|
314
|
-
if (this.cameras.has(input.deviceId)) await this.detach(input.deviceId);
|
|
315
|
-
const index2 = new RamIndex();
|
|
316
|
-
const writers = [];
|
|
317
|
-
const pipelineKeys = [];
|
|
318
|
-
for (const profile of input.profiles) {
|
|
319
|
-
const source = await this.deps.acquireSource(input.deviceId, profile);
|
|
320
|
-
const outDir = input.outDirs?.[profile] ?? `${this.deps.dataDir}/${input.deviceId}/${profile}`;
|
|
321
|
-
const writer = this.deps.makeWriter({ rtspUrl: source.url, outDir, segmentSeconds: input.segmentSeconds });
|
|
322
|
-
writer.start();
|
|
323
|
-
writers.push(writer);
|
|
324
|
-
pipelineKeys.push(source.pipelineKey);
|
|
325
|
-
}
|
|
326
|
-
this.cameras.set(input.deviceId, {
|
|
327
|
-
index: index2,
|
|
328
|
-
writers,
|
|
329
|
-
profiles: [...input.profiles],
|
|
330
|
-
pipelineKeys,
|
|
331
|
-
rules: input.rules ? [...input.rules] : [],
|
|
332
|
-
triggers: {}
|
|
333
|
-
});
|
|
334
|
-
this.deps.logger.info("recorder attached", {
|
|
335
|
-
meta: {
|
|
336
|
-
deviceId: input.deviceId,
|
|
337
|
-
profiles: input.profiles,
|
|
338
|
-
segmentSeconds: input.segmentSeconds,
|
|
339
|
-
ruleCount: input.rules?.length ?? 0
|
|
340
|
-
}
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
/**
|
|
344
|
-
* Record a motion / audio trigger for a camera (drives `onMotion` /
|
|
345
|
-
* `onAudioThreshold` rules). The caller applies the threshold test (motion
|
|
346
|
-
* happened / audio over `thresholdDbfs`) before calling — the core only
|
|
347
|
-
* stores the latest qualifying timestamp. No-op for an unattached camera.
|
|
348
|
-
*/
|
|
349
|
-
recordTrigger(deviceId, kind, tsMs) {
|
|
350
|
-
const cam = this.cameras.get(deviceId);
|
|
351
|
-
if (!cam) return;
|
|
352
|
-
if (kind === "motion") cam.triggers.lastMotionMs = tsMs;
|
|
353
|
-
else cam.triggers.lastAudioMs = tsMs;
|
|
354
|
-
}
|
|
355
|
-
/**
|
|
356
|
-
* Feed an audio level (dBFS) sample for a camera. Records an audio trigger
|
|
357
|
-
* iff some `onAudioThreshold` rule's `thresholdDbfs` is met (louder = dBFS
|
|
358
|
-
* closer to 0, so `dbfs >= thresholdDbfs`). The threshold lives on the rule,
|
|
359
|
-
* so the comparison stays here rather than in the event-bus glue. No-op for an
|
|
360
|
-
* unattached camera or one with no audio rule.
|
|
361
|
-
*/
|
|
362
|
-
onAudioLevel(deviceId, dbfs, tsMs) {
|
|
363
|
-
const cam = this.cameras.get(deviceId);
|
|
364
|
-
if (!cam) return;
|
|
365
|
-
const over = cam.rules.some(
|
|
366
|
-
(r) => r.mode === "onAudioThreshold" && r.thresholdDbfs != null && dbfs >= r.thresholdDbfs
|
|
367
|
-
);
|
|
368
|
-
if (over) cam.triggers.lastAudioMs = tsMs;
|
|
369
|
-
}
|
|
370
|
-
/**
|
|
371
|
-
* Decide whether a finalized segment `[segStartMs, segEndMs]` should be
|
|
372
|
-
* kept (relocated into the permanent tree + indexed) or discarded. No rules
|
|
373
|
-
* = continuous (always keep). Trigger modes keep only when recording is
|
|
374
|
-
* demanded at the segment's end (within `postBufferSec` of a trigger).
|
|
375
|
-
* `preBufferSec` (retroactive retain BEFORE a trigger) is NOT handled here —
|
|
376
|
-
* that's increment B. Unknown camera → discard.
|
|
377
|
-
*/
|
|
378
|
-
shouldRetainSegment(deviceId, _segStartMs, segEndMs, clock) {
|
|
379
|
-
const cam = this.cameras.get(deviceId);
|
|
380
|
-
if (!cam) return false;
|
|
381
|
-
if (cam.rules.length === 0) return true;
|
|
382
|
-
return isRecordingDemanded(cam.rules, clock, cam.triggers, segEndMs);
|
|
383
|
-
}
|
|
384
|
-
/** Mark an indexed segment policy-retained (reaper stops reconsidering it). */
|
|
385
|
-
markSegmentRetained(deviceId, profile, startMs) {
|
|
386
|
-
this.cameras.get(deviceId)?.index.markRetained(String(deviceId), profile, startMs);
|
|
387
|
-
}
|
|
388
|
-
/**
|
|
389
|
-
* Policy reaper — the pre/post-buffer enforcement (modes engine increment B).
|
|
390
|
-
* For every rules-camera, walk its un-retained (candidate) segments:
|
|
391
|
-
* - inside a trigger window (incl. `preBufferSec` BEFORE a trigger) → mark
|
|
392
|
-
* retained so it survives (only retention prunes it after);
|
|
393
|
-
* - else, once the segment is older than the longest `preBufferSec` (so a
|
|
394
|
-
* late trigger can no longer claim it retroactively) → delete it;
|
|
395
|
-
* - otherwise hold it — re-checked next sweep.
|
|
396
|
-
* Cameras with NO rules are pure-continuous: skipped here (retention-only).
|
|
397
|
-
*/
|
|
398
|
-
async sweepPolicy(toClock, nowMs, deleteSegment) {
|
|
399
|
-
for (const [deviceId, cam] of this.cameras) {
|
|
400
|
-
if (cam.rules.length === 0) continue;
|
|
401
|
-
const maxPreBufferMs = Math.max(0, ...cam.rules.map((r) => r.preBufferSec)) * 1e3;
|
|
402
|
-
const camKey = String(deviceId);
|
|
403
|
-
for (const c of cam.index.policyCandidates(camKey)) {
|
|
404
|
-
if (isSegmentDemanded(cam.rules, toClock(c.endMs), cam.triggers, c.startMs, c.endMs)) {
|
|
405
|
-
cam.index.markRetained(camKey, c.profile, c.startMs);
|
|
406
|
-
} else if (c.endMs + maxPreBufferMs <= nowMs) {
|
|
407
|
-
try {
|
|
408
|
-
await deleteSegment(deviceId, c.profile, c.startMs, c.durMs, c.locationId, c.subtree);
|
|
409
|
-
cam.index.remove(camKey, c.profile, c.startMs);
|
|
410
|
-
} catch (err) {
|
|
411
|
-
this.deps.logger.warn("recorder policy reap rm failed", {
|
|
412
|
-
tags: { deviceId },
|
|
413
|
-
meta: { deviceId, profile: c.profile, startMs: c.startMs, error: String(err) }
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
/** Record a finalized segment into the camera's in-memory index. `locationId`
|
|
421
|
-
* is the storage location its file was written to; `subtree` the
|
|
422
|
-
* continuous/events subtree it was relocated into (per-subtree retention +
|
|
423
|
-
* path resolution). */
|
|
424
|
-
onSegmentFinalized(deviceId, profile, startMs, durMs, bytes, locationId, subtree = "continuous") {
|
|
425
|
-
this.cameras.get(deviceId)?.index.add(String(deviceId), profile, startMs, durMs, bytes, locationId, subtree);
|
|
426
|
-
}
|
|
427
|
-
/**
|
|
428
|
-
* Seed a camera's live index with pre-existing on-disk segments discovered by
|
|
429
|
-
* a sized scan. Idempotent: `RamIndex.add` already dedupes by `(profile,
|
|
430
|
-
* startMs)` — re-seeding or a concurrent `onSegmentFinalized` for the same
|
|
431
|
-
* start timestamp is silently ignored, so there is no double-count.
|
|
432
|
-
*
|
|
433
|
-
* MUST be called AFTER `core.attach(deviceId, …)` so the seed lands in the
|
|
434
|
-
* camera's live index. If the camera is not attached (i.e. `attach` was never
|
|
435
|
-
* called or already detached), this is a no-op — warn and return.
|
|
436
|
-
*/
|
|
437
|
-
seedIndexFromScan(deviceId, entries) {
|
|
438
|
-
const cam = this.cameras.get(deviceId);
|
|
439
|
-
if (!cam) {
|
|
440
|
-
this.deps.logger.warn("recorder seedIndexFromScan: device not attached, skipping seed", { tags: { deviceId }, meta: { deviceId } });
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
const camKey = String(deviceId);
|
|
444
|
-
for (const e of entries) {
|
|
445
|
-
cam.index.add(camKey, e.profile, e.startMs, e.durMs, e.bytes, e.locationId, e.subtree);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
/** Raw segments for one (camera,profile) overlapping [fromMs,toMs]. Falls
|
|
449
|
-
* back to the scanned playback index when the camera isn't attached. Each
|
|
450
|
-
* ref carries its `locationId` + `subtree` so playback resolves the path. */
|
|
451
|
-
listSegments(deviceId, profile, fromMs, toMs) {
|
|
452
|
-
return this.indexFor(deviceId).listSegments(String(deviceId), profile, fromMs, toMs);
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
|
-
* Classify a finalized segment ending at `segEndMs` into its on-disk subtree:
|
|
456
|
-
* `continuous` when continuous recording is active at that time (a continuous
|
|
457
|
-
* rule's schedule applies, or the camera has NO rules = pure continuous),
|
|
458
|
-
* else `events` (kept only by a motion/audio trigger). Drives where the
|
|
459
|
-
* watcher relocates the segment and how it's retained. Unknown camera →
|
|
460
|
-
* `continuous` (harmless default; an unattached camera never finalizes).
|
|
461
|
-
*/
|
|
462
|
-
classifySubtree(deviceId, _segEndMs, clock) {
|
|
463
|
-
const cam = this.cameras.get(deviceId);
|
|
464
|
-
if (!cam || cam.rules.length === 0) return "continuous";
|
|
465
|
-
return isContinuousActive(cam.rules, clock) ? "continuous" : "events";
|
|
466
|
-
}
|
|
467
|
-
/** Profiles this camera has recordings for — live profiles if attached, else
|
|
468
|
-
* whatever the disk scan found. */
|
|
469
|
-
getProfiles(deviceId) {
|
|
470
|
-
const cam = this.cameras.get(deviceId);
|
|
471
|
-
return cam ? cam.profiles : this.playbackIndex.profiles(String(deviceId));
|
|
472
|
-
}
|
|
473
|
-
getAvailability(deviceId, fromMs, toMs) {
|
|
474
|
-
return { deviceId, ranges: this.indexFor(deviceId).availability(String(deviceId), fromMs, toMs) };
|
|
475
|
-
}
|
|
476
|
-
getStatus(deviceId) {
|
|
477
|
-
const cam = this.cameras.get(deviceId);
|
|
478
|
-
const enabled = (cam?.writers.length ?? 0) > 0;
|
|
479
|
-
return {
|
|
480
|
-
deviceId,
|
|
481
|
-
enabled,
|
|
482
|
-
activeMode: enabled ? "continuous" : "off",
|
|
483
|
-
nodeId: this.deps.nodeId,
|
|
484
|
-
storageBytes: this.indexFor(deviceId).totalBytes(String(deviceId))
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
/**
|
|
488
|
-
* Prune aged-out / over-cap hour buckets for every attached camera, applying
|
|
489
|
-
* the `continuous` and `events` subtree policies INDEPENDENTLY (spec §6 —
|
|
490
|
-
* event footage usually outlives continuous).
|
|
491
|
-
*
|
|
492
|
-
* Per camera: aggregate the RAM index into hour buckets, split them by
|
|
493
|
-
* subtree, ask {@link planRetention} which to delete using that subtree's
|
|
494
|
-
* policy, `rm` each bucket dir, and — only on a successful `rm` — drop its
|
|
495
|
-
* segments from the index so the storage counter stays truthful. A failed
|
|
496
|
-
* `rm` leaves the bucket in the index for the next sweep to retry (no silent
|
|
497
|
-
* byte-counter drift). Pure decision, injected I/O.
|
|
498
|
-
*/
|
|
499
|
-
async sweepRetention(resolvePolicy, nowMs, removeBucketDir) {
|
|
500
|
-
const results = [];
|
|
501
|
-
for (const [deviceId, cam] of this.cameras) {
|
|
502
|
-
const { deletedBuckets, reclaimedBytes } = await this.pruneSubtrees(
|
|
503
|
-
deviceId,
|
|
504
|
-
cam.index,
|
|
505
|
-
resolvePolicy,
|
|
506
|
-
nowMs,
|
|
507
|
-
removeBucketDir,
|
|
508
|
-
"recorder retention rm failed"
|
|
509
|
-
);
|
|
510
|
-
results.push({ deviceId, deletedBuckets, reclaimedBytes });
|
|
511
|
-
}
|
|
512
|
-
return results;
|
|
513
|
-
}
|
|
514
|
-
/**
|
|
515
|
-
* Shared per-device retention prune used by {@link sweepRetention} and
|
|
516
|
-
* {@link pruneFootageForDevice}: split the camera's hour buckets by subtree,
|
|
517
|
-
* `rm` each doomed bucket, sync the index only on a successful `rm`, and emit
|
|
518
|
-
* one structured `recorder eviction` INFO line per (subtree, reason) group
|
|
519
|
-
* that actually deleted something — carrying the reason, segment + byte
|
|
520
|
-
* totals, and the policy condition that triggered the eviction.
|
|
521
|
-
*/
|
|
522
|
-
async pruneSubtrees(deviceId, index2, resolvePolicy, nowMs, removeBucketDir, rmFailedMsg) {
|
|
523
|
-
const camKey = String(deviceId);
|
|
524
|
-
let deletedBuckets = 0;
|
|
525
|
-
let reclaimedBytes = 0;
|
|
526
|
-
const bySubtree = /* @__PURE__ */ new Map();
|
|
527
|
-
for (const b of index2.hourBuckets(camKey)) {
|
|
528
|
-
const st = b.subtree ?? "continuous";
|
|
529
|
-
const arr = bySubtree.get(st);
|
|
530
|
-
if (arr) arr.push(b);
|
|
531
|
-
else bySubtree.set(st, [b]);
|
|
532
|
-
}
|
|
533
|
-
for (const [subtree, buckets] of bySubtree) {
|
|
534
|
-
const policy = resolvePolicy(deviceId, subtree);
|
|
535
|
-
const usedBytesBefore = index2.totalBytes(camKey);
|
|
536
|
-
const plan = planRetention(buckets, policy, nowMs);
|
|
537
|
-
const byReason = /* @__PURE__ */ new Map();
|
|
538
|
-
for (const b of plan.deleteBuckets) {
|
|
539
|
-
try {
|
|
540
|
-
await removeBucketDir(b.relPath, b.locationId);
|
|
541
|
-
} catch (err) {
|
|
542
|
-
this.deps.logger.warn(rmFailedMsg, {
|
|
543
|
-
tags: { deviceId },
|
|
544
|
-
meta: { deviceId, relPath: b.relPath, locationId: b.locationId, error: String(err) }
|
|
545
|
-
});
|
|
546
|
-
continue;
|
|
547
|
-
}
|
|
548
|
-
const before = index2.totalBytes(camKey);
|
|
549
|
-
index2.removeBucketByRelPath(b.relPath, b.locationId);
|
|
550
|
-
const reclaimed = before - index2.totalBytes(camKey);
|
|
551
|
-
reclaimedBytes += reclaimed;
|
|
552
|
-
deletedBuckets++;
|
|
553
|
-
const prev = byReason.get(b.reason);
|
|
554
|
-
byReason.set(b.reason, {
|
|
555
|
-
deletedBuckets: (prev?.deletedBuckets ?? 0) + 1,
|
|
556
|
-
deletedSegments: (prev?.deletedSegments ?? 0) + b.segmentCount,
|
|
557
|
-
reclaimedBytes: (prev?.reclaimedBytes ?? 0) + reclaimed
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
for (const [reason, tally] of byReason) {
|
|
561
|
-
this.deps.logger.info("recorder eviction", {
|
|
562
|
-
tags: { deviceId },
|
|
563
|
-
meta: {
|
|
564
|
-
deviceId,
|
|
565
|
-
reason,
|
|
566
|
-
subtree,
|
|
567
|
-
deletedBuckets: tally.deletedBuckets,
|
|
568
|
-
deletedSegments: tally.deletedSegments,
|
|
569
|
-
reclaimedBytes: tally.reclaimedBytes,
|
|
570
|
-
condition: reason === "age" ? { maxAgeDays: policy.maxAgeMs != null ? policy.maxAgeMs / 864e5 : null } : { maxSizeGb: policy.maxSizeBytes != null ? policy.maxSizeBytes / 1e9 : null, usedBytesBefore }
|
|
571
|
-
}
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
return { deletedBuckets, reclaimedBytes };
|
|
576
|
-
}
|
|
577
|
-
/**
|
|
578
|
-
* Apply this device's retention policy to its footage RIGHT NOW for one
|
|
579
|
-
* device, returning the oldest surviving segment start (the "retention
|
|
580
|
-
* floor") plus deletion accounting. The floor is the key output B3 uses to
|
|
581
|
-
* prune analytics events to the same boundary.
|
|
582
|
-
*
|
|
583
|
-
* Mirrors the per-device inner loop of {@link sweepRetention} but operates
|
|
584
|
-
* on a single camera and exposes the result — reuses the same
|
|
585
|
-
* `planRetention` + `removeBucketDir` path so the math stays consistent.
|
|
586
|
-
*
|
|
587
|
-
* @param deviceId Camera to prune.
|
|
588
|
-
* @param nowMs Wall-clock time for the age cutoff.
|
|
589
|
-
* @param resolvePolicy Returns the retention policy for each subtree.
|
|
590
|
-
* @param removeBucketDir Injected `rm -rf` for one hour-bucket dir.
|
|
591
|
-
*/
|
|
592
|
-
async pruneFootageForDevice(deviceId, nowMs, resolvePolicy, removeBucketDir) {
|
|
593
|
-
const cam = this.cameras.get(deviceId);
|
|
594
|
-
if (!cam) {
|
|
595
|
-
return { floorMs: null, deletedBuckets: 0, reclaimedBytes: 0 };
|
|
596
|
-
}
|
|
597
|
-
const camKey = String(deviceId);
|
|
598
|
-
const { deletedBuckets, reclaimedBytes } = await this.pruneSubtrees(
|
|
599
|
-
deviceId,
|
|
600
|
-
cam.index,
|
|
601
|
-
resolvePolicy,
|
|
602
|
-
nowMs,
|
|
603
|
-
removeBucketDir,
|
|
604
|
-
"recorder pruneFootage rm failed"
|
|
605
|
-
);
|
|
606
|
-
const floorMs = cam.index.oldestStartMs(camKey);
|
|
607
|
-
return { floorMs, deletedBuckets, reclaimedBytes };
|
|
608
|
-
}
|
|
609
|
-
/**
|
|
610
|
-
* Every device with recordings on this node — attached cameras (live RAM
|
|
611
|
-
* index) plus detached ones surfaced by the disk-scan playback index. The
|
|
612
|
-
* free-space guard frees space "from every device", so it walks the union.
|
|
613
|
-
*/
|
|
614
|
-
recordedDeviceIds() {
|
|
615
|
-
const ids = /* @__PURE__ */ new Set();
|
|
616
|
-
for (const id of this.cameras.keys()) ids.add(id);
|
|
617
|
-
for (const cam of this.playbackIndex.cameras()) {
|
|
618
|
-
const n = Number(cam);
|
|
619
|
-
if (Number.isFinite(n)) ids.add(n);
|
|
620
|
-
}
|
|
621
|
-
return [...ids];
|
|
622
|
-
}
|
|
623
|
-
/**
|
|
624
|
-
* Total footage bytes stored on a location, across every recorded device.
|
|
625
|
-
* Backs the `storage-evictable` provider's `getEvictableUsage` so the core
|
|
626
|
-
* StoragePressureManager can fan out a deficit proportionally.
|
|
627
|
-
*/
|
|
628
|
-
evictableBytesOnLocation(locationId) {
|
|
629
|
-
let total = 0;
|
|
630
|
-
for (const deviceId of this.recordedDeviceIds()) {
|
|
631
|
-
const camKey = String(deviceId);
|
|
632
|
-
for (const b of this.indexFor(deviceId).hourBuckets(camKey)) {
|
|
633
|
-
if (b.locationId === locationId) total += b.bytes;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
return total;
|
|
637
|
-
}
|
|
638
|
-
/**
|
|
639
|
-
* Free ~`targetBytes` of footage on a location by deleting the OLDEST hour
|
|
640
|
-
* buckets first, globally across devices (oldest-anywhere wins). Backs the
|
|
641
|
-
* `storage-evictable` provider's `evict`. Only successfully-removed buckets
|
|
642
|
-
* count toward `reclaimedBytes`. `exhausted` = ran out of buckets before
|
|
643
|
-
* hitting the target. Per-device eviction is logged device-tagged.
|
|
644
|
-
*/
|
|
645
|
-
async evictBytesOnLocation(locationId, targetBytes, removeBucketDir) {
|
|
646
|
-
const candidates = [];
|
|
647
|
-
for (const deviceId of this.recordedDeviceIds()) {
|
|
648
|
-
const camKey = String(deviceId);
|
|
649
|
-
for (const b of this.indexFor(deviceId).hourBuckets(camKey)) {
|
|
650
|
-
if (b.locationId === locationId) candidates.push({ deviceId, bucket: b });
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
candidates.sort((a, b) => a.bucket.hourStartMs - b.bucket.hourStartMs);
|
|
654
|
-
let reclaimed = 0;
|
|
655
|
-
const perDevice = /* @__PURE__ */ new Map();
|
|
656
|
-
let hitTarget = false;
|
|
657
|
-
for (const { deviceId, bucket } of candidates) {
|
|
658
|
-
if (reclaimed >= targetBytes) {
|
|
659
|
-
hitTarget = true;
|
|
660
|
-
break;
|
|
661
|
-
}
|
|
662
|
-
try {
|
|
663
|
-
await removeBucketDir(bucket.relPath, bucket.locationId);
|
|
664
|
-
} catch (err) {
|
|
665
|
-
this.deps.logger.warn("recorder free-space rm failed", {
|
|
666
|
-
tags: { deviceId },
|
|
667
|
-
meta: { deviceId, relPath: bucket.relPath, locationId: bucket.locationId, error: String(err) }
|
|
668
|
-
});
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
const index2 = this.indexFor(deviceId);
|
|
672
|
-
const camKey = String(deviceId);
|
|
673
|
-
const before = index2.totalBytes(camKey);
|
|
674
|
-
index2.removeBucketByRelPath(bucket.relPath, bucket.locationId);
|
|
675
|
-
const r = before - index2.totalBytes(camKey);
|
|
676
|
-
reclaimed += r;
|
|
677
|
-
const tally = perDevice.get(deviceId) ?? { buckets: 0, bytes: 0 };
|
|
678
|
-
tally.buckets += 1;
|
|
679
|
-
tally.bytes += r;
|
|
680
|
-
perDevice.set(deviceId, tally);
|
|
681
|
-
}
|
|
682
|
-
for (const [deviceId, tally] of perDevice) {
|
|
683
|
-
if (tally.buckets > 0) {
|
|
684
|
-
this.deps.logger.info("recorder eviction", {
|
|
685
|
-
tags: { deviceId },
|
|
686
|
-
meta: { deviceId, reason: "free-space", locationId, deletedBuckets: tally.buckets, reclaimedBytes: tally.bytes }
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
return { reclaimedBytes: reclaimed, exhausted: !hitTarget };
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* Clear the live index for an attached camera so it can be re-seeded from a
|
|
694
|
-
* fresh sized disk scan. All writers and policy state are preserved — only the
|
|
695
|
-
* segment byte accounting is reset. No-op when the camera is not attached
|
|
696
|
-
* (matches the `seedIndexFromScan` contract: seeding a non-attached device is
|
|
697
|
-
* also a no-op, so a clear+reseed pair is always safe to call unconditionally).
|
|
698
|
-
*
|
|
699
|
-
* Intended usage (see `rescanStorage` provider in `index.ts`):
|
|
700
|
-
* 1. `core.rescanDevice(deviceId)` — drop stale index entries
|
|
701
|
-
* 2. `await seedIndexForDevice(core, deviceId)` — re-populate from disk
|
|
702
|
-
* 3. `core.getStatus(deviceId)` — now reflects true on-disk bytes
|
|
703
|
-
*/
|
|
704
|
-
rescanDevice(deviceId) {
|
|
705
|
-
const cam = this.cameras.get(deviceId);
|
|
706
|
-
if (!cam) return;
|
|
707
|
-
this.cameras.set(deviceId, { ...cam, index: new RamIndex() });
|
|
708
|
-
}
|
|
709
|
-
/**
|
|
710
|
-
* Stop all writers for a camera, release its broker sources, and drop its
|
|
711
|
-
* index. Writers are stopped FIRST (ffmpeg disconnects from the restream)
|
|
712
|
-
* before the broker demand is released, so the broker can idle cleanly.
|
|
713
|
-
*/
|
|
714
|
-
async detach(deviceId) {
|
|
715
|
-
const cam = this.cameras.get(deviceId);
|
|
716
|
-
if (!cam) return;
|
|
717
|
-
for (const w of cam.writers) w.stop();
|
|
718
|
-
this.cameras.delete(deviceId);
|
|
719
|
-
const releases = await Promise.allSettled(
|
|
720
|
-
cam.pipelineKeys.map((key) => this.deps.releaseSource(key))
|
|
721
|
-
);
|
|
722
|
-
for (const r of releases) {
|
|
723
|
-
if (r.status === "rejected") {
|
|
724
|
-
this.deps.logger.warn("recorder source release failed", {
|
|
725
|
-
tags: { deviceId },
|
|
726
|
-
meta: { deviceId, error: String(r.reason) }
|
|
727
|
-
});
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
this.deps.logger.info("recorder detached", { tags: { deviceId }, meta: { deviceId } });
|
|
731
|
-
}
|
|
732
|
-
/** Stop every camera — used on addon shutdown. */
|
|
733
|
-
async detachAll() {
|
|
734
|
-
await Promise.all([...this.cameras.keys()].map((deviceId) => this.detach(deviceId)));
|
|
735
|
-
}
|
|
736
|
-
/** Device ids currently recording. */
|
|
737
|
-
attachedDevices() {
|
|
738
|
-
return [...this.cameras.keys()];
|
|
739
|
-
}
|
|
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);
|
|
740
500
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
} catch (err) {
|
|
762
|
-
logger.warn("recorder: pruneEventsBefore failed", {
|
|
763
|
-
meta: { deviceId, error: index.errMsg(err) }
|
|
764
|
-
});
|
|
765
|
-
}
|
|
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";
|
|
766
521
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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";
|
|
776
534
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
+
}] };
|
|
788
551
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
+
});
|
|
797
586
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
587
|
+
function serialize(map) {
|
|
588
|
+
const out = {};
|
|
589
|
+
for (const [id, config] of map) out[String(id)] = config;
|
|
590
|
+
return out;
|
|
802
591
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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;
|
|
807
601
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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;
|
|
812
618
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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";
|
|
828
641
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
|
837
656
|
};
|
|
838
|
-
|
|
839
|
-
|
|
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
|
+
};
|
|
840
673
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
for (const id of locationIds) {
|
|
845
|
-
const entry = free.find((f) => f.locationId === id);
|
|
846
|
-
const bytes = entry?.availableBytes ?? -1;
|
|
847
|
-
if (best === null || bytes > bestBytes) {
|
|
848
|
-
best = id;
|
|
849
|
-
bestBytes = bytes;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
return best;
|
|
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));
|
|
853
677
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
+
};
|
|
901
733
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
start() {
|
|
913
|
-
if (this.stopped || this.proc) return;
|
|
914
|
-
const args = buildPassthroughArgs(this.cfg);
|
|
915
|
-
const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
|
|
916
|
-
this.proc = proc;
|
|
917
|
-
const stderrTail = [];
|
|
918
|
-
proc.stderr?.on("data", (chunk) => {
|
|
919
|
-
const text = chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk);
|
|
920
|
-
for (const line of text.split("\n")) {
|
|
921
|
-
const trimmed = line.trim();
|
|
922
|
-
if (!trimmed) continue;
|
|
923
|
-
stderrTail.push(trimmed);
|
|
924
|
-
if (stderrTail.length > STDERR_TAIL_LINES) stderrTail.shift();
|
|
925
|
-
}
|
|
926
|
-
});
|
|
927
|
-
proc.on("exit", (code) => {
|
|
928
|
-
this.proc = null;
|
|
929
|
-
if (this.stopped) return;
|
|
930
|
-
if (this.restarts >= MAX_RESTARTS) {
|
|
931
|
-
this.deps.logger.warn("SegmentWriter giving up after max restarts", {
|
|
932
|
-
meta: { outDir: this.cfg.outDir, code, stderrTail: stderrTail.join(" | ") }
|
|
933
|
-
});
|
|
934
|
-
return;
|
|
935
|
-
}
|
|
936
|
-
this.restarts++;
|
|
937
|
-
this.deps.logger.info("SegmentWriter restarting", { meta: { outDir: this.cfg.outDir, attempt: this.restarts } });
|
|
938
|
-
this.start();
|
|
939
|
-
});
|
|
940
|
-
}
|
|
941
|
-
stop() {
|
|
942
|
-
this.stopped = true;
|
|
943
|
-
this.proc?.kill();
|
|
944
|
-
this.proc = null;
|
|
945
|
-
}
|
|
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
|
+
};
|
|
946
744
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
segments.forEach((s, i) => {
|
|
957
|
-
if (i > 0) lines.push("#EXT-X-DISCONTINUITY");
|
|
958
|
-
lines.push(`#EXT-X-PROGRAM-DATE-TIME:${new Date(s.startMs).toISOString()}`);
|
|
959
|
-
lines.push(`#EXTINF:${(s.durMs / 1e3).toFixed(3)},`);
|
|
960
|
-
lines.push(s.uri);
|
|
961
|
-
});
|
|
962
|
-
lines.push("#EXT-X-ENDLIST");
|
|
963
|
-
return lines.join("\n") + "\n";
|
|
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;
|
|
964
754
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
+
];
|
|
976
992
|
}
|
|
977
|
-
|
|
978
|
-
|
|
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
|
+
*/
|
|
979
1104
|
function parseEpochStartMs(segPath) {
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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;
|
|
984
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
|
+
*/
|
|
985
1121
|
function correctedDurMs(durSeconds, startMs, nextStartMs) {
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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);
|
|
991
1127
|
}
|
|
1128
|
+
/** Parse a `live.m3u8` body into ordered `#EXTINF` → segment-path pairs. */
|
|
992
1129
|
function parseLivePlaylist(body) {
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
const startMs = parseEpochStartMs(abs);
|
|
1016
|
-
if (startMs === null) return null;
|
|
1017
|
-
const newAbs = path.join(outDir, subtree, hourBucketRelPath(startMs), `${startMs}-${durMs}.m4s`);
|
|
1018
|
-
return { startMs, durMs, oldAbs: abs, newAbs };
|
|
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;
|
|
1019
1152
|
}
|
|
1153
|
+
/** `fs.stat` size, or null when the path does not exist. */
|
|
1020
1154
|
async function statSize(p) {
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1155
|
+
try {
|
|
1156
|
+
return (await node_fs.promises.stat(p)).size;
|
|
1157
|
+
} catch {
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1026
1160
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
onSegment(resolved.startMs, resolved.durMs, bytes, subtree);
|
|
1051
|
-
return "retained";
|
|
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";
|
|
1052
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
|
+
*/
|
|
1053
1195
|
function planFinalizations(body, processed) {
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
+
};
|
|
1067
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
|
+
*/
|
|
1068
1222
|
function startSegmentWatcher(deps) {
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
clearInterval(timer);
|
|
1124
|
-
}
|
|
1125
|
-
};
|
|
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
|
+
} };
|
|
1126
1277
|
}
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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);
|
|
1153
1347
|
}
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
const rel = name.split(path.sep).join("/");
|
|
1165
|
-
const parsed = parseSegmentRelPath(rel);
|
|
1166
|
-
if (!parsed) continue;
|
|
1167
|
-
let size;
|
|
1168
|
-
try {
|
|
1169
|
-
size = (await fs.promises.stat(path.join(rootDir, name))).size;
|
|
1170
|
-
} catch {
|
|
1171
|
-
continue;
|
|
1172
|
-
}
|
|
1173
|
-
perDevice.set(parsed.camera, (perDevice.get(parsed.camera) ?? 0) + size);
|
|
1174
|
-
totalBytes += size;
|
|
1175
|
-
}
|
|
1176
|
-
return { perDevice, totalBytes };
|
|
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);
|
|
1177
1358
|
}
|
|
1178
|
-
function
|
|
1179
|
-
|
|
1180
|
-
let totalBytes = 0;
|
|
1181
|
-
for (const u of usages) {
|
|
1182
|
-
for (const [device, bytes] of u.perDevice) {
|
|
1183
|
-
perDevice.set(device, (perDevice.get(device) ?? 0) + bytes);
|
|
1184
|
-
}
|
|
1185
|
-
totalBytes += u.totalBytes;
|
|
1186
|
-
}
|
|
1187
|
-
return { perDevice, totalBytes };
|
|
1359
|
+
function activeBandAt(config, date) {
|
|
1360
|
+
return config.bands.find((b) => bandActiveAt(b, date)) ?? null;
|
|
1188
1361
|
}
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
if (size < headerSize) return null;
|
|
1203
|
-
return { size, type, headerSize };
|
|
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;
|
|
1204
1375
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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());
|
|
1214
1389
|
}
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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;
|
|
1221
1403
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
+
};
|
|
1259
1746
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
retention: index.RecordingRetentionSchema.optional()
|
|
1266
|
-
});
|
|
1267
|
-
const AttachCameraResultSchema = index.object({
|
|
1268
|
-
success: index.literal(true)
|
|
1269
|
-
});
|
|
1270
|
-
const DetachCameraInputSchema = index.object({
|
|
1271
|
-
deviceId: index.number().int().nonnegative()
|
|
1272
|
-
});
|
|
1273
|
-
const DetachCameraResultSchema = index.object({
|
|
1274
|
-
success: index.literal(true)
|
|
1275
|
-
});
|
|
1276
|
-
const SetPlacementInputSchema = index.object({
|
|
1277
|
-
assignments: index.record(index.string(), index.array(index.string()))
|
|
1278
|
-
});
|
|
1279
|
-
const SetPlacementResultSchema = index.object({
|
|
1280
|
-
success: index.literal(true)
|
|
1281
|
-
});
|
|
1282
|
-
const recorderActions = index.defineCustomActions({
|
|
1283
|
-
attachCamera: index.customAction(AttachCameraInputSchema, AttachCameraResultSchema, { kind: "mutation" }),
|
|
1284
|
-
detachCamera: index.customAction(DetachCameraInputSchema, DetachCameraResultSchema, { kind: "mutation" }),
|
|
1285
|
-
setPlacement: index.customAction(SetPlacementInputSchema, SetPlacementResultSchema, { kind: "mutation" })
|
|
1286
|
-
});
|
|
1287
|
-
function configFromTarget(target) {
|
|
1288
|
-
if (!target) return { enabled: false, rules: [] };
|
|
1289
|
-
return {
|
|
1290
|
-
enabled: target.enabled,
|
|
1291
|
-
profiles: target.profiles,
|
|
1292
|
-
segmentSeconds: target.segmentSeconds,
|
|
1293
|
-
rules: target.rules ?? [],
|
|
1294
|
-
retention: target.retention
|
|
1295
|
-
};
|
|
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);
|
|
1296
1752
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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);
|
|
1305
1780
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
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;
|
|
1311
1796
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
|
1335
1825
|
};
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
+
}
|
|
1340
2095
|
};
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
core = null;
|
|
1344
|
-
nodeId = "hub";
|
|
1345
|
-
dataDir = "";
|
|
1346
|
-
/** Storage location recordings are written to (the managed `recordings`
|
|
1347
|
-
* location). `undefined` = legacy addon-local fallback (storage cap down). */
|
|
1348
|
-
recordingLocationId = void 0;
|
|
1349
|
-
/** Resolved placement: profile/track → location ids. NO fallback — an
|
|
1350
|
-
* unassigned profile fails to record, explicitly. */
|
|
1351
|
-
placement = { assignments: {} };
|
|
1352
|
-
/** Durable handle over the `recordingPlacement` addon-store key. Both the
|
|
1353
|
-
* boot load and `setPlacement` route through it so the WHOLE blob round-trips
|
|
1354
|
-
* (no hand-listed field can be dropped on save). Built in `onInitialize`. */
|
|
1355
|
-
placementState = null;
|
|
1356
|
-
/** Cache of locationId → absolute root path (via `storage.resolve`). */
|
|
1357
|
-
locationRootCache = /* @__PURE__ */ new Map();
|
|
1358
|
-
/** Cache of `${deviceId}/${profile}` → master-playlist codec/resolution,
|
|
1359
|
-
* probed once from a segment's moov (codec is stable per camera profile). */
|
|
1360
|
-
profileCodecCache = /* @__PURE__ */ new Map();
|
|
1361
|
-
/** GLOBAL write location chosen per profile/track. Cached so EVERY camera
|
|
1362
|
-
* records a given profile to the SAME location (assignment is global, not
|
|
1363
|
-
* per-camera). Reset on addon restart (re-config). */
|
|
1364
|
-
profileWriteLocation = /* @__PURE__ */ new Map();
|
|
1365
|
-
/** One watcher per (deviceId, profile). Keyed `${deviceId}/${profile}`. */
|
|
1366
|
-
watchers = /* @__PURE__ */ new Map();
|
|
1367
|
-
/** Periodic retention sweep; cleared on shutdown. */
|
|
1368
|
-
retentionTimer = null;
|
|
1369
|
-
/** Periodic modes-engine policy reaper (pre/post-buffer enforcement). */
|
|
1370
|
-
policyTimer = null;
|
|
1371
|
-
/** Playback HTTP data-plane (hub reverse-proxies to it); null until served. */
|
|
1372
|
-
playbackDataPlane = null;
|
|
1373
|
-
/** Periodic disk rescan rebuilding the playback index (past recordings). */
|
|
1374
|
-
playbackRescanTimer = null;
|
|
1375
|
-
/** In-memory mirror of persisted targets so the SYNC retention resolver can
|
|
1376
|
-
* read per-device overrides without I/O. Refreshed on attach/detach/setConfig
|
|
1377
|
-
* and boot-restore. */
|
|
1378
|
-
deviceTargets = /* @__PURE__ */ new Map();
|
|
1379
|
-
/** Short-lived stat-based byte counts keyed by deviceId — used ONLY in the
|
|
1380
|
-
* `getStatus` fallback path (index === 0 && footage exists). Entries expire
|
|
1381
|
-
* after {@link STAT_CACHE_TTL_MS} so repeated polls don't re-stat on every
|
|
1382
|
-
* call, yet the value stays reasonably fresh. */
|
|
1383
|
-
statSizeCache = /* @__PURE__ */ new Map();
|
|
1384
|
-
constructor() {
|
|
1385
|
-
super({ ...DEFAULT_CONFIG });
|
|
1386
|
-
}
|
|
1387
|
-
async onInitialize() {
|
|
1388
|
-
const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
|
|
1389
|
-
this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
|
|
1390
|
-
const loc = await this.resolveRecordingsLocation();
|
|
1391
|
-
this.dataDir = loc.root;
|
|
1392
|
-
this.recordingLocationId = loc.locationId;
|
|
1393
|
-
if (loc.locationId) this.locationRootCache.set(loc.locationId, loc.root);
|
|
1394
|
-
const fallbackAssignments = {};
|
|
1395
|
-
for (const [key, ids] of Object.entries(this.config.recordingPlacement?.assignments ?? {})) {
|
|
1396
|
-
fallbackAssignments[key] = [...ids];
|
|
1397
|
-
}
|
|
1398
|
-
this.placementState = this.state(
|
|
1399
|
-
"recordingPlacement",
|
|
1400
|
-
RecordingPlacementBlobSchema,
|
|
1401
|
-
{ assignments: fallbackAssignments }
|
|
1402
|
-
);
|
|
1403
|
-
const loadedPlacement = await this.placementState.get();
|
|
1404
|
-
this.placement = { assignments: loadedPlacement.assignments ?? {} };
|
|
1405
|
-
this.core = new RecorderCore({
|
|
1406
|
-
dataDir: this.dataDir,
|
|
1407
|
-
nodeId: this.nodeId,
|
|
1408
|
-
acquireSource: (deviceId, profile) => this.acquireSource(deviceId, profile),
|
|
1409
|
-
releaseSource: (pipelineKey) => this.releaseSource(pipelineKey),
|
|
1410
|
-
makeWriter: (cfg) => {
|
|
1411
|
-
fs.mkdirSync(cfg.outDir, { recursive: true });
|
|
1412
|
-
return new SegmentWriter(cfg, { spawn: node_child_process.spawn, logger: this.ctx.logger });
|
|
1413
|
-
},
|
|
1414
|
-
logger: this.ctx.logger
|
|
1415
|
-
});
|
|
1416
|
-
this.startRetentionTimer();
|
|
1417
|
-
this.startPolicyTimer();
|
|
1418
|
-
this.subscribeTriggers();
|
|
1419
|
-
try {
|
|
1420
|
-
const handler = core.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
|
|
1421
|
-
this.playbackDataPlane = await this.ctx.dataPlane?.serve({ prefix: "playback", access: "admin", handler }) ?? null;
|
|
1422
|
-
this.ctx.logger.info("recorder playback data-plane served", {
|
|
1423
|
-
meta: { baseUrl: this.playbackDataPlane?.baseUrl ?? "(no dataPlane facility)" }
|
|
1424
|
-
});
|
|
1425
|
-
} catch (err) {
|
|
1426
|
-
this.ctx.logger.warn("recorder playback data-plane failed to serve", { meta: { error: index.errMsg(err) } });
|
|
1427
|
-
}
|
|
1428
|
-
await this.rescanPlayback();
|
|
1429
|
-
this.startPlaybackRescanTimer();
|
|
1430
|
-
this.ctx.logger.info("recorder addon started", {
|
|
1431
|
-
tags: { nodeId: this.nodeId },
|
|
1432
|
-
meta: {
|
|
1433
|
-
dataDir: this.dataDir,
|
|
1434
|
-
segmentSeconds: this.config.segmentSeconds,
|
|
1435
|
-
retentionMaxAgeDays: this.config.retentionMaxAgeDays,
|
|
1436
|
-
retentionMaxSizeGb: this.config.retentionMaxSizeGb,
|
|
1437
|
-
placement: this.placement
|
|
1438
|
-
}
|
|
1439
|
-
});
|
|
1440
|
-
const provider = {
|
|
1441
|
-
getStatus: async ({ deviceId }) => {
|
|
1442
|
-
if (!this.core) return null;
|
|
1443
|
-
const status = this.core.getStatus(deviceId);
|
|
1444
|
-
const indexBytes = status.storageBytes;
|
|
1445
|
-
if (indexBytes > 0) return status;
|
|
1446
|
-
const availability = this.core.getAvailability(deviceId, 0, Date.now());
|
|
1447
|
-
const hasRanges = availability.ranges.length > 0;
|
|
1448
|
-
const statSize2 = await this.deviceStatSize(deviceId, hasRanges);
|
|
1449
|
-
return { ...status, storageBytes: resolveStorageBytes({ indexBytes, hasRanges, statSize: statSize2 }) };
|
|
1450
|
-
},
|
|
1451
|
-
getAvailability: async ({ deviceId, fromMs, toMs }) => {
|
|
1452
|
-
if (!this.core) return { deviceId, ranges: [] };
|
|
1453
|
-
return this.core.getAvailability(deviceId, fromMs, toMs);
|
|
1454
|
-
},
|
|
1455
|
-
getPlaybackManifest: ({ deviceId, fromMs, toMs }) => this.buildPlaybackManifest(deviceId, fromMs, toMs),
|
|
1456
|
-
getStorageUsage: () => this.buildStorageUsage(),
|
|
1457
|
-
getDeviceConfig: async ({ deviceId }) => {
|
|
1458
|
-
const settings = this.ctxIfReady?.settings;
|
|
1459
|
-
const target = settings ? (await readRecordingTargets(settings)).get(deviceId) : void 0;
|
|
1460
|
-
return configFromTarget(target);
|
|
1461
|
-
},
|
|
1462
|
-
rescanStorage: async ({ deviceId }) => {
|
|
1463
|
-
const core2 = this.core;
|
|
1464
|
-
if (!core2) return {
|
|
1465
|
-
deviceId,
|
|
1466
|
-
enabled: false,
|
|
1467
|
-
activeMode: "off",
|
|
1468
|
-
nodeId: this.nodeId,
|
|
1469
|
-
storageBytes: 0
|
|
1470
|
-
};
|
|
1471
|
-
core2.rescanDevice(deviceId);
|
|
1472
|
-
await this.seedIndexForDevice(core2, deviceId);
|
|
1473
|
-
this.statSizeCache.delete(deviceId);
|
|
1474
|
-
const status = core2.getStatus(deviceId);
|
|
1475
|
-
const indexBytes = status.storageBytes;
|
|
1476
|
-
if (indexBytes > 0) return status;
|
|
1477
|
-
const availability = core2.getAvailability(deviceId, 0, Date.now());
|
|
1478
|
-
const hasRanges = availability.ranges.length > 0;
|
|
1479
|
-
const statSize2 = await this.deviceStatSize(deviceId, hasRanges);
|
|
1480
|
-
return { ...status, storageBytes: resolveStorageBytes({ indexBytes, hasRanges, statSize: statSize2 }) };
|
|
1481
|
-
},
|
|
1482
|
-
setDeviceConfig: async ({ deviceId, config }) => {
|
|
1483
|
-
const next = targetFromConfig(config);
|
|
1484
|
-
if (next.enabled) {
|
|
1485
|
-
await this.attachCamera({ deviceId, profiles: next.profiles, segmentSeconds: next.segmentSeconds, rules: next.rules, retention: next.retention });
|
|
1486
|
-
} else {
|
|
1487
|
-
await this.detachCamera({ deviceId });
|
|
1488
|
-
const disabledSettings = this.ctx.settings;
|
|
1489
|
-
if (disabledSettings) {
|
|
1490
|
-
await upsertRecordingTarget(disabledSettings, deviceId, next);
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
this.deviceTargets.set(deviceId, next);
|
|
1494
|
-
return configFromTarget(next);
|
|
1495
|
-
},
|
|
1496
|
-
// Per-device recording settings surfaced in device-details (the system
|
|
1497
|
-
// contributions pass consults this cap even though it isn't device-bound).
|
|
1498
|
-
getDeviceSettingsContribution: (_input) => this.buildDeviceSettingsContribution(),
|
|
1499
|
-
getDeviceLiveContribution: async () => null,
|
|
1500
|
-
applyDeviceSettingsPatch: async ({ patch }) => {
|
|
1501
|
-
const keys = Object.keys(patch ?? {});
|
|
1502
|
-
if (keys.length > 0) {
|
|
1503
|
-
this.ctx.logger.debug("recorder: ignoring legacy settings patch (widget owns settings)", { meta: { keys } });
|
|
1504
|
-
}
|
|
1505
|
-
return { success: true };
|
|
1506
|
-
},
|
|
1507
|
-
pruneFootage: async ({ deviceId }) => {
|
|
1508
|
-
const core2 = this.core;
|
|
1509
|
-
if (!core2) return { floorMs: null, deletedBuckets: 0, reclaimedBytes: 0 };
|
|
1510
|
-
return core2.pruneFootageForDevice(
|
|
1511
|
-
deviceId,
|
|
1512
|
-
Date.now(),
|
|
1513
|
-
(id, subtree) => this.resolveRetentionPolicy(id, subtree),
|
|
1514
|
-
(relPath, locationId) => this.removeBucketDir(relPath, locationId)
|
|
1515
|
-
);
|
|
1516
|
-
}
|
|
1517
|
-
};
|
|
1518
|
-
const evictableProvider = {
|
|
1519
|
-
getEvictableUsage: async ({ locationId }) => {
|
|
1520
|
-
const core2 = this.core;
|
|
1521
|
-
return { bytes: core2 ? core2.evictableBytesOnLocation(locationId) : 0 };
|
|
1522
|
-
},
|
|
1523
|
-
evict: async ({ locationId, targetBytes }) => {
|
|
1524
|
-
const core2 = this.core;
|
|
1525
|
-
if (!core2) return { reclaimedBytes: 0, exhausted: true };
|
|
1526
|
-
return core2.evictBytesOnLocation(
|
|
1527
|
-
locationId,
|
|
1528
|
-
targetBytes,
|
|
1529
|
-
(relPath, locId) => this.removeBucketDir(relPath, locId)
|
|
1530
|
-
);
|
|
1531
|
-
}
|
|
1532
|
-
};
|
|
1533
|
-
return {
|
|
1534
|
-
providers: [
|
|
1535
|
-
{ capability: index.recordingCapability, provider },
|
|
1536
|
-
{ capability: index.storageEvictableCapability, provider: evictableProvider }
|
|
1537
|
-
],
|
|
1538
|
-
customActions: recorderActions,
|
|
1539
|
-
actionHandlers: {
|
|
1540
|
-
attachCamera: async (input) => this.attachCamera(input),
|
|
1541
|
-
detachCamera: async (input) => this.detachCamera(input),
|
|
1542
|
-
setPlacement: async (input) => this.setPlacement(input)
|
|
1543
|
-
}
|
|
1544
|
-
};
|
|
1545
|
-
}
|
|
1546
|
-
async onShutdown() {
|
|
1547
|
-
if (this.retentionTimer) {
|
|
1548
|
-
clearInterval(this.retentionTimer);
|
|
1549
|
-
this.retentionTimer = null;
|
|
1550
|
-
}
|
|
1551
|
-
if (this.policyTimer) {
|
|
1552
|
-
clearInterval(this.policyTimer);
|
|
1553
|
-
this.policyTimer = null;
|
|
1554
|
-
}
|
|
1555
|
-
if (this.playbackRescanTimer) {
|
|
1556
|
-
clearInterval(this.playbackRescanTimer);
|
|
1557
|
-
this.playbackRescanTimer = null;
|
|
1558
|
-
}
|
|
1559
|
-
for (const handle of this.watchers.values()) handle.stop();
|
|
1560
|
-
this.watchers.clear();
|
|
1561
|
-
await this.playbackDataPlane?.dispose();
|
|
1562
|
-
this.playbackDataPlane = null;
|
|
1563
|
-
await this.core?.detachAll();
|
|
1564
|
-
this.core = null;
|
|
1565
|
-
}
|
|
1566
|
-
// ── Modes engine triggers ──────────────────────────────────────────────
|
|
1567
|
-
/**
|
|
1568
|
-
* Feed motion events into the policy engine. A `detected:true` transition for
|
|
1569
|
-
* a camera records a motion trigger; `onMotion` rules then retain segments
|
|
1570
|
-
* for their `postBufferSec`. Recording continuously-connected cameras get the
|
|
1571
|
-
* event even though the recorder is a separate process (UDS event bridge).
|
|
1572
|
-
* `onAudioThreshold` ingestion is deferred (audio analysis is stream-demand
|
|
1573
|
-
* gated) — the policy schema already supports it.
|
|
1574
|
-
*/
|
|
1575
|
-
subscribeTriggers() {
|
|
1576
|
-
this.ctx.eventBus.subscribe({ category: index.EventCategory.MotionOnMotionChanged }, (event) => {
|
|
1577
|
-
if (!event.data.detected) return;
|
|
1578
|
-
this.core?.recordTrigger(event.data.deviceId, "motion", event.data.timestamp);
|
|
1579
|
-
});
|
|
1580
|
-
this.ctx.eventBus.subscribe({ category: index.EventCategory.PipelineAudioInferenceResult }, (event) => {
|
|
1581
|
-
const dbfs = event.data.frame.level?.dbfs;
|
|
1582
|
-
if (dbfs == null) return;
|
|
1583
|
-
this.core?.onAudioLevel(event.data.deviceId, dbfs, event.data.frame.timestamp);
|
|
1584
|
-
});
|
|
1585
|
-
}
|
|
1586
|
-
// ── Modes engine policy reaper (pre/post-buffer enforcement) ────────────
|
|
1587
|
-
startPolicyTimer() {
|
|
1588
|
-
const timer = setInterval(() => void this.sweepPolicy(), this.config.policySweepIntervalMs);
|
|
1589
|
-
timer.unref?.();
|
|
1590
|
-
this.policyTimer = timer;
|
|
1591
|
-
}
|
|
1592
|
-
/** Run one policy sweep: retain triggered segments, reap expired-untriggered. */
|
|
1593
|
-
async sweepPolicy() {
|
|
1594
|
-
const core2 = this.core;
|
|
1595
|
-
if (!core2) return;
|
|
1596
|
-
try {
|
|
1597
|
-
await core2.sweepPolicy(
|
|
1598
|
-
localClock,
|
|
1599
|
-
Date.now(),
|
|
1600
|
-
(deviceId, profile, startMs, durMs, locationId, subtree) => this.deleteSegmentFile(deviceId, profile, startMs, durMs, locationId, subtree)
|
|
1601
|
-
);
|
|
1602
|
-
} catch (err) {
|
|
1603
|
-
this.ctx.logger.warn("recorder policy sweep failed", { meta: { error: index.errMsg(err) } });
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
/** Delete one relocated segment file under its storage location's root, in the
|
|
1607
|
-
* subtree it was written to. */
|
|
1608
|
-
async deleteSegmentFile(deviceId, profile, startMs, durMs, locationId, subtree) {
|
|
1609
|
-
const root = await this.locationRootOrDefault(locationId);
|
|
1610
|
-
const abs = path.resolve(root, segmentRelPath(String(deviceId), profile, subtree, startMs, durMs));
|
|
1611
|
-
await fs.promises.rm(abs, { force: true });
|
|
1612
|
-
}
|
|
1613
|
-
// ── Retention ──────────────────────────────────────────────────────────
|
|
1614
|
-
startRetentionTimer() {
|
|
1615
|
-
const intervalMs = this.config.retentionSweepIntervalMs;
|
|
1616
|
-
const timer = setInterval(() => void this.sweepRetention(), intervalMs);
|
|
1617
|
-
timer.unref?.();
|
|
1618
|
-
this.retentionTimer = timer;
|
|
1619
|
-
}
|
|
1620
|
-
/** Run one retention sweep across every attached camera, then enforce the
|
|
1621
|
-
* disk-pressure free-space guard across every device. */
|
|
1622
|
-
async sweepRetention() {
|
|
1623
|
-
const core2 = this.core;
|
|
1624
|
-
if (!core2) return;
|
|
1625
|
-
const nowMs = Date.now();
|
|
1626
|
-
for (const deviceId of core2.attachedDevices()) {
|
|
1627
|
-
try {
|
|
1628
|
-
await pruneDeviceWithEvents(
|
|
1629
|
-
core2,
|
|
1630
|
-
deviceId,
|
|
1631
|
-
nowMs,
|
|
1632
|
-
(id, subtree) => this.resolveRetentionPolicy(id, subtree),
|
|
1633
|
-
(relPath, locationId) => this.removeBucketDir(relPath, locationId),
|
|
1634
|
-
this.ctx.api,
|
|
1635
|
-
this.ctx.logger
|
|
1636
|
-
);
|
|
1637
|
-
} catch (err) {
|
|
1638
|
-
this.ctx.logger.warn("recorder retention sweep failed for device", { tags: { deviceId }, meta: { error: index.errMsg(err) } });
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
/** Available + total bytes on a volume (`statfs`); null when unstattable. */
|
|
1643
|
-
async rootCapacity(root) {
|
|
1644
|
-
try {
|
|
1645
|
-
const st = await fs.promises.statfs(root);
|
|
1646
|
-
return { availableBytes: st.bavail * st.bsize, totalBytes: st.blocks * st.bsize };
|
|
1647
|
-
} catch {
|
|
1648
|
-
return null;
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
// ── Storage-usage report (stat-based, on-demand) ───────────────────────
|
|
1652
|
-
/**
|
|
1653
|
-
* Accurate recordings storage usage for this node: global total + per-camera
|
|
1654
|
-
* (summed over every profile/subtree/location) + per-location used/available.
|
|
1655
|
-
* Stat-based so detached cameras count correctly (the playback index is
|
|
1656
|
-
* name-only). Includes the read-only legacy root (locationId null) in the
|
|
1657
|
-
* totals + per-camera, but never as a guarded managed location.
|
|
1658
|
-
*/
|
|
1659
|
-
async buildStorageUsage() {
|
|
1660
|
-
const managed = await this.listRecordingsLocations();
|
|
1661
|
-
const roots = managed.map((l) => ({ id: l.id ?? null, root: l.root }));
|
|
1662
|
-
const sized = await Promise.all(
|
|
1663
|
-
roots.map(async (r) => ({ ...r, usage: await sizeRecordingsTree(r.root) }))
|
|
1664
|
-
);
|
|
1665
|
-
const merged = mergeRootUsage(sized.map((s) => s.usage));
|
|
1666
|
-
const devices = [...merged.perDevice].map(([camera, usedBytes]) => ({ deviceId: Number(camera), usedBytes })).filter((d) => Number.isFinite(d.deviceId)).sort((a, b) => b.usedBytes - a.usedBytes);
|
|
1667
|
-
const locations = await Promise.all(
|
|
1668
|
-
sized.map(async (s) => {
|
|
1669
|
-
const cap = await this.rootCapacity(s.root);
|
|
1670
|
-
return {
|
|
1671
|
-
locationId: s.id,
|
|
1672
|
-
usedBytes: s.usage.totalBytes,
|
|
1673
|
-
availableBytes: cap?.availableBytes ?? null,
|
|
1674
|
-
totalBytes: cap?.totalBytes ?? null
|
|
1675
|
-
};
|
|
1676
|
-
})
|
|
1677
|
-
);
|
|
1678
|
-
return { nodeId: this.nodeId, totalUsedBytes: merged.totalBytes, devices, locations };
|
|
1679
|
-
}
|
|
1680
|
-
/**
|
|
1681
|
-
* Stat the on-disk tree for a single device and return its total bytes.
|
|
1682
|
-
* Uses a short-lived cache ({@link STAT_CACHE_TTL_MS}) so repeated `getStatus`
|
|
1683
|
-
* polls on the zero-index fallback path don't re-stat on every call.
|
|
1684
|
-
*
|
|
1685
|
-
* Called ONLY when `indexBytes === 0 && hasRanges` — not on the common
|
|
1686
|
-
* non-zero path, so disk I/O cost is limited to the edge case.
|
|
1687
|
-
*
|
|
1688
|
-
* @param deviceId The device to size.
|
|
1689
|
-
* @param hasRanges If false the call is skipped and 0 is returned immediately.
|
|
1690
|
-
*/
|
|
1691
|
-
async deviceStatSize(deviceId, hasRanges) {
|
|
1692
|
-
if (!hasRanges) return 0;
|
|
1693
|
-
const now = Date.now();
|
|
1694
|
-
const cached = this.statSizeCache.get(deviceId);
|
|
1695
|
-
if (cached && now < cached.expiresAt) return cached.bytes;
|
|
1696
|
-
const roots = this.playbackRoots();
|
|
1697
|
-
const key = String(deviceId);
|
|
1698
|
-
let total = 0;
|
|
1699
|
-
for (const root of roots) {
|
|
1700
|
-
const usage = await sizeRecordingsTree(root);
|
|
1701
|
-
total += usage.perDevice.get(key) ?? 0;
|
|
1702
|
-
}
|
|
1703
|
-
this.statSizeCache.set(deviceId, { bytes: total, expiresAt: now + STAT_CACHE_TTL_MS });
|
|
1704
|
-
return total;
|
|
1705
|
-
}
|
|
1706
|
-
/**
|
|
1707
|
-
* Resolve the retention policy for a device's `continuous` vs `events`
|
|
1708
|
-
* subtree: the per-subtree override (`> 0`) wins, else the global default.
|
|
1709
|
-
* P2: global addon config only (per-device override arrives with the
|
|
1710
|
-
* per-device settings UI in P6 — `deviceId` is already threaded for that swap).
|
|
1711
|
-
*/
|
|
1712
|
-
resolveRetentionPolicy(deviceId, subtree) {
|
|
1713
|
-
const c = this.config;
|
|
1714
|
-
const pick = (ovr, base) => ovr > 0 ? ovr : base;
|
|
1715
|
-
const defaultAgeDays = subtree === "events" ? pick(c.eventRetentionMaxAgeDays, c.retentionMaxAgeDays) : pick(c.continuousRetentionMaxAgeDays, c.retentionMaxAgeDays);
|
|
1716
|
-
const defaultSizeGb = subtree === "events" ? pick(c.eventRetentionMaxSizeGb, c.retentionMaxSizeGb) : pick(c.continuousRetentionMaxSizeGb, c.retentionMaxSizeGb);
|
|
1717
|
-
const override = this.deviceTargets.get(deviceId)?.retention;
|
|
1718
|
-
return resolveDeviceRetention(override, defaultAgeDays, defaultSizeGb);
|
|
1719
|
-
}
|
|
1720
|
-
/**
|
|
1721
|
-
* `rm -rf` one hour-bucket directory under its storage location's root.
|
|
1722
|
-
* `relPath` originates from our own RAM index
|
|
1723
|
-
* (`<deviceId>/<profile>/<Y>/<M>/<D>/<H>`) and `locationId` is the location it
|
|
1724
|
-
* lives on; resolve against that location's root and refuse anything that
|
|
1725
|
-
* escapes it as a defense-in-depth guard.
|
|
1726
|
-
*/
|
|
1727
|
-
async removeBucketDir(relPath, locationId) {
|
|
1728
|
-
const root = path.resolve(await this.locationRootOrDefault(locationId));
|
|
1729
|
-
const abs = path.resolve(root, relPath);
|
|
1730
|
-
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
1731
|
-
throw new Error(`recorder retention: refusing to rm outside location root: ${relPath}`);
|
|
1732
|
-
}
|
|
1733
|
-
await fs.promises.rm(abs, { recursive: true, force: true });
|
|
1734
|
-
}
|
|
1735
|
-
// ── Custom actions ────────────────────────────────────────────────────
|
|
1736
|
-
async attachCamera(input) {
|
|
1737
|
-
const core2 = this.core;
|
|
1738
|
-
if (!core2) throw new Error("RecorderAddon: attachCamera called before initialize completed");
|
|
1739
|
-
const profiles = await this.resolveRecordingProfiles(input.deviceId, input.profiles);
|
|
1740
|
-
if (profiles.length === 0) {
|
|
1741
|
-
throw new Error(
|
|
1742
|
-
`RecorderAddon.attachCamera: no assigned broker sources for device ${input.deviceId}`
|
|
1743
|
-
);
|
|
1744
|
-
}
|
|
1745
|
-
const segmentSeconds = input.segmentSeconds ?? this.config.segmentSeconds;
|
|
1746
|
-
const rules = input.rules ?? [];
|
|
1747
|
-
this.stopWatchers(input.deviceId);
|
|
1748
|
-
const placements = await this.resolveProfilePlacements(input.deviceId, profiles);
|
|
1749
|
-
const outDirs = {};
|
|
1750
|
-
for (const [profile, pl] of placements) outDirs[profile] = pl.outDir;
|
|
1751
|
-
await core2.attach({ deviceId: input.deviceId, profiles, segmentSeconds, rules, outDirs });
|
|
1752
|
-
for (const profile of profiles) {
|
|
1753
|
-
const pl = placements.get(profile);
|
|
1754
|
-
const key = index.makeProfileBrokerId(input.deviceId, profile);
|
|
1755
|
-
const handle = startSegmentWatcher({
|
|
1756
|
-
outDir: pl.outDir,
|
|
1757
|
-
intervalMs: this.config.watchIntervalMs,
|
|
1758
|
-
// Classify each finalized segment into its continuous/events subtree
|
|
1759
|
-
// (node-local clock) so the watcher relocates it into the right tree.
|
|
1760
|
-
classify: (startMs, durMs) => this.core?.classifySubtree(input.deviceId, startMs + durMs, localClock(startMs + durMs)) ?? "continuous",
|
|
1761
|
-
onSegment: (startMs, durMs, bytes, subtree) => {
|
|
1762
|
-
const core22 = this.core;
|
|
1763
|
-
if (!core22) return;
|
|
1764
|
-
core22.onSegmentFinalized(input.deviceId, profile, startMs, durMs, bytes, pl.locationId, subtree);
|
|
1765
|
-
if (core22.shouldRetainSegment(input.deviceId, startMs, startMs + durMs, localClock(startMs + durMs))) {
|
|
1766
|
-
core22.markSegmentRetained(input.deviceId, profile, startMs);
|
|
1767
|
-
}
|
|
1768
|
-
},
|
|
1769
|
-
logger: this.ctx.logger
|
|
1770
|
-
});
|
|
1771
|
-
this.watchers.set(key, handle);
|
|
1772
|
-
}
|
|
1773
|
-
await this.persistTarget(input);
|
|
1774
|
-
this.deviceTargets.set(input.deviceId, {
|
|
1775
|
-
enabled: true,
|
|
1776
|
-
profiles: input.profiles ? [...input.profiles] : void 0,
|
|
1777
|
-
segmentSeconds: input.segmentSeconds,
|
|
1778
|
-
rules: input.rules ? [...input.rules] : void 0,
|
|
1779
|
-
retention: input.retention
|
|
1780
|
-
});
|
|
1781
|
-
await this.seedIndexForDevice(core2, input.deviceId);
|
|
1782
|
-
this.ctx.logger.info("recorder attachCamera", {
|
|
1783
|
-
tags: { deviceId: input.deviceId },
|
|
1784
|
-
meta: { profiles, segmentSeconds }
|
|
1785
|
-
});
|
|
1786
|
-
return { success: true };
|
|
1787
|
-
}
|
|
1788
|
-
/**
|
|
1789
|
-
* Set the GLOBAL recording placement (profile/track → location ids). Persists
|
|
1790
|
-
* to the addon store (so it survives restarts) and rebuilds `this.placement`
|
|
1791
|
-
* + clears the per-profile location cache so the next attach re-resolves. The
|
|
1792
|
-
* admin UI (and the live-verification path) call this.
|
|
1793
|
-
*/
|
|
1794
|
-
async setPlacement(input) {
|
|
1795
|
-
const assignments = {};
|
|
1796
|
-
for (const [key, ids] of Object.entries(input.assignments ?? {})) assignments[key] = [...ids];
|
|
1797
|
-
await this.placementState?.set({ assignments });
|
|
1798
|
-
this.placement = { assignments: input.assignments ?? {} };
|
|
1799
|
-
this.profileWriteLocation.clear();
|
|
1800
|
-
this.ctx.logger.info("recorder placement updated", { meta: { assignments: input.assignments } });
|
|
1801
|
-
return { success: true };
|
|
1802
|
-
}
|
|
1803
|
-
async detachCamera(input) {
|
|
1804
|
-
await this.clearTarget(input.deviceId);
|
|
1805
|
-
this.stopWatchers(input.deviceId);
|
|
1806
|
-
await this.core?.detach(input.deviceId);
|
|
1807
|
-
await this.rescanPlayback();
|
|
1808
|
-
this.ctx.logger.info("recorder detachCamera", { tags: { deviceId: input.deviceId } });
|
|
1809
|
-
return { success: true };
|
|
1810
|
-
}
|
|
1811
|
-
// ── Boot-time index seeding (sized scan per device) ──────────────────────
|
|
1812
|
-
/**
|
|
1813
|
-
* Scan every recordings location for this device's sub-tree (with sizes) and
|
|
1814
|
-
* seed the live RAM index so `storageBytes` and size-retention reflect ALL
|
|
1815
|
-
* on-disk footage, not just segments finalized since the last attach. Scoping
|
|
1816
|
-
* the scan to `<root>/<deviceId>/` keeps it cheap on large volumes.
|
|
1817
|
-
*
|
|
1818
|
-
* Must be called after `core.attach(deviceId, …)` so the seed lands in the
|
|
1819
|
-
* camera's live index (not a stale pre-attach sentinel). Best-effort: any
|
|
1820
|
-
* per-location failure is warned and skipped.
|
|
1821
|
-
*/
|
|
1822
|
-
async seedIndexForDevice(core2, deviceId) {
|
|
1823
|
-
let locations;
|
|
1824
|
-
try {
|
|
1825
|
-
locations = await this.listRecordingsLocations();
|
|
1826
|
-
} catch (err) {
|
|
1827
|
-
this.ctx.logger.warn("recorder seedIndex: could not list locations", {
|
|
1828
|
-
tags: { deviceId },
|
|
1829
|
-
meta: { error: index.errMsg(err) }
|
|
1830
|
-
});
|
|
1831
|
-
return;
|
|
1832
|
-
}
|
|
1833
|
-
const roots = [...locations];
|
|
1834
|
-
const segments = [];
|
|
1835
|
-
for (const loc of roots) {
|
|
1836
|
-
const deviceRoot = path.join(loc.root, String(deviceId));
|
|
1837
|
-
try {
|
|
1838
|
-
const entries = await scanRecordings(deviceRoot, { locationId: loc.id, withSizes: true });
|
|
1839
|
-
for (const e of entries) {
|
|
1840
|
-
const fullRelPath = `${deviceId}/${e.relPath}`;
|
|
1841
|
-
const parsed = parseSegmentRelPath(fullRelPath);
|
|
1842
|
-
if (!parsed) continue;
|
|
1843
|
-
segments.push({
|
|
1844
|
-
profile: parsed.profile,
|
|
1845
|
-
startMs: parsed.startMs,
|
|
1846
|
-
durMs: parsed.durMs,
|
|
1847
|
-
bytes: e.bytes,
|
|
1848
|
-
locationId: e.locationId,
|
|
1849
|
-
subtree: parsed.subtree
|
|
1850
|
-
});
|
|
1851
|
-
}
|
|
1852
|
-
} catch (err) {
|
|
1853
|
-
this.ctx.logger.warn("recorder seedIndex: scan failed for location", {
|
|
1854
|
-
tags: { deviceId },
|
|
1855
|
-
meta: { locationId: loc.id, error: index.errMsg(err) }
|
|
1856
|
-
});
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
if (segments.length === 0) return;
|
|
1860
|
-
core2.seedIndexFromScan(deviceId, segments);
|
|
1861
|
-
this.ctx.logger.info("recorder seedIndex: seeded from disk scan", {
|
|
1862
|
-
tags: { deviceId },
|
|
1863
|
-
meta: { segmentCount: segments.length }
|
|
1864
|
-
});
|
|
1865
|
-
}
|
|
1866
|
-
// ── Playback index (past recordings via disk scan) ──────────────────────
|
|
1867
|
-
startPlaybackRescanTimer() {
|
|
1868
|
-
const timer = setInterval(() => void this.rescanPlayback(), this.config.playbackRescanIntervalMs);
|
|
1869
|
-
timer.unref?.();
|
|
1870
|
-
this.playbackRescanTimer = timer;
|
|
1871
|
-
}
|
|
1872
|
-
/** Rebuild the playback index from a shallow disk scan of EVERY recordings
|
|
1873
|
-
* storage location plus the read-only legacy root, stamping each segment with
|
|
1874
|
-
* the location it came from. */
|
|
1875
|
-
async rescanPlayback() {
|
|
1876
|
-
const core2 = this.core;
|
|
1877
|
-
if (!core2) return;
|
|
1878
|
-
try {
|
|
1879
|
-
const locations = await this.listRecordingsLocations();
|
|
1880
|
-
const scanRoots = [...locations];
|
|
1881
|
-
const scanned = await Promise.all(scanRoots.map((l) => scanRecordings(l.root, { locationId: l.id })));
|
|
1882
|
-
core2.setPlaybackIndex(scanned.flat());
|
|
1883
|
-
} catch (err) {
|
|
1884
|
-
this.ctx.logger.warn("recorder playback rescan failed", { meta: { error: index.errMsg(err) } });
|
|
1885
|
-
}
|
|
1886
|
-
}
|
|
1887
|
-
/** All recordings storage locations (every `recordings*` type) as
|
|
1888
|
-
* `{ id, root }`, resolving (and caching) each root. Falls back to the
|
|
1889
|
-
* single default root when the storage cap is unreachable. */
|
|
1890
|
-
async listRecordingsLocations() {
|
|
1891
|
-
try {
|
|
1892
|
-
const locs = await this.ctx.api.storage.listLocations.query({});
|
|
1893
|
-
const recordings = locs.filter((l) => l.type === "recordings" || l.type === "recordingsLow");
|
|
1894
|
-
if (recordings.length > 0) {
|
|
1895
|
-
return Promise.all(recordings.map(async (l) => ({ id: l.id, root: await this.resolveLocationRoot(l.id) })));
|
|
1896
|
-
}
|
|
1897
|
-
} catch (err) {
|
|
1898
|
-
this.ctx.logger.warn("recorder: listLocations failed — scanning default root only", { meta: { error: index.errMsg(err) } });
|
|
1899
|
-
}
|
|
1900
|
-
return [{ id: this.recordingLocationId, root: this.dataDir }];
|
|
1901
|
-
}
|
|
1902
|
-
/**
|
|
1903
|
-
* Resolve the recordings write root via the `storage` cap (the default
|
|
1904
|
-
* `recordings` location). Falls back to the addon-local data dir with no
|
|
1905
|
-
* locationId when the storage cap is unreachable at boot, so recording still
|
|
1906
|
-
* works degraded rather than not at all.
|
|
1907
|
-
*/
|
|
1908
|
-
async resolveRecordingsLocation() {
|
|
1909
|
-
try {
|
|
1910
|
-
const def = await this.ctx.api.storage.getDefaultLocation.query({ type: "recordings" });
|
|
1911
|
-
if (def) {
|
|
1912
|
-
const root = await this.ctx.api.storage.resolve.query({ location: def.id, relativePath: "" });
|
|
1913
|
-
return { root, locationId: def.id };
|
|
1914
|
-
}
|
|
1915
|
-
this.ctx.logger.warn("recorder: no default recordings storage location — using addon data dir");
|
|
1916
|
-
} catch (err) {
|
|
1917
|
-
this.ctx.logger.warn("recorder: storage cap unreachable — using addon data dir", { meta: { error: index.errMsg(err) } });
|
|
1918
|
-
}
|
|
1919
|
-
return { root: path.join(this.ctx.dataDir, "recordings"), locationId: void 0 };
|
|
1920
|
-
}
|
|
1921
|
-
/** Distinct recordings roots the playback server searches: the default
|
|
1922
|
-
* location root plus every location root we've resolved (write or scan). */
|
|
1923
|
-
playbackRoots() {
|
|
1924
|
-
return [.../* @__PURE__ */ new Set([this.dataDir, ...this.locationRootCache.values()])];
|
|
1925
|
-
}
|
|
1926
|
-
/** Absolute root of a storage location (cached `storage.resolve(id, '')`). */
|
|
1927
|
-
async resolveLocationRoot(locationId) {
|
|
1928
|
-
const cached = this.locationRootCache.get(locationId);
|
|
1929
|
-
if (cached) return cached;
|
|
1930
|
-
const root = await this.ctx.api.storage.resolve.query({ location: locationId, relativePath: "" });
|
|
1931
|
-
this.locationRootCache.set(locationId, root);
|
|
1932
|
-
return root;
|
|
1933
|
-
}
|
|
1934
|
-
/** A location's absolute root, or the default recordings root when the id is
|
|
1935
|
-
* absent (legacy) or unresolvable. */
|
|
1936
|
-
async locationRootOrDefault(locationId) {
|
|
1937
|
-
if (!locationId) return this.dataDir;
|
|
1938
|
-
try {
|
|
1939
|
-
return await this.resolveLocationRoot(locationId);
|
|
1940
|
-
} catch {
|
|
1941
|
-
return this.dataDir;
|
|
1942
|
-
}
|
|
1943
|
-
}
|
|
1944
|
-
/** Free bytes available on a location's volume (`statfs`); null on failure. */
|
|
1945
|
-
async locationFreeBytes(locationId) {
|
|
1946
|
-
try {
|
|
1947
|
-
const st = await fs.promises.statfs(await this.resolveLocationRoot(locationId));
|
|
1948
|
-
return st.bavail * st.bsize;
|
|
1949
|
-
} catch {
|
|
1950
|
-
return null;
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
/**
|
|
1954
|
-
* The GLOBAL write location for a profile/track — camera-independent, so all
|
|
1955
|
-
* cameras record that profile to the SAME location. The placement-assigned
|
|
1956
|
-
* location(s) are chosen by free space (least-full wins) ONCE and cached.
|
|
1957
|
-
* Returns `null` when the profile is UNASSIGNED — there is no fallback; the
|
|
1958
|
-
* caller must fail the recording explicitly. (Free-space-driven switching
|
|
1959
|
-
* when a disk fills is part of the deferred multi-location structure rework.)
|
|
1960
|
-
*/
|
|
1961
|
-
async resolveGlobalProfileLocation(profile) {
|
|
1962
|
-
const cached = this.profileWriteLocation.get(profile);
|
|
1963
|
-
if (cached) return cached;
|
|
1964
|
-
const ids = resolveAssignedLocations(this.placement, profile);
|
|
1965
|
-
if (ids.length === 0) return null;
|
|
1966
|
-
const free = await Promise.all(
|
|
1967
|
-
ids.map(async (id) => ({ locationId: id, availableBytes: await this.locationFreeBytes(id) }))
|
|
1968
|
-
);
|
|
1969
|
-
const chosen = chooseWriteLocation(ids, free);
|
|
1970
|
-
if (chosen === null) return null;
|
|
1971
|
-
const choice = { locationId: chosen, root: await this.resolveLocationRoot(chosen) };
|
|
1972
|
-
this.profileWriteLocation.set(profile, choice);
|
|
1973
|
-
return choice;
|
|
1974
|
-
}
|
|
1975
|
-
/**
|
|
1976
|
-
* Resolve where each profile's segments are written for this camera: the
|
|
1977
|
-
* global per-profile location's root + `<deviceId>/<profile>`. THROWS a clear
|
|
1978
|
-
* error when any profile has no assigned storage location — recording must
|
|
1979
|
-
* fail explicitly rather than write somewhere unintended.
|
|
1980
|
-
*/
|
|
1981
|
-
async resolveProfilePlacements(deviceId, profiles) {
|
|
1982
|
-
const out = /* @__PURE__ */ new Map();
|
|
1983
|
-
const unassigned = [];
|
|
1984
|
-
for (const profile of profiles) {
|
|
1985
|
-
const choice = await this.resolveGlobalProfileLocation(profile);
|
|
1986
|
-
if (choice === null) {
|
|
1987
|
-
unassigned.push(profile);
|
|
1988
|
-
continue;
|
|
1989
|
-
}
|
|
1990
|
-
out.set(profile, { outDir: path.join(choice.root, String(deviceId), profile), locationId: choice.locationId });
|
|
1991
|
-
}
|
|
1992
|
-
if (unassigned.length > 0) {
|
|
1993
|
-
throw new Error(
|
|
1994
|
-
`recorder: no storage location assigned for profile(s) [${unassigned.join(", ")}] — assign one in recording settings before enabling recording`
|
|
1995
|
-
);
|
|
1996
|
-
}
|
|
1997
|
-
return out;
|
|
1998
|
-
}
|
|
1999
|
-
// ── Per-device settings (device-details recording widget) ─────────────
|
|
2000
|
-
/** Build the device-details recording contribution: the widget section only. */
|
|
2001
|
-
async buildDeviceSettingsContribution() {
|
|
2002
|
-
if (!this.ctxIfReady?.settings) return null;
|
|
2003
|
-
return index.hydrateSchema(buildRecordingDeviceSchema(), {});
|
|
2004
|
-
}
|
|
2005
|
-
// ── Durable enable (persisted recording targets) ───────────────────────
|
|
2006
|
-
/** Best-effort persist of one camera's recording intent. */
|
|
2007
|
-
async persistTarget(input) {
|
|
2008
|
-
const settings = this.ctx.settings;
|
|
2009
|
-
if (!settings) return;
|
|
2010
|
-
try {
|
|
2011
|
-
await upsertRecordingTarget(settings, input.deviceId, {
|
|
2012
|
-
enabled: true,
|
|
2013
|
-
profiles: input.profiles ? [...input.profiles] : void 0,
|
|
2014
|
-
segmentSeconds: input.segmentSeconds,
|
|
2015
|
-
rules: input.rules ? [...input.rules] : void 0,
|
|
2016
|
-
retention: input.retention
|
|
2017
|
-
});
|
|
2018
|
-
} catch (err) {
|
|
2019
|
-
this.ctx.logger.warn("recorder persist target failed", {
|
|
2020
|
-
meta: { deviceId: input.deviceId, error: index.errMsg(err) }
|
|
2021
|
-
});
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
/** Best-effort removal of one camera's persisted recording intent. */
|
|
2025
|
-
async clearTarget(deviceId) {
|
|
2026
|
-
const settings = this.ctx.settings;
|
|
2027
|
-
if (!settings) return;
|
|
2028
|
-
try {
|
|
2029
|
-
await removeRecordingTarget(settings, deviceId);
|
|
2030
|
-
} catch (err) {
|
|
2031
|
-
this.ctx.logger.warn("recorder clear target failed", { meta: { deviceId, error: index.errMsg(err) } });
|
|
2032
|
-
}
|
|
2033
|
-
}
|
|
2034
|
-
/**
|
|
2035
|
-
* Re-attach every persisted enabled camera once the hub is reachable
|
|
2036
|
-
* (`ctx.api.*` is safe here, not in `onInitialize`). Per-device failures —
|
|
2037
|
-
* a camera that's offline at boot — are logged and kept (they resume on the
|
|
2038
|
-
* next attach), never aborting the rest of the restore.
|
|
2039
|
-
*/
|
|
2040
|
-
async onHubReachable() {
|
|
2041
|
-
const settings = this.ctx.settings;
|
|
2042
|
-
if (!settings) return;
|
|
2043
|
-
const targets = await readRecordingTargets(settings);
|
|
2044
|
-
const enabled = [...targets].filter(([, t]) => t.enabled);
|
|
2045
|
-
if (enabled.length === 0) return;
|
|
2046
|
-
try {
|
|
2047
|
-
await this.ctx.acquireCapability("stream-broker", { type: "node", nodeId: this.nodeId }, { timeoutMs: 6e4 });
|
|
2048
|
-
} catch (err) {
|
|
2049
|
-
this.ctx.logger.warn("recorder restore: stream-broker not ready in time, attempting anyway", {
|
|
2050
|
-
meta: { error: index.errMsg(err) }
|
|
2051
|
-
});
|
|
2052
|
-
}
|
|
2053
|
-
this.ctx.logger.info("recorder restoring recording targets", { meta: { count: enabled.length } });
|
|
2054
|
-
for (const [deviceId, target] of enabled) {
|
|
2055
|
-
this.deviceTargets.set(deviceId, target);
|
|
2056
|
-
try {
|
|
2057
|
-
await this.attachCamera({ deviceId, profiles: target.profiles, segmentSeconds: target.segmentSeconds, rules: target.rules, retention: target.retention });
|
|
2058
|
-
} catch (err) {
|
|
2059
|
-
this.ctx.logger.warn("recorder restore attach failed", {
|
|
2060
|
-
tags: { deviceId },
|
|
2061
|
-
meta: { error: index.errMsg(err) }
|
|
2062
|
-
});
|
|
2063
|
-
}
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
stopWatchers(deviceId) {
|
|
2067
|
-
const prefix = `${deviceId}/`;
|
|
2068
|
-
for (const [key, handle] of [...this.watchers]) {
|
|
2069
|
-
if (!key.startsWith(prefix)) continue;
|
|
2070
|
-
handle.stop();
|
|
2071
|
-
this.watchers.delete(key);
|
|
2072
|
-
}
|
|
2073
|
-
}
|
|
2074
|
-
// ── Source resolution (broker single-dial) ─────────────────────────────
|
|
2075
|
-
/**
|
|
2076
|
-
* Pick which profiles to record for a device. The recorder enumerates the
|
|
2077
|
-
* broker's ASSIGNED profile slots, deduped by physical source
|
|
2078
|
-
* (`selectAssignedProfileSlots`) so the same camera encoder is never recorded
|
|
2079
|
-
* twice. An operator `override` (the per-device "Profiles" multiselect)
|
|
2080
|
-
* DISABLES the unselected profiles: the result is the assigned set INTERSECTED
|
|
2081
|
-
* with the selection. Per the spec, recording keeps a MINIMUM OF 1 — if the
|
|
2082
|
-
* selection matches none of the assigned sources (stale pick / wrong camera),
|
|
2083
|
-
* it's ignored and every assigned profile is recorded. Empty/absent override =
|
|
2084
|
-
* record every assigned source.
|
|
2085
|
-
*/
|
|
2086
|
-
async resolveRecordingProfiles(deviceId, override) {
|
|
2087
|
-
const api = this.ctx.api;
|
|
2088
|
-
if (!api) throw new Error(`RecorderAddon.resolveRecordingProfiles: ctx.api unavailable (device ${deviceId})`);
|
|
2089
|
-
const slots = await api.streamBroker.listAllProfileSlots.query();
|
|
2090
|
-
const assigned = index.selectAssignedProfileSlots(slots, deviceId).map((slot) => slot.profile);
|
|
2091
|
-
if (!override || override.length === 0) return assigned;
|
|
2092
|
-
const selected = assigned.filter((p) => override.includes(p));
|
|
2093
|
-
return selected.length > 0 ? selected : assigned;
|
|
2094
|
-
}
|
|
2095
|
-
/**
|
|
2096
|
-
* Acquire a demand-counted broker stream for one (deviceId, profile) via
|
|
2097
|
-
* `getStreamWithCodec` (passthrough — `video:'copy'`). The returned
|
|
2098
|
-
* `pipelineKey` is released on detach, so the recorder rides the broker's
|
|
2099
|
-
* single source dial instead of opening a second camera connection.
|
|
2100
|
-
*/
|
|
2101
|
-
async acquireSource(deviceId, profile) {
|
|
2102
|
-
const api = this.ctx.api;
|
|
2103
|
-
if (!api) throw new Error(`RecorderAddon.acquireSource: ctx.api unavailable (device ${deviceId})`);
|
|
2104
|
-
const source = await api.streamBroker.getStreamWithCodec.mutate({
|
|
2105
|
-
deviceId,
|
|
2106
|
-
video: "copy",
|
|
2107
|
-
audio: "aac",
|
|
2108
|
-
profile,
|
|
2109
|
-
tag: `recorder:${deviceId}/${profile}`
|
|
2110
|
-
});
|
|
2111
|
-
return { url: source.url, pipelineKey: source.pipelineKey };
|
|
2112
|
-
}
|
|
2113
|
-
async releaseSource(pipelineKey) {
|
|
2114
|
-
const api = this.ctx.api;
|
|
2115
|
-
if (!api) return;
|
|
2116
|
-
try {
|
|
2117
|
-
await api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey });
|
|
2118
|
-
} catch (err) {
|
|
2119
|
-
this.ctx.logger.warn("releaseStreamWithCodec failed", {
|
|
2120
|
-
meta: { pipelineKey, error: index.errMsg(err) }
|
|
2121
|
-
});
|
|
2122
|
-
}
|
|
2123
|
-
}
|
|
2124
|
-
// ── Playback manifest ─────────────────────────────────────────────────
|
|
2125
|
-
/**
|
|
2126
|
-
* Build a master + per-profile variant playlist on disk for the
|
|
2127
|
-
* requested window and return the local master path. Returns
|
|
2128
|
-
* `localMasterPath: null` when the camera has no recorded segments in
|
|
2129
|
-
* the range.
|
|
2130
|
-
*/
|
|
2131
|
-
/**
|
|
2132
|
-
* Probe a profile's video codec + resolution for the master playlist, reading
|
|
2133
|
-
* only the head of one segment (the `moov` sits at the start) and caching per
|
|
2134
|
-
* (device, profile) — the camera's encoding is stable. Returns null on any
|
|
2135
|
-
* read/parse failure (the variant then ships without CODECS, as before).
|
|
2136
|
-
*/
|
|
2137
|
-
async probeProfileCodec(deviceId, profile, segAbsPath) {
|
|
2138
|
-
const key = `${deviceId}/${profile}`;
|
|
2139
|
-
const cached = this.profileCodecCache.get(key);
|
|
2140
|
-
if (cached) return cached;
|
|
2141
|
-
try {
|
|
2142
|
-
const fh = await fs.promises.open(segAbsPath, "r");
|
|
2143
|
-
try {
|
|
2144
|
-
const buf = Buffer.alloc(65536);
|
|
2145
|
-
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
|
2146
|
-
const info = extractVideoCodecInfo(buf.subarray(0, bytesRead));
|
|
2147
|
-
if (!info) return null;
|
|
2148
|
-
const result = { codecs: info.codec, resolution: { width: info.width, height: info.height } };
|
|
2149
|
-
this.profileCodecCache.set(key, result);
|
|
2150
|
-
return result;
|
|
2151
|
-
} finally {
|
|
2152
|
-
await fh.close();
|
|
2153
|
-
}
|
|
2154
|
-
} catch {
|
|
2155
|
-
return null;
|
|
2156
|
-
}
|
|
2157
|
-
}
|
|
2158
|
-
async buildPlaybackManifest(deviceId, fromMs, toMs) {
|
|
2159
|
-
const core2 = this.core;
|
|
2160
|
-
if (!core2) return { deviceId, localMasterPath: null, playbackUrl: null, playbackEndpoints: [] };
|
|
2161
|
-
const profiles = core2.getProfiles(deviceId);
|
|
2162
|
-
const deviceDir = path.join(this.dataDir, String(deviceId));
|
|
2163
|
-
const variants = [];
|
|
2164
|
-
for (const profile of profiles) {
|
|
2165
|
-
const segments = core2.listSegments(deviceId, profile, fromMs, toMs);
|
|
2166
|
-
if (segments.length === 0) continue;
|
|
2167
|
-
const variantSegments = [];
|
|
2168
|
-
let firstSegAbs = null;
|
|
2169
|
-
for (const s of segments) {
|
|
2170
|
-
const rel = segmentRelPath(String(deviceId), profile, s.subtree, s.startMs, s.durMs);
|
|
2171
|
-
const segRoot = await this.locationRootOrDefault(s.locationId);
|
|
2172
|
-
const segAbs = path.join(segRoot, rel);
|
|
2173
|
-
try {
|
|
2174
|
-
await fs.promises.stat(segAbs);
|
|
2175
|
-
} catch {
|
|
2176
|
-
continue;
|
|
2177
|
-
}
|
|
2178
|
-
if (firstSegAbs === null) firstSegAbs = segAbs;
|
|
2179
|
-
variantSegments.push({ uri: rel.split("/").slice(1).join("/"), startMs: s.startMs, durMs: s.durMs });
|
|
2180
|
-
}
|
|
2181
|
-
if (variantSegments.length === 0) continue;
|
|
2182
|
-
const variantBody = buildVariantPlaylist(variantSegments);
|
|
2183
|
-
const variantPath = path.join(deviceDir, `${profile}.m3u8`);
|
|
2184
|
-
await fs.promises.mkdir(deviceDir, { recursive: true });
|
|
2185
|
-
await fs.promises.writeFile(variantPath, variantBody, "utf8");
|
|
2186
|
-
const codec = firstSegAbs ? await this.probeProfileCodec(deviceId, profile, firstSegAbs) : null;
|
|
2187
|
-
variants.push({
|
|
2188
|
-
profile,
|
|
2189
|
-
bandwidth: PROFILE_BANDWIDTH[profile] ?? DEFAULT_BANDWIDTH,
|
|
2190
|
-
uri: `${profile}.m3u8`,
|
|
2191
|
-
...codec ? { codecs: codec.codecs, resolution: codec.resolution } : {}
|
|
2192
|
-
});
|
|
2193
|
-
}
|
|
2194
|
-
if (variants.length === 0) return { deviceId, localMasterPath: null, playbackUrl: null, playbackEndpoints: [] };
|
|
2195
|
-
const masterPath = path.join(deviceDir, "master.m3u8");
|
|
2196
|
-
await fs.promises.mkdir(deviceDir, { recursive: true });
|
|
2197
|
-
await fs.promises.writeFile(masterPath, buildMasterPlaylist(variants), "utf8");
|
|
2198
|
-
const playbackUrl = `/addon/recorder/playback/${deviceId}/master.m3u8?from=${fromMs}&to=${toMs}`;
|
|
2199
|
-
return { deviceId, localMasterPath: masterPath, playbackUrl, playbackEndpoints: [playbackUrl] };
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
function localClock(ms) {
|
|
2203
|
-
const d = new Date(ms);
|
|
2204
|
-
return { weekday: d.getDay(), minutesOfDay: d.getHours() * 60 + d.getMinutes() };
|
|
2205
|
-
}
|
|
2206
|
-
exports.RecorderCore = RecorderCore;
|
|
2207
|
-
exports.customActions = recorderActions;
|
|
2208
|
-
exports.default = RecorderAddon;
|
|
2209
|
-
//# sourceMappingURL=index.js.map
|
|
2096
|
+
//#endregion
|
|
2097
|
+
module.exports = RecorderV2Addon;
|