@camstack/addon-pipeline 0.1.18 → 0.1.20
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 +12 -4
- package/dist/audio-analyzer/index.js.map +1 -1
- package/dist/audio-analyzer/index.mjs +12 -4
- package/dist/audio-analyzer/index.mjs.map +1 -1
- package/dist/audio-codec-nodeav/index.js +1 -1
- package/dist/audio-codec-nodeav/index.mjs +1 -1
- package/dist/decoder-nodeav/index.js +2 -2
- package/dist/decoder-nodeav/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +47 -44
- package/dist/detection-pipeline/index.js.map +1 -1
- package/dist/detection-pipeline/index.mjs +47 -44
- package/dist/detection-pipeline/index.mjs.map +1 -1
- package/dist/{index-asZs8U_s.mjs → index-5aYef068.mjs} +4020 -820
- package/dist/index-5aYef068.mjs.map +1 -0
- package/dist/{index-DLHaHm6u.js → index-B36NMAdu.js} +3996 -796
- package/dist/index-B36NMAdu.js.map +1 -0
- package/dist/{index-D_cl0Qqb.js → index-CMcx_k6Y.js} +48 -48
- package/dist/{index-D_cl0Qqb.js.map → index-CMcx_k6Y.js.map} +1 -1
- package/dist/{index-UbcdLS7a.mjs → index-CYb7cFrv.mjs} +46 -46
- package/dist/{index-UbcdLS7a.mjs.map → index-CYb7cFrv.mjs.map} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +205 -90
- package/dist/pipeline-runner/index.js.map +1 -1
- package/dist/pipeline-runner/index.mjs +206 -91
- package/dist/pipeline-runner/index.mjs.map +1 -1
- package/dist/recorder/index.js +2209 -0
- package/dist/recorder/index.js.map +1 -0
- package/dist/recorder/index.mjs +2209 -0
- package/dist/recorder/index.mjs.map +1 -0
- package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/FfmpegParamsField.d.ts +41 -0
- package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/GeometryBuilder.d.ts +54 -0
- package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/format-ua.d.ts +13 -0
- package/dist/stream-broker/@mf-types/compiled-types/stream-broker/widgets/index.d.ts +2 -0
- 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-h5aXOPSA.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-lantnv8e.mjs} +1 -1
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-DJ3UNg7O.mjs +30 -0
- 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 +21 -0
- package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-DAssX3h0.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs-CaDEYBIU.mjs} +9 -7
- package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-DFoJJhpt.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-D6EROtlA.mjs} +1 -1
- package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-gBEZsQrp.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-x6pP3Ghk.mjs} +2 -2
- package/dist/stream-broker/{__mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-x7XMEeuJ.mjs → __mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-CcnN6sbA.mjs} +1 -1
- package/dist/stream-broker/_stub.js +963 -333
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-3TxRVJ5L.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CL9DR49k.mjs} +6 -6
- package/dist/stream-broker/{client-CZXrddDR.mjs → client-BvTmMOQu.mjs} +2 -2
- package/dist/stream-broker/{hostInit-De6APW25.mjs → hostInit-ChmiMPS0.mjs} +12 -12
- package/dist/stream-broker/{index-cYW01SNH.mjs → index-BxsFuFmE.mjs} +24 -24
- package/dist/stream-broker/{index-KtR7Pp0O.mjs → index-C-248uOU.mjs} +2 -2
- package/dist/stream-broker/{index-C0BzaWmB.mjs → index-C05B6jqp.mjs} +1 -1
- package/dist/stream-broker/index-DOJoSShD.mjs +67784 -0
- package/dist/stream-broker/index-DtOI1aTU.mjs +18504 -0
- package/dist/stream-broker/{index-BvV3RVTZ.mjs → index-oMq6ilgR.mjs} +254 -268
- package/dist/stream-broker/{index-CZNxa0ad.mjs → index-vIWZQBIL.mjs} +1 -1
- package/dist/stream-broker/index.js +4666 -756
- package/dist/stream-broker/index.js.map +1 -1
- package/dist/stream-broker/index.mjs +4668 -756
- package/dist/stream-broker/index.mjs.map +1 -1
- package/dist/stream-broker/{jsx-runtime-B_evVsXl.mjs → jsx-runtime-BRT_HL0A.mjs} +1 -1
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/stream-broker/{schemas-ChN4Ih0h.mjs → schemas-B7L0qZtq.mjs} +530 -515
- package/package.json +51 -3
- package/dist/index-DLHaHm6u.js.map +0 -1
- package/dist/index-asZs8U_s.mjs.map +0 -1
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-d8PmLbO2.mjs +0 -19
- package/dist/stream-broker/__mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-B4l8Nb2y.mjs +0 -20
- package/dist/stream-broker/index-CUXiTSWS.mjs +0 -13883
- package/dist/stream-broker/index-Kb4xa8FX.mjs +0 -36403
|
@@ -0,0 +1,2209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
|
|
3
|
+
const node_child_process = require("node:child_process");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const index = require("../index-B36NMAdu.js");
|
|
7
|
+
const core = require("@camstack/core");
|
|
8
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
9
|
+
const DEFAULT_SUBTREE = "continuous";
|
|
10
|
+
function hourBucketRelPath(startMs) {
|
|
11
|
+
const d = new Date(startMs);
|
|
12
|
+
return `${d.getUTCFullYear()}/${pad(d.getUTCMonth() + 1)}/${pad(d.getUTCDate())}/${pad(d.getUTCHours())}`;
|
|
13
|
+
}
|
|
14
|
+
function bucketRelPath(camera, profile, subtree, startMs) {
|
|
15
|
+
return `${camera}/${profile}/${subtree}/${hourBucketRelPath(startMs)}`;
|
|
16
|
+
}
|
|
17
|
+
function segmentRelPath(camera, profile, subtree, startMs, durMs) {
|
|
18
|
+
return `${bucketRelPath(camera, profile, subtree, startMs)}/${startMs}-${durMs}.m4s`;
|
|
19
|
+
}
|
|
20
|
+
function parseSegmentRelPath(rel) {
|
|
21
|
+
const parts = rel.split("/");
|
|
22
|
+
const hasSubtree = parts.length === 8;
|
|
23
|
+
if (parts.length !== 7 && parts.length !== 8) return null;
|
|
24
|
+
const camera = parts[0];
|
|
25
|
+
const profile = parts[1];
|
|
26
|
+
const subtreeRaw = hasSubtree ? parts[2] : DEFAULT_SUBTREE;
|
|
27
|
+
if (subtreeRaw !== "continuous" && subtreeRaw !== "events") return null;
|
|
28
|
+
const file = parts[parts.length - 1];
|
|
29
|
+
const m = /^(\d+)-(\d+)\.m4s$/.exec(file);
|
|
30
|
+
if (!m) return null;
|
|
31
|
+
return { camera, profile, subtree: subtreeRaw, startMs: Number(m[1]), durMs: Number(m[2]) };
|
|
32
|
+
}
|
|
33
|
+
const HOUR_MS$1 = 36e5;
|
|
34
|
+
class RamIndex {
|
|
35
|
+
byCam = /* @__PURE__ */ new Map();
|
|
36
|
+
static fromScan(entries) {
|
|
37
|
+
const idx = new RamIndex();
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
const p = parseSegmentRelPath(e.relPath);
|
|
40
|
+
if (p) idx.add(p.camera, p.profile, p.startMs, p.durMs, e.bytes, e.locationId, p.subtree);
|
|
41
|
+
}
|
|
42
|
+
return idx;
|
|
43
|
+
}
|
|
44
|
+
add(camera, profile, startMs, durMs, bytes, locationId, subtree = DEFAULT_SUBTREE) {
|
|
45
|
+
let profiles = this.byCam.get(camera);
|
|
46
|
+
if (!profiles) {
|
|
47
|
+
profiles = /* @__PURE__ */ new Map();
|
|
48
|
+
this.byCam.set(camera, profiles);
|
|
49
|
+
}
|
|
50
|
+
let segs = profiles.get(profile);
|
|
51
|
+
if (!segs) {
|
|
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
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const HOUR_MS = 36e5;
|
|
220
|
+
function planRetention(buckets, policy, nowMs) {
|
|
221
|
+
const sorted = [...buckets].sort((a, b) => a.hourStartMs - b.hourStartMs);
|
|
222
|
+
const survivors = [];
|
|
223
|
+
const doomed = [];
|
|
224
|
+
if (policy.maxAgeMs != null) {
|
|
225
|
+
const cutoff = nowMs - policy.maxAgeMs;
|
|
226
|
+
for (const b of sorted) {
|
|
227
|
+
if (b.hourStartMs + HOUR_MS <= cutoff) doomed.push({ ...b, reason: "age" });
|
|
228
|
+
else survivors.push(b);
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
survivors.push(...sorted);
|
|
232
|
+
}
|
|
233
|
+
if (policy.maxSizeBytes != null) {
|
|
234
|
+
let total = survivors.reduce((sum, b) => sum + b.bytes, 0);
|
|
235
|
+
while (total > policy.maxSizeBytes && survivors.length > 1) {
|
|
236
|
+
const oldest = survivors.shift();
|
|
237
|
+
total -= oldest.bytes;
|
|
238
|
+
doomed.push({ ...oldest, reason: "size" });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
doomed.sort((a, b) => a.hourStartMs - b.hourStartMs);
|
|
242
|
+
return {
|
|
243
|
+
deleteBuckets: doomed,
|
|
244
|
+
reclaimedBytes: doomed.reduce((sum, b) => sum + b.bytes, 0)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function toMinutes(hhmm) {
|
|
248
|
+
const [h, m] = hhmm.split(":");
|
|
249
|
+
return Number(h) * 60 + Number(m);
|
|
250
|
+
}
|
|
251
|
+
function isScheduleActive(schedule, clock) {
|
|
252
|
+
if (schedule.kind === "always") return true;
|
|
253
|
+
if (schedule.days && !schedule.days.includes(clock.weekday)) return false;
|
|
254
|
+
const start = toMinutes(schedule.start);
|
|
255
|
+
const end = toMinutes(schedule.end);
|
|
256
|
+
const m = clock.minutesOfDay;
|
|
257
|
+
if (start === end) return false;
|
|
258
|
+
if (start < end) return m >= start && m < end;
|
|
259
|
+
return m >= start || m < end;
|
|
260
|
+
}
|
|
261
|
+
function isRecordingDemanded(rules, clock, triggers, nowMs) {
|
|
262
|
+
for (const r of rules) {
|
|
263
|
+
if (!isScheduleActive(r.schedule, clock)) continue;
|
|
264
|
+
if (r.mode === "continuous") return true;
|
|
265
|
+
const lastMs = r.mode === "onMotion" ? triggers.lastMotionMs : triggers.lastAudioMs;
|
|
266
|
+
if (lastMs != null && nowMs - lastMs <= r.postBufferSec * 1e3) return true;
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
function isContinuousActive(rules, clock) {
|
|
271
|
+
return rules.some((r) => r.mode === "continuous" && isScheduleActive(r.schedule, clock));
|
|
272
|
+
}
|
|
273
|
+
function isSegmentDemanded(rules, clock, triggers, segStartMs, segEndMs) {
|
|
274
|
+
for (const r of rules) {
|
|
275
|
+
if (!isScheduleActive(r.schedule, clock)) continue;
|
|
276
|
+
if (r.mode === "continuous") return true;
|
|
277
|
+
const lastMs = r.mode === "onMotion" ? triggers.lastMotionMs : triggers.lastAudioMs;
|
|
278
|
+
if (lastMs == null) continue;
|
|
279
|
+
const winStart = lastMs - r.preBufferSec * 1e3;
|
|
280
|
+
const winEnd = lastMs + r.postBufferSec * 1e3;
|
|
281
|
+
if (segEndMs >= winStart && segStartMs <= winEnd) return true;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
class RecorderCore {
|
|
286
|
+
constructor(deps) {
|
|
287
|
+
this.deps = deps;
|
|
288
|
+
}
|
|
289
|
+
cameras = /* @__PURE__ */ new Map();
|
|
290
|
+
/**
|
|
291
|
+
* Index of ALL recordings on disk (attached or not), rebuilt from a periodic
|
|
292
|
+
* disk scan. Read-only fallback for queries about a camera that isn't
|
|
293
|
+
* currently attached — so past recordings stay playable after detach/restart.
|
|
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
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async function pruneDeviceWithEvents(core2, deviceId, nowMs, resolvePolicy, removeBucketDir, api, logger) {
|
|
742
|
+
const result = await core2.pruneFootageForDevice(
|
|
743
|
+
deviceId,
|
|
744
|
+
nowMs,
|
|
745
|
+
resolvePolicy,
|
|
746
|
+
removeBucketDir
|
|
747
|
+
);
|
|
748
|
+
if (result.deletedBuckets > 0) {
|
|
749
|
+
logger.info("recorder footage eviction", {
|
|
750
|
+
meta: {
|
|
751
|
+
deviceId,
|
|
752
|
+
deletedBuckets: result.deletedBuckets,
|
|
753
|
+
reclaimedBytes: result.reclaimedBytes,
|
|
754
|
+
floorMs: result.floorMs
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
if (result.floorMs == null) return;
|
|
759
|
+
try {
|
|
760
|
+
await api.pipelineAnalytics.pruneEventsBefore.mutate({ deviceId, cutoffMs: result.floorMs });
|
|
761
|
+
} catch (err) {
|
|
762
|
+
logger.warn("recorder: pruneEventsBefore failed", {
|
|
763
|
+
meta: { deviceId, error: index.errMsg(err) }
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const MS_PER_DAY = 864e5;
|
|
768
|
+
const BYTES_PER_GB = 1e9;
|
|
769
|
+
function resolveDeviceRetention(override, defaultAgeDays, defaultSizeGb) {
|
|
770
|
+
const ageDays = override?.maxAgeDays && override.maxAgeDays > 0 ? override.maxAgeDays : defaultAgeDays;
|
|
771
|
+
const sizeGb = override?.maxSizeGb && override.maxSizeGb > 0 ? override.maxSizeGb : defaultSizeGb;
|
|
772
|
+
return {
|
|
773
|
+
maxAgeMs: ageDays > 0 ? ageDays * MS_PER_DAY : void 0,
|
|
774
|
+
maxSizeBytes: sizeGb > 0 ? sizeGb * BYTES_PER_GB : void 0
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
const RecordingTargetSchema = index.RecordingConfigSchema;
|
|
778
|
+
const RecordingTargetsBlobSchema = index.record(index.string(), RecordingTargetSchema);
|
|
779
|
+
const RECORDING_TARGETS_KEY = "recordingTargets";
|
|
780
|
+
function targetsState(store) {
|
|
781
|
+
return index.createDurableState({
|
|
782
|
+
key: RECORDING_TARGETS_KEY,
|
|
783
|
+
schema: RecordingTargetsBlobSchema,
|
|
784
|
+
fallback: {},
|
|
785
|
+
read: () => store.readAddonStore(),
|
|
786
|
+
write: (patch) => store.writeAddonStore(patch)
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
async function readRecordingTargets(store) {
|
|
790
|
+
const blob = await targetsState(store).get();
|
|
791
|
+
const map = /* @__PURE__ */ new Map();
|
|
792
|
+
for (const [key, target] of Object.entries(blob)) {
|
|
793
|
+
const id = Number(key);
|
|
794
|
+
if (Number.isInteger(id)) map.set(id, target);
|
|
795
|
+
}
|
|
796
|
+
return map;
|
|
797
|
+
}
|
|
798
|
+
async function upsertRecordingTarget(store, deviceId, target) {
|
|
799
|
+
const map = await readRecordingTargets(store);
|
|
800
|
+
map.set(deviceId, target);
|
|
801
|
+
await targetsState(store).set(serialize(map));
|
|
802
|
+
}
|
|
803
|
+
async function removeRecordingTarget(store, deviceId) {
|
|
804
|
+
const map = await readRecordingTargets(store);
|
|
805
|
+
if (!map.delete(deviceId)) return;
|
|
806
|
+
await targetsState(store).set(serialize(map));
|
|
807
|
+
}
|
|
808
|
+
function serialize(map) {
|
|
809
|
+
const out = {};
|
|
810
|
+
for (const [id, target] of map) out[String(id)] = target;
|
|
811
|
+
return out;
|
|
812
|
+
}
|
|
813
|
+
function buildRecordingDeviceSchema() {
|
|
814
|
+
return {
|
|
815
|
+
sections: [
|
|
816
|
+
{
|
|
817
|
+
id: "recording-playback",
|
|
818
|
+
tab: "recording",
|
|
819
|
+
location: "top-tab",
|
|
820
|
+
title: "Recording",
|
|
821
|
+
order: 10,
|
|
822
|
+
fields: [
|
|
823
|
+
{ type: "widget", key: "_recordingPanel", label: "", widgetId: "host/recording-panel" }
|
|
824
|
+
]
|
|
825
|
+
}
|
|
826
|
+
]
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
const RecordingPlacementBlobSchema = index.object({
|
|
830
|
+
assignments: index.record(index.string(), index.array(index.string())).optional()
|
|
831
|
+
});
|
|
832
|
+
const DEFAULT_RECORDING_PLACEMENT = {
|
|
833
|
+
high: ["recordings:default"],
|
|
834
|
+
mid: ["recordings:default"],
|
|
835
|
+
low: ["recordingsLow:default"],
|
|
836
|
+
scrub: ["recordingsLow:default"]
|
|
837
|
+
};
|
|
838
|
+
function resolveAssignedLocations(placement, key) {
|
|
839
|
+
return placement.assignments[key] ?? [];
|
|
840
|
+
}
|
|
841
|
+
function chooseWriteLocation(locationIds, free) {
|
|
842
|
+
let best = null;
|
|
843
|
+
let bestBytes = -Infinity;
|
|
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;
|
|
853
|
+
}
|
|
854
|
+
function buildPassthroughArgs(a) {
|
|
855
|
+
return [
|
|
856
|
+
"-rtsp_transport",
|
|
857
|
+
"tcp",
|
|
858
|
+
"-i",
|
|
859
|
+
a.rtspUrl,
|
|
860
|
+
// Map ALL input streams (video + audio). Without this the segment muxer
|
|
861
|
+
// selects nothing and ffmpeg aborts with "Output file does not contain
|
|
862
|
+
// any stream".
|
|
863
|
+
"-map",
|
|
864
|
+
"0",
|
|
865
|
+
// Copy the (already-fragmentable) video, but always re-encode audio to AAC.
|
|
866
|
+
// The MP4 segment muxer rejects raw G.711 (pcm_mulaw/pcm_alaw, common on
|
|
867
|
+
// Hikvision/ONVIF) with "codec not currently supported in container",
|
|
868
|
+
// which made ffmpeg exit before writing a single frame → 0-byte segments,
|
|
869
|
+
// no playlist, empty availability. The broker cannot report the source
|
|
870
|
+
// audio codec reliably (it advertises AAC even for µ-law streams), so we
|
|
871
|
+
// unconditionally transcode audio to AAC: cheap (low-bitrate mono/stereo)
|
|
872
|
+
// and muxable across every vendor. An `-c:a` with no audio stream is a no-op.
|
|
873
|
+
"-c:v",
|
|
874
|
+
"copy",
|
|
875
|
+
"-c:a",
|
|
876
|
+
"aac",
|
|
877
|
+
"-f",
|
|
878
|
+
"segment",
|
|
879
|
+
"-segment_time",
|
|
880
|
+
String(a.segmentSeconds),
|
|
881
|
+
"-segment_format",
|
|
882
|
+
"mp4",
|
|
883
|
+
"-segment_format_options",
|
|
884
|
+
"movflags=+frag_keyframe+empty_moov+default_base_moof",
|
|
885
|
+
"-reset_timestamps",
|
|
886
|
+
"1",
|
|
887
|
+
"-strftime",
|
|
888
|
+
"1",
|
|
889
|
+
"-segment_list",
|
|
890
|
+
`${a.outDir}/live.m3u8`,
|
|
891
|
+
"-segment_list_type",
|
|
892
|
+
"m3u8",
|
|
893
|
+
// FLAT epoch-named segments directly under `outDir`. We deliberately do
|
|
894
|
+
// NOT let ffmpeg write the date buckets: its segment muxer cannot create
|
|
895
|
+
// directories on this ffmpeg build (`-strftime_mkdir` is absent) and its
|
|
896
|
+
// `-strftime` expands in LOCAL time, whereas the playback layout
|
|
897
|
+
// (`segmentRelPath`) is UTC. The watcher relocates into the UTC bucket so
|
|
898
|
+
// disk + playback URIs stay consistent.
|
|
899
|
+
`${a.outDir}/%s.m4s`
|
|
900
|
+
];
|
|
901
|
+
}
|
|
902
|
+
const MAX_RESTARTS = 10;
|
|
903
|
+
const STDERR_TAIL_LINES = 12;
|
|
904
|
+
class SegmentWriter {
|
|
905
|
+
constructor(cfg, deps) {
|
|
906
|
+
this.cfg = cfg;
|
|
907
|
+
this.deps = deps;
|
|
908
|
+
}
|
|
909
|
+
proc = null;
|
|
910
|
+
stopped = false;
|
|
911
|
+
restarts = 0;
|
|
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
|
+
}
|
|
946
|
+
}
|
|
947
|
+
function buildVariantPlaylist(segments) {
|
|
948
|
+
const maxDur = segments.reduce((m, s) => Math.max(m, s.durMs), 0);
|
|
949
|
+
const lines = [
|
|
950
|
+
"#EXTM3U",
|
|
951
|
+
"#EXT-X-VERSION:7",
|
|
952
|
+
"#EXT-X-PLAYLIST-TYPE:VOD",
|
|
953
|
+
`#EXT-X-TARGETDURATION:${Math.ceil(maxDur / 1e3)}`,
|
|
954
|
+
"#EXT-X-MEDIA-SEQUENCE:0"
|
|
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";
|
|
964
|
+
}
|
|
965
|
+
function buildMasterPlaylist(variants) {
|
|
966
|
+
const lines = ["#EXTM3U", "#EXT-X-VERSION:7"];
|
|
967
|
+
for (const v of variants) {
|
|
968
|
+
const attrs = [`BANDWIDTH=${v.bandwidth}`];
|
|
969
|
+
if (v.resolution) attrs.push(`RESOLUTION=${v.resolution.width}x${v.resolution.height}`);
|
|
970
|
+
if (v.codecs) attrs.push(`CODECS="${v.codecs}"`);
|
|
971
|
+
attrs.push(`NAME="${v.profile}"`);
|
|
972
|
+
lines.push(`#EXT-X-STREAM-INF:${attrs.join(",")}`);
|
|
973
|
+
lines.push(v.uri);
|
|
974
|
+
}
|
|
975
|
+
return lines.join("\n") + "\n";
|
|
976
|
+
}
|
|
977
|
+
const EPOCH_NAME_RE = /^(\d+)\.m4s$/;
|
|
978
|
+
const MAX_PENDING_TICKS = 8;
|
|
979
|
+
function parseEpochStartMs(segPath) {
|
|
980
|
+
const m = EPOCH_NAME_RE.exec(path.basename(segPath));
|
|
981
|
+
if (!m) return null;
|
|
982
|
+
const epochSec = Number(m[1]);
|
|
983
|
+
return Number.isFinite(epochSec) ? epochSec * 1e3 : null;
|
|
984
|
+
}
|
|
985
|
+
function correctedDurMs(durSeconds, startMs, nextStartMs) {
|
|
986
|
+
const extinfMs = Math.round(durSeconds * 1e3);
|
|
987
|
+
if (nextStartMs === null) return extinfMs;
|
|
988
|
+
const gapMs = nextStartMs - startMs;
|
|
989
|
+
if (gapMs <= 0) return extinfMs;
|
|
990
|
+
return Math.min(extinfMs, gapMs);
|
|
991
|
+
}
|
|
992
|
+
function parseLivePlaylist(body) {
|
|
993
|
+
const lines = body.split("\n");
|
|
994
|
+
const out = [];
|
|
995
|
+
let pendingDur = null;
|
|
996
|
+
for (const raw of lines) {
|
|
997
|
+
const line = raw.trim();
|
|
998
|
+
if (line.length === 0) continue;
|
|
999
|
+
if (line.startsWith("#EXTINF:")) {
|
|
1000
|
+
const v = line.slice("#EXTINF:".length).replace(/,.*$/, "");
|
|
1001
|
+
const dur = Number(v);
|
|
1002
|
+
pendingDur = Number.isFinite(dur) ? dur : null;
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
if (line.startsWith("#")) continue;
|
|
1006
|
+
if (pendingDur !== null) {
|
|
1007
|
+
out.push({ durSeconds: pendingDur, segPath: line });
|
|
1008
|
+
pendingDur = null;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return out;
|
|
1012
|
+
}
|
|
1013
|
+
function resolveFinalizedSegment(outDir, segPath, durMs, subtree) {
|
|
1014
|
+
const abs = path.isAbsolute(segPath) ? segPath : path.join(outDir, segPath);
|
|
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 };
|
|
1019
|
+
}
|
|
1020
|
+
async function statSize(p) {
|
|
1021
|
+
try {
|
|
1022
|
+
return (await fs.promises.stat(p)).size;
|
|
1023
|
+
} catch {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
async function handleSegmentEntry(outDir, entry, durMs, classify, onSegment, logger) {
|
|
1028
|
+
const startMs = parseEpochStartMs(path.isAbsolute(entry.segPath) ? entry.segPath : path.join(outDir, entry.segPath));
|
|
1029
|
+
if (startMs === null) return "skipped";
|
|
1030
|
+
const subtree = classify(startMs, durMs);
|
|
1031
|
+
const resolved = resolveFinalizedSegment(outDir, entry.segPath, durMs, subtree);
|
|
1032
|
+
if (!resolved) return "skipped";
|
|
1033
|
+
const existing = await statSize(resolved.newAbs);
|
|
1034
|
+
if (existing !== null) {
|
|
1035
|
+
onSegment(resolved.startMs, resolved.durMs, existing, subtree);
|
|
1036
|
+
return "retained";
|
|
1037
|
+
}
|
|
1038
|
+
if (await statSize(resolved.oldAbs) === null) {
|
|
1039
|
+
logger.debug?.("segment flat file not on disk yet — will retry", { meta: { path: resolved.oldAbs } });
|
|
1040
|
+
return "pending";
|
|
1041
|
+
}
|
|
1042
|
+
try {
|
|
1043
|
+
await fs.promises.mkdir(path.dirname(resolved.newAbs), { recursive: true });
|
|
1044
|
+
await fs.promises.rename(resolved.oldAbs, resolved.newAbs);
|
|
1045
|
+
} catch {
|
|
1046
|
+
logger.debug?.("segment relocate failed", { meta: { from: resolved.oldAbs, to: resolved.newAbs } });
|
|
1047
|
+
}
|
|
1048
|
+
const bytes = await statSize(resolved.newAbs);
|
|
1049
|
+
if (bytes === null) return "pending";
|
|
1050
|
+
onSegment(resolved.startMs, resolved.durMs, bytes, subtree);
|
|
1051
|
+
return "retained";
|
|
1052
|
+
}
|
|
1053
|
+
function planFinalizations(body, processed) {
|
|
1054
|
+
const entries = parseLivePlaylist(body);
|
|
1055
|
+
const endlist = body.includes("#EXT-X-ENDLIST");
|
|
1056
|
+
const hw = entries.length < processed ? 0 : processed;
|
|
1057
|
+
const finalizable = endlist ? entries.length : Math.max(0, entries.length - 1);
|
|
1058
|
+
const toHandle = [];
|
|
1059
|
+
for (let i = hw; i < finalizable; i++) {
|
|
1060
|
+
const entry = entries[i];
|
|
1061
|
+
const startMs = parseEpochStartMs(entry.segPath);
|
|
1062
|
+
const nextStartMs = i + 1 < entries.length ? parseEpochStartMs(entries[i + 1].segPath) : null;
|
|
1063
|
+
const durMs = startMs === null ? Math.round(entry.durSeconds * 1e3) : correctedDurMs(entry.durSeconds, startMs, nextStartMs);
|
|
1064
|
+
toHandle.push({ entry, durMs });
|
|
1065
|
+
}
|
|
1066
|
+
return { toHandle, from: hw, processed: Math.max(hw, finalizable) };
|
|
1067
|
+
}
|
|
1068
|
+
function startSegmentWatcher(deps) {
|
|
1069
|
+
const playlistPath = path.join(deps.outDir, "live.m3u8");
|
|
1070
|
+
let processed = 0;
|
|
1071
|
+
let stopped = false;
|
|
1072
|
+
let ticking = false;
|
|
1073
|
+
let pendingIndex = -1;
|
|
1074
|
+
let pendingTicks = 0;
|
|
1075
|
+
const tick = async () => {
|
|
1076
|
+
if (stopped || ticking) return;
|
|
1077
|
+
ticking = true;
|
|
1078
|
+
try {
|
|
1079
|
+
let body;
|
|
1080
|
+
try {
|
|
1081
|
+
body = await fs.promises.readFile(playlistPath, "utf8");
|
|
1082
|
+
} catch {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
const plan = planFinalizations(body, processed);
|
|
1086
|
+
let index2 = plan.from;
|
|
1087
|
+
for (const { entry, durMs } of plan.toHandle) {
|
|
1088
|
+
const status = await handleSegmentEntry(deps.outDir, entry, durMs, deps.classify, deps.onSegment, deps.logger);
|
|
1089
|
+
if (status === "pending") {
|
|
1090
|
+
if (pendingIndex === index2) {
|
|
1091
|
+
pendingTicks++;
|
|
1092
|
+
if (pendingTicks >= MAX_PENDING_TICKS) {
|
|
1093
|
+
deps.logger.warn("segment file never landed — skipping", { meta: { seg: entry.segPath } });
|
|
1094
|
+
pendingIndex = -1;
|
|
1095
|
+
pendingTicks = 0;
|
|
1096
|
+
index2++;
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
} else {
|
|
1100
|
+
pendingIndex = index2;
|
|
1101
|
+
pendingTicks = 1;
|
|
1102
|
+
}
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
index2++;
|
|
1106
|
+
}
|
|
1107
|
+
if (pendingIndex !== index2) {
|
|
1108
|
+
pendingIndex = -1;
|
|
1109
|
+
pendingTicks = 0;
|
|
1110
|
+
}
|
|
1111
|
+
processed = index2;
|
|
1112
|
+
} finally {
|
|
1113
|
+
ticking = false;
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
const timer = setInterval(() => {
|
|
1117
|
+
void tick();
|
|
1118
|
+
}, deps.intervalMs);
|
|
1119
|
+
timer.unref?.();
|
|
1120
|
+
return {
|
|
1121
|
+
stop() {
|
|
1122
|
+
stopped = true;
|
|
1123
|
+
clearInterval(timer);
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
async function scanRecordings(rootDir, opts) {
|
|
1128
|
+
const locationId = opts?.locationId;
|
|
1129
|
+
const withSizes = opts?.withSizes ?? false;
|
|
1130
|
+
let names;
|
|
1131
|
+
try {
|
|
1132
|
+
names = await fs.promises.readdir(rootDir, { recursive: true });
|
|
1133
|
+
} catch {
|
|
1134
|
+
return [];
|
|
1135
|
+
}
|
|
1136
|
+
const entries = [];
|
|
1137
|
+
for (const name of names) {
|
|
1138
|
+
const rel = name.split(path.sep).join("/");
|
|
1139
|
+
if (!parseSegmentRelPath(rel)) continue;
|
|
1140
|
+
if (withSizes) {
|
|
1141
|
+
let st;
|
|
1142
|
+
try {
|
|
1143
|
+
st = await fs.promises.stat(path.join(rootDir, name));
|
|
1144
|
+
} catch {
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
entries.push({ relPath: rel, bytes: st.size, locationId });
|
|
1148
|
+
} else {
|
|
1149
|
+
entries.push({ relPath: rel, bytes: 0, locationId });
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return entries;
|
|
1153
|
+
}
|
|
1154
|
+
async function sizeRecordingsTree(rootDir) {
|
|
1155
|
+
let names;
|
|
1156
|
+
try {
|
|
1157
|
+
names = await fs.promises.readdir(rootDir, { recursive: true });
|
|
1158
|
+
} catch {
|
|
1159
|
+
return { perDevice: /* @__PURE__ */ new Map(), totalBytes: 0 };
|
|
1160
|
+
}
|
|
1161
|
+
const perDevice = /* @__PURE__ */ new Map();
|
|
1162
|
+
let totalBytes = 0;
|
|
1163
|
+
for (const name of names) {
|
|
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 };
|
|
1177
|
+
}
|
|
1178
|
+
function mergeRootUsage(usages) {
|
|
1179
|
+
const perDevice = /* @__PURE__ */ new Map();
|
|
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 };
|
|
1188
|
+
}
|
|
1189
|
+
const CONTAINER_BOXES = /* @__PURE__ */ new Set(["moov", "trak", "mdia", "minf", "stbl"]);
|
|
1190
|
+
const H264_CODINGS = /* @__PURE__ */ new Set(["avc1", "avc3"]);
|
|
1191
|
+
const HEVC_CODINGS = /* @__PURE__ */ new Set(["hvc1", "hev1"]);
|
|
1192
|
+
function readBoxHeader(buf, o) {
|
|
1193
|
+
if (o + 8 > buf.length) return null;
|
|
1194
|
+
let size = buf.readUInt32BE(o);
|
|
1195
|
+
let headerSize = 8;
|
|
1196
|
+
if (size === 1) {
|
|
1197
|
+
if (o + 16 > buf.length) return null;
|
|
1198
|
+
size = Number(buf.readBigUInt64BE(o + 8));
|
|
1199
|
+
headerSize = 16;
|
|
1200
|
+
}
|
|
1201
|
+
const type = buf.toString("latin1", o + 4, o + 8);
|
|
1202
|
+
if (size < headerSize) return null;
|
|
1203
|
+
return { size, type, headerSize };
|
|
1204
|
+
}
|
|
1205
|
+
function findChild(buf, start, end, wanted) {
|
|
1206
|
+
let o = start;
|
|
1207
|
+
while (o + 8 <= end) {
|
|
1208
|
+
const h = readBoxHeader(buf, o);
|
|
1209
|
+
if (!h) return null;
|
|
1210
|
+
if (h.type === wanted) return { contentStart: o + h.headerSize, end: Math.min(o + h.size, end) };
|
|
1211
|
+
o += h.size;
|
|
1212
|
+
}
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
function avcCodecString(buf, avcCContentStart) {
|
|
1216
|
+
const profile = buf.readUInt8(avcCContentStart + 1);
|
|
1217
|
+
const compat = buf.readUInt8(avcCContentStart + 2);
|
|
1218
|
+
const level = buf.readUInt8(avcCContentStart + 3);
|
|
1219
|
+
const hex = (n) => n.toString(16).padStart(2, "0");
|
|
1220
|
+
return `avc1.${hex(profile)}${hex(compat)}${hex(level)}`;
|
|
1221
|
+
}
|
|
1222
|
+
function extractVideoCodecInfo(buf) {
|
|
1223
|
+
let result = null;
|
|
1224
|
+
const walk = (start, end) => {
|
|
1225
|
+
let o = start;
|
|
1226
|
+
while (o + 8 <= end && !result) {
|
|
1227
|
+
const h = readBoxHeader(buf, o);
|
|
1228
|
+
if (!h) return;
|
|
1229
|
+
const boxEnd = Math.min(o + h.size, end);
|
|
1230
|
+
if (CONTAINER_BOXES.has(h.type)) {
|
|
1231
|
+
walk(o + h.headerSize, boxEnd);
|
|
1232
|
+
} else if (h.type === "stsd") {
|
|
1233
|
+
let p = o + h.headerSize + 8;
|
|
1234
|
+
while (p + 8 <= boxEnd && !result) {
|
|
1235
|
+
const e = readBoxHeader(buf, p);
|
|
1236
|
+
if (!e) break;
|
|
1237
|
+
const isH264 = H264_CODINGS.has(e.type);
|
|
1238
|
+
const isHevc = HEVC_CODINGS.has(e.type);
|
|
1239
|
+
if (isH264 || isHevc) {
|
|
1240
|
+
const width = p + 34 <= boxEnd ? buf.readUInt16BE(p + 32) : 0;
|
|
1241
|
+
const height = p + 36 <= boxEnd ? buf.readUInt16BE(p + 34) : 0;
|
|
1242
|
+
let codec;
|
|
1243
|
+
if (isH264) {
|
|
1244
|
+
const avcC = findChild(buf, p + 8 + 78, Math.min(p + e.size, boxEnd), "avcC");
|
|
1245
|
+
codec = avcC ? avcCodecString(buf, avcC.contentStart) : "avc1.640029";
|
|
1246
|
+
} else {
|
|
1247
|
+
codec = `${e.type}.1.6.L120.B0`;
|
|
1248
|
+
}
|
|
1249
|
+
result = { codec, width, height };
|
|
1250
|
+
}
|
|
1251
|
+
p += e.size;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
o += h.size;
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
walk(0, buf.length);
|
|
1258
|
+
return result;
|
|
1259
|
+
}
|
|
1260
|
+
const AttachCameraInputSchema = index.object({
|
|
1261
|
+
deviceId: index.number().int().nonnegative(),
|
|
1262
|
+
profiles: index.array(index.CamProfileSchema).optional(),
|
|
1263
|
+
segmentSeconds: index.number().int().positive().optional(),
|
|
1264
|
+
rules: index.array(index.RecordingRuleSchema).optional(),
|
|
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
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
function targetFromConfig(config) {
|
|
1298
|
+
return {
|
|
1299
|
+
enabled: config.enabled,
|
|
1300
|
+
profiles: config.profiles,
|
|
1301
|
+
segmentSeconds: config.segmentSeconds,
|
|
1302
|
+
rules: config.rules,
|
|
1303
|
+
retention: config.retention
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
function resolveStorageBytes(input) {
|
|
1307
|
+
const { indexBytes, hasRanges, statSize: statSize2 } = input;
|
|
1308
|
+
if (indexBytes > 0) return indexBytes;
|
|
1309
|
+
if (hasRanges) return statSize2;
|
|
1310
|
+
return 0;
|
|
1311
|
+
}
|
|
1312
|
+
const STAT_CACHE_TTL_MS = 3e4;
|
|
1313
|
+
const DEFAULT_CONFIG = {
|
|
1314
|
+
segmentSeconds: 4,
|
|
1315
|
+
watchIntervalMs: 2e3,
|
|
1316
|
+
// Sane default so an attached camera can't fill the disk unbounded; the
|
|
1317
|
+
// operator raises/lowers it via global addon settings (and, in P6, per
|
|
1318
|
+
// device). Age-only by default — size cap is opt-in.
|
|
1319
|
+
retentionMaxAgeDays: 14,
|
|
1320
|
+
retentionMaxSizeGb: 0,
|
|
1321
|
+
// 0 = inherit the global default; operator raises the event window for longer
|
|
1322
|
+
// motion/audio-clip retention. Present in defaults so resolveConfig reads them.
|
|
1323
|
+
continuousRetentionMaxAgeDays: 0,
|
|
1324
|
+
continuousRetentionMaxSizeGb: 0,
|
|
1325
|
+
eventRetentionMaxAgeDays: 0,
|
|
1326
|
+
eventRetentionMaxSizeGb: 0,
|
|
1327
|
+
retentionSweepIntervalMs: 15 * 6e4,
|
|
1328
|
+
policySweepIntervalMs: 5e3,
|
|
1329
|
+
playbackRescanIntervalMs: 5 * 6e4,
|
|
1330
|
+
// Present in defaults so `BaseAddon.resolveConfig` reads it from the store
|
|
1331
|
+
// (it only merges keys that exist in defaults). Seeded to the standard
|
|
1332
|
+
// placement so a fresh install routes high/mid → recordings, low/scrub →
|
|
1333
|
+
// recordingsLow automatically, without operator intervention.
|
|
1334
|
+
recordingPlacement: { assignments: DEFAULT_RECORDING_PLACEMENT }
|
|
1335
|
+
};
|
|
1336
|
+
const PROFILE_BANDWIDTH = {
|
|
1337
|
+
high: 4e6,
|
|
1338
|
+
mid: 15e5,
|
|
1339
|
+
low: 5e5
|
|
1340
|
+
};
|
|
1341
|
+
const DEFAULT_BANDWIDTH = 2e6;
|
|
1342
|
+
class RecorderAddon extends index.BaseAddon {
|
|
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
|