@camstack/addon-post-analysis 0.1.19 → 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/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/enrichment-engine/index.js +1 -1
- package/dist/enrichment-engine/index.mjs +1 -1
- package/dist/{index-BFbwYH1P.js → index-B0RhVv1c.js} +3514 -750
- package/dist/index-B0RhVv1c.js.map +1 -0
- package/dist/{index-BrTlzsrE.mjs → index-ot5PeFg_.mjs} +3517 -753
- package/dist/index-ot5PeFg_.mjs.map +1 -0
- package/dist/pipeline-analytics/@mf-types.zip +0 -0
- package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-h5aXOPSA.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-lantnv8e.mjs} +1 -1
- package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-BD3oMNGB.mjs +29 -0
- package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-BgOHCakr.mjs +18 -0
- package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs-BZTB2scQ.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs-D1qPKjvR.mjs} +2 -1
- package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-CJO5YKGV.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-B5X50Xa4.mjs} +1 -1
- package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-B0h0AGOH.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-B10b5k5J.mjs} +1 -1
- package/dist/pipeline-analytics/_stub.js +2 -3
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-kZBmgzMg.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DWB3apaJ.mjs} +6 -6
- package/dist/pipeline-analytics/{client-BlxIUpgf.mjs → client-C6xdgLZU.mjs} +2 -2
- package/dist/pipeline-analytics/{hostInit-qBB1Thhi.mjs → hostInit-3cyL9eyG.mjs} +12 -12
- package/dist/pipeline-analytics/{index-Dw6Q30NI.mjs → index-BCTHeI2m.mjs} +253 -267
- package/dist/pipeline-analytics/{index-DlhiA9R0.mjs → index-BuWLz0GG.mjs} +1 -1
- package/dist/pipeline-analytics/{index-DtdgkNgf.mjs → index-CIwq-tQL.mjs} +1 -1
- package/dist/pipeline-analytics/{index-BoL0rgZt.mjs → index-CWBMDbou.mjs} +1 -1
- package/dist/pipeline-analytics/index-CZhagnlH.mjs +67784 -0
- package/dist/pipeline-analytics/{index-CR1aiZDH.mjs → index-D883Q5B8.mjs} +1 -1
- package/dist/pipeline-analytics/{index-Dy2V7VOm.mjs → index-DtOI1aTU.mjs} +10112 -5987
- package/dist/pipeline-analytics/index.js +605 -42
- package/dist/pipeline-analytics/index.js.map +1 -1
- package/dist/pipeline-analytics/index.mjs +604 -42
- package/dist/pipeline-analytics/index.mjs.map +1 -1
- package/dist/pipeline-analytics/{jsx-runtime-Dlbl3gpr.mjs → jsx-runtime-DdLhuHmJ.mjs} +1 -1
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/dist/pipeline-analytics/{schemas-ClCuS4qa.mjs → schemas-B7L0qZtq.mjs} +411 -406
- package/package.json +12 -27
- package/dist/ffmpeg-config-DRONlBsj.mjs +0 -56
- package/dist/ffmpeg-config-DRONlBsj.mjs.map +0 -1
- package/dist/ffmpeg-config-uANz3sV5.js +0 -73
- package/dist/ffmpeg-config-uANz3sV5.js.map +0 -1
- package/dist/index-BFbwYH1P.js.map +0 -1
- package/dist/index-BrTlzsrE.mjs.map +0 -1
- package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-NjF4kxzW.mjs +0 -19
- package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-7HAAnpQu.mjs +0 -18
- package/dist/pipeline-analytics/index-i47purqY.mjs +0 -37880
- package/dist/playlist-generator-EhPaB7Hn.js +0 -48
- package/dist/playlist-generator-EhPaB7Hn.js.map +0 -1
- package/dist/playlist-generator-VTkgn53O.mjs +0 -48
- package/dist/playlist-generator-VTkgn53O.mjs.map +0 -1
- package/dist/recording/index.js +0 -257
- package/dist/recording/index.js.map +0 -1
- package/dist/recording/index.mjs +0 -235
- package/dist/recording/index.mjs.map +0 -1
- package/dist/recording-coordinator-BoGr5moz.js +0 -1052
- package/dist/recording-coordinator-BoGr5moz.js.map +0 -1
- package/dist/recording-coordinator-CsYH9LqF.mjs +0 -1012
- package/dist/recording-coordinator-CsYH9LqF.mjs.map +0 -1
- package/dist/recording-db-gOgaoQh0.js +0 -348
- package/dist/recording-db-gOgaoQh0.js.map +0 -1
- package/dist/recording-db-lIkSMTLq.mjs +0 -348
- package/dist/recording-db-lIkSMTLq.mjs.map +0 -1
- package/dist/recording-service-facade-B9lG6OFn.mjs +0 -123
- package/dist/recording-service-facade-B9lG6OFn.mjs.map +0 -1
- package/dist/recording-service-facade-Do1PKlAL.js +0 -123
- package/dist/recording-service-facade-Do1PKlAL.js.map +0 -1
- package/dist/storage-estimator-CRpoQc9j.js +0 -72
- package/dist/storage-estimator-CRpoQc9j.js.map +0 -1
- package/dist/storage-estimator-DzD8gWJH.mjs +0 -72
- package/dist/storage-estimator-DzD8gWJH.mjs.map +0 -1
|
@@ -1,1052 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __create = Object.create;
|
|
3
|
-
var __defProp = Object.defineProperty;
|
|
4
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
-
var __copyProps = (to, from, except, desc) => {
|
|
9
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
-
for (let key of __getOwnPropNames(from))
|
|
11
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
-
}
|
|
14
|
-
return to;
|
|
15
|
-
};
|
|
16
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
-
mod
|
|
23
|
-
));
|
|
24
|
-
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
25
|
-
const index = require("./index-BFbwYH1P.js");
|
|
26
|
-
const ffmpegConfig = require("./ffmpeg-config-uANz3sV5.js");
|
|
27
|
-
const node_crypto = require("node:crypto");
|
|
28
|
-
const node_child_process = require("node:child_process");
|
|
29
|
-
const fs = require("node:fs/promises");
|
|
30
|
-
const path = require("node:path");
|
|
31
|
-
const sharp = require("sharp");
|
|
32
|
-
const playlistGenerator = require("./playlist-generator-EhPaB7Hn.js");
|
|
33
|
-
const storageEstimator = require("./storage-estimator-CRpoQc9j.js");
|
|
34
|
-
function _interopNamespaceDefault(e) {
|
|
35
|
-
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
|
|
36
|
-
if (e) {
|
|
37
|
-
for (const k in e) {
|
|
38
|
-
if (k !== "default") {
|
|
39
|
-
const d = Object.getOwnPropertyDescriptor(e, k);
|
|
40
|
-
Object.defineProperty(n, k, d.get ? d : {
|
|
41
|
-
enumerable: true,
|
|
42
|
-
get: () => e[k]
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
n.default = e;
|
|
48
|
-
return Object.freeze(n);
|
|
49
|
-
}
|
|
50
|
-
const fs__namespace = /* @__PURE__ */ _interopNamespaceDefault(fs);
|
|
51
|
-
const path__namespace = /* @__PURE__ */ _interopNamespaceDefault(path);
|
|
52
|
-
class SegmentRingBuffer {
|
|
53
|
-
constructor(maxDurationSec) {
|
|
54
|
-
this.maxDurationSec = maxDurationSec;
|
|
55
|
-
}
|
|
56
|
-
segments = [];
|
|
57
|
-
totalDurationSec = 0;
|
|
58
|
-
push(segment) {
|
|
59
|
-
this.segments.push(segment);
|
|
60
|
-
this.totalDurationSec += segment.duration;
|
|
61
|
-
while (this.totalDurationSec > this.maxDurationSec && this.segments.length > 1) {
|
|
62
|
-
const evicted = this.segments.shift();
|
|
63
|
-
this.totalDurationSec -= evicted.duration;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
flush() {
|
|
67
|
-
const result = [...this.segments];
|
|
68
|
-
this.segments = [];
|
|
69
|
-
this.totalDurationSec = 0;
|
|
70
|
-
return result;
|
|
71
|
-
}
|
|
72
|
-
get memoryEstimateBytes() {
|
|
73
|
-
return this.segments.reduce((sum, s) => sum + s.data.length, 0);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
class SegmentWriter {
|
|
77
|
-
constructor(config, logger, eventBus, db, _networkTracker) {
|
|
78
|
-
this.config = config;
|
|
79
|
-
this.logger = logger;
|
|
80
|
-
this.eventBus = eventBus;
|
|
81
|
-
this.db = db;
|
|
82
|
-
this._mode = config.mode;
|
|
83
|
-
this.ringBuffer = new SegmentRingBuffer(config.preBufferSec);
|
|
84
|
-
}
|
|
85
|
-
_state = "idle";
|
|
86
|
-
_mode;
|
|
87
|
-
ffmpeg = null;
|
|
88
|
-
activeSegment = null;
|
|
89
|
-
restartCount = 0;
|
|
90
|
-
restartWindowStart = 0;
|
|
91
|
-
healthTimer = null;
|
|
92
|
-
lastDataTime = 0;
|
|
93
|
-
ringBuffer;
|
|
94
|
-
restartTimeout = null;
|
|
95
|
-
pendingFinalization = null;
|
|
96
|
-
paused = false;
|
|
97
|
-
detectedCodec = "h264";
|
|
98
|
-
detectedHasAudio = false;
|
|
99
|
-
static MAX_RESTARTS = 10;
|
|
100
|
-
static RESTART_WINDOW_MS = 5 * 60 * 1e3;
|
|
101
|
-
static HEALTH_CHECK_INTERVAL_MS = 5e3;
|
|
102
|
-
static DATA_TIMEOUT_MS = 15e3;
|
|
103
|
-
static MIN_SEGMENT_DURATION_SEC = 0.5;
|
|
104
|
-
static CRITICAL_DISK_GB = 1;
|
|
105
|
-
get state() {
|
|
106
|
-
return this._state;
|
|
107
|
-
}
|
|
108
|
-
get mode() {
|
|
109
|
-
return this._mode;
|
|
110
|
-
}
|
|
111
|
-
get isPaused() {
|
|
112
|
-
return this.paused;
|
|
113
|
-
}
|
|
114
|
-
// --- Public API ---
|
|
115
|
-
async start(rtspUrl) {
|
|
116
|
-
if (this._state !== "idle") return;
|
|
117
|
-
const segmentDir = path__namespace.join(
|
|
118
|
-
this.config.storagePath,
|
|
119
|
-
this.config.subDirectory,
|
|
120
|
-
String(this.config.deviceId)
|
|
121
|
-
);
|
|
122
|
-
await fs__namespace.mkdir(segmentDir, { recursive: true });
|
|
123
|
-
this._state = "recording";
|
|
124
|
-
this.lastDataTime = Date.now();
|
|
125
|
-
this.restartCount = 0;
|
|
126
|
-
this.restartWindowStart = Date.now();
|
|
127
|
-
const segmentPattern = path__namespace.join(segmentDir, "%d.mp4");
|
|
128
|
-
const args = SegmentWriter.buildSegmentationArgs(
|
|
129
|
-
this.config.ffmpeg,
|
|
130
|
-
rtspUrl,
|
|
131
|
-
segmentPattern,
|
|
132
|
-
this.config.segmentDurationSec
|
|
133
|
-
);
|
|
134
|
-
this.spawnFfmpeg(args, rtspUrl);
|
|
135
|
-
this.startHealthCheck(rtspUrl);
|
|
136
|
-
}
|
|
137
|
-
async stop() {
|
|
138
|
-
if (this._state === "idle") return;
|
|
139
|
-
this._state = "stopping";
|
|
140
|
-
this.stopHealthCheck();
|
|
141
|
-
this.clearRestartTimeout();
|
|
142
|
-
this.killFfmpeg();
|
|
143
|
-
this.finalizeActiveSegment();
|
|
144
|
-
if (this.pendingFinalization) {
|
|
145
|
-
await this.pendingFinalization;
|
|
146
|
-
}
|
|
147
|
-
this._state = "idle";
|
|
148
|
-
}
|
|
149
|
-
resume(rtspUrl) {
|
|
150
|
-
if (!this.paused) return;
|
|
151
|
-
this.paused = false;
|
|
152
|
-
this.logger.info("Resuming recording after disk space freed", {
|
|
153
|
-
tags: { deviceId: this.config.deviceId }
|
|
154
|
-
});
|
|
155
|
-
this._state = "idle";
|
|
156
|
-
void this.start(rtspUrl);
|
|
157
|
-
}
|
|
158
|
-
async flushAndContinue() {
|
|
159
|
-
if (this._mode !== "buffer") return;
|
|
160
|
-
const buffered = this.ringBuffer.flush();
|
|
161
|
-
this.logger.info("Flushing buffered segments to disk", {
|
|
162
|
-
tags: { deviceId: this.config.deviceId },
|
|
163
|
-
meta: { count: buffered.length }
|
|
164
|
-
});
|
|
165
|
-
for (const seg of buffered) {
|
|
166
|
-
await this.writeBufferedSegmentToDisk(seg);
|
|
167
|
-
}
|
|
168
|
-
this._mode = "continuous";
|
|
169
|
-
}
|
|
170
|
-
switchToBuffer() {
|
|
171
|
-
this._mode = "buffer";
|
|
172
|
-
this.ringBuffer = new SegmentRingBuffer(this.config.preBufferSec);
|
|
173
|
-
}
|
|
174
|
-
// --- Static helpers ---
|
|
175
|
-
static generateSegmentId(deviceId, streamId, startTime) {
|
|
176
|
-
const suffix = node_crypto.randomBytes(2).toString("hex");
|
|
177
|
-
return `${deviceId}_${streamId}_${startTime}_${suffix}`;
|
|
178
|
-
}
|
|
179
|
-
static buildSegmentationArgs(config, inputUrl, outputPattern, segmentDuration) {
|
|
180
|
-
const inputArgs = ffmpegConfig.buildFfmpegInputArgs(config, inputUrl);
|
|
181
|
-
const outputArgs = ffmpegConfig.buildFfmpegOutputArgs(config);
|
|
182
|
-
const segmentArgs = [
|
|
183
|
-
"-f",
|
|
184
|
-
"segment",
|
|
185
|
-
"-segment_time",
|
|
186
|
-
String(segmentDuration),
|
|
187
|
-
"-segment_format",
|
|
188
|
-
"mp4",
|
|
189
|
-
"-movflags",
|
|
190
|
-
"+frag_keyframe+empty_moov+default_base_moof",
|
|
191
|
-
"-reset_timestamps",
|
|
192
|
-
"1",
|
|
193
|
-
"-strftime",
|
|
194
|
-
"0"
|
|
195
|
-
];
|
|
196
|
-
return [...inputArgs, ...outputArgs, ...segmentArgs, outputPattern];
|
|
197
|
-
}
|
|
198
|
-
static async checkDiskSpace(storagePath, statfsFn) {
|
|
199
|
-
const doStatfs = statfsFn ?? (async (p) => {
|
|
200
|
-
const { statfs: nodeStatfs } = await import("node:fs/promises");
|
|
201
|
-
return nodeStatfs(p);
|
|
202
|
-
});
|
|
203
|
-
try {
|
|
204
|
-
const stats = await doStatfs(storagePath);
|
|
205
|
-
const availableBytes = stats.bfree * stats.bsize;
|
|
206
|
-
const availableGb = availableBytes / (1024 * 1024 * 1024);
|
|
207
|
-
return { ok: availableGb >= SegmentWriter.CRITICAL_DISK_GB, availableGb };
|
|
208
|
-
} catch {
|
|
209
|
-
return { ok: true, availableGb: -1 };
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
// --- Private: ffmpeg process management ---
|
|
213
|
-
spawnFfmpeg(args, rtspUrl) {
|
|
214
|
-
this.ffmpeg = node_child_process.spawn(this.config.ffmpeg.path, args, {
|
|
215
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
216
|
-
});
|
|
217
|
-
this.ffmpeg.stdout?.on("data", () => {
|
|
218
|
-
this.lastDataTime = Date.now();
|
|
219
|
-
});
|
|
220
|
-
this.ffmpeg.stderr?.on("data", (data) => {
|
|
221
|
-
this.lastDataTime = Date.now();
|
|
222
|
-
const msg = data.toString().trim();
|
|
223
|
-
if (msg) {
|
|
224
|
-
this.logger.debug("ffmpeg stderr", { meta: { msg } });
|
|
225
|
-
this.parseSegmentOutput(msg);
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
this.ffmpeg.on("error", (err) => {
|
|
229
|
-
this.logger.warn("ffmpeg process error", { meta: { error: err.message } });
|
|
230
|
-
this.handleCrash(rtspUrl);
|
|
231
|
-
});
|
|
232
|
-
this.ffmpeg.on("exit", (code) => {
|
|
233
|
-
if (code !== 0 && code !== null && this._state === "recording") {
|
|
234
|
-
this.logger.warn("ffmpeg exited with non-zero code", { meta: { code } });
|
|
235
|
-
this.handleCrash(rtspUrl);
|
|
236
|
-
}
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
handleCrash(rtspUrl) {
|
|
240
|
-
this.ffmpeg = null;
|
|
241
|
-
const prevFinalization = this.pendingFinalization;
|
|
242
|
-
this.pendingFinalization = (prevFinalization ?? Promise.resolve()).then(() => {
|
|
243
|
-
return this.finalizeActiveSegment();
|
|
244
|
-
});
|
|
245
|
-
if (this._state !== "recording") return;
|
|
246
|
-
if (this.paused) return;
|
|
247
|
-
const now = Date.now();
|
|
248
|
-
if (now - this.restartWindowStart > SegmentWriter.RESTART_WINDOW_MS) {
|
|
249
|
-
this.restartCount = 0;
|
|
250
|
-
this.restartWindowStart = now;
|
|
251
|
-
}
|
|
252
|
-
this.restartCount++;
|
|
253
|
-
this.eventBus.emit({
|
|
254
|
-
id: `rec-err-${now}`,
|
|
255
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
256
|
-
source: { type: "addon", id: "recording-engine" },
|
|
257
|
-
category: index.EventCategory.RecordingError,
|
|
258
|
-
data: {
|
|
259
|
-
deviceId: this.config.deviceId,
|
|
260
|
-
streamId: this.config.streamId,
|
|
261
|
-
restartAttempt: this.restartCount
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
if (this.restartCount > SegmentWriter.MAX_RESTARTS) {
|
|
265
|
-
this.logger.error("Max restarts exceeded", {
|
|
266
|
-
tags: { deviceId: this.config.deviceId, streamId: this.config.streamId }
|
|
267
|
-
});
|
|
268
|
-
this._state = "idle";
|
|
269
|
-
this.eventBus.emit({
|
|
270
|
-
id: `rec-degraded-${now}`,
|
|
271
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
272
|
-
source: { type: "addon", id: "recording-engine" },
|
|
273
|
-
category: index.EventCategory.RecordingHealthDegraded,
|
|
274
|
-
data: {
|
|
275
|
-
deviceId: this.config.deviceId,
|
|
276
|
-
streamId: this.config.streamId
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
const backoffMs = Math.min(3e4, 1e3 * Math.pow(2, this.restartCount - 1));
|
|
282
|
-
this.logger.info("Restarting ffmpeg", { meta: { backoffMs, attempt: this.restartCount } });
|
|
283
|
-
this.restartTimeout = setTimeout(() => {
|
|
284
|
-
this.restartTimeout = null;
|
|
285
|
-
if (this._state === "recording") {
|
|
286
|
-
const segmentDir = path__namespace.join(
|
|
287
|
-
this.config.storagePath,
|
|
288
|
-
this.config.subDirectory,
|
|
289
|
-
String(this.config.deviceId)
|
|
290
|
-
);
|
|
291
|
-
const segmentPattern = path__namespace.join(segmentDir, "%d.mp4");
|
|
292
|
-
const args = SegmentWriter.buildSegmentationArgs(
|
|
293
|
-
this.config.ffmpeg,
|
|
294
|
-
rtspUrl,
|
|
295
|
-
segmentPattern,
|
|
296
|
-
this.config.segmentDurationSec
|
|
297
|
-
);
|
|
298
|
-
this.spawnFfmpeg(args, rtspUrl);
|
|
299
|
-
}
|
|
300
|
-
}, backoffMs);
|
|
301
|
-
}
|
|
302
|
-
// --- Private: health monitoring ---
|
|
303
|
-
startHealthCheck(rtspUrl) {
|
|
304
|
-
this.healthTimer = setInterval(() => {
|
|
305
|
-
if (this._state !== "recording") return;
|
|
306
|
-
const elapsed = Date.now() - this.lastDataTime;
|
|
307
|
-
if (elapsed > SegmentWriter.DATA_TIMEOUT_MS) {
|
|
308
|
-
this.logger.warn("No data received, restarting ffmpeg", { meta: { elapsedMs: elapsed } });
|
|
309
|
-
this.killFfmpeg();
|
|
310
|
-
this.handleCrash(rtspUrl);
|
|
311
|
-
}
|
|
312
|
-
}, SegmentWriter.HEALTH_CHECK_INTERVAL_MS);
|
|
313
|
-
}
|
|
314
|
-
stopHealthCheck() {
|
|
315
|
-
if (this.healthTimer) {
|
|
316
|
-
clearInterval(this.healthTimer);
|
|
317
|
-
this.healthTimer = null;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
clearRestartTimeout() {
|
|
321
|
-
if (this.restartTimeout) {
|
|
322
|
-
clearTimeout(this.restartTimeout);
|
|
323
|
-
this.restartTimeout = null;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
killFfmpeg() {
|
|
327
|
-
if (this.ffmpeg) {
|
|
328
|
-
this.ffmpeg.kill("SIGTERM");
|
|
329
|
-
this.ffmpeg = null;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
// --- Private: segment parsing and finalization ---
|
|
333
|
-
parseSegmentOutput(msg) {
|
|
334
|
-
const videoMatch = msg.match(/Stream\s+#\d+:\d+.*Video:\s+(h264|hevc|h265)/i);
|
|
335
|
-
if (videoMatch) {
|
|
336
|
-
const codec = videoMatch[1].toLowerCase();
|
|
337
|
-
this.detectedCodec = codec === "hevc" || codec === "h265" ? "h265" : "h264";
|
|
338
|
-
}
|
|
339
|
-
const audioMatch = msg.match(/Stream\s+#\d+:\d+.*Audio:/i);
|
|
340
|
-
if (audioMatch) {
|
|
341
|
-
this.detectedHasAudio = true;
|
|
342
|
-
}
|
|
343
|
-
const openMatch = msg.match(/Opening '(.+\.mp4)' for writing/);
|
|
344
|
-
if (openMatch) {
|
|
345
|
-
const prevFinalization = this.pendingFinalization;
|
|
346
|
-
this.pendingFinalization = (prevFinalization ?? Promise.resolve()).then(() => {
|
|
347
|
-
return this.finalizeActiveSegment();
|
|
348
|
-
});
|
|
349
|
-
const absolutePath = openMatch[1];
|
|
350
|
-
const segPath = absolutePath.startsWith(this.config.storagePath) ? absolutePath.slice(this.config.storagePath.length).replace(/^\//, "") : absolutePath;
|
|
351
|
-
this.activeSegment = {
|
|
352
|
-
id: SegmentWriter.generateSegmentId(
|
|
353
|
-
this.config.deviceId,
|
|
354
|
-
this.config.streamId,
|
|
355
|
-
Date.now()
|
|
356
|
-
),
|
|
357
|
-
path: segPath,
|
|
358
|
-
startTime: Date.now()
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
async finalizeActiveSegment() {
|
|
363
|
-
if (!this.activeSegment) return;
|
|
364
|
-
const seg = this.activeSegment;
|
|
365
|
-
this.activeSegment = null;
|
|
366
|
-
const endTime = Date.now();
|
|
367
|
-
const duration = (endTime - seg.startTime) / 1e3;
|
|
368
|
-
if (duration < SegmentWriter.MIN_SEGMENT_DURATION_SEC) return;
|
|
369
|
-
if (this._mode === "buffer") {
|
|
370
|
-
await this.bufferSegmentFromDisk(seg, endTime, duration);
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
await this.finalizeSegmentToDisk(seg, endTime, duration);
|
|
374
|
-
}
|
|
375
|
-
async bufferSegmentFromDisk(seg, _endTime, duration) {
|
|
376
|
-
try {
|
|
377
|
-
const data = await fs__namespace.readFile(seg.path);
|
|
378
|
-
this.ringBuffer.push({ data, startTime: seg.startTime, duration });
|
|
379
|
-
await fs__namespace.unlink(seg.path).catch(() => {
|
|
380
|
-
});
|
|
381
|
-
} catch (err) {
|
|
382
|
-
this.logger.warn("Failed to buffer segment", { meta: { error: String(err) } });
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
async finalizeSegmentToDisk(seg, endTime, duration) {
|
|
386
|
-
try {
|
|
387
|
-
const diskCheck = await SegmentWriter.checkDiskSpace(this.config.storagePath);
|
|
388
|
-
if (!diskCheck.ok) {
|
|
389
|
-
this.eventBus.emit({
|
|
390
|
-
id: `storage-critical-${Date.now()}`,
|
|
391
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
392
|
-
source: { type: "addon", id: "recording-engine" },
|
|
393
|
-
category: index.EventCategory.RecordingStorageCritical,
|
|
394
|
-
data: {
|
|
395
|
-
storageId: this.config.storageName,
|
|
396
|
-
availableGB: diskCheck.availableGb
|
|
397
|
-
}
|
|
398
|
-
});
|
|
399
|
-
this.logger.error("Disk space critically low, pausing recording");
|
|
400
|
-
this.paused = true;
|
|
401
|
-
this.killFfmpeg();
|
|
402
|
-
this._state = "idle";
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
let sizeBytes = 0;
|
|
406
|
-
try {
|
|
407
|
-
const fileStat = await fs__namespace.stat(seg.path);
|
|
408
|
-
sizeBytes = fileStat.size;
|
|
409
|
-
} catch {
|
|
410
|
-
}
|
|
411
|
-
const codec = this.detectedCodec;
|
|
412
|
-
const hasAudio = this.detectedHasAudio;
|
|
413
|
-
const segment = {
|
|
414
|
-
id: seg.id,
|
|
415
|
-
deviceId: this.config.deviceId,
|
|
416
|
-
streamId: this.config.streamId,
|
|
417
|
-
startTime: seg.startTime,
|
|
418
|
-
endTime,
|
|
419
|
-
duration,
|
|
420
|
-
path: seg.path,
|
|
421
|
-
storageName: this.config.storageName,
|
|
422
|
-
subDirectory: this.config.subDirectory,
|
|
423
|
-
sizeBytes,
|
|
424
|
-
codec,
|
|
425
|
-
hasAudio
|
|
426
|
-
};
|
|
427
|
-
try {
|
|
428
|
-
this.db.insertSegment(segment);
|
|
429
|
-
this.eventBus.emit({
|
|
430
|
-
id: `seg-${seg.id}`,
|
|
431
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
432
|
-
source: { type: "addon", id: "recording-engine" },
|
|
433
|
-
category: index.EventCategory.RecordingSegmentWritten,
|
|
434
|
-
data: {
|
|
435
|
-
deviceId: this.config.deviceId,
|
|
436
|
-
streamId: this.config.streamId,
|
|
437
|
-
segmentId: seg.id,
|
|
438
|
-
duration,
|
|
439
|
-
sizeBytes
|
|
440
|
-
}
|
|
441
|
-
});
|
|
442
|
-
} catch (err) {
|
|
443
|
-
this.logger.error("Failed to insert segment", { meta: { error: String(err) } });
|
|
444
|
-
}
|
|
445
|
-
} catch (err) {
|
|
446
|
-
this.logger.error("Disk space check failed", { meta: { error: String(err) } });
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
async writeBufferedSegmentToDisk(buffered) {
|
|
450
|
-
const segId = SegmentWriter.generateSegmentId(
|
|
451
|
-
this.config.deviceId,
|
|
452
|
-
this.config.streamId,
|
|
453
|
-
buffered.startTime
|
|
454
|
-
);
|
|
455
|
-
const relativePath = `${this.config.subDirectory}/${this.config.deviceId}/${segId}.mp4`;
|
|
456
|
-
try {
|
|
457
|
-
await this.config.fileStorage?.writeFile(relativePath, buffered.data);
|
|
458
|
-
const sizeBytes = buffered.data.length;
|
|
459
|
-
const segment = {
|
|
460
|
-
id: segId,
|
|
461
|
-
deviceId: this.config.deviceId,
|
|
462
|
-
streamId: this.config.streamId,
|
|
463
|
-
startTime: buffered.startTime,
|
|
464
|
-
endTime: buffered.startTime + buffered.duration * 1e3,
|
|
465
|
-
duration: buffered.duration,
|
|
466
|
-
path: relativePath,
|
|
467
|
-
storageName: this.config.storageName,
|
|
468
|
-
subDirectory: this.config.subDirectory,
|
|
469
|
-
sizeBytes,
|
|
470
|
-
codec: this.detectedCodec,
|
|
471
|
-
hasAudio: this.detectedHasAudio
|
|
472
|
-
};
|
|
473
|
-
this.db.insertSegment(segment);
|
|
474
|
-
this.eventBus.emit({
|
|
475
|
-
id: `seg-${segId}`,
|
|
476
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
477
|
-
source: { type: "addon", id: "recording-engine" },
|
|
478
|
-
category: index.EventCategory.RecordingSegmentWritten,
|
|
479
|
-
data: {
|
|
480
|
-
deviceId: this.config.deviceId,
|
|
481
|
-
streamId: this.config.streamId,
|
|
482
|
-
segmentId: segId,
|
|
483
|
-
duration: buffered.duration,
|
|
484
|
-
sizeBytes
|
|
485
|
-
}
|
|
486
|
-
});
|
|
487
|
-
} catch (err) {
|
|
488
|
-
this.logger.error("Failed to write buffered segment to disk", { meta: { error: String(err) } });
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
class ThumbnailExtractor {
|
|
493
|
-
constructor(config, logger, db) {
|
|
494
|
-
this.config = config;
|
|
495
|
-
this.logger = logger;
|
|
496
|
-
this.db = db;
|
|
497
|
-
}
|
|
498
|
-
id = "thumbnail-extractor";
|
|
499
|
-
name = "Thumbnail Extractor";
|
|
500
|
-
needsAudio = false;
|
|
501
|
-
videoRequirements = {
|
|
502
|
-
keyframeOnly: true,
|
|
503
|
-
maxFps: 1,
|
|
504
|
-
format: "jpeg"
|
|
505
|
-
};
|
|
506
|
-
unsubscribe = null;
|
|
507
|
-
active = false;
|
|
508
|
-
attachToPipeline(pipeline, _deviceId) {
|
|
509
|
-
this.active = true;
|
|
510
|
-
this.unsubscribe = pipeline.onVideoFrame(
|
|
511
|
-
(frame) => {
|
|
512
|
-
this.handleFrame(frame).catch((err) => this.logger.debug("Thumbnail error", { meta: { error: String(err) } }));
|
|
513
|
-
},
|
|
514
|
-
this.videoRequirements
|
|
515
|
-
);
|
|
516
|
-
this.logger.info("ThumbnailExtractor attached", { tags: { deviceId: this.config.deviceId } });
|
|
517
|
-
}
|
|
518
|
-
detachFromPipeline(_deviceId) {
|
|
519
|
-
this.active = false;
|
|
520
|
-
if (this.unsubscribe) {
|
|
521
|
-
this.unsubscribe();
|
|
522
|
-
this.unsubscribe = null;
|
|
523
|
-
}
|
|
524
|
-
this.logger.info("ThumbnailExtractor detached", { tags: { deviceId: this.config.deviceId } });
|
|
525
|
-
}
|
|
526
|
-
setActive(active) {
|
|
527
|
-
this.active = active;
|
|
528
|
-
}
|
|
529
|
-
async handleFrame(frame) {
|
|
530
|
-
if (!this.active) return;
|
|
531
|
-
const timestamp = frame.timestamp || Date.now();
|
|
532
|
-
const relativePath = ThumbnailExtractor.thumbnailPath(
|
|
533
|
-
this.config.subDirectory,
|
|
534
|
-
this.config.deviceId,
|
|
535
|
-
timestamp
|
|
536
|
-
);
|
|
537
|
-
const resized = await sharp(frame.data).resize({ width: this.config.maxWidthPx, withoutEnlargement: true }).jpeg({ quality: this.config.jpegQuality }).toBuffer();
|
|
538
|
-
await this.config.fileStorage?.writeFile(relativePath, resized);
|
|
539
|
-
this.db.insertThumbnail({
|
|
540
|
-
deviceId: this.config.deviceId,
|
|
541
|
-
timestamp,
|
|
542
|
-
path: relativePath,
|
|
543
|
-
storageName: this.config.storageName,
|
|
544
|
-
subDirectory: this.config.subDirectory,
|
|
545
|
-
sizeBytes: resized.length,
|
|
546
|
-
category: "scrub"
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
static thumbnailPath(subDirectory, deviceId, timestamp) {
|
|
550
|
-
return `${subDirectory}/${deviceId}/${timestamp}.jpg`;
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
const NORMAL_INTERVAL_MS = 5 * 60 * 1e3;
|
|
554
|
-
const HIGH_USAGE_INTERVAL_MS = 30 * 1e3;
|
|
555
|
-
const STORAGE_WARNING_THRESHOLD = 0.8;
|
|
556
|
-
const STORAGE_CRITICAL_THRESHOLD = 0.95;
|
|
557
|
-
const STORAGE_HIGH_USAGE_THRESHOLD = 0.9;
|
|
558
|
-
class RetentionManager {
|
|
559
|
-
constructor(db, logger, eventBus, storageProvider) {
|
|
560
|
-
this.db = db;
|
|
561
|
-
this.logger = logger;
|
|
562
|
-
this.eventBus = eventBus;
|
|
563
|
-
this.storageProvider = storageProvider;
|
|
564
|
-
}
|
|
565
|
-
timer = null;
|
|
566
|
-
start() {
|
|
567
|
-
this.scheduleNextCycle(NORMAL_INTERVAL_MS);
|
|
568
|
-
}
|
|
569
|
-
stop() {
|
|
570
|
-
if (this.timer) {
|
|
571
|
-
clearTimeout(this.timer);
|
|
572
|
-
this.timer = null;
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
async runCycle() {
|
|
576
|
-
this.db.resetStaleCleanups();
|
|
577
|
-
const policies = this.db.getEnabledPolicies();
|
|
578
|
-
let totalFreedBytes = 0;
|
|
579
|
-
let totalDeletedSegments = 0;
|
|
580
|
-
let highUsage = false;
|
|
581
|
-
for (const policy of policies) {
|
|
582
|
-
for (const sp of policy.streams) {
|
|
583
|
-
const category = `recording:${sp.streamId}`;
|
|
584
|
-
const config = this.db.resolveStorageConfig(policy.deviceId, category);
|
|
585
|
-
if (!config) continue;
|
|
586
|
-
if (config.retentionDays !== null) {
|
|
587
|
-
const cutoff = Date.now() - config.retentionDays * 864e5;
|
|
588
|
-
const deleted = this.db.deleteSegmentsBefore(policy.deviceId, sp.streamId, cutoff);
|
|
589
|
-
totalDeletedSegments += deleted.length;
|
|
590
|
-
for (const seg of deleted) {
|
|
591
|
-
totalFreedBytes += seg.sizeBytes;
|
|
592
|
-
await this.deleteFile(seg.path);
|
|
593
|
-
}
|
|
594
|
-
this.db.deleteThumbnailsBefore(policy.deviceId, cutoff);
|
|
595
|
-
}
|
|
596
|
-
if (config.retentionGb !== null) {
|
|
597
|
-
const maxBytes = config.retentionGb * 1024 * 1024 * 1024;
|
|
598
|
-
let usage = this.db.getStorageUsage(policy.deviceId, sp.streamId);
|
|
599
|
-
const usageRatio = usage.totalBytes / maxBytes;
|
|
600
|
-
if (usageRatio > STORAGE_CRITICAL_THRESHOLD) {
|
|
601
|
-
this.emitStorageEvent("recording.storage.critical", policy.deviceId, sp.streamId, usageRatio);
|
|
602
|
-
} else if (usageRatio > STORAGE_WARNING_THRESHOLD) {
|
|
603
|
-
this.emitStorageEvent("recording.storage.warning", policy.deviceId, sp.streamId, usageRatio);
|
|
604
|
-
}
|
|
605
|
-
if (usageRatio > STORAGE_HIGH_USAGE_THRESHOLD) {
|
|
606
|
-
highUsage = true;
|
|
607
|
-
}
|
|
608
|
-
while (usage.totalBytes > maxBytes && usage.segmentCount > 0) {
|
|
609
|
-
const oldest = this.db.getOldestSegments(policy.deviceId, sp.streamId, 10);
|
|
610
|
-
if (oldest.length === 0) break;
|
|
611
|
-
for (const seg of oldest) {
|
|
612
|
-
this.db.deleteSegmentsBefore(policy.deviceId, sp.streamId, seg.endTime + 1);
|
|
613
|
-
totalFreedBytes += seg.sizeBytes;
|
|
614
|
-
totalDeletedSegments++;
|
|
615
|
-
await this.deleteFile(seg.path);
|
|
616
|
-
}
|
|
617
|
-
usage = this.db.getStorageUsage(policy.deviceId, sp.streamId);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
const pending = this.db.getPendingCleanups();
|
|
623
|
-
for (const entry of pending) {
|
|
624
|
-
this.db.markCleanupInProgress(entry.deviceId);
|
|
625
|
-
try {
|
|
626
|
-
const deleted = this.db.deleteSegmentsForDevice(entry.deviceId);
|
|
627
|
-
for (const seg of deleted) {
|
|
628
|
-
totalFreedBytes += seg.sizeBytes;
|
|
629
|
-
totalDeletedSegments++;
|
|
630
|
-
await this.deleteFile(seg.path);
|
|
631
|
-
}
|
|
632
|
-
this.db.deleteThumbnailsForDevice(entry.deviceId);
|
|
633
|
-
this.db.markCleanupCompleted(entry.deviceId);
|
|
634
|
-
} catch (err) {
|
|
635
|
-
this.logger.error("Cleanup failed", { tags: { deviceId: entry.deviceId }, meta: { error: String(err) } });
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
if (totalDeletedSegments > 0) {
|
|
639
|
-
this.eventBus.emit({
|
|
640
|
-
id: `retention-${Date.now()}`,
|
|
641
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
642
|
-
source: { type: "addon", id: "recording-engine" },
|
|
643
|
-
category: index.EventCategory.RecordingRetentionCompleted,
|
|
644
|
-
data: {
|
|
645
|
-
freedMB: Math.round(totalFreedBytes / 1024 / 1024),
|
|
646
|
-
deletedSegments: totalDeletedSegments
|
|
647
|
-
}
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
return highUsage;
|
|
651
|
-
}
|
|
652
|
-
scheduleNextCycle(intervalMs) {
|
|
653
|
-
this.timer = setTimeout(async () => {
|
|
654
|
-
try {
|
|
655
|
-
const storageHighUsage = await this.runCycle();
|
|
656
|
-
const nextInterval = storageHighUsage ? HIGH_USAGE_INTERVAL_MS : NORMAL_INTERVAL_MS;
|
|
657
|
-
this.scheduleNextCycle(nextInterval);
|
|
658
|
-
} catch (err) {
|
|
659
|
-
this.logger.error("Retention cycle error", { meta: { error: String(err) } });
|
|
660
|
-
this.scheduleNextCycle(NORMAL_INTERVAL_MS);
|
|
661
|
-
}
|
|
662
|
-
}, intervalMs);
|
|
663
|
-
}
|
|
664
|
-
emitStorageEvent(category, deviceId, streamId, usageRatio) {
|
|
665
|
-
this.eventBus.emit({
|
|
666
|
-
id: `${category}-${deviceId}-${Date.now()}`,
|
|
667
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
668
|
-
source: { type: "addon", id: "recording-engine" },
|
|
669
|
-
category,
|
|
670
|
-
data: {
|
|
671
|
-
deviceId,
|
|
672
|
-
streamId,
|
|
673
|
-
usagePercent: Math.round(usageRatio * 100)
|
|
674
|
-
}
|
|
675
|
-
});
|
|
676
|
-
}
|
|
677
|
-
async deleteFile(filePath) {
|
|
678
|
-
try {
|
|
679
|
-
await this.storageProvider.delete({ location: "recordings", relativePath: filePath });
|
|
680
|
-
} catch {
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
const DEFAULT_SEGMENT_DURATION_SEC = 4;
|
|
685
|
-
const POLICY_EVAL_INTERVAL_MS = 1e3;
|
|
686
|
-
const MOTION_FALLBACK_TIMEOUT_MS = 6e4;
|
|
687
|
-
class RecordingCoordinator {
|
|
688
|
-
db;
|
|
689
|
-
logger;
|
|
690
|
-
eventBus;
|
|
691
|
-
streamingEngine;
|
|
692
|
-
pipelineManager;
|
|
693
|
-
networkTracker;
|
|
694
|
-
storageProvider;
|
|
695
|
-
globalFfmpegConfig;
|
|
696
|
-
detectedFfmpegConfig;
|
|
697
|
-
segmentDurationSec;
|
|
698
|
-
recordings = /* @__PURE__ */ new Map();
|
|
699
|
-
policyTimer = null;
|
|
700
|
-
retentionManager;
|
|
701
|
-
playlistGenerator;
|
|
702
|
-
storageEstimator;
|
|
703
|
-
constructor(config) {
|
|
704
|
-
this.db = config.db;
|
|
705
|
-
this.logger = config.logger;
|
|
706
|
-
this.eventBus = config.eventBus;
|
|
707
|
-
this.streamingEngine = config.streamingEngine;
|
|
708
|
-
this.pipelineManager = config.pipelineManager;
|
|
709
|
-
this.networkTracker = config.networkTracker;
|
|
710
|
-
this.storageProvider = config.storageProvider;
|
|
711
|
-
this.globalFfmpegConfig = config.globalFfmpegConfig;
|
|
712
|
-
this.detectedFfmpegConfig = config.detectedFfmpegConfig;
|
|
713
|
-
this.segmentDurationSec = config.segmentDurationSec ?? DEFAULT_SEGMENT_DURATION_SEC;
|
|
714
|
-
this.retentionManager = new RetentionManager(
|
|
715
|
-
this.db,
|
|
716
|
-
this.logger.child("retention"),
|
|
717
|
-
this.eventBus,
|
|
718
|
-
this.storageProvider
|
|
719
|
-
);
|
|
720
|
-
this.playlistGenerator = new playlistGenerator.PlaylistGenerator(this.db);
|
|
721
|
-
this.storageEstimator = new storageEstimator.StorageEstimator(this.db, this.networkTracker);
|
|
722
|
-
}
|
|
723
|
-
async start() {
|
|
724
|
-
this.logger.info("RecordingCoordinator starting");
|
|
725
|
-
this.retentionManager.start();
|
|
726
|
-
const enabledPolicies = this.db.getEnabledPolicies();
|
|
727
|
-
for (const policy of enabledPolicies) {
|
|
728
|
-
try {
|
|
729
|
-
await this.enableRecording(policy.deviceId, {
|
|
730
|
-
policy: {
|
|
731
|
-
mode: policy.mode,
|
|
732
|
-
streams: policy.streams,
|
|
733
|
-
enabled: policy.enabled,
|
|
734
|
-
preBufferSec: policy.preBufferSec,
|
|
735
|
-
postBufferSec: policy.postBufferSec,
|
|
736
|
-
scheduleRules: policy.scheduleRules
|
|
737
|
-
}
|
|
738
|
-
});
|
|
739
|
-
} catch (err) {
|
|
740
|
-
this.logger.error("Failed to start recording", { tags: { deviceId: policy.deviceId }, meta: { error: String(err) } });
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
this.policyTimer = setInterval(() => {
|
|
744
|
-
this.evaluatePolicies();
|
|
745
|
-
}, POLICY_EVAL_INTERVAL_MS);
|
|
746
|
-
this.logger.info("RecordingCoordinator started");
|
|
747
|
-
}
|
|
748
|
-
stop() {
|
|
749
|
-
this.logger.info("RecordingCoordinator stopping");
|
|
750
|
-
if (this.policyTimer) {
|
|
751
|
-
clearInterval(this.policyTimer);
|
|
752
|
-
this.policyTimer = null;
|
|
753
|
-
}
|
|
754
|
-
this.retentionManager.stop();
|
|
755
|
-
for (const [deviceId] of this.recordings) {
|
|
756
|
-
this.stopRecordingInternal(deviceId);
|
|
757
|
-
}
|
|
758
|
-
this.recordings.clear();
|
|
759
|
-
this.logger.info("RecordingCoordinator stopped");
|
|
760
|
-
}
|
|
761
|
-
async enableRecording(deviceId, config) {
|
|
762
|
-
if (this.recordings.has(deviceId)) {
|
|
763
|
-
this.stopRecordingInternal(deviceId);
|
|
764
|
-
this.recordings.delete(deviceId);
|
|
765
|
-
}
|
|
766
|
-
const policy = {
|
|
767
|
-
deviceId,
|
|
768
|
-
mode: config.policy.mode,
|
|
769
|
-
streams: config.policy.streams,
|
|
770
|
-
enabled: config.policy.enabled,
|
|
771
|
-
preBufferSec: config.policy.preBufferSec,
|
|
772
|
-
postBufferSec: config.policy.postBufferSec,
|
|
773
|
-
scheduleRules: config.policy.scheduleRules
|
|
774
|
-
};
|
|
775
|
-
this.db.upsertPolicy({
|
|
776
|
-
deviceId,
|
|
777
|
-
enabled: policy.enabled,
|
|
778
|
-
mode: policy.mode,
|
|
779
|
-
streams: policy.streams,
|
|
780
|
-
preBufferSec: policy.preBufferSec,
|
|
781
|
-
postBufferSec: policy.postBufferSec,
|
|
782
|
-
scheduleRules: policy.scheduleRules
|
|
783
|
-
});
|
|
784
|
-
this.db.cancelCleanup(deviceId);
|
|
785
|
-
const ffmpegConfig$1 = ffmpegConfig.resolveFfmpegConfig(
|
|
786
|
-
config.ffmpegOverrides,
|
|
787
|
-
this.globalFfmpegConfig,
|
|
788
|
-
this.detectedFfmpegConfig
|
|
789
|
-
);
|
|
790
|
-
const writerMode = policy.mode === "motion" ? "buffer" : "continuous";
|
|
791
|
-
const writers = [];
|
|
792
|
-
for (const sp of policy.streams) {
|
|
793
|
-
const storageConfig = this.db.resolveStorageConfig(deviceId, `recording:${sp.streamId}`);
|
|
794
|
-
const storageName = storageConfig?.storageName ?? "recordings";
|
|
795
|
-
const subDirectory = storageConfig?.subDirectory ?? `recordings/${sp.streamId}`;
|
|
796
|
-
const resolvedStoragePath = await this.storageProvider.resolve({ location: storageName, relativePath: "" });
|
|
797
|
-
const writerConfig = {
|
|
798
|
-
deviceId,
|
|
799
|
-
streamId: sp.streamId,
|
|
800
|
-
segmentDurationSec: this.segmentDurationSec,
|
|
801
|
-
storagePath: resolvedStoragePath,
|
|
802
|
-
storageName,
|
|
803
|
-
subDirectory,
|
|
804
|
-
ffmpeg: ffmpegConfig$1,
|
|
805
|
-
mode: writerMode,
|
|
806
|
-
preBufferSec: policy.preBufferSec
|
|
807
|
-
};
|
|
808
|
-
const writer = new SegmentWriter(
|
|
809
|
-
writerConfig,
|
|
810
|
-
this.logger.child(`writer:${deviceId}:${sp.streamId}`),
|
|
811
|
-
this.eventBus,
|
|
812
|
-
this.db,
|
|
813
|
-
this.networkTracker
|
|
814
|
-
);
|
|
815
|
-
const rtspUrl = this.streamingEngine.getStreamUrl(`${policy.deviceId}_${sp.streamId}`, "rtsp");
|
|
816
|
-
if (rtspUrl) {
|
|
817
|
-
await writer.start(rtspUrl);
|
|
818
|
-
}
|
|
819
|
-
writers.push(writer);
|
|
820
|
-
}
|
|
821
|
-
const thumbStorageConfig = this.db.resolveStorageConfig(deviceId, "thumbnail:scrub");
|
|
822
|
-
const thumbStorageName = thumbStorageConfig?.storageName ?? "recordings";
|
|
823
|
-
const thumbConfig = {
|
|
824
|
-
deviceId,
|
|
825
|
-
storagePath: await this.storageProvider.resolve({ location: thumbStorageName, relativePath: "" }),
|
|
826
|
-
storageName: thumbStorageName,
|
|
827
|
-
subDirectory: thumbStorageConfig?.subDirectory ?? "thumbnails/scrub",
|
|
828
|
-
maxWidthPx: 160,
|
|
829
|
-
jpegQuality: 65
|
|
830
|
-
};
|
|
831
|
-
const thumbnailExtractor = new ThumbnailExtractor(
|
|
832
|
-
thumbConfig,
|
|
833
|
-
this.logger.child(`thumb:${deviceId}`),
|
|
834
|
-
this.db
|
|
835
|
-
);
|
|
836
|
-
const pipeline = this.pipelineManager.getPipeline(deviceId);
|
|
837
|
-
if (pipeline) {
|
|
838
|
-
thumbnailExtractor.attachToPipeline(pipeline, deviceId);
|
|
839
|
-
}
|
|
840
|
-
if (policy.mode === "motion") {
|
|
841
|
-
thumbnailExtractor.setActive(false);
|
|
842
|
-
}
|
|
843
|
-
const motionUnsubscribe = this.subscribeToMotionEvents(deviceId, policy);
|
|
844
|
-
const state = {
|
|
845
|
-
deviceId,
|
|
846
|
-
policy,
|
|
847
|
-
writers,
|
|
848
|
-
thumbnailExtractor,
|
|
849
|
-
motionUnsubscribe,
|
|
850
|
-
motionActive: false,
|
|
851
|
-
motionTimeout: null,
|
|
852
|
-
motionFallbackTimeout: null,
|
|
853
|
-
motionReceived: false
|
|
854
|
-
};
|
|
855
|
-
this.recordings.set(deviceId, state);
|
|
856
|
-
if (policy.mode === "motion") {
|
|
857
|
-
state.motionFallbackTimeout = setTimeout(() => {
|
|
858
|
-
const currentState = this.recordings.get(deviceId);
|
|
859
|
-
if (!currentState || currentState.motionReceived) return;
|
|
860
|
-
this.logger.warn("No motion events received — falling back to continuous recording", {
|
|
861
|
-
tags: { deviceId },
|
|
862
|
-
meta: { timeoutSec: MOTION_FALLBACK_TIMEOUT_MS / 1e3 }
|
|
863
|
-
});
|
|
864
|
-
this.eventBus.emit({
|
|
865
|
-
id: `recording-policy-fallback-${deviceId}-${Date.now()}`,
|
|
866
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
867
|
-
source: { type: "addon", id: "recording-engine" },
|
|
868
|
-
category: index.EventCategory.RecordingPolicyFallback,
|
|
869
|
-
data: {
|
|
870
|
-
deviceId,
|
|
871
|
-
originalMode: "motion",
|
|
872
|
-
fallbackMode: "continuous",
|
|
873
|
-
reason: "no_motion_events"
|
|
874
|
-
}
|
|
875
|
-
});
|
|
876
|
-
for (const writer of currentState.writers) {
|
|
877
|
-
writer.flushAndContinue().catch((err) => {
|
|
878
|
-
this.logger.error("Failed to flush buffer during fallback", { tags: { deviceId }, meta: { error: String(err) } });
|
|
879
|
-
});
|
|
880
|
-
}
|
|
881
|
-
currentState.thumbnailExtractor.setActive(true);
|
|
882
|
-
}, MOTION_FALLBACK_TIMEOUT_MS);
|
|
883
|
-
}
|
|
884
|
-
this.eventBus.emit({
|
|
885
|
-
id: `recording-started-${deviceId}-${Date.now()}`,
|
|
886
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
887
|
-
source: { type: "addon", id: "recording-engine" },
|
|
888
|
-
category: index.EventCategory.RecordingStarted,
|
|
889
|
-
data: {
|
|
890
|
-
deviceId,
|
|
891
|
-
mode: policy.mode,
|
|
892
|
-
streams: policy.streams.map((s) => s.streamId)
|
|
893
|
-
}
|
|
894
|
-
});
|
|
895
|
-
this.logger.info("Recording enabled", { tags: { deviceId }, meta: { mode: policy.mode } });
|
|
896
|
-
}
|
|
897
|
-
async disableRecording(deviceId) {
|
|
898
|
-
const state = this.recordings.get(deviceId);
|
|
899
|
-
if (!state) {
|
|
900
|
-
this.logger.warn("No active recording", { tags: { deviceId } });
|
|
901
|
-
return;
|
|
902
|
-
}
|
|
903
|
-
let totalSegmentCount = 0;
|
|
904
|
-
let totalSizeBytes = 0;
|
|
905
|
-
for (const sp of state.policy.streams) {
|
|
906
|
-
const usage = this.db.getStorageUsage(deviceId, sp.streamId);
|
|
907
|
-
totalSegmentCount += usage.segmentCount;
|
|
908
|
-
totalSizeBytes += usage.totalBytes;
|
|
909
|
-
}
|
|
910
|
-
const totalMB = Math.round(totalSizeBytes / 1024 / 1024);
|
|
911
|
-
this.stopRecordingInternal(deviceId);
|
|
912
|
-
this.recordings.delete(deviceId);
|
|
913
|
-
this.db.addToCleanupQueue(deviceId, Date.now());
|
|
914
|
-
this.eventBus.emit({
|
|
915
|
-
id: `recording-stopped-${deviceId}-${Date.now()}`,
|
|
916
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
917
|
-
source: { type: "addon", id: "recording-engine" },
|
|
918
|
-
category: index.EventCategory.RecordingStopped,
|
|
919
|
-
data: {
|
|
920
|
-
deviceId,
|
|
921
|
-
segmentCount: totalSegmentCount,
|
|
922
|
-
totalMB
|
|
923
|
-
}
|
|
924
|
-
});
|
|
925
|
-
this.logger.info("Recording disabled", { tags: { deviceId }, meta: { segmentCount: totalSegmentCount, totalMB } });
|
|
926
|
-
}
|
|
927
|
-
isRecording(deviceId) {
|
|
928
|
-
return this.recordings.has(deviceId);
|
|
929
|
-
}
|
|
930
|
-
/** Number of devices currently being recorded. */
|
|
931
|
-
getActiveCount() {
|
|
932
|
-
return this.recordings.size;
|
|
933
|
-
}
|
|
934
|
-
evaluatePolicies() {
|
|
935
|
-
const now = /* @__PURE__ */ new Date();
|
|
936
|
-
for (const [_deviceId, state] of this.recordings) {
|
|
937
|
-
const { policy } = state;
|
|
938
|
-
if (policy.mode === "scheduled" || policy.mode === "composite") {
|
|
939
|
-
if (!policy.scheduleRules || policy.scheduleRules.length === 0) continue;
|
|
940
|
-
const matchingRule = policy.scheduleRules.find(
|
|
941
|
-
(rule) => RecordingCoordinator.evaluateScheduleRule(rule, now)
|
|
942
|
-
);
|
|
943
|
-
if (matchingRule) {
|
|
944
|
-
const targetMode = matchingRule.mode === "motion" ? "buffer" : "continuous";
|
|
945
|
-
for (const writer of state.writers) {
|
|
946
|
-
if (writer.mode !== targetMode) {
|
|
947
|
-
if (targetMode === "buffer") {
|
|
948
|
-
writer.switchToBuffer();
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
} else {
|
|
953
|
-
for (const writer of state.writers) {
|
|
954
|
-
if (writer.mode !== "buffer") {
|
|
955
|
-
writer.switchToBuffer();
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
static evaluateScheduleRule(rule, date) {
|
|
963
|
-
const dayOfWeek = date.getDay();
|
|
964
|
-
const timeMinutes = date.getHours() * 60 + date.getMinutes();
|
|
965
|
-
const [startH, startM] = rule.startTime.split(":").map(Number);
|
|
966
|
-
const [endH, endM] = rule.endTime.split(":").map(Number);
|
|
967
|
-
const startMinutes = startH * 60 + startM;
|
|
968
|
-
const endMinutes = endH * 60 + endM;
|
|
969
|
-
if (endMinutes > startMinutes) {
|
|
970
|
-
return rule.days.includes(dayOfWeek) && timeMinutes >= startMinutes && timeMinutes < endMinutes;
|
|
971
|
-
}
|
|
972
|
-
if (rule.days.includes(dayOfWeek) && timeMinutes >= startMinutes) {
|
|
973
|
-
return true;
|
|
974
|
-
}
|
|
975
|
-
const previousDay = (dayOfWeek + 6) % 7;
|
|
976
|
-
if (rule.days.includes(previousDay) && timeMinutes < endMinutes) {
|
|
977
|
-
return true;
|
|
978
|
-
}
|
|
979
|
-
return false;
|
|
980
|
-
}
|
|
981
|
-
subscribeToMotionEvents(deviceId, policy) {
|
|
982
|
-
if (policy.mode !== "motion" && policy.mode !== "composite") {
|
|
983
|
-
return null;
|
|
984
|
-
}
|
|
985
|
-
return this.eventBus.subscribe(
|
|
986
|
-
{ category: `motion.${deviceId}` },
|
|
987
|
-
(event) => {
|
|
988
|
-
this.handleMotionEvent(deviceId, event);
|
|
989
|
-
}
|
|
990
|
-
);
|
|
991
|
-
}
|
|
992
|
-
handleMotionEvent(deviceId, event) {
|
|
993
|
-
const state = this.recordings.get(deviceId);
|
|
994
|
-
if (!state) return;
|
|
995
|
-
if (!state.motionReceived) {
|
|
996
|
-
state.motionReceived = true;
|
|
997
|
-
if (state.motionFallbackTimeout) {
|
|
998
|
-
clearTimeout(state.motionFallbackTimeout);
|
|
999
|
-
state.motionFallbackTimeout = null;
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
const motionDetected = event.data.active === true || event.data.type === "start";
|
|
1003
|
-
if (motionDetected) {
|
|
1004
|
-
state.motionActive = true;
|
|
1005
|
-
if (state.motionTimeout) {
|
|
1006
|
-
clearTimeout(state.motionTimeout);
|
|
1007
|
-
state.motionTimeout = null;
|
|
1008
|
-
}
|
|
1009
|
-
for (const writer of state.writers) {
|
|
1010
|
-
writer.flushAndContinue().catch((err) => {
|
|
1011
|
-
this.logger.error("Failed to flush buffer", { tags: { deviceId }, meta: { error: String(err) } });
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
state.thumbnailExtractor.setActive(true);
|
|
1015
|
-
} else {
|
|
1016
|
-
if (state.motionTimeout) {
|
|
1017
|
-
clearTimeout(state.motionTimeout);
|
|
1018
|
-
}
|
|
1019
|
-
state.motionTimeout = setTimeout(() => {
|
|
1020
|
-
state.motionActive = false;
|
|
1021
|
-
state.motionTimeout = null;
|
|
1022
|
-
for (const writer of state.writers) {
|
|
1023
|
-
writer.switchToBuffer();
|
|
1024
|
-
}
|
|
1025
|
-
if (state.policy.mode === "motion") {
|
|
1026
|
-
state.thumbnailExtractor.setActive(false);
|
|
1027
|
-
}
|
|
1028
|
-
}, state.policy.postBufferSec * 1e3);
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
stopRecordingInternal(deviceId) {
|
|
1032
|
-
const state = this.recordings.get(deviceId);
|
|
1033
|
-
if (!state) return;
|
|
1034
|
-
for (const writer of state.writers) {
|
|
1035
|
-
writer.stop();
|
|
1036
|
-
}
|
|
1037
|
-
state.thumbnailExtractor.detachFromPipeline(deviceId);
|
|
1038
|
-
if (state.motionUnsubscribe) {
|
|
1039
|
-
state.motionUnsubscribe();
|
|
1040
|
-
}
|
|
1041
|
-
if (state.motionTimeout) {
|
|
1042
|
-
clearTimeout(state.motionTimeout);
|
|
1043
|
-
state.motionTimeout = null;
|
|
1044
|
-
}
|
|
1045
|
-
if (state.motionFallbackTimeout) {
|
|
1046
|
-
clearTimeout(state.motionFallbackTimeout);
|
|
1047
|
-
state.motionFallbackTimeout = null;
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
exports.RecordingCoordinator = RecordingCoordinator;
|
|
1052
|
-
//# sourceMappingURL=recording-coordinator-BoGr5moz.js.map
|