@camstack/addon-post-analysis 1.2.16 → 1.2.18
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/{dist-BsUHqtgU.mjs → dist-C41w6Xvl.mjs} +451 -79
- package/dist/{dist-BV2hm-VZ.js → dist-CjwPOJKc.js} +468 -78
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +3 -1
- package/dist/{node-BuPMb-ti.mjs → node-BmVBZiOw.mjs} +11 -10
- package/dist/{node-BY0XZAen.js → node-Dwh-F2Zf.js} +2 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-pcMwP3TG.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C0HgiJXz.mjs} +3 -3
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-54-w8Eqz.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-KORSzXI5.mjs → hostInit-DisaD6Xu.mjs} +3 -3
- package/dist/pipeline-analytics/index.js +1615 -144
- package/dist/pipeline-analytics/index.mjs +1615 -146
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DCdWQhcm.mjs +0 -26
|
@@ -2,10 +2,351 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-CjwPOJKc.js");
|
|
6
|
+
let node_fs = require("node:fs");
|
|
7
|
+
let node_path = require("node:path");
|
|
8
|
+
node_path = require_dist.__toESM(node_path);
|
|
6
9
|
let node_crypto = require("node:crypto");
|
|
7
10
|
let sharp = require("sharp");
|
|
8
11
|
sharp = require_dist.__toESM(sharp);
|
|
12
|
+
//#region src/notification-center/artifact-url.ts
|
|
13
|
+
/**
|
|
14
|
+
* Signed, externally-reachable URLs for notification artifacts.
|
|
15
|
+
*
|
|
16
|
+
* WHY this exists: the dispatcher only ever produced attachment BYTES, and the
|
|
17
|
+
* degrade engine drops a bytes-only attachment for a `mode:'url'` target
|
|
18
|
+
* (`attachment:noUrl`) — so WhatsApp and gotify received no media at all,
|
|
19
|
+
* silently. A URL also lets an oversized attachment degrade to a link instead
|
|
20
|
+
* of being dropped on `maxBytes`.
|
|
21
|
+
*
|
|
22
|
+
* Two decisions the shape depends on:
|
|
23
|
+
*
|
|
24
|
+
* - **Signed, not authenticated.** The fetcher is a notifier BACKEND (or the
|
|
25
|
+
* recipient's phone) — it holds no session and no admin token. The route is
|
|
26
|
+
* therefore `access:'public'` and the authority is the signature: an
|
|
27
|
+
* unguessable HMAC over `(id, exp)` with a per-install secret, expiring on
|
|
28
|
+
* its own. Nothing else about the install is reachable through it.
|
|
29
|
+
* - **The base URL is CHOSEN, never assumed.** A notification carrying
|
|
30
|
+
* `http://127.0.0.1:...` is useless (the Alexa `hubUrl` bug, again). The
|
|
31
|
+
* ranked endpoint list the `local-network` cap already computes — public
|
|
32
|
+
* tunnel > mesh > LAN, loopback last — is the authority, and loopback is
|
|
33
|
+
* refused outright rather than shipped as a broken link.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Pick the base URL an artifact link should use: the LOWEST-priority-number
|
|
37
|
+
* endpoint that is not loopback (the list is already ranked public > mesh >
|
|
38
|
+
* LAN > loopback). `null` when only loopback is available — a link nobody
|
|
39
|
+
* outside the host could open is worse than no link, because the notification
|
|
40
|
+
* would arrive advertising media that 404s.
|
|
41
|
+
*/
|
|
42
|
+
function pickArtifactBaseUrl(endpoints) {
|
|
43
|
+
return endpoints.filter((e) => e.kind !== "loopback").toSorted((a, b) => a.priority - b.priority)[0]?.baseUrl ?? null;
|
|
44
|
+
}
|
|
45
|
+
/** Sign `(id, exp)` — the exact string the verifier recomputes. */
|
|
46
|
+
function signArtifact(secret, id, expMs) {
|
|
47
|
+
return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${expMs}`).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build the fully-qualified, signed URL for an artifact.
|
|
51
|
+
* `baseUrl` comes from {@link pickArtifactBaseUrl}, `routePrefix` from the
|
|
52
|
+
* data-plane handle (`/addon/<addonId>/nc-artifact`).
|
|
53
|
+
*/
|
|
54
|
+
function buildArtifactUrl(input) {
|
|
55
|
+
const sig = signArtifact(input.secret, input.id, input.expMs);
|
|
56
|
+
return `${input.baseUrl.endsWith("/") ? input.baseUrl.slice(0, -1) : input.baseUrl}${input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Verify a request's `(id, exp, sig)`. Constant-time on the signature so a
|
|
60
|
+
* public route cannot be probed for it byte by byte, and expiry-checked before
|
|
61
|
+
* the compare so an expired link is rejected even with a valid signature.
|
|
62
|
+
*/
|
|
63
|
+
function verifyArtifactSignature(input) {
|
|
64
|
+
if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
|
|
65
|
+
const expected = signArtifact(input.secret, input.id, input.exp);
|
|
66
|
+
const a = Buffer.from(expected, "utf8");
|
|
67
|
+
const b = Buffer.from(input.sig, "utf8");
|
|
68
|
+
if (a.length !== b.length) return false;
|
|
69
|
+
return (0, node_crypto.timingSafeEqual)(a, b);
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/notification-center/artifact-plane.ts
|
|
73
|
+
/**
|
|
74
|
+
* The hub's default HTTPS API port — the same constant `derive-hub-url.ts`
|
|
75
|
+
* documents. Only used for the LAN candidates; an operator who runs the hub on
|
|
76
|
+
* another port sets `CAMSTACK_HUB_PUBLIC_URL`, which wins outright.
|
|
77
|
+
*/
|
|
78
|
+
var HUB_API_PORT = 4443;
|
|
79
|
+
/**
|
|
80
|
+
* Ranking of the public sources. All NEGATIVE: `local-network` gives its
|
|
81
|
+
* preferred LAN interface priority `0`, so a public candidate has to sit below
|
|
82
|
+
* zero to outrank it.
|
|
83
|
+
*/
|
|
84
|
+
var PRIORITY_MARKED = -400;
|
|
85
|
+
var PRIORITY_CONFIGURED = -300;
|
|
86
|
+
var PRIORITY_CONNECTED = -200;
|
|
87
|
+
var PRIORITY_EXTERNAL_LIST = -100;
|
|
88
|
+
/**
|
|
89
|
+
* Build the ranked candidate list, in the operator's own order: an explicitly
|
|
90
|
+
* configured public origin, then the connected external ingress (tunnels),
|
|
91
|
+
* then the LAN addresses. Every source is best-effort — one that throws
|
|
92
|
+
* contributes nothing rather than costing the whole list.
|
|
93
|
+
*/
|
|
94
|
+
async function collectArtifactEndpoints(sources) {
|
|
95
|
+
const out = [];
|
|
96
|
+
const marked = sources.markedBaseUrl;
|
|
97
|
+
if (marked !== void 0 && marked !== "") out.push({
|
|
98
|
+
baseUrl: marked,
|
|
99
|
+
kind: "public",
|
|
100
|
+
priority: PRIORITY_MARKED
|
|
101
|
+
});
|
|
102
|
+
const configured = sources.configuredPublicUrl;
|
|
103
|
+
if (configured !== void 0 && configured !== "" && !/127\.0\.0\.1|localhost/.test(configured)) out.push({
|
|
104
|
+
baseUrl: configured,
|
|
105
|
+
kind: "public",
|
|
106
|
+
priority: PRIORITY_CONFIGURED
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
const connected = await sources.getConnected();
|
|
110
|
+
if (connected !== null && connected.protocol === "https") out.push({
|
|
111
|
+
baseUrl: connected.url,
|
|
112
|
+
kind: "public",
|
|
113
|
+
priority: PRIORITY_CONNECTED
|
|
114
|
+
});
|
|
115
|
+
} catch (err) {
|
|
116
|
+
sources.logger.debug("connected endpoint lookup failed", { meta: { error: String(err) } });
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const external = await sources.listExternal();
|
|
120
|
+
for (const [i, e] of external.entries()) {
|
|
121
|
+
if (e.protocol !== "https") continue;
|
|
122
|
+
out.push({
|
|
123
|
+
baseUrl: e.url,
|
|
124
|
+
kind: "public",
|
|
125
|
+
priority: PRIORITY_EXTERNAL_LIST + i
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
sources.logger.debug("external endpoint lookup failed", { meta: { error: String(err) } });
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
out.push(...await sources.listLan(HUB_API_PORT));
|
|
133
|
+
} catch (err) {
|
|
134
|
+
sources.logger.debug("LAN endpoint lookup failed", { meta: { error: String(err) } });
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
var DEFAULT_TTL_MS = 24 * 36e5;
|
|
139
|
+
var DEFAULT_ENDPOINT_CACHE_MS = 6e4;
|
|
140
|
+
var NcArtifactPlane = class {
|
|
141
|
+
deps;
|
|
142
|
+
now;
|
|
143
|
+
cachedBaseUrl = null;
|
|
144
|
+
/** Last base URL announced in the log — so the line appears on CHANGE only. */
|
|
145
|
+
lastLoggedBaseUrl = null;
|
|
146
|
+
cachedAt = 0;
|
|
147
|
+
constructor(deps) {
|
|
148
|
+
this.deps = deps;
|
|
149
|
+
this.now = deps.now ?? (() => Date.now());
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Store bytes and mint a signed, externally-reachable URL for them.
|
|
153
|
+
* Returns `null` when no usable base URL exists (loopback only) — the caller
|
|
154
|
+
* then ships bytes alone, exactly as before. Never throws: an artifact URL is
|
|
155
|
+
* an ENHANCEMENT, and failing to mint one must not cost the notification.
|
|
156
|
+
*/
|
|
157
|
+
async publish(bytes, mime) {
|
|
158
|
+
try {
|
|
159
|
+
const baseUrl = await this.resolveBaseUrl();
|
|
160
|
+
if (baseUrl === null) return null;
|
|
161
|
+
const artifact = await this.deps.store.put(bytes, mime);
|
|
162
|
+
return buildArtifactUrl({
|
|
163
|
+
baseUrl,
|
|
164
|
+
routePrefix: this.deps.routePrefix,
|
|
165
|
+
id: artifact.id,
|
|
166
|
+
secret: this.deps.secret,
|
|
167
|
+
expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
|
|
168
|
+
});
|
|
169
|
+
} catch (err) {
|
|
170
|
+
this.deps.logger.debug("artifact publish failed — shipping bytes only", { meta: { error: String(err) } });
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** The data-plane handler for `<prefix>/<id>?exp=&sig=`. */
|
|
175
|
+
handler = async (req, res) => {
|
|
176
|
+
const url = new URL(req.url ?? "/", "http://placeholder");
|
|
177
|
+
const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
|
|
178
|
+
if (!verifyArtifactSignature({
|
|
179
|
+
secret: this.deps.secret,
|
|
180
|
+
id,
|
|
181
|
+
exp: Number(url.searchParams.get("exp")),
|
|
182
|
+
sig: url.searchParams.get("sig") ?? "",
|
|
183
|
+
nowMs: this.now()
|
|
184
|
+
})) {
|
|
185
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
186
|
+
res.end("not found");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const found = await this.deps.store.read(id);
|
|
190
|
+
if (found === null) {
|
|
191
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
192
|
+
res.end("not found");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
res.writeHead(200, {
|
|
196
|
+
"content-type": found.mime,
|
|
197
|
+
"content-length": String(found.bytes.byteLength),
|
|
198
|
+
"cache-control": "private, max-age=3600"
|
|
199
|
+
});
|
|
200
|
+
res.end(found.bytes);
|
|
201
|
+
};
|
|
202
|
+
/** The chosen base URL, cached briefly (the interface list barely moves). */
|
|
203
|
+
async resolveBaseUrl() {
|
|
204
|
+
const ttl = this.deps.endpointCacheMs ?? DEFAULT_ENDPOINT_CACHE_MS;
|
|
205
|
+
if (this.cachedBaseUrl !== null && this.now() - this.cachedAt < ttl) return this.cachedBaseUrl;
|
|
206
|
+
const picked = pickArtifactBaseUrl(await this.deps.listEndpoints());
|
|
207
|
+
if (picked === null) {
|
|
208
|
+
this.deps.logger.debug("no externally-reachable endpoint — artifacts ship as bytes only");
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
if (picked !== this.lastLoggedBaseUrl) {
|
|
212
|
+
this.deps.logger.info("artifact links will use", { meta: { baseUrl: picked } });
|
|
213
|
+
this.lastLoggedBaseUrl = picked;
|
|
214
|
+
}
|
|
215
|
+
this.cachedBaseUrl = picked;
|
|
216
|
+
this.cachedAt = this.now();
|
|
217
|
+
return picked;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/notification-center/artifact-store.ts
|
|
222
|
+
/**
|
|
223
|
+
* NcArtifactStore — the bytes behind a signed notification-artifact URL.
|
|
224
|
+
*
|
|
225
|
+
* A notification attachment is transient by nature: it exists to be fetched
|
|
226
|
+
* once, by a notifier backend or the recipient's phone, within minutes of the
|
|
227
|
+
* event. So this is a BOUNDED SCRATCH, not a media library — the durable copy
|
|
228
|
+
* of an event's media already lives in the media store, and duplicating it here
|
|
229
|
+
* permanently would grow without limit for no benefit.
|
|
230
|
+
*
|
|
231
|
+
* Two bounds, both enforced on every write (age first, then count): whichever
|
|
232
|
+
* bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
|
|
233
|
+
* left behind — the directory is disposable by construction, so a lost file is
|
|
234
|
+
* a 404 on one link, never a corrupt state.
|
|
235
|
+
*/
|
|
236
|
+
var DEFAULT_MAX_AGE_MS = 6 * 36e5;
|
|
237
|
+
var DEFAULT_MAX_ENTRIES = 500;
|
|
238
|
+
/** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
|
|
239
|
+
function extensionFor(mime) {
|
|
240
|
+
if (mime === "image/jpeg") return "jpg";
|
|
241
|
+
if (mime === "image/gif") return "gif";
|
|
242
|
+
if (mime === "video/mp4") return "mp4";
|
|
243
|
+
if (mime === "image/png") return "png";
|
|
244
|
+
return "bin";
|
|
245
|
+
}
|
|
246
|
+
var NcArtifactStore = class {
|
|
247
|
+
options;
|
|
248
|
+
now;
|
|
249
|
+
maxAgeMs;
|
|
250
|
+
maxEntries;
|
|
251
|
+
/** id → mime, so the route can answer with the right content-type without
|
|
252
|
+
* re-deriving it from the extension on every request. */
|
|
253
|
+
mimeById = /* @__PURE__ */ new Map();
|
|
254
|
+
constructor(options) {
|
|
255
|
+
this.options = options;
|
|
256
|
+
this.now = options.now ?? (() => Date.now());
|
|
257
|
+
this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
258
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
259
|
+
}
|
|
260
|
+
/** Create the directory and sweep whatever a previous run left behind. */
|
|
261
|
+
async start() {
|
|
262
|
+
await node_fs.promises.mkdir(this.options.dir, { recursive: true });
|
|
263
|
+
await this.sweep();
|
|
264
|
+
}
|
|
265
|
+
/** Persist bytes and return the artifact handle (never throws on sweep). */
|
|
266
|
+
async put(bytes, mime) {
|
|
267
|
+
const id = `${this.now()}-${(0, node_crypto.randomUUID)()}`;
|
|
268
|
+
const file = node_path.default.join(this.options.dir, `${id}.${extensionFor(mime)}`);
|
|
269
|
+
await node_fs.promises.mkdir(this.options.dir, { recursive: true });
|
|
270
|
+
await node_fs.promises.writeFile(file, bytes);
|
|
271
|
+
this.mimeById.set(id, mime);
|
|
272
|
+
await this.sweep();
|
|
273
|
+
return {
|
|
274
|
+
id,
|
|
275
|
+
mime,
|
|
276
|
+
bytes: bytes.byteLength
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
|
|
280
|
+
async read(id) {
|
|
281
|
+
if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
|
|
282
|
+
const found = (await this.list()).find((e) => e.id === id);
|
|
283
|
+
if (!found) return null;
|
|
284
|
+
try {
|
|
285
|
+
return {
|
|
286
|
+
bytes: await node_fs.promises.readFile(found.file),
|
|
287
|
+
mime: this.mimeById.get(id) ?? mimeFromExtension(found.file)
|
|
288
|
+
};
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Delete every artifact (operator cleanup / shutdown). */
|
|
294
|
+
async clear() {
|
|
295
|
+
await node_fs.promises.rm(this.options.dir, {
|
|
296
|
+
recursive: true,
|
|
297
|
+
force: true
|
|
298
|
+
}).catch(() => {});
|
|
299
|
+
this.mimeById.clear();
|
|
300
|
+
}
|
|
301
|
+
/** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
|
|
302
|
+
async sweep() {
|
|
303
|
+
const entries = await this.list();
|
|
304
|
+
const cutoff = this.now() - this.maxAgeMs;
|
|
305
|
+
const doomed = entries.filter((e) => e.mtimeMs < cutoff);
|
|
306
|
+
const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
|
|
307
|
+
doomed.push(...survivors.slice(this.maxEntries));
|
|
308
|
+
for (const e of doomed) try {
|
|
309
|
+
await node_fs.promises.rm(e.file, { force: true });
|
|
310
|
+
this.mimeById.delete(e.id);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
this.options.logger.debug("artifact sweep failed to unlink", { meta: {
|
|
313
|
+
file: e.file,
|
|
314
|
+
error: String(err)
|
|
315
|
+
} });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async list() {
|
|
319
|
+
let names;
|
|
320
|
+
try {
|
|
321
|
+
names = await node_fs.promises.readdir(this.options.dir);
|
|
322
|
+
} catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
const out = [];
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
const file = node_path.default.join(this.options.dir, name);
|
|
328
|
+
try {
|
|
329
|
+
const st = await node_fs.promises.stat(file);
|
|
330
|
+
if (!st.isFile()) continue;
|
|
331
|
+
out.push({
|
|
332
|
+
id: name.slice(0, name.lastIndexOf(".")),
|
|
333
|
+
file,
|
|
334
|
+
mtimeMs: st.mtimeMs
|
|
335
|
+
});
|
|
336
|
+
} catch {}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
function mimeFromExtension(file) {
|
|
342
|
+
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
343
|
+
if (ext === "jpg") return "image/jpeg";
|
|
344
|
+
if (ext === "gif") return "image/gif";
|
|
345
|
+
if (ext === "mp4") return "video/mp4";
|
|
346
|
+
if (ext === "png") return "image/png";
|
|
347
|
+
return "application/octet-stream";
|
|
348
|
+
}
|
|
349
|
+
//#endregion
|
|
9
350
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
10
351
|
var SOURCE = "analytics";
|
|
11
352
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -4162,6 +4503,42 @@ function attachmentKindPreference(policy, ownerKind) {
|
|
|
4162
4503
|
];
|
|
4163
4504
|
}
|
|
4164
4505
|
/**
|
|
4506
|
+
* The kind ladder for an EXPLICIT frame choice. Strict by design — each option
|
|
4507
|
+
* stays inside its own family, because a rule that asked for the clean scene
|
|
4508
|
+
* and received the boxed one (or vice versa) is not "degrading gracefully", it
|
|
4509
|
+
* is answering a different question.
|
|
4510
|
+
*
|
|
4511
|
+
* `boxed` is the one exception: with no annotated frame stored, the CLEAN
|
|
4512
|
+
* scene is the honest fallback (same picture, no annotation) — never a subject
|
|
4513
|
+
* crop, which shows something else entirely.
|
|
4514
|
+
*/
|
|
4515
|
+
function framePreference(frame, ownerKind) {
|
|
4516
|
+
if (frame === "cropped") return ownerKind === "track" ? [
|
|
4517
|
+
"thumbnail",
|
|
4518
|
+
"thumbnailSmall",
|
|
4519
|
+
"crop"
|
|
4520
|
+
] : [
|
|
4521
|
+
"crop",
|
|
4522
|
+
"thumbnail",
|
|
4523
|
+
"thumbnailSmall"
|
|
4524
|
+
];
|
|
4525
|
+
if (frame === "boxed") return [
|
|
4526
|
+
"fullFrameBoxed",
|
|
4527
|
+
"keyFrame",
|
|
4528
|
+
"keyFrameSmall",
|
|
4529
|
+
"fullFrame"
|
|
4530
|
+
];
|
|
4531
|
+
return ownerKind === "track" ? [
|
|
4532
|
+
"keyFrame",
|
|
4533
|
+
"keyFrameSmall",
|
|
4534
|
+
"firstFrame"
|
|
4535
|
+
] : [
|
|
4536
|
+
"fullFrame",
|
|
4537
|
+
"keyFrame",
|
|
4538
|
+
"keyFrameSmall"
|
|
4539
|
+
];
|
|
4540
|
+
}
|
|
4541
|
+
/**
|
|
4165
4542
|
* Derive the `best-matching` media signal from a matched rule's condition
|
|
4166
4543
|
* summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
|
|
4167
4544
|
* (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
|
|
@@ -4190,6 +4567,20 @@ function bestMatchingKindPreference(signal, ownerKind) {
|
|
|
4190
4567
|
//#endregion
|
|
4191
4568
|
//#region src/notification-center/dispatcher.ts
|
|
4192
4569
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
4570
|
+
/** GIF and MP4 are the same cut in two containers — one render request each. */
|
|
4571
|
+
var FOOTAGE_FORMATS = [{
|
|
4572
|
+
flag: "mediaGif",
|
|
4573
|
+
format: "gif",
|
|
4574
|
+
mediaType: "gif",
|
|
4575
|
+
mime: "image/gif",
|
|
4576
|
+
name: "event.gif"
|
|
4577
|
+
}, {
|
|
4578
|
+
flag: "mediaClip",
|
|
4579
|
+
format: "mp4",
|
|
4580
|
+
mediaType: "video",
|
|
4581
|
+
mime: "video/mp4",
|
|
4582
|
+
name: "event.mp4"
|
|
4583
|
+
}];
|
|
4193
4584
|
var NcDispatcher = class {
|
|
4194
4585
|
deps;
|
|
4195
4586
|
targetCache = null;
|
|
@@ -4259,14 +4650,19 @@ var NcDispatcher = class {
|
|
|
4259
4650
|
async resolveTarget(targetId) {
|
|
4260
4651
|
const cached = this.cachedTarget(targetId);
|
|
4261
4652
|
if (cached !== null) return cached;
|
|
4653
|
+
let targets;
|
|
4262
4654
|
try {
|
|
4263
|
-
|
|
4264
|
-
this.targetCache = new Map(targets.map((t) => [t.id, t]));
|
|
4265
|
-
this.targetCacheAt = this.now();
|
|
4655
|
+
targets = await this.deps.listTargets();
|
|
4266
4656
|
} catch (err) {
|
|
4267
4657
|
this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
|
|
4268
4658
|
throw err instanceof Error ? err : new Error(String(err));
|
|
4269
4659
|
}
|
|
4660
|
+
if (targets.length === 0) {
|
|
4661
|
+
this.deps.logger.warn("target catalog came back EMPTY — treating as transient", { meta: { targetId } });
|
|
4662
|
+
throw new Error(`target catalog empty (transient) while resolving ${targetId}`);
|
|
4663
|
+
}
|
|
4664
|
+
this.targetCache = new Map(targets.map((t) => [t.id, t]));
|
|
4665
|
+
this.targetCacheAt = this.now();
|
|
4270
4666
|
return this.targetCache.get(targetId) ?? null;
|
|
4271
4667
|
}
|
|
4272
4668
|
cachedTarget(targetId) {
|
|
@@ -4280,7 +4676,7 @@ var NcDispatcher = class {
|
|
|
4280
4676
|
const vars = buildTemplateVars(entry, deviceName);
|
|
4281
4677
|
const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
|
|
4282
4678
|
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
|
|
4283
|
-
const
|
|
4679
|
+
const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
|
|
4284
4680
|
const params = pickParams(entry.payload.params);
|
|
4285
4681
|
return {
|
|
4286
4682
|
body,
|
|
@@ -4290,7 +4686,7 @@ var NcDispatcher = class {
|
|
|
4290
4686
|
tag: entry.ruleId,
|
|
4291
4687
|
deviceId: subject.deviceId,
|
|
4292
4688
|
...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
|
|
4293
|
-
...
|
|
4689
|
+
...attachments.length > 0 ? { attachments } : {}
|
|
4294
4690
|
};
|
|
4295
4691
|
}
|
|
4296
4692
|
/**
|
|
@@ -4302,9 +4698,142 @@ var NcDispatcher = class {
|
|
|
4302
4698
|
* explains the fired condition (`faceCrop`/`plateCrop`, both event-owned)
|
|
4303
4699
|
* then degrades to the plain `best` → `keyFrame` ladders.
|
|
4304
4700
|
*/
|
|
4305
|
-
|
|
4306
|
-
|
|
4701
|
+
/**
|
|
4702
|
+
* Mint a signed URL for each attachment, alongside the bytes it already
|
|
4703
|
+
* carries. Best-effort per attachment: a failed mint leaves that one
|
|
4704
|
+
* bytes-only rather than costing the notification. Without this a url-mode
|
|
4705
|
+
* target (WhatsApp, gotify) silently received NO media — the degrade engine
|
|
4706
|
+
* drops a bytes-only attachment for them (`attachment:noUrl`).
|
|
4707
|
+
*/
|
|
4708
|
+
/**
|
|
4709
|
+
* A failed footage render is best-effort — ship the still, skip the video.
|
|
4710
|
+
*
|
|
4711
|
+
* There is nothing to wait for: the clip ring only grows FORWARD, so a window
|
|
4712
|
+
* it does not already cover will never be covered by retrying. (An earlier
|
|
4713
|
+
* revision deferred delivery here, when clips were cut from finalized
|
|
4714
|
+
* recording segments; retrying under the pre-buffer would just age the
|
|
4715
|
+
* pre-roll out of the ring.)
|
|
4716
|
+
*/
|
|
4717
|
+
notePendingFootage(entry, err, what) {
|
|
4718
|
+
this.deps.logger.debug(`${what} attachment render failed`, {
|
|
4719
|
+
tags: { deviceId: entry.payload.subject.deviceId },
|
|
4720
|
+
meta: {
|
|
4721
|
+
error: String(err),
|
|
4722
|
+
action: "send-without-video"
|
|
4723
|
+
}
|
|
4724
|
+
});
|
|
4725
|
+
}
|
|
4726
|
+
async withArtifactUrls(attachments) {
|
|
4727
|
+
const publish = this.deps.publishArtifact;
|
|
4728
|
+
if (publish === void 0) return [...attachments];
|
|
4729
|
+
const out = [];
|
|
4730
|
+
for (const att of attachments) {
|
|
4731
|
+
const url = await publish(att.bytes, att.mime).catch(() => null);
|
|
4732
|
+
out.push(url === null ? { ...att } : {
|
|
4733
|
+
...att,
|
|
4734
|
+
url
|
|
4735
|
+
});
|
|
4736
|
+
}
|
|
4737
|
+
return out;
|
|
4738
|
+
}
|
|
4739
|
+
/** The full attachment list: the policy still (zone-cropped when the rule
|
|
4740
|
+
* froze zone ids) plus the footage cut from the broker's clip ring when
|
|
4741
|
+
* requested. Each part is best-effort — a failed crop falls back to the
|
|
4742
|
+
* uncropped still, a failed render just omits the video. */
|
|
4743
|
+
async resolveAttachments(entry) {
|
|
4744
|
+
const out = [];
|
|
4745
|
+
const zoneIdsWanted = entry.payload.mediaZoneIds;
|
|
4746
|
+
const still = await this.resolveAttachment(entry, zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0);
|
|
4747
|
+
if (still !== null) {
|
|
4748
|
+
const zoneIds = zoneIdsWanted;
|
|
4749
|
+
if (zoneIds !== void 0 && zoneIds.length > 0) {
|
|
4750
|
+
const cropped = await this.zoneCrop(entry.payload.subject.deviceId, zoneIds, still.bytes);
|
|
4751
|
+
out.push(cropped !== null ? {
|
|
4752
|
+
...still,
|
|
4753
|
+
bytes: cropped,
|
|
4754
|
+
name: "zone.jpg"
|
|
4755
|
+
} : still);
|
|
4756
|
+
} else out.push(still);
|
|
4757
|
+
}
|
|
4758
|
+
for (const want of FOOTAGE_FORMATS) {
|
|
4759
|
+
if (entry.payload[want.flag] !== true || !this.deps.renderFootage) continue;
|
|
4760
|
+
try {
|
|
4761
|
+
const rendered = await this.deps.renderFootage({
|
|
4762
|
+
deviceId: entry.payload.subject.deviceId,
|
|
4763
|
+
aroundMs: entry.payload.subject.timestamp,
|
|
4764
|
+
format: want.format,
|
|
4765
|
+
...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
|
|
4766
|
+
...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
|
|
4767
|
+
...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
|
|
4768
|
+
});
|
|
4769
|
+
if (rendered !== null && rendered.byteLength > 0) {
|
|
4770
|
+
const bytes = new Uint8Array(rendered.byteLength);
|
|
4771
|
+
bytes.set(rendered);
|
|
4772
|
+
out.push({
|
|
4773
|
+
mediaType: want.mediaType,
|
|
4774
|
+
bytes,
|
|
4775
|
+
mime: want.mime,
|
|
4776
|
+
name: want.name
|
|
4777
|
+
});
|
|
4778
|
+
}
|
|
4779
|
+
} catch (err) {
|
|
4780
|
+
this.notePendingFootage(entry, err, want.format === "gif" ? "gif" : "clip");
|
|
4781
|
+
}
|
|
4782
|
+
}
|
|
4783
|
+
return out;
|
|
4784
|
+
}
|
|
4785
|
+
/** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
|
|
4786
|
+
* pixel rect via sharp metadata). Null on any failure — caller falls back
|
|
4787
|
+
* to the uncropped still. */
|
|
4788
|
+
async zoneCrop(deviceId, zoneIds, jpeg) {
|
|
4789
|
+
try {
|
|
4790
|
+
const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
|
|
4791
|
+
if (points.length === 0) return null;
|
|
4792
|
+
const { default: sharp$7 } = await import("sharp");
|
|
4793
|
+
const img = sharp$7(Buffer.from(jpeg));
|
|
4794
|
+
const meta = await img.metadata();
|
|
4795
|
+
const W = meta.width ?? 0;
|
|
4796
|
+
const H = meta.height ?? 0;
|
|
4797
|
+
if (W === 0 || H === 0) return null;
|
|
4798
|
+
let minX = 1;
|
|
4799
|
+
let minY = 1;
|
|
4800
|
+
let maxX = 0;
|
|
4801
|
+
let maxY = 0;
|
|
4802
|
+
for (const p of points) {
|
|
4803
|
+
if (p.x < minX) minX = p.x;
|
|
4804
|
+
if (p.y < minY) minY = p.y;
|
|
4805
|
+
if (p.x > maxX) maxX = p.x;
|
|
4806
|
+
if (p.y > maxY) maxY = p.y;
|
|
4807
|
+
}
|
|
4808
|
+
if (maxX <= minX || maxY <= minY) return null;
|
|
4809
|
+
const padX = (maxX - minX) * .1;
|
|
4810
|
+
const padY = (maxY - minY) * .1;
|
|
4811
|
+
const left = Math.max(0, Math.floor((minX - padX) * W));
|
|
4812
|
+
const top = Math.max(0, Math.floor((minY - padY) * H));
|
|
4813
|
+
const width = Math.min(W - left, Math.ceil((maxX - minX + 2 * padX) * W));
|
|
4814
|
+
const height = Math.min(H - top, Math.ceil((maxY - minY + 2 * padY) * H));
|
|
4815
|
+
if (width < 16 || height < 16) return null;
|
|
4816
|
+
const outBuf = await img.extract({
|
|
4817
|
+
left,
|
|
4818
|
+
top,
|
|
4819
|
+
width,
|
|
4820
|
+
height
|
|
4821
|
+
}).jpeg({ quality: 82 }).toBuffer();
|
|
4822
|
+
const bytes = new Uint8Array(outBuf.byteLength);
|
|
4823
|
+
bytes.set(outBuf);
|
|
4824
|
+
return bytes;
|
|
4825
|
+
} catch (err) {
|
|
4826
|
+
this.deps.logger.debug("zone crop failed — attaching uncropped still", { meta: {
|
|
4827
|
+
deviceId,
|
|
4828
|
+
error: String(err)
|
|
4829
|
+
} });
|
|
4830
|
+
return null;
|
|
4831
|
+
}
|
|
4832
|
+
}
|
|
4833
|
+
async resolveAttachment(entry, policyOverride) {
|
|
4834
|
+
const policy = policyOverride ?? entry.payload.media;
|
|
4307
4835
|
if (policy === "none") return null;
|
|
4836
|
+
const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
|
|
4308
4837
|
const subject = entry.payload.subject;
|
|
4309
4838
|
const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
|
|
4310
4839
|
const owners = [];
|
|
@@ -4316,11 +4845,11 @@ var NcDispatcher = class {
|
|
|
4316
4845
|
kind: "track",
|
|
4317
4846
|
id: subject.trackId
|
|
4318
4847
|
});
|
|
4319
|
-
if (policy === "keyFrame") owners.reverse();
|
|
4848
|
+
if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
|
|
4320
4849
|
for (const owner of owners) try {
|
|
4321
4850
|
const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
4322
4851
|
if (files.length === 0) continue;
|
|
4323
|
-
const preference = policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
4852
|
+
const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
4324
4853
|
for (const kind of preference) {
|
|
4325
4854
|
const file = files.find((f) => f.kind === kind);
|
|
4326
4855
|
if (file === void 0) continue;
|
|
@@ -4642,7 +5171,7 @@ var NC_OCCUPANCY_INDEXES = [{
|
|
|
4642
5171
|
}];
|
|
4643
5172
|
/** Query cap — a per-(device, zone, class) key set is small; this is a
|
|
4644
5173
|
* generous ceiling that still bounds a pathological read. */
|
|
4645
|
-
var LOAD_LIMIT = 1e5;
|
|
5174
|
+
var LOAD_LIMIT$1 = 1e5;
|
|
4646
5175
|
var OccupancyStore = class {
|
|
4647
5176
|
cache = /* @__PURE__ */ new Map();
|
|
4648
5177
|
store;
|
|
@@ -4669,7 +5198,7 @@ var OccupancyStore = class {
|
|
|
4669
5198
|
try {
|
|
4670
5199
|
const records = await this.store.query.query({
|
|
4671
5200
|
collection: NC_OCCUPANCY_COLLECTION,
|
|
4672
|
-
filter: { limit: LOAD_LIMIT }
|
|
5201
|
+
filter: { limit: LOAD_LIMIT$1 }
|
|
4673
5202
|
});
|
|
4674
5203
|
this.cache.clear();
|
|
4675
5204
|
let skipped = 0;
|
|
@@ -5428,25 +5957,273 @@ var NcRuleStore = class {
|
|
|
5428
5957
|
this.byId.delete(ruleId);
|
|
5429
5958
|
try {
|
|
5430
5959
|
await this.store.delete.mutate({
|
|
5431
|
-
collection: NC_RULES_COLLECTION,
|
|
5960
|
+
collection: NC_RULES_COLLECTION,
|
|
5961
|
+
key: ruleId
|
|
5962
|
+
});
|
|
5963
|
+
} catch (err) {
|
|
5964
|
+
this.logger.warn("notification rule delete failed", { meta: {
|
|
5965
|
+
ruleId,
|
|
5966
|
+
error: String(err)
|
|
5967
|
+
} });
|
|
5968
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
5969
|
+
}
|
|
5970
|
+
}
|
|
5971
|
+
async persist(rule) {
|
|
5972
|
+
await this.store.set.mutate({
|
|
5973
|
+
collection: NC_RULES_COLLECTION,
|
|
5974
|
+
key: rule.id,
|
|
5975
|
+
value: {
|
|
5976
|
+
name: rule.name,
|
|
5977
|
+
enabled: rule.enabled,
|
|
5978
|
+
delivery: rule.delivery,
|
|
5979
|
+
updatedAt: rule.updatedAt,
|
|
5980
|
+
rule
|
|
5981
|
+
}
|
|
5982
|
+
});
|
|
5983
|
+
}
|
|
5984
|
+
};
|
|
5985
|
+
//#endregion
|
|
5986
|
+
//#region src/notification-center/timelapse/timelapse-store.ts
|
|
5987
|
+
var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
|
|
5988
|
+
var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
5989
|
+
{
|
|
5990
|
+
name: "id",
|
|
5991
|
+
type: "TEXT",
|
|
5992
|
+
primaryKey: true,
|
|
5993
|
+
notNull: true
|
|
5994
|
+
},
|
|
5995
|
+
{
|
|
5996
|
+
name: "name",
|
|
5997
|
+
type: "TEXT",
|
|
5998
|
+
notNull: true
|
|
5999
|
+
},
|
|
6000
|
+
{
|
|
6001
|
+
name: "enabled",
|
|
6002
|
+
type: "BOOLEAN",
|
|
6003
|
+
notNull: true
|
|
6004
|
+
},
|
|
6005
|
+
{
|
|
6006
|
+
name: "updatedAt",
|
|
6007
|
+
type: "INTEGER",
|
|
6008
|
+
notNull: true
|
|
6009
|
+
},
|
|
6010
|
+
(
|
|
6011
|
+
/** The FULL rule object (Zod-validated on read) — scalars above are
|
|
6012
|
+
* indexed projections only. */
|
|
6013
|
+
{
|
|
6014
|
+
name: "rule",
|
|
6015
|
+
type: "JSON",
|
|
6016
|
+
notNull: true
|
|
6017
|
+
})
|
|
6018
|
+
];
|
|
6019
|
+
var NC_TIMELAPSE_RULES_INDEXES = [{
|
|
6020
|
+
name: "idx_nc_timelapse_rules_enabled",
|
|
6021
|
+
columns: ["enabled"]
|
|
6022
|
+
}];
|
|
6023
|
+
/** Query cap — the rule set is operator-authored and tiny; a generous ceiling. */
|
|
6024
|
+
var LOAD_LIMIT = 1e4;
|
|
6025
|
+
/**
|
|
6026
|
+
* Resolve the three-way `template` patch signal onto a merged rule, immutably:
|
|
6027
|
+
* `undefined` (key absent) leaves it as-is, `null` DROPS the key, an object
|
|
6028
|
+
* replaces it. Keeping `null` out of the persisted rule is what lets
|
|
6029
|
+
* `TimelapseRuleSchema` stay a plain `.optional()`.
|
|
6030
|
+
*/
|
|
6031
|
+
function applyTemplatePatch(merged, template) {
|
|
6032
|
+
if (template === void 0) return merged;
|
|
6033
|
+
if (template !== null) return {
|
|
6034
|
+
...merged,
|
|
6035
|
+
template
|
|
6036
|
+
};
|
|
6037
|
+
const { template: _cleared, ...withoutTemplate } = merged;
|
|
6038
|
+
return withoutTemplate;
|
|
6039
|
+
}
|
|
6040
|
+
var TimelapseStore = class {
|
|
6041
|
+
byId = /* @__PURE__ */ new Map();
|
|
6042
|
+
store;
|
|
6043
|
+
logger;
|
|
6044
|
+
now;
|
|
6045
|
+
newId;
|
|
6046
|
+
constructor(deps) {
|
|
6047
|
+
this.store = deps.store;
|
|
6048
|
+
this.logger = deps.logger;
|
|
6049
|
+
this.now = deps.now ?? (() => Date.now());
|
|
6050
|
+
this.newId = deps.newId ?? (() => (0, node_crypto.randomUUID)());
|
|
6051
|
+
}
|
|
6052
|
+
static async declare(store) {
|
|
6053
|
+
await store.declareCollection.mutate({
|
|
6054
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6055
|
+
columns: [...NC_TIMELAPSE_RULES_COLUMNS],
|
|
6056
|
+
indexes: [...NC_TIMELAPSE_RULES_INDEXES]
|
|
6057
|
+
});
|
|
6058
|
+
}
|
|
6059
|
+
/**
|
|
6060
|
+
* (Re)hydrate the FULL rule set from the store — called at boot and on the
|
|
6061
|
+
* periodic refresh tick. Replaces the cache wholesale; a row whose JSON no
|
|
6062
|
+
* longer validates is skipped with a warning (a degraded rule must never
|
|
6063
|
+
* crash the scheduler).
|
|
6064
|
+
*/
|
|
6065
|
+
async load() {
|
|
6066
|
+
try {
|
|
6067
|
+
const rows = await this.store.query.query({
|
|
6068
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6069
|
+
filter: { limit: LOAD_LIMIT }
|
|
6070
|
+
});
|
|
6071
|
+
this.byId.clear();
|
|
6072
|
+
let skipped = 0;
|
|
6073
|
+
for (const row of rows) {
|
|
6074
|
+
const parsed = require_dist.TimelapseRuleSchema.safeParse(row.data["rule"]);
|
|
6075
|
+
if (!parsed.success) {
|
|
6076
|
+
skipped += 1;
|
|
6077
|
+
continue;
|
|
6078
|
+
}
|
|
6079
|
+
this.byId.set(parsed.data.id, parsed.data);
|
|
6080
|
+
}
|
|
6081
|
+
this.logger.debug("timelapse rules loaded", { meta: {
|
|
6082
|
+
rules: this.byId.size,
|
|
6083
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
6084
|
+
} });
|
|
6085
|
+
} catch (err) {
|
|
6086
|
+
this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
|
|
6087
|
+
}
|
|
6088
|
+
}
|
|
6089
|
+
/** Every rule, newest-first (admin path). */
|
|
6090
|
+
list() {
|
|
6091
|
+
return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
6092
|
+
}
|
|
6093
|
+
/** The scheduler's read: only rules that should be ticked. */
|
|
6094
|
+
listEnabled() {
|
|
6095
|
+
return this.list().filter((r) => r.enabled);
|
|
6096
|
+
}
|
|
6097
|
+
get(ruleId) {
|
|
6098
|
+
return this.byId.get(ruleId) ?? null;
|
|
6099
|
+
}
|
|
6100
|
+
/**
|
|
6101
|
+
* Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
|
|
6102
|
+
* userId`) plus every admin/global rule (`ownerUserId` absent). Never another
|
|
6103
|
+
* user's personal rows. Newest-first (inherits {@link list}).
|
|
6104
|
+
*
|
|
6105
|
+
* The caller identity is server-derived; an absent/undefined caller must be
|
|
6106
|
+
* resolved to a fail-closed value by the bridge action BEFORE calling here —
|
|
6107
|
+
* this store never treats a missing caller as admin/global.
|
|
6108
|
+
*/
|
|
6109
|
+
listForOwner(userId) {
|
|
6110
|
+
return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
|
|
6111
|
+
}
|
|
6112
|
+
/**
|
|
6113
|
+
* Mutation gate for a NON-admin caller: true only for a PERSONAL rule this
|
|
6114
|
+
* user owns. A global rule (no `ownerUserId`) returns false — global rules
|
|
6115
|
+
* are admin-only, and the bridge action grants admins the mutation without
|
|
6116
|
+
* consulting this check. An unknown rule id is false (fail-closed).
|
|
6117
|
+
*/
|
|
6118
|
+
isOwnedBy(ruleId, userId) {
|
|
6119
|
+
const rule = this.byId.get(ruleId);
|
|
6120
|
+
return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
|
|
6121
|
+
}
|
|
6122
|
+
/**
|
|
6123
|
+
* Create a rule. `createdBy` is the SERVER-injected caller userId;
|
|
6124
|
+
* `ownerUserId` is the server-derived owner (omit for an admin/global rule).
|
|
6125
|
+
* Neither is ever read from `input`.
|
|
6126
|
+
*
|
|
6127
|
+
* The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
|
|
6128
|
+
* spread — that schema carries no ownership/provenance keys, so it strips any
|
|
6129
|
+
* that rode in on the blob. Without it, an `ownerUserId` on `input` would
|
|
6130
|
+
* survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
|
|
6131
|
+
* path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
|
|
6132
|
+
* so a future "duplicate rule" action (`create(existingRule, caller)`) would
|
|
6133
|
+
* compile cleanly and silently clone the ORIGINAL owner.
|
|
6134
|
+
*/
|
|
6135
|
+
async create(input, createdBy, ownerUserId) {
|
|
6136
|
+
const now = this.now();
|
|
6137
|
+
const rule = require_dist.TimelapseRuleSchema.parse({
|
|
6138
|
+
...require_dist.TimelapseRuleInputSchema.parse(input),
|
|
6139
|
+
id: this.newId(),
|
|
6140
|
+
...ownerUserId !== void 0 ? { ownerUserId } : {},
|
|
6141
|
+
createdBy,
|
|
6142
|
+
createdAt: now,
|
|
6143
|
+
updatedAt: now
|
|
6144
|
+
});
|
|
6145
|
+
await this.persist(rule);
|
|
6146
|
+
this.byId.set(rule.id, rule);
|
|
6147
|
+
return rule;
|
|
6148
|
+
}
|
|
6149
|
+
/**
|
|
6150
|
+
* Apply a partial patch. Immutable: returns the NEW rule object. Identity,
|
|
6151
|
+
* ownership and generation state are re-pinned from the existing rule AFTER
|
|
6152
|
+
* the spread — the patch schema carries none of them, and this makes a
|
|
6153
|
+
* hand-built (unparsed) patch object equally unable to re-own a rule.
|
|
6154
|
+
*
|
|
6155
|
+
* `template` is the one clearable field: an absent key leaves it unchanged,
|
|
6156
|
+
* an explicit `null` CLEARS it (the persisted rule loses the key — `null`
|
|
6157
|
+
* never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
|
|
6158
|
+
* note.
|
|
6159
|
+
*/
|
|
6160
|
+
async update(ruleId, patch) {
|
|
6161
|
+
const existing = this.byId.get(ruleId);
|
|
6162
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
6163
|
+
const { template, ...rest } = patch;
|
|
6164
|
+
const merged = {
|
|
6165
|
+
...existing,
|
|
6166
|
+
...rest,
|
|
6167
|
+
id: existing.id,
|
|
6168
|
+
ownerUserId: existing.ownerUserId,
|
|
6169
|
+
lastGeneratedAt: existing.lastGeneratedAt,
|
|
6170
|
+
createdBy: existing.createdBy,
|
|
6171
|
+
createdAt: existing.createdAt,
|
|
6172
|
+
updatedAt: this.now()
|
|
6173
|
+
};
|
|
6174
|
+
return this.write(applyTemplatePatch(merged, template));
|
|
6175
|
+
}
|
|
6176
|
+
async setEnabled(ruleId, enabled) {
|
|
6177
|
+
return this.update(ruleId, { enabled });
|
|
6178
|
+
}
|
|
6179
|
+
/**
|
|
6180
|
+
* Record a successful generation. `at` is the generation epoch-ms — the
|
|
6181
|
+
* durable state behind the 1-hour re-generation guard. Does NOT bump
|
|
6182
|
+
* `updatedAt` (generation is not an edit of the rule definition).
|
|
6183
|
+
*/
|
|
6184
|
+
async markGenerated(ruleId, at) {
|
|
6185
|
+
const existing = this.byId.get(ruleId);
|
|
6186
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
6187
|
+
return this.write({
|
|
6188
|
+
...existing,
|
|
6189
|
+
lastGeneratedAt: at
|
|
6190
|
+
});
|
|
6191
|
+
}
|
|
6192
|
+
/** Idempotent delete — unknown ids are a no-op. */
|
|
6193
|
+
async delete(ruleId) {
|
|
6194
|
+
this.byId.delete(ruleId);
|
|
6195
|
+
try {
|
|
6196
|
+
await this.store.delete.mutate({
|
|
6197
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
5432
6198
|
key: ruleId
|
|
5433
6199
|
});
|
|
5434
6200
|
} catch (err) {
|
|
5435
|
-
this.logger.warn("
|
|
6201
|
+
this.logger.warn("timelapse rule delete failed", { meta: {
|
|
5436
6202
|
ruleId,
|
|
5437
6203
|
error: String(err)
|
|
5438
6204
|
} });
|
|
5439
6205
|
throw err instanceof Error ? err : new Error(String(err));
|
|
5440
6206
|
}
|
|
5441
6207
|
}
|
|
6208
|
+
/**
|
|
6209
|
+
* Re-validate a merged candidate, persist it, then cache it. Re-validating
|
|
6210
|
+
* means a patch can never persist a rule that would be skipped at the next
|
|
6211
|
+
* `load()`; provenance/ownership survive because they are spread from the
|
|
6212
|
+
* existing rule and never present on a patch.
|
|
6213
|
+
*/
|
|
6214
|
+
async write(candidate) {
|
|
6215
|
+
const rule = require_dist.TimelapseRuleSchema.parse(candidate);
|
|
6216
|
+
await this.persist(rule);
|
|
6217
|
+
this.byId.set(rule.id, rule);
|
|
6218
|
+
return rule;
|
|
6219
|
+
}
|
|
5442
6220
|
async persist(rule) {
|
|
5443
6221
|
await this.store.set.mutate({
|
|
5444
|
-
collection:
|
|
6222
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
5445
6223
|
key: rule.id,
|
|
5446
6224
|
value: {
|
|
5447
6225
|
name: rule.name,
|
|
5448
6226
|
enabled: rule.enabled,
|
|
5449
|
-
delivery: rule.delivery,
|
|
5450
6227
|
updatedAt: rule.updatedAt,
|
|
5451
6228
|
rule
|
|
5452
6229
|
}
|
|
@@ -5548,7 +6325,7 @@ function outboxEntryToHistory(entry) {
|
|
|
5548
6325
|
}
|
|
5549
6326
|
};
|
|
5550
6327
|
}
|
|
5551
|
-
var NotificationCenter = class {
|
|
6328
|
+
var NotificationCenter = class NotificationCenter {
|
|
5552
6329
|
logger;
|
|
5553
6330
|
rules;
|
|
5554
6331
|
outbox;
|
|
@@ -5610,11 +6387,19 @@ var NotificationCenter = class {
|
|
|
5610
6387
|
get ruleStore() {
|
|
5611
6388
|
return this.rules;
|
|
5612
6389
|
}
|
|
5613
|
-
/**
|
|
6390
|
+
/**
|
|
6391
|
+
* Declare every Notification Center collection (idempotent, boot-time).
|
|
6392
|
+
*
|
|
6393
|
+
* EVERY store the module can touch belongs here, including ones whose
|
|
6394
|
+
* feature is not wired yet: an UNDECLARED collection answers 412 on first
|
|
6395
|
+
* use and takes the whole runner down with it. The timelapse store shipped
|
|
6396
|
+
* without this line and was one enable away from doing exactly that.
|
|
6397
|
+
*/
|
|
5614
6398
|
static async declare(store) {
|
|
5615
6399
|
await NcRuleStore.declare(store);
|
|
5616
6400
|
await NcOutbox.declare(store);
|
|
5617
6401
|
await OccupancyStore.declare(store);
|
|
6402
|
+
await TimelapseStore.declare(store);
|
|
5618
6403
|
}
|
|
5619
6404
|
/**
|
|
5620
6405
|
* Load rules (every node — the cap provider serves CRUD from any node).
|
|
@@ -5799,6 +6584,7 @@ var NotificationCenter = class {
|
|
|
5799
6584
|
listRules: async () => ({ rules: [...this.rules.list()] }),
|
|
5800
6585
|
getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
|
|
5801
6586
|
createRule: async ({ rule, caller }) => {
|
|
6587
|
+
NotificationCenter.assertHasAddressee(rule.targets, rule.targetUsers);
|
|
5802
6588
|
await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
|
|
5803
6589
|
const created = await this.rules.create(rule, caller.userId);
|
|
5804
6590
|
this.logger.info("notification rule created", { meta: {
|
|
@@ -5810,6 +6596,10 @@ var NotificationCenter = class {
|
|
|
5810
6596
|
},
|
|
5811
6597
|
updateRule: async ({ ruleId, patch, caller }) => {
|
|
5812
6598
|
if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
|
|
6599
|
+
if (patch.targets !== void 0 || patch.targetUsers !== void 0) {
|
|
6600
|
+
const existing = this.rules.get(ruleId);
|
|
6601
|
+
NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
|
|
6602
|
+
}
|
|
5813
6603
|
const { disabledTargetIds: _optOut, ...safePatch } = patch;
|
|
5814
6604
|
const updated = await this.rules.update(ruleId, safePatch);
|
|
5815
6605
|
this.logger.info("notification rule updated", { meta: {
|
|
@@ -5827,7 +6617,10 @@ var NotificationCenter = class {
|
|
|
5827
6617
|
return { success: true };
|
|
5828
6618
|
},
|
|
5829
6619
|
testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
|
|
5830
|
-
getConditionCatalog: async () => ({
|
|
6620
|
+
getConditionCatalog: async () => ({
|
|
6621
|
+
catalog: [...require_dist.NC_CONDITION_CATALOG],
|
|
6622
|
+
taxonomy: require_dist.NC_TAXONOMY
|
|
6623
|
+
}),
|
|
5831
6624
|
getHistory: async ({ filter }) => {
|
|
5832
6625
|
const entries = await this.outbox.queryHistory({
|
|
5833
6626
|
...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
|
|
@@ -5868,26 +6661,76 @@ var NotificationCenter = class {
|
|
|
5868
6661
|
async evaluateAndEnqueue(subject, kind) {
|
|
5869
6662
|
const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
|
|
5870
6663
|
const candidates = this.rules.listEnabled(delivery);
|
|
5871
|
-
if (candidates.length === 0)
|
|
6664
|
+
if (candidates.length === 0) {
|
|
6665
|
+
this.logger.debug("no enabled rule for this trigger", {
|
|
6666
|
+
tags: { deviceId: subject.deviceId },
|
|
6667
|
+
meta: {
|
|
6668
|
+
delivery,
|
|
6669
|
+
kind,
|
|
6670
|
+
rulesLoaded: this.rules.list().length
|
|
6671
|
+
}
|
|
6672
|
+
});
|
|
6673
|
+
return;
|
|
6674
|
+
}
|
|
5872
6675
|
const now = this.now();
|
|
5873
6676
|
for (const rule of candidates) {
|
|
5874
6677
|
const evaluation = evaluateRule(rule, subject);
|
|
5875
|
-
if (!evaluation.matched)
|
|
6678
|
+
if (!evaluation.matched) {
|
|
6679
|
+
this.logger.debug("rule did not match", {
|
|
6680
|
+
tags: { deviceId: subject.deviceId },
|
|
6681
|
+
meta: {
|
|
6682
|
+
ruleId: rule.id,
|
|
6683
|
+
rule: rule.name,
|
|
6684
|
+
kind,
|
|
6685
|
+
failed: evaluation.failedCondition,
|
|
6686
|
+
classes: subject.classNames
|
|
6687
|
+
}
|
|
6688
|
+
});
|
|
6689
|
+
continue;
|
|
6690
|
+
}
|
|
5876
6691
|
const key = cooldownKey(rule, subject);
|
|
5877
|
-
if (isCoolingDown(rule, this.lastFiredAt.get(key), now))
|
|
5878
|
-
|
|
6692
|
+
if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) {
|
|
6693
|
+
this.logger.debug("rule matched but is cooling down", {
|
|
6694
|
+
tags: { deviceId: subject.deviceId },
|
|
6695
|
+
meta: {
|
|
6696
|
+
ruleId: rule.id,
|
|
6697
|
+
rule: rule.name,
|
|
6698
|
+
key
|
|
6699
|
+
}
|
|
6700
|
+
});
|
|
6701
|
+
continue;
|
|
6702
|
+
}
|
|
6703
|
+
this.logger.info("rule matched — enqueueing", {
|
|
6704
|
+
tags: { deviceId: subject.deviceId },
|
|
6705
|
+
meta: {
|
|
6706
|
+
ruleId: rule.id,
|
|
6707
|
+
rule: rule.name,
|
|
6708
|
+
kind,
|
|
6709
|
+
targets: rule.targets.length
|
|
6710
|
+
}
|
|
6711
|
+
});
|
|
6712
|
+
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6713
|
+
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
|
|
5879
6714
|
}
|
|
5880
6715
|
}
|
|
5881
|
-
buildEntries(rule, subject, kind, matchedOn) {
|
|
6716
|
+
buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
|
|
5882
6717
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
5883
6718
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
5884
|
-
|
|
6719
|
+
const direct = new Set(rule.targets.map((t) => t.targetId));
|
|
6720
|
+
return [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))].filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
|
|
5885
6721
|
const payload = {
|
|
5886
6722
|
ruleName: rule.name,
|
|
5887
6723
|
delivery: rule.delivery,
|
|
5888
6724
|
priority: rule.priority,
|
|
5889
6725
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
5890
6726
|
media: rule.media.attach,
|
|
6727
|
+
...rule.media.zoneCrop === true && rule.conditions.zones !== void 0 ? { mediaZoneIds: [...rule.conditions.zones.ids] } : {},
|
|
6728
|
+
...rule.media.frame !== void 0 ? { mediaFrame: rule.media.frame } : {},
|
|
6729
|
+
...rule.media.profile !== void 0 ? { mediaProfile: rule.media.profile } : {},
|
|
6730
|
+
...rule.media.gif === true ? { mediaGif: true } : {},
|
|
6731
|
+
...rule.media.clip === true ? { mediaClip: true } : {},
|
|
6732
|
+
...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
|
|
6733
|
+
...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
|
|
5891
6734
|
...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
|
|
5892
6735
|
...target.params !== void 0 ? { params: target.params } : {},
|
|
5893
6736
|
subject: {
|
|
@@ -5913,6 +6756,63 @@ var NotificationCenter = class {
|
|
|
5913
6756
|
};
|
|
5914
6757
|
});
|
|
5915
6758
|
}
|
|
6759
|
+
/** rule→user fan-out caches (60 s users/targets; device routes immutable). */
|
|
6760
|
+
userGrantsCache = null;
|
|
6761
|
+
userGrantsAt = 0;
|
|
6762
|
+
ownedTargetsCache = null;
|
|
6763
|
+
ownedTargetsAt = 0;
|
|
6764
|
+
deviceRouteCache = /* @__PURE__ */ new Map();
|
|
6765
|
+
userFanoutUnavailableLogged = false;
|
|
6766
|
+
/**
|
|
6767
|
+
* The personal targets a rule's `targetUsers` resolve to for THIS device:
|
|
6768
|
+
* each addressed user contributes the enabled targets they own — but only
|
|
6769
|
+
* when their `allowedDevices` grant covers the firing camera (admin users
|
|
6770
|
+
* always pass). A user must never be notified about a device they cannot
|
|
6771
|
+
* open.
|
|
6772
|
+
*/
|
|
6773
|
+
async resolveUserTargets(rule, deviceId) {
|
|
6774
|
+
const users = rule.targetUsers;
|
|
6775
|
+
if (users === void 0 || users.length === 0) return [];
|
|
6776
|
+
const { listUsers, getDeviceRoute } = this.deps;
|
|
6777
|
+
if (listUsers === void 0 || getDeviceRoute === void 0) {
|
|
6778
|
+
if (!this.userFanoutUnavailableLogged) {
|
|
6779
|
+
this.userFanoutUnavailableLogged = true;
|
|
6780
|
+
this.logger.warn("rule addresses users but the user fan-out deps are not wired", { meta: { ruleId: rule.id } });
|
|
6781
|
+
}
|
|
6782
|
+
return [];
|
|
6783
|
+
}
|
|
6784
|
+
const now = this.now();
|
|
6785
|
+
if (this.userGrantsCache === null || now - this.userGrantsAt > 6e4) {
|
|
6786
|
+
this.userGrantsCache = await listUsers();
|
|
6787
|
+
this.userGrantsAt = now;
|
|
6788
|
+
}
|
|
6789
|
+
if (this.ownedTargetsCache === null || now - this.ownedTargetsAt > 6e4) {
|
|
6790
|
+
const targets = await this.deps.dispatcher.listTargets();
|
|
6791
|
+
const owned = /* @__PURE__ */ new Map();
|
|
6792
|
+
for (const t of targets) {
|
|
6793
|
+
if (t.ownerUserId === void 0 || !t.enabled) continue;
|
|
6794
|
+
const list = owned.get(t.ownerUserId) ?? [];
|
|
6795
|
+
list.push(t.id);
|
|
6796
|
+
owned.set(t.ownerUserId, list);
|
|
6797
|
+
}
|
|
6798
|
+
this.ownedTargetsCache = owned;
|
|
6799
|
+
this.ownedTargetsAt = now;
|
|
6800
|
+
}
|
|
6801
|
+
if (!this.deviceRouteCache.has(deviceId)) this.deviceRouteCache.set(deviceId, await getDeviceRoute(deviceId));
|
|
6802
|
+
const route = this.deviceRouteCache.get(deviceId) ?? null;
|
|
6803
|
+
const out = [];
|
|
6804
|
+
for (const userId of users) {
|
|
6805
|
+
const user = this.userGrantsCache.find((u) => u.id === userId);
|
|
6806
|
+
if (user === void 0) continue;
|
|
6807
|
+
if (!user.isAdmin) {
|
|
6808
|
+
if (route === null) continue;
|
|
6809
|
+
const grant = user.allowedDevices[route.addonId];
|
|
6810
|
+
if (!(grant === "*" || Array.isArray(grant) && grant.includes(route.stableId))) continue;
|
|
6811
|
+
}
|
|
6812
|
+
for (const targetId of this.ownedTargetsCache.get(userId) ?? []) out.push({ targetId });
|
|
6813
|
+
}
|
|
6814
|
+
return out;
|
|
6815
|
+
}
|
|
5916
6816
|
/** Rebuild the cooldown map from persisted outbox rows (restart-proof). */
|
|
5917
6817
|
async seedCooldowns() {
|
|
5918
6818
|
const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
|
|
@@ -6026,10 +6926,21 @@ var NotificationCenter = class {
|
|
|
6026
6926
|
this.drainTicks += 1;
|
|
6027
6927
|
if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) await this.outbox.setWatermark(this.now());
|
|
6028
6928
|
}
|
|
6929
|
+
/** The "at least one addressee" invariant lives here, not in Zod: the
|
|
6930
|
+
* schema allows empty `targets` (a users-only rule) and a cross-field
|
|
6931
|
+
* refine would break `NcRulePatchSchema.partial()`. */
|
|
6932
|
+
static assertHasAddressee(targets, targetUsers) {
|
|
6933
|
+
if (targets.length === 0 && (targetUsers?.length ?? 0) === 0) throw new Error("a rule needs at least one delivery target or user");
|
|
6934
|
+
}
|
|
6029
6935
|
/** Rule-save referential check: every targetId must resolve in the
|
|
6030
6936
|
* live notification-output catalog (spec §2.3 save-time validation). */
|
|
6031
6937
|
async validateTargetRefs(targetIds) {
|
|
6938
|
+
if (targetIds.length === 0) return;
|
|
6032
6939
|
const targets = await this.deps.dispatcher.listTargets();
|
|
6940
|
+
if (targets.length === 0) {
|
|
6941
|
+
this.logger.warn("target catalog came back EMPTY at rule save — the notification-output catalog is unreachable or under-reporting (collection fan-out regression?). Skipping the referential check; delivery re-checks with retry semantics.", { meta: { targetIds: [...targetIds] } });
|
|
6942
|
+
return;
|
|
6943
|
+
}
|
|
6033
6944
|
const known = new Set(targets.map((t) => t.id));
|
|
6034
6945
|
for (const id of targetIds) if (!known.has(id)) throw new Error(`unknown notification target: ${id}`);
|
|
6035
6946
|
}
|
|
@@ -6144,15 +7055,28 @@ function makeNcActionHandlers(deps) {
|
|
|
6144
7055
|
if (rule === null) throw new Error(`forbidden: rule not found: ${ruleId}`);
|
|
6145
7056
|
return rule;
|
|
6146
7057
|
};
|
|
6147
|
-
|
|
7058
|
+
/**
|
|
7059
|
+
* A rule the caller may EDIT. Ownership binds non-admins; an ADMIN
|
|
7060
|
+
* administers every rule — global ones (`ownerUserId: undefined`, what the
|
|
7061
|
+
* admin UI creates) and, for support, a user's personal rule.
|
|
7062
|
+
*
|
|
7063
|
+
* Without the admin bypass the comparison `rule.ownerUserId === userId` is
|
|
7064
|
+
* false for EVERY caller on a global rule, so the viewer refused to let an
|
|
7065
|
+
* admin edit an admin rule (operator report, 2026-07-30). Same bypass shape
|
|
7066
|
+
* as `assertTargetsOwned`.
|
|
7067
|
+
*/
|
|
7068
|
+
const assertRuleEditable = (ruleId, caller) => {
|
|
6148
7069
|
const rule = assertOwnsRule(ruleId);
|
|
6149
|
-
if (
|
|
7070
|
+
if (caller.isAdmin) return rule;
|
|
7071
|
+
if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
|
|
6150
7072
|
return rule;
|
|
6151
7073
|
};
|
|
6152
|
-
/** A rule the caller may SEE — his own personal rule
|
|
6153
|
-
|
|
7074
|
+
/** A rule the caller may SEE — his own personal rule, a global/admin rule, or
|
|
7075
|
+
* (for an admin) any rule at all. */
|
|
7076
|
+
const assertRuleVisible = (ruleId, caller) => {
|
|
6154
7077
|
const rule = assertOwnsRule(ruleId);
|
|
6155
|
-
if (
|
|
7078
|
+
if (caller.isAdmin) return rule;
|
|
7079
|
+
if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
|
|
6156
7080
|
return rule;
|
|
6157
7081
|
};
|
|
6158
7082
|
const assertTargetsOwned = async (targetIds, caller) => {
|
|
@@ -6163,9 +7087,9 @@ function makeNcActionHandlers(deps) {
|
|
|
6163
7087
|
return {
|
|
6164
7088
|
"nc.listRules": async (_input, caller) => {
|
|
6165
7089
|
const c = requireCaller(caller);
|
|
6166
|
-
return { rules: deps.ruleStore.listForOwner(c.userId).map((r) => ({
|
|
7090
|
+
return { rules: (c.isAdmin ? deps.ruleStore.list() : deps.ruleStore.listForOwner(c.userId)).map((r) => ({
|
|
6167
7091
|
...r,
|
|
6168
|
-
readOnly: r.ownerUserId !== c.userId
|
|
7092
|
+
readOnly: !c.isAdmin && r.ownerUserId !== c.userId
|
|
6169
7093
|
})) };
|
|
6170
7094
|
},
|
|
6171
7095
|
"nc.getConditionCatalog": async () => ({
|
|
@@ -6188,7 +7112,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6188
7112
|
},
|
|
6189
7113
|
"nc.updateRule": async (input, caller) => {
|
|
6190
7114
|
const c = requireCaller(caller);
|
|
6191
|
-
|
|
7115
|
+
assertRuleEditable(input.ruleId, c);
|
|
6192
7116
|
const patch = require_dist.NcRulePatchSchema.parse(input.patch);
|
|
6193
7117
|
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
6194
7118
|
const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
|
|
@@ -6201,7 +7125,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6201
7125
|
},
|
|
6202
7126
|
"nc.deleteRule": async (input, caller) => {
|
|
6203
7127
|
const c = requireCaller(caller);
|
|
6204
|
-
|
|
7128
|
+
assertRuleEditable(input.ruleId, c);
|
|
6205
7129
|
await deps.ruleStore.delete(input.ruleId);
|
|
6206
7130
|
deps.logger.info("nc rule deleted", { meta: {
|
|
6207
7131
|
ruleId: input.ruleId,
|
|
@@ -6211,7 +7135,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6211
7135
|
},
|
|
6212
7136
|
"nc.setRuleTargetEnabled": async (input, caller) => {
|
|
6213
7137
|
const c = requireCaller(caller);
|
|
6214
|
-
assertRuleVisible(input.ruleId, c
|
|
7138
|
+
assertRuleVisible(input.ruleId, c);
|
|
6215
7139
|
await assertTargetsOwned([input.targetId], c);
|
|
6216
7140
|
await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
|
|
6217
7141
|
return { success: true };
|
|
@@ -8863,7 +9787,15 @@ var MEDIA_COLUMNS = [
|
|
|
8863
9787
|
name: "sizeBytes",
|
|
8864
9788
|
type: "INTEGER",
|
|
8865
9789
|
notNull: true
|
|
8866
|
-
}
|
|
9790
|
+
},
|
|
9791
|
+
(
|
|
9792
|
+
/** Storage location holding the blob. NULL = the default `eventMedia`
|
|
9793
|
+
* location (every pre-Phase-3 row, and every write until multi-location
|
|
9794
|
+
* events exist) — the relocate mover stamps real ids as it moves blobs. */
|
|
9795
|
+
{
|
|
9796
|
+
name: "locationId",
|
|
9797
|
+
type: "TEXT"
|
|
9798
|
+
})
|
|
8867
9799
|
];
|
|
8868
9800
|
var MEDIA_INDEXES = [{
|
|
8869
9801
|
name: "idx_media_owner",
|
|
@@ -8872,11 +9804,18 @@ var MEDIA_INDEXES = [{
|
|
|
8872
9804
|
name: "idx_media_device_ts",
|
|
8873
9805
|
columns: ["deviceId", "timestamp"]
|
|
8874
9806
|
}];
|
|
9807
|
+
/** The storage location of a media row/record: its stamped `locationId`, or
|
|
9808
|
+
* the default `eventMedia` location for NULL (pre-multi-location) rows. */
|
|
9809
|
+
var DEFAULT_MEDIA_LOCATION = "eventMedia";
|
|
9810
|
+
function mediaRowLocation(data) {
|
|
9811
|
+
const id = data?.["locationId"];
|
|
9812
|
+
return typeof id === "string" && id.length > 0 ? id : DEFAULT_MEDIA_LOCATION;
|
|
9813
|
+
}
|
|
8875
9814
|
function buildKey(params) {
|
|
8876
9815
|
return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
|
|
8877
9816
|
}
|
|
8878
9817
|
function buildPath(params) {
|
|
8879
|
-
const base =
|
|
9818
|
+
const base = `${params.deviceId}/events/${params.ownerKind}/${params.ownerId}`;
|
|
8880
9819
|
return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
|
|
8881
9820
|
}
|
|
8882
9821
|
var MediaStore = class {
|
|
@@ -8907,7 +9846,8 @@ var MediaStore = class {
|
|
|
8907
9846
|
kind: params.kind,
|
|
8908
9847
|
timestamp: params.timestamp,
|
|
8909
9848
|
path,
|
|
8910
|
-
sizeBytes: params.data.length
|
|
9849
|
+
sizeBytes: params.data.length,
|
|
9850
|
+
locationId: null
|
|
8911
9851
|
};
|
|
8912
9852
|
try {
|
|
8913
9853
|
await this.storage.write({
|
|
@@ -8964,10 +9904,11 @@ var MediaStore = class {
|
|
|
8964
9904
|
const newKey = await this.put(params);
|
|
8965
9905
|
for (const row of existing) {
|
|
8966
9906
|
if (row.id === newKey) continue;
|
|
8967
|
-
const
|
|
9907
|
+
const rowData = row.data;
|
|
9908
|
+
const path = String(rowData["path"] ?? "");
|
|
8968
9909
|
if (path) try {
|
|
8969
9910
|
await this.storage.delete({
|
|
8970
|
-
location:
|
|
9911
|
+
location: mediaRowLocation(rowData),
|
|
8971
9912
|
relativePath: path
|
|
8972
9913
|
});
|
|
8973
9914
|
} catch {}
|
|
@@ -9035,7 +9976,7 @@ var MediaStore = class {
|
|
|
9035
9976
|
key,
|
|
9036
9977
|
kind,
|
|
9037
9978
|
base64: (await this.storage.read({
|
|
9038
|
-
location:
|
|
9979
|
+
location: mediaRowLocation(data),
|
|
9039
9980
|
relativePath: path
|
|
9040
9981
|
})).toString("base64"),
|
|
9041
9982
|
sizeBytes,
|
|
@@ -9055,10 +9996,11 @@ var MediaStore = class {
|
|
|
9055
9996
|
key
|
|
9056
9997
|
});
|
|
9057
9998
|
if (!row) return;
|
|
9058
|
-
const
|
|
9999
|
+
const data = row;
|
|
10000
|
+
const path = String(data["path"] ?? "");
|
|
9059
10001
|
if (path) try {
|
|
9060
10002
|
await this.storage.delete({
|
|
9061
|
-
location:
|
|
10003
|
+
location: mediaRowLocation(data),
|
|
9062
10004
|
relativePath: path
|
|
9063
10005
|
});
|
|
9064
10006
|
} catch {}
|
|
@@ -9097,7 +10039,7 @@ var MediaStore = class {
|
|
|
9097
10039
|
const kind = String(data["kind"]);
|
|
9098
10040
|
try {
|
|
9099
10041
|
const buf = await this.storage.read({
|
|
9100
|
-
location:
|
|
10042
|
+
location: mediaRowLocation(data),
|
|
9101
10043
|
relativePath: path
|
|
9102
10044
|
});
|
|
9103
10045
|
files.push({
|
|
@@ -9132,10 +10074,11 @@ var MediaStore = class {
|
|
|
9132
10074
|
} }
|
|
9133
10075
|
});
|
|
9134
10076
|
for (const row of rows) {
|
|
9135
|
-
const
|
|
10077
|
+
const rowData = row.data;
|
|
10078
|
+
const path = String(rowData["path"] ?? "");
|
|
9136
10079
|
try {
|
|
9137
10080
|
if (path) await this.storage.delete({
|
|
9138
|
-
location:
|
|
10081
|
+
location: mediaRowLocation(rowData),
|
|
9139
10082
|
relativePath: path
|
|
9140
10083
|
});
|
|
9141
10084
|
} catch {}
|
|
@@ -9231,14 +10174,14 @@ var MediaStore = class {
|
|
|
9231
10174
|
kind,
|
|
9232
10175
|
timestamp,
|
|
9233
10176
|
data: await this.storage.read({
|
|
9234
|
-
location:
|
|
10177
|
+
location: mediaRowLocation(meta),
|
|
9235
10178
|
relativePath: oldPath
|
|
9236
10179
|
})
|
|
9237
10180
|
};
|
|
9238
10181
|
const newKey = await this.put(newParams);
|
|
9239
10182
|
try {
|
|
9240
10183
|
await this.storage.delete({
|
|
9241
|
-
location:
|
|
10184
|
+
location: mediaRowLocation(meta),
|
|
9242
10185
|
relativePath: oldPath
|
|
9243
10186
|
});
|
|
9244
10187
|
} catch (err) {
|
|
@@ -9308,10 +10251,11 @@ var MediaStore = class {
|
|
|
9308
10251
|
if (rows.length === 0) break;
|
|
9309
10252
|
let deletedInPage = 0;
|
|
9310
10253
|
for (const row of rows) {
|
|
9311
|
-
const
|
|
10254
|
+
const rowData = row.data;
|
|
10255
|
+
const path = String(rowData["path"] ?? "");
|
|
9312
10256
|
try {
|
|
9313
10257
|
if (path) await this.storage.delete({
|
|
9314
|
-
location:
|
|
10258
|
+
location: mediaRowLocation(rowData),
|
|
9315
10259
|
relativePath: path
|
|
9316
10260
|
});
|
|
9317
10261
|
} catch {}
|
|
@@ -9867,6 +10811,7 @@ var EventStore = class {
|
|
|
9867
10811
|
const rows = await this.store.query.query({
|
|
9868
10812
|
collection,
|
|
9869
10813
|
filter: {
|
|
10814
|
+
...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
|
|
9870
10815
|
whereBetween: { timestamp: [0, cutoffMs] },
|
|
9871
10816
|
limit: 500
|
|
9872
10817
|
}
|
|
@@ -10267,6 +11212,183 @@ function stripNulls(data) {
|
|
|
10267
11212
|
return out;
|
|
10268
11213
|
}
|
|
10269
11214
|
//#endregion
|
|
11215
|
+
//#region src/pipeline-analytics/location-aware-media-storage.ts
|
|
11216
|
+
/**
|
|
11217
|
+
* Location-aware blob storage for event media (entity-routing spec, Phase 3).
|
|
11218
|
+
*
|
|
11219
|
+
* Media rows may carry a `locationId` (stamped by the relocate mover when a
|
|
11220
|
+
* blob is moved off the default location). The write-rate bypass provider
|
|
11221
|
+
* only knows the default media root — this wrapper routes any OTHER location
|
|
11222
|
+
* id through a resolver (the storage cap's `resolve`, cached forever: a
|
|
11223
|
+
* location's root only changes via operator reconfig, which restarts us) and
|
|
11224
|
+
* does direct fs I/O against that root, keeping the bypass's
|
|
11225
|
+
* no-RPC-per-blob property for every location.
|
|
11226
|
+
*/
|
|
11227
|
+
function createLocationAwareMediaStorage(deps) {
|
|
11228
|
+
const roots = /* @__PURE__ */ new Map();
|
|
11229
|
+
const rootOf = async (locationId) => {
|
|
11230
|
+
const cached = roots.get(locationId);
|
|
11231
|
+
if (cached !== void 0) return cached;
|
|
11232
|
+
const root = await deps.resolveRoot(locationId);
|
|
11233
|
+
roots.set(locationId, root);
|
|
11234
|
+
return root;
|
|
11235
|
+
};
|
|
11236
|
+
const absOf = async (locationId, relativePath) => node_path.default.join(await rootOf(locationId), relativePath);
|
|
11237
|
+
return {
|
|
11238
|
+
write: async (input) => {
|
|
11239
|
+
if (input.location === deps.defaultLocation) return deps.base.write(input);
|
|
11240
|
+
const abs = await absOf(input.location, input.relativePath);
|
|
11241
|
+
await node_fs.promises.mkdir(node_path.default.dirname(abs), { recursive: true });
|
|
11242
|
+
await node_fs.promises.writeFile(abs, input.data);
|
|
11243
|
+
},
|
|
11244
|
+
read: async (input) => {
|
|
11245
|
+
if (input.location === deps.defaultLocation) return deps.base.read(input);
|
|
11246
|
+
return node_fs.promises.readFile(await absOf(input.location, input.relativePath));
|
|
11247
|
+
},
|
|
11248
|
+
delete: async (input) => {
|
|
11249
|
+
if (input.location === deps.defaultLocation) return deps.base.delete(input);
|
|
11250
|
+
await node_fs.promises.rm(await absOf(input.location, input.relativePath), { force: true });
|
|
11251
|
+
}
|
|
11252
|
+
};
|
|
11253
|
+
}
|
|
11254
|
+
//#endregion
|
|
11255
|
+
//#region src/pipeline-analytics/media-relocate-engine.ts
|
|
11256
|
+
var PAGE_SIZE = 200;
|
|
11257
|
+
var DEFAULT_THROTTLE_MBPS = 40;
|
|
11258
|
+
function snapshot(j) {
|
|
11259
|
+
return {
|
|
11260
|
+
jobId: j.jobId,
|
|
11261
|
+
state: j.state,
|
|
11262
|
+
fromLocationId: j.fromLocationId,
|
|
11263
|
+
toLocationId: j.toLocationId,
|
|
11264
|
+
deviceId: j.deviceId,
|
|
11265
|
+
entities: ["media"],
|
|
11266
|
+
filesMoved: j.filesMoved,
|
|
11267
|
+
bytesMoved: j.bytesMoved,
|
|
11268
|
+
filesTotal: null,
|
|
11269
|
+
startedAt: j.startedAt,
|
|
11270
|
+
finishedAt: j.finishedAt,
|
|
11271
|
+
error: j.error
|
|
11272
|
+
};
|
|
11273
|
+
}
|
|
11274
|
+
var MediaRelocateEngine = class {
|
|
11275
|
+
deps;
|
|
11276
|
+
jobs = /* @__PURE__ */ new Map();
|
|
11277
|
+
constructor(deps) {
|
|
11278
|
+
this.deps = deps;
|
|
11279
|
+
}
|
|
11280
|
+
list() {
|
|
11281
|
+
return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
|
|
11282
|
+
}
|
|
11283
|
+
cancel(jobId) {
|
|
11284
|
+
const job = this.jobs.get(jobId);
|
|
11285
|
+
if (!job || job.state !== "running") return false;
|
|
11286
|
+
job.cancelRequested = true;
|
|
11287
|
+
return true;
|
|
11288
|
+
}
|
|
11289
|
+
start(input) {
|
|
11290
|
+
for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
|
|
11291
|
+
const job = {
|
|
11292
|
+
jobId: this.deps.newId(),
|
|
11293
|
+
state: "running",
|
|
11294
|
+
fromLocationId: "*",
|
|
11295
|
+
toLocationId: input.toLocationId,
|
|
11296
|
+
deviceId: input.deviceId ?? null,
|
|
11297
|
+
filesMoved: 0,
|
|
11298
|
+
bytesMoved: 0,
|
|
11299
|
+
startedAt: this.deps.now(),
|
|
11300
|
+
finishedAt: null,
|
|
11301
|
+
error: null,
|
|
11302
|
+
cancelRequested: false
|
|
11303
|
+
};
|
|
11304
|
+
this.jobs.set(job.jobId, job);
|
|
11305
|
+
this.run(job, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS);
|
|
11306
|
+
return job.jobId;
|
|
11307
|
+
}
|
|
11308
|
+
async run(job, throttleMbps) {
|
|
11309
|
+
const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
11310
|
+
const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
|
|
11311
|
+
try {
|
|
11312
|
+
await this.deps.resolveTargetRoot(job.toLocationId);
|
|
11313
|
+
let cursor = 0;
|
|
11314
|
+
let seenAtCursor = /* @__PURE__ */ new Set();
|
|
11315
|
+
for (;;) {
|
|
11316
|
+
if (job.cancelRequested) break;
|
|
11317
|
+
const fresh = (await this.deps.store.query.query({
|
|
11318
|
+
collection: MEDIA_COLLECTION,
|
|
11319
|
+
filter: {
|
|
11320
|
+
...job.deviceId !== null ? { where: { deviceId: job.deviceId } } : {},
|
|
11321
|
+
whereBetween: { timestamp: [cursor, Number.MAX_SAFE_INTEGER] },
|
|
11322
|
+
orderBy: {
|
|
11323
|
+
field: "timestamp",
|
|
11324
|
+
direction: "asc"
|
|
11325
|
+
},
|
|
11326
|
+
limit: PAGE_SIZE
|
|
11327
|
+
}
|
|
11328
|
+
})).filter((r) => !seenAtCursor.has(r.id));
|
|
11329
|
+
if (fresh.length === 0) break;
|
|
11330
|
+
for (const row of fresh) {
|
|
11331
|
+
if (job.cancelRequested) break;
|
|
11332
|
+
const data = row.data;
|
|
11333
|
+
const from = mediaRowLocation(data);
|
|
11334
|
+
if (from === job.toLocationId) continue;
|
|
11335
|
+
const relativePath = String(data["path"] ?? "");
|
|
11336
|
+
if (relativePath.length === 0) continue;
|
|
11337
|
+
try {
|
|
11338
|
+
const bytes = await this.deps.storage.read({
|
|
11339
|
+
location: from,
|
|
11340
|
+
relativePath
|
|
11341
|
+
});
|
|
11342
|
+
await this.deps.storage.write({
|
|
11343
|
+
location: job.toLocationId,
|
|
11344
|
+
relativePath,
|
|
11345
|
+
data: bytes
|
|
11346
|
+
});
|
|
11347
|
+
await this.deps.store.set.mutate({
|
|
11348
|
+
collection: MEDIA_COLLECTION,
|
|
11349
|
+
key: row.id,
|
|
11350
|
+
value: {
|
|
11351
|
+
...data,
|
|
11352
|
+
locationId: job.toLocationId
|
|
11353
|
+
}
|
|
11354
|
+
});
|
|
11355
|
+
await this.deps.storage.delete({
|
|
11356
|
+
location: from,
|
|
11357
|
+
relativePath
|
|
11358
|
+
});
|
|
11359
|
+
job.filesMoved++;
|
|
11360
|
+
job.bytesMoved += bytes.length;
|
|
11361
|
+
await sleep(bytes.length / bytesPerMs);
|
|
11362
|
+
} catch (err) {
|
|
11363
|
+
this.deps.logger.debug("media relocate row failed", { meta: {
|
|
11364
|
+
key: row.id,
|
|
11365
|
+
error: String(err)
|
|
11366
|
+
} });
|
|
11367
|
+
}
|
|
11368
|
+
}
|
|
11369
|
+
const last = fresh[fresh.length - 1];
|
|
11370
|
+
const lastTs = Number(last.data["timestamp"] ?? cursor);
|
|
11371
|
+
if (lastTs === cursor) for (const r of fresh) seenAtCursor.add(r.id);
|
|
11372
|
+
else {
|
|
11373
|
+
cursor = lastTs;
|
|
11374
|
+
seenAtCursor = new Set(fresh.filter((r) => Number(r.data["timestamp"]) === lastTs).map((r) => r.id));
|
|
11375
|
+
}
|
|
11376
|
+
}
|
|
11377
|
+
job.state = job.cancelRequested ? "cancelled" : "done";
|
|
11378
|
+
} catch (err) {
|
|
11379
|
+
job.state = "failed";
|
|
11380
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
11381
|
+
this.deps.logger.warn("media relocate job failed", { meta: {
|
|
11382
|
+
jobId: job.jobId,
|
|
11383
|
+
error: job.error
|
|
11384
|
+
} });
|
|
11385
|
+
} finally {
|
|
11386
|
+
job.finishedAt = this.deps.now();
|
|
11387
|
+
this.deps.onFinished?.(snapshot(job));
|
|
11388
|
+
}
|
|
11389
|
+
}
|
|
11390
|
+
};
|
|
11391
|
+
//#endregion
|
|
10270
11392
|
//#region src/pipeline-analytics/store/sensor-event-store.ts
|
|
10271
11393
|
var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
|
|
10272
11394
|
var SENSOR_EVENT_COLUMNS = [
|
|
@@ -11393,7 +12515,7 @@ var EventMediaDispatcher = class {
|
|
|
11393
12515
|
if (sn.rollingLastFrame && boxed) lastFrameWritten = await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
11394
12516
|
let thumbnailWritten = false;
|
|
11395
12517
|
if (sn.bestThumbnail) {
|
|
11396
|
-
const variants = await this.cropSubjectVariants(frameHandle, fw, fh, sn.bbox);
|
|
12518
|
+
const variants = await this.cropSubjectVariants(deviceId, frameHandle, fw, fh, sn.bbox);
|
|
11397
12519
|
if (variants) {
|
|
11398
12520
|
thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, variants.thumbnail);
|
|
11399
12521
|
await this.replaceKind(deviceId, sn.trackId, "thumbnailSmall", sn.timestamp, variants.thumbnailSmall);
|
|
@@ -11495,12 +12617,15 @@ var EventMediaDispatcher = class {
|
|
|
11495
12617
|
* per-frame retry lands a real native crop later. Never a local resize
|
|
11496
12618
|
* upscale of a ≤640 tile (a blurred lie).
|
|
11497
12619
|
*/
|
|
11498
|
-
async cropSubjectVariants(frameHandle, fw, fh, bbox) {
|
|
12620
|
+
async cropSubjectVariants(deviceId, frameHandle, fw, fh, bbox) {
|
|
11499
12621
|
if (!this.deps.getNativeCropJpeg) {
|
|
11500
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11501
|
-
|
|
11502
|
-
|
|
11503
|
-
|
|
12622
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12623
|
+
tags: { deviceId },
|
|
12624
|
+
meta: {
|
|
12625
|
+
shmId: frameHandle.shmId,
|
|
12626
|
+
reason: "no-native-cap"
|
|
12627
|
+
}
|
|
12628
|
+
});
|
|
11504
12629
|
return null;
|
|
11505
12630
|
}
|
|
11506
12631
|
try {
|
|
@@ -11508,12 +12633,19 @@ var EventMediaDispatcher = class {
|
|
|
11508
12633
|
W: fw,
|
|
11509
12634
|
H: fh
|
|
11510
12635
|
});
|
|
12636
|
+
const askedAt = Date.now();
|
|
11511
12637
|
const slab = await this.deps.getNativeCropJpeg(frameHandle, layout.fetch);
|
|
11512
12638
|
if (!slab) {
|
|
11513
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11514
|
-
|
|
11515
|
-
|
|
11516
|
-
|
|
12639
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12640
|
+
tags: { deviceId },
|
|
12641
|
+
meta: {
|
|
12642
|
+
shmId: frameHandle.shmId,
|
|
12643
|
+
handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
|
|
12644
|
+
handleNodeId: frameHandle.nodeId,
|
|
12645
|
+
roundTripMs: Date.now() - askedAt,
|
|
12646
|
+
reason: "native-miss"
|
|
12647
|
+
}
|
|
12648
|
+
});
|
|
11517
12649
|
return null;
|
|
11518
12650
|
}
|
|
11519
12651
|
const thumbnail = await composeWideCentralSquareThumbnail(slab, layout);
|
|
@@ -11522,10 +12654,14 @@ var EventMediaDispatcher = class {
|
|
|
11522
12654
|
thumbnailSmall: await deriveThumbnailSmall(thumbnail)
|
|
11523
12655
|
};
|
|
11524
12656
|
} catch (err) {
|
|
11525
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11526
|
-
|
|
11527
|
-
|
|
11528
|
-
|
|
12657
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12658
|
+
tags: { deviceId },
|
|
12659
|
+
meta: {
|
|
12660
|
+
shmId: frameHandle.shmId,
|
|
12661
|
+
reason: "threw",
|
|
12662
|
+
error: err instanceof Error ? err.message : String(err)
|
|
12663
|
+
}
|
|
12664
|
+
});
|
|
11529
12665
|
return null;
|
|
11530
12666
|
}
|
|
11531
12667
|
}
|
|
@@ -13586,37 +14722,7 @@ var AnalyticsQueryFacade = class {
|
|
|
13586
14722
|
}));
|
|
13587
14723
|
}
|
|
13588
14724
|
};
|
|
13589
|
-
|
|
13590
|
-
//#region src/pipeline-analytics/track-retention-sweep.ts
|
|
13591
|
-
/**
|
|
13592
|
-
* Periodic track retention sweep (design §6) — the piece that makes retention
|
|
13593
|
-
* actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
|
|
13594
|
-
* calls it; this sweep ages persisted tracks out on the provider's existing
|
|
13595
|
-
* retention interval.
|
|
13596
|
-
*
|
|
13597
|
-
* `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
|
|
13598
|
-
* `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
|
|
13599
|
-
* cascade store layer (design §4), not here.
|
|
13600
|
-
*
|
|
13601
|
-
* Kept as a pure, dependency-injected function so the loop is unit-testable
|
|
13602
|
-
* without booting the addon.
|
|
13603
|
-
*/
|
|
13604
|
-
var DAY_MS = 1440 * 60 * 1e3;
|
|
13605
|
-
/** Per-device track retention setting. Default 7 days; 0 = keep forever. */
|
|
13606
|
-
var TrackRetentionSettingsSchema = require_dist.object({ trackRetentionDays: require_dist.number().min(0).default(7) });
|
|
13607
|
-
var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
|
|
13608
|
-
/** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
|
|
13609
|
-
* missing value falls back to the default (parse never throws). */
|
|
13610
|
-
function resolveTrackRetentionDays(raw) {
|
|
13611
|
-
const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
|
|
13612
|
-
return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
|
|
13613
|
-
}
|
|
13614
|
-
/** Cutoff timestamp for a retention window. `null` when retention is disabled
|
|
13615
|
-
* (`retentionDays <= 0` → keep forever, the sweep skips the device). */
|
|
13616
|
-
function trackRetentionCutoff(nowMs, retentionDays) {
|
|
13617
|
-
if (retentionDays <= 0) return null;
|
|
13618
|
-
return nowMs - retentionDays * DAY_MS;
|
|
13619
|
-
}
|
|
14725
|
+
var TRACK_RETENTION_DEFAULT_DAYS = require_dist.object({ trackRetentionDays: require_dist.number().min(0).default(7) }).parse({}).trackRetentionDays;
|
|
13620
14726
|
/**
|
|
13621
14727
|
* Sweep every device with persisted tracks: skip retention-disabled devices,
|
|
13622
14728
|
* prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
|
|
@@ -13627,7 +14733,7 @@ async function sweepTrackRetention(deps) {
|
|
|
13627
14733
|
const nowMs = deps.now();
|
|
13628
14734
|
let totalTracks = 0;
|
|
13629
14735
|
for (const deviceId of devices) try {
|
|
13630
|
-
const cutoffMs =
|
|
14736
|
+
const cutoffMs = await deps.resolveCutoffMs(deviceId, nowMs);
|
|
13631
14737
|
if (cutoffMs === null) continue;
|
|
13632
14738
|
const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
|
|
13633
14739
|
totalTracks += counts.tracks;
|
|
@@ -14192,6 +15298,19 @@ function retagDetectionSections(sections) {
|
|
|
14192
15298
|
} : s);
|
|
14193
15299
|
}
|
|
14194
15300
|
/**
|
|
15301
|
+
* Re-home the analytics `retention` section onto the recorder's `recording`
|
|
15302
|
+
* top-tab (operator ask, 2026-07-29): footage and analytics retention are ONE
|
|
15303
|
+
* unified policy since the follow-recordings default, so their controls
|
|
15304
|
+
* belong on ONE tab. Same pure-retag mechanism as the detection sections —
|
|
15305
|
+
* `DeviceDetail` folds matching-tab sections together, no admin-ui change.
|
|
15306
|
+
*/
|
|
15307
|
+
function retagRetentionSection(sections) {
|
|
15308
|
+
return sections.map((s) => s.id === "retention" ? {
|
|
15309
|
+
...s,
|
|
15310
|
+
tab: "recording"
|
|
15311
|
+
} : s);
|
|
15312
|
+
}
|
|
15313
|
+
/**
|
|
14195
15314
|
* Fields that live ONLY on the global settings page and must never surface in a
|
|
14196
15315
|
* per-device contribution. The face-recognition `enabled` switch is the GLOBAL
|
|
14197
15316
|
* master kill for the whole subsystem — per-camera face production is governed
|
|
@@ -14370,9 +15489,23 @@ function buildGlobalSettingsSchema() {
|
|
|
14370
15489
|
{
|
|
14371
15490
|
id: "retention",
|
|
14372
15491
|
title: "Retention",
|
|
14373
|
-
description: "How long analytics history is kept
|
|
15492
|
+
description: "How long analytics history (tracks, events, media) is kept. Default: as long as the camera's recordings — one policy for footage and events. The day windows below apply in Custom mode, and as the bound when the camera has no footage.",
|
|
14374
15493
|
columns: 3,
|
|
14375
15494
|
fields: [
|
|
15495
|
+
{
|
|
15496
|
+
type: "select",
|
|
15497
|
+
key: "retentionMode",
|
|
15498
|
+
label: "Mode",
|
|
15499
|
+
description: "Follow recordings: tracks/events live exactly as long as retained footage (age retention AND disk eviction; never prunes the last 7 days). Custom: the day windows below.",
|
|
15500
|
+
default: "follow-recordings",
|
|
15501
|
+
options: [{
|
|
15502
|
+
value: "follow-recordings",
|
|
15503
|
+
label: "Follow recordings (default)"
|
|
15504
|
+
}, {
|
|
15505
|
+
value: "custom",
|
|
15506
|
+
label: "Custom windows"
|
|
15507
|
+
}]
|
|
15508
|
+
},
|
|
14376
15509
|
{
|
|
14377
15510
|
type: "number",
|
|
14378
15511
|
key: "trackRetentionDays",
|
|
@@ -14907,6 +16040,86 @@ async function encodeKeyFrameVariants(native) {
|
|
|
14907
16040
|
};
|
|
14908
16041
|
}
|
|
14909
16042
|
//#endregion
|
|
16043
|
+
//#region src/pipeline-analytics/retention-policy.ts
|
|
16044
|
+
/**
|
|
16045
|
+
* Unified analytics retention policy (storage entity-routing spec, Phase 1).
|
|
16046
|
+
*
|
|
16047
|
+
* Two modes, per device:
|
|
16048
|
+
*
|
|
16049
|
+
* - `follow-recordings` (the DEFAULT): tracks and events live exactly as long
|
|
16050
|
+
* as the camera's retained footage — ONE cutoff, the instant of the oldest
|
|
16051
|
+
* segment still on disk. That follows the recorder's age retention AND its
|
|
16052
|
+
* disk-pressure eviction automatically, with no second policy to keep in
|
|
16053
|
+
* sync. A camera with no footage at all (recording off / brand new) falls
|
|
16054
|
+
* back to the custom day windows below, so analytics stay bounded either
|
|
16055
|
+
* way.
|
|
16056
|
+
*
|
|
16057
|
+
* - `custom`: the per-kind day windows (tracks / motion / object / audio),
|
|
16058
|
+
* exactly the pre-unification behaviour — except the knobs are now actually
|
|
16059
|
+
* READ (they were exposed in settings and silently ignored by the sweep,
|
|
16060
|
+
* which used hardcoded 14/30/7).
|
|
16061
|
+
*
|
|
16062
|
+
* Follow-mode floor: events younger than {@link FOLLOW_FLOOR_DAYS} are never
|
|
16063
|
+
* pruned, even when footage is younger (a camera that STARTED recording
|
|
16064
|
+
* yesterday must not nuke its whole event history back to the first segment).
|
|
16065
|
+
*/
|
|
16066
|
+
var DAY_MS = 1440 * 60 * 1e3;
|
|
16067
|
+
/** Per-device analytics retention settings as stored in the device blob.
|
|
16068
|
+
* `retentionMode` absent = follow-recordings (the new default). The day
|
|
16069
|
+
* windows keep their historical defaults and double as the no-footage
|
|
16070
|
+
* fallback in follow mode. */
|
|
16071
|
+
var RetentionSettingsSchema = require_dist.object({
|
|
16072
|
+
retentionMode: require_dist._enum(["follow-recordings", "custom"]).default("follow-recordings"),
|
|
16073
|
+
trackRetentionDays: require_dist.number().min(0).default(7),
|
|
16074
|
+
retentionMotionDays: require_dist.number().min(1).default(14),
|
|
16075
|
+
retentionObjectDays: require_dist.number().min(1).default(30),
|
|
16076
|
+
retentionAudioDays: require_dist.number().min(1).default(7)
|
|
16077
|
+
});
|
|
16078
|
+
/** Resolve the settings from a raw device-store blob — invalid or missing
|
|
16079
|
+
* fields fall back to defaults, never throw. */
|
|
16080
|
+
function resolveRetentionSettings(raw) {
|
|
16081
|
+
const parsed = RetentionSettingsSchema.safeParse(raw);
|
|
16082
|
+
if (parsed.success) return parsed.data;
|
|
16083
|
+
const shape = RetentionSettingsSchema.shape;
|
|
16084
|
+
const field = (k) => shape[k].safeParse(raw[k]).success ? shape[k].parse(raw[k]) : RetentionSettingsSchema.parse({})[k];
|
|
16085
|
+
return {
|
|
16086
|
+
retentionMode: field("retentionMode"),
|
|
16087
|
+
trackRetentionDays: field("trackRetentionDays"),
|
|
16088
|
+
retentionMotionDays: field("retentionMotionDays"),
|
|
16089
|
+
retentionObjectDays: field("retentionObjectDays"),
|
|
16090
|
+
retentionAudioDays: field("retentionAudioDays")
|
|
16091
|
+
};
|
|
16092
|
+
}
|
|
16093
|
+
/**
|
|
16094
|
+
* Follow-mode cutoff: the oldest retained footage instant, floored so
|
|
16095
|
+
* anything younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage
|
|
16096
|
+
* (none on disk) → `null`, the caller falls back to custom windows.
|
|
16097
|
+
*/
|
|
16098
|
+
function followCutoffMs(nowMs, earliestFootageMs) {
|
|
16099
|
+
if (earliestFootageMs === null) return null;
|
|
16100
|
+
return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS);
|
|
16101
|
+
}
|
|
16102
|
+
/** Compute the effective cutoffs for one device at `nowMs`. */
|
|
16103
|
+
function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
|
|
16104
|
+
if (settings.retentionMode === "follow-recordings") {
|
|
16105
|
+
const cutoff = followCutoffMs(nowMs, earliestFootageMs);
|
|
16106
|
+
if (cutoff !== null) return {
|
|
16107
|
+
trackCutoffMs: cutoff,
|
|
16108
|
+
motionCutoffMs: cutoff,
|
|
16109
|
+
objectCutoffMs: cutoff,
|
|
16110
|
+
audioCutoffMs: cutoff,
|
|
16111
|
+
effectiveMode: "follow-recordings"
|
|
16112
|
+
};
|
|
16113
|
+
}
|
|
16114
|
+
return {
|
|
16115
|
+
trackCutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS,
|
|
16116
|
+
motionCutoffMs: nowMs - settings.retentionMotionDays * DAY_MS,
|
|
16117
|
+
objectCutoffMs: nowMs - settings.retentionObjectDays * DAY_MS,
|
|
16118
|
+
audioCutoffMs: nowMs - settings.retentionAudioDays * DAY_MS,
|
|
16119
|
+
effectiveMode: "custom"
|
|
16120
|
+
};
|
|
16121
|
+
}
|
|
16122
|
+
//#endregion
|
|
14910
16123
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
14911
16124
|
/**
|
|
14912
16125
|
* IdentityStore — per-person identity registry for face recognition.
|
|
@@ -18293,6 +19506,17 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
|
|
|
18293
19506
|
* Absent / empty / non-string all fall back to the hub default — the exact
|
|
18294
19507
|
* narrowing the old raw read applied inline. */
|
|
18295
19508
|
var PostProcessingNodeIdSchema = require_dist.string().min(1);
|
|
19509
|
+
/**
|
|
19510
|
+
* Footage-attachment window + geometry, used when the rule states none. The
|
|
19511
|
+
* window is CENTRED on the event, so the recipient sees the approach and what
|
|
19512
|
+
* followed rather than one side of it.
|
|
19513
|
+
*/
|
|
19514
|
+
var NC_FOOTAGE_PRE_ROLL_SEC = 3;
|
|
19515
|
+
var NC_FOOTAGE_POST_ROLL_SEC = 5;
|
|
19516
|
+
var NC_FOOTAGE_MAX_WIDTH = 480;
|
|
19517
|
+
var NC_FOOTAGE_FPS = 5;
|
|
19518
|
+
/** Per-install HMAC secret behind the signed artifact links (minted once). */
|
|
19519
|
+
var NcArtifactSecretSchema = require_dist.string();
|
|
18296
19520
|
var EmbeddingEnabledSchema = require_dist.boolean();
|
|
18297
19521
|
var SILENCE_FLOOR_DBFS = -55;
|
|
18298
19522
|
var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
@@ -18364,6 +19588,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
18364
19588
|
eventStore = null;
|
|
18365
19589
|
/** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
|
|
18366
19590
|
eventsOpsLog = null;
|
|
19591
|
+
/** Event-media relocation engine (entity-routing Phase 4). */
|
|
19592
|
+
mediaRelocate = null;
|
|
18367
19593
|
/** Per-camera history of LINKED-device sensor state changes (Part B). */
|
|
18368
19594
|
sensorEventStore = null;
|
|
18369
19595
|
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
@@ -18422,6 +19648,88 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
18422
19648
|
retentionSweepTimer = null;
|
|
18423
19649
|
/** Handle for the event-media data-plane listener (dispose on shutdown). */
|
|
18424
19650
|
eventMediaDataPlane = null;
|
|
19651
|
+
/** The NC artifact plane (signed public links for notification media) and its
|
|
19652
|
+
* data-plane handle. Null until served / when the facility is absent. */
|
|
19653
|
+
ncArtifactPlane = null;
|
|
19654
|
+
ncArtifactDataPlane = null;
|
|
19655
|
+
/** The operator's marked notification endpoint, or null (AUTO / unavailable). */
|
|
19656
|
+
async markedNotificationEndpoint() {
|
|
19657
|
+
try {
|
|
19658
|
+
return (await this.ctx.api.localNetwork.getNotificationEndpoint.query()).baseUrl ?? void 0;
|
|
19659
|
+
} catch {
|
|
19660
|
+
return;
|
|
19661
|
+
}
|
|
19662
|
+
}
|
|
19663
|
+
/**
|
|
19664
|
+
* Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
|
|
19665
|
+
* each link (see `notification-center/artifact-url.ts`). It exists because
|
|
19666
|
+
* attachments used to carry BYTES only, and the degrade engine drops a
|
|
19667
|
+
* bytes-only attachment for a url-mode backend — WhatsApp and gotify were
|
|
19668
|
+
* receiving no media at all.
|
|
19669
|
+
*
|
|
19670
|
+
* Best-effort at every step: no facility, no secret or no reachable base URL
|
|
19671
|
+
* ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
|
|
19672
|
+
*/
|
|
19673
|
+
async serveNcArtifactPlane() {
|
|
19674
|
+
try {
|
|
19675
|
+
const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
|
|
19676
|
+
let secret = await secretState.get();
|
|
19677
|
+
if (secret === "") {
|
|
19678
|
+
secret = (0, node_crypto.randomUUID)().replace(/-/g, "");
|
|
19679
|
+
await secretState.set(secret);
|
|
19680
|
+
}
|
|
19681
|
+
const store = new NcArtifactStore({
|
|
19682
|
+
dir: node_path.default.join(this.ctx.dataDir, "nc-artifacts"),
|
|
19683
|
+
logger: this.ctx.logger.child("nc-artifacts")
|
|
19684
|
+
});
|
|
19685
|
+
await store.start();
|
|
19686
|
+
const plane = new NcArtifactPlane({
|
|
19687
|
+
store,
|
|
19688
|
+
secret,
|
|
19689
|
+
logger: this.ctx.logger.child("nc-artifacts"),
|
|
19690
|
+
routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
|
|
19691
|
+
listEndpoints: async () => collectArtifactEndpoints({
|
|
19692
|
+
markedBaseUrl: await this.markedNotificationEndpoint(),
|
|
19693
|
+
configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
|
|
19694
|
+
getConnected: async () => {
|
|
19695
|
+
const status = await this.ctx.api.networkAccess.getStatus.query();
|
|
19696
|
+
this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
|
|
19697
|
+
connected: status.connected,
|
|
19698
|
+
url: status.endpoint?.url ?? null,
|
|
19699
|
+
protocol: status.endpoint?.protocol ?? null
|
|
19700
|
+
} });
|
|
19701
|
+
return status.connected && status.endpoint !== null ? {
|
|
19702
|
+
url: status.endpoint.url,
|
|
19703
|
+
protocol: status.endpoint.protocol
|
|
19704
|
+
} : null;
|
|
19705
|
+
},
|
|
19706
|
+
listExternal: async () => {
|
|
19707
|
+
return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
|
|
19708
|
+
url: e.url,
|
|
19709
|
+
protocol: e.protocol
|
|
19710
|
+
}));
|
|
19711
|
+
},
|
|
19712
|
+
listLan: async (port) => {
|
|
19713
|
+
return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
|
|
19714
|
+
baseUrl: e.baseUrl,
|
|
19715
|
+
kind: e.kind,
|
|
19716
|
+
priority: e.priority
|
|
19717
|
+
}));
|
|
19718
|
+
},
|
|
19719
|
+
logger: this.ctx.logger
|
|
19720
|
+
})
|
|
19721
|
+
});
|
|
19722
|
+
this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
|
|
19723
|
+
prefix: "nc-artifact",
|
|
19724
|
+
access: "public",
|
|
19725
|
+
handler: plane.handler
|
|
19726
|
+
}) ?? null;
|
|
19727
|
+
this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
|
|
19728
|
+
this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
|
|
19729
|
+
} catch (err) {
|
|
19730
|
+
this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
19731
|
+
}
|
|
19732
|
+
}
|
|
18425
19733
|
/** Public base URL for event thumbnails: `/addon/<addonId>/event-media`.
|
|
18426
19734
|
* Set once the data-plane is registered; null until then (e.g. no
|
|
18427
19735
|
* dataPlane facility in the current environment). */
|
|
@@ -18725,12 +20033,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
18725
20033
|
let storage = this.ctx.kernel.storage;
|
|
18726
20034
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
18727
20035
|
if (mediaRoot) {
|
|
18728
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
18729
|
-
storage = new FilesystemStorageProvider(mediaRoot);
|
|
20036
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-Dwh-F2Zf.js"));
|
|
20037
|
+
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
18730
20038
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
18731
20039
|
}
|
|
18732
20040
|
if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
|
|
18733
|
-
return
|
|
20041
|
+
return createLocationAwareMediaStorage({
|
|
20042
|
+
base: storage,
|
|
20043
|
+
defaultLocation: "eventMedia",
|
|
20044
|
+
resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
|
|
20045
|
+
location: locationId,
|
|
20046
|
+
relativePath: ""
|
|
20047
|
+
})
|
|
20048
|
+
});
|
|
18734
20049
|
}
|
|
18735
20050
|
/** Constructs every SQLite-backed store plus the stationary/package-drop/
|
|
18736
20051
|
* sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
|
|
@@ -18778,6 +20093,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
18778
20093
|
logger: logger.child("MediaStore")
|
|
18779
20094
|
});
|
|
18780
20095
|
this.mediaStore = mediaStore;
|
|
20096
|
+
this.mediaRelocate = new MediaRelocateEngine({
|
|
20097
|
+
store: api.settingsStore,
|
|
20098
|
+
storage,
|
|
20099
|
+
logger: logger.child("MediaRelocate"),
|
|
20100
|
+
now: () => Date.now(),
|
|
20101
|
+
newId: () => (0, node_crypto.randomUUID)(),
|
|
20102
|
+
resolveTargetRoot: (locationId) => api.storage.resolve.query({
|
|
20103
|
+
location: locationId,
|
|
20104
|
+
relativePath: ""
|
|
20105
|
+
}),
|
|
20106
|
+
onFinished: (job) => {
|
|
20107
|
+
this.eventsOpsLog?.append({
|
|
20108
|
+
op: "relocate",
|
|
20109
|
+
reason: "operator",
|
|
20110
|
+
deviceId: job.deviceId,
|
|
20111
|
+
itemsAffected: job.filesMoved,
|
|
20112
|
+
bytesReclaimed: 0,
|
|
20113
|
+
detail: `${job.state}: media → ${job.toLocationId} (${job.bytesMoved} bytes${job.error ? `; ${job.error}` : ""})`
|
|
20114
|
+
});
|
|
20115
|
+
}
|
|
20116
|
+
});
|
|
18781
20117
|
const eventStore = new EventStore({
|
|
18782
20118
|
store: api.settingsStore,
|
|
18783
20119
|
logger: logger.child("EventStore"),
|
|
@@ -19023,15 +20359,59 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19023
20359
|
this.notificationCenter = new NotificationCenter({
|
|
19024
20360
|
store: api.settingsStore,
|
|
19025
20361
|
logger: logger.child("NotificationCenter"),
|
|
20362
|
+
listUsers: async () => {
|
|
20363
|
+
return (await api.userManagement.listUsers.query()).map((u) => ({
|
|
20364
|
+
id: u.id,
|
|
20365
|
+
isAdmin: u.isAdmin,
|
|
20366
|
+
allowedDevices: u.allowedDevices
|
|
20367
|
+
}));
|
|
20368
|
+
},
|
|
20369
|
+
getDeviceRoute: async (deviceId) => {
|
|
20370
|
+
const device = await api.deviceManager.getDevice.query({ deviceId });
|
|
20371
|
+
return device === null ? null : {
|
|
20372
|
+
stableId: device.stableId,
|
|
20373
|
+
addonId: device.addonId
|
|
20374
|
+
};
|
|
20375
|
+
},
|
|
19026
20376
|
dispatcher: {
|
|
20377
|
+
getZonePolygons: async (deviceId, zoneIds) => {
|
|
20378
|
+
const zones = await api.zones.listZones.query({ deviceId });
|
|
20379
|
+
const wanted = new Set(zoneIds);
|
|
20380
|
+
return zones.filter((z) => wanted.has(z.id) && Array.isArray(z.polygon)).map((z) => (z.polygon ?? []).map((pt) => ({
|
|
20381
|
+
x: pt.x,
|
|
20382
|
+
y: pt.y
|
|
20383
|
+
})));
|
|
20384
|
+
},
|
|
20385
|
+
renderFootage: async (req) => {
|
|
20386
|
+
const res = await api.streamBroker.renderPreBufferClip.mutate({
|
|
20387
|
+
deviceId: req.deviceId,
|
|
20388
|
+
aroundMs: req.aroundMs,
|
|
20389
|
+
format: req.format,
|
|
20390
|
+
preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
|
|
20391
|
+
postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
|
|
20392
|
+
maxWidth: NC_FOOTAGE_MAX_WIDTH,
|
|
20393
|
+
fps: NC_FOOTAGE_FPS,
|
|
20394
|
+
...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
|
|
20395
|
+
});
|
|
20396
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
20397
|
+
if (buf.byteLength === 0) return null;
|
|
20398
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
20399
|
+
bytes.set(buf);
|
|
20400
|
+
return bytes;
|
|
20401
|
+
},
|
|
20402
|
+
publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
|
|
19027
20403
|
listTargets: async () => {
|
|
19028
|
-
return (await api.notificationOutput.listTargets.query({})).map((t) =>
|
|
19029
|
-
|
|
19030
|
-
|
|
19031
|
-
|
|
19032
|
-
|
|
19033
|
-
|
|
19034
|
-
|
|
20404
|
+
return (await api.notificationOutput.listTargets.query({})).map((t) => {
|
|
20405
|
+
const owner = t.config["ownerUserId"];
|
|
20406
|
+
return {
|
|
20407
|
+
id: t.id,
|
|
20408
|
+
addonId: t.addonId,
|
|
20409
|
+
name: t.name,
|
|
20410
|
+
kind: t.kind,
|
|
20411
|
+
enabled: t.enabled,
|
|
20412
|
+
...typeof owner === "string" && owner.length > 0 ? { ownerUserId: owner } : {}
|
|
20413
|
+
};
|
|
20414
|
+
});
|
|
19035
20415
|
},
|
|
19036
20416
|
send: async (input) => {
|
|
19037
20417
|
const { attachments, ...notification } = input.notification;
|
|
@@ -19085,6 +20465,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
19085
20465
|
handler
|
|
19086
20466
|
}) ?? null;
|
|
19087
20467
|
this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
|
|
20468
|
+
await this.serveNcArtifactPlane();
|
|
19088
20469
|
this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
|
|
19089
20470
|
} catch (err) {
|
|
19090
20471
|
this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
@@ -20729,42 +22110,112 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20729
22110
|
if (this.shuttingDown || !this.trackStore) return;
|
|
20730
22111
|
await this.trackCloser.sweep();
|
|
20731
22112
|
}
|
|
22113
|
+
/** Unified retention (Phase 1): earliest retained footage per device — the
|
|
22114
|
+
* follow-recordings cutoff source. Cached 10 min: availability answers in
|
|
22115
|
+
* ~70 ms but the sweep asks once per device per pass. `null` = no footage
|
|
22116
|
+
* (or recorder unreachable) → the policy falls back to the custom windows. */
|
|
22117
|
+
earliestFootageCache = /* @__PURE__ */ new Map();
|
|
22118
|
+
async earliestFootageMs(deviceId) {
|
|
22119
|
+
const cached = this.earliestFootageCache.get(deviceId);
|
|
22120
|
+
if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
|
|
22121
|
+
let value = null;
|
|
22122
|
+
try {
|
|
22123
|
+
const res = await this.ctx.api.recording.getAvailability.query({
|
|
22124
|
+
deviceId,
|
|
22125
|
+
fromMs: 0,
|
|
22126
|
+
toMs: Date.now()
|
|
22127
|
+
});
|
|
22128
|
+
let min = Number.POSITIVE_INFINITY;
|
|
22129
|
+
for (const r of res.ranges) if (r.startMs < min) min = r.startMs;
|
|
22130
|
+
value = Number.isFinite(min) ? min : null;
|
|
22131
|
+
} catch {
|
|
22132
|
+
value = null;
|
|
22133
|
+
}
|
|
22134
|
+
this.earliestFootageCache.set(deviceId, {
|
|
22135
|
+
at: Date.now(),
|
|
22136
|
+
value
|
|
22137
|
+
});
|
|
22138
|
+
return value;
|
|
22139
|
+
}
|
|
22140
|
+
/** The per-device effective cutoffs (mode + footage → numbers). */
|
|
22141
|
+
async deviceRetentionCutoffs(deviceId, nowMs) {
|
|
22142
|
+
const settings = resolveRetentionSettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
22143
|
+
return resolveRetentionCutoffs(settings, nowMs, settings.retentionMode === "follow-recordings" ? await this.earliestFootageMs(deviceId) : null);
|
|
22144
|
+
}
|
|
22145
|
+
/** The devices one retention pass covers: every camera the hub knows plus
|
|
22146
|
+
* any device that still has persisted tracks (covers deleted cameras whose
|
|
22147
|
+
* history must keep aging out). */
|
|
22148
|
+
async retentionDeviceIds() {
|
|
22149
|
+
const ids = /* @__PURE__ */ new Set();
|
|
22150
|
+
try {
|
|
22151
|
+
const all = await this.ctx.api.deviceManager.listAll.query({});
|
|
22152
|
+
for (const d of all) ids.add(d.id);
|
|
22153
|
+
} catch {}
|
|
22154
|
+
try {
|
|
22155
|
+
for (const id of await this.trackStore?.listDeviceIds() ?? []) ids.add(id);
|
|
22156
|
+
} catch {}
|
|
22157
|
+
return [...ids];
|
|
22158
|
+
}
|
|
20732
22159
|
async sweepRetention() {
|
|
20733
22160
|
if (this.shuttingDown || !this.eventStore || !this.mediaStore) return;
|
|
20734
22161
|
const now = Date.now();
|
|
20735
22162
|
const day = 1440 * 60 * 1e3;
|
|
20736
|
-
const OBJECT_RETENTION_DAYS = 30;
|
|
20737
|
-
const motionCutoffMs = now - 14 * day;
|
|
20738
|
-
const objectCutoffMs = now - OBJECT_RETENTION_DAYS * day;
|
|
20739
|
-
const audioCutoffMs = now - 7 * day;
|
|
20740
22163
|
try {
|
|
20741
|
-
const
|
|
20742
|
-
|
|
20743
|
-
|
|
20744
|
-
|
|
20745
|
-
|
|
20746
|
-
|
|
20747
|
-
|
|
20748
|
-
|
|
20749
|
-
|
|
20750
|
-
|
|
20751
|
-
|
|
20752
|
-
|
|
20753
|
-
|
|
20754
|
-
|
|
20755
|
-
|
|
20756
|
-
|
|
20757
|
-
|
|
20758
|
-
|
|
20759
|
-
|
|
20760
|
-
|
|
22164
|
+
const deviceIds = await this.retentionDeviceIds();
|
|
22165
|
+
const evictedIds = [];
|
|
22166
|
+
let minObjectCutoffMs = Number.POSITIVE_INFINITY;
|
|
22167
|
+
for (const deviceId of deviceIds) {
|
|
22168
|
+
if (this.shuttingDown) return;
|
|
22169
|
+
try {
|
|
22170
|
+
const cutoffs = await this.deviceRetentionCutoffs(deviceId, now);
|
|
22171
|
+
if (cutoffs.objectCutoffMs !== null && cutoffs.objectCutoffMs < minObjectCutoffMs) minObjectCutoffMs = cutoffs.objectCutoffMs;
|
|
22172
|
+
if (cutoffs.motionCutoffMs === null && cutoffs.objectCutoffMs === null && cutoffs.audioCutoffMs === null) continue;
|
|
22173
|
+
const evicted = await this.eventStore.evictBefore({
|
|
22174
|
+
deviceId,
|
|
22175
|
+
motionCutoffMs: cutoffs.motionCutoffMs ?? 0,
|
|
22176
|
+
objectCutoffMs: cutoffs.objectCutoffMs ?? 0,
|
|
22177
|
+
audioCutoffMs: cutoffs.audioCutoffMs ?? 0
|
|
22178
|
+
});
|
|
22179
|
+
const ids = [
|
|
22180
|
+
...evicted.motion,
|
|
22181
|
+
...evicted.object,
|
|
22182
|
+
...evicted.audio
|
|
22183
|
+
];
|
|
22184
|
+
if (ids.length > 0) {
|
|
22185
|
+
evictedIds.push(...ids);
|
|
22186
|
+
this.eventsOpsLog?.append({
|
|
22187
|
+
op: "prune",
|
|
22188
|
+
reason: "retention",
|
|
22189
|
+
deviceId,
|
|
22190
|
+
itemsAffected: ids.length,
|
|
22191
|
+
bytesReclaimed: 0,
|
|
22192
|
+
detail: `age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.object.length} object, ${evicted.audio.length} audio`
|
|
22193
|
+
});
|
|
22194
|
+
this.ctx.logger.info("analytics event eviction (age sweep)", {
|
|
22195
|
+
tags: { deviceId },
|
|
22196
|
+
meta: {
|
|
22197
|
+
motion: evicted.motion.length,
|
|
22198
|
+
object: evicted.object.length,
|
|
22199
|
+
audio: evicted.audio.length,
|
|
22200
|
+
mode: cutoffs.effectiveMode,
|
|
22201
|
+
objectCutoffMs: cutoffs.objectCutoffMs
|
|
22202
|
+
}
|
|
22203
|
+
});
|
|
22204
|
+
}
|
|
22205
|
+
} catch (err) {
|
|
22206
|
+
this.ctx.logger.debug("event retention sweep (device) failed", {
|
|
22207
|
+
tags: { deviceId },
|
|
22208
|
+
meta: { error: String(err) }
|
|
22209
|
+
});
|
|
22210
|
+
}
|
|
20761
22211
|
}
|
|
20762
|
-
await this.mediaStore.
|
|
20763
|
-
if (
|
|
20764
|
-
|
|
22212
|
+
if (evictedIds.length > 0) await this.mediaStore.deleteForEvents(evictedIds);
|
|
22213
|
+
if (Number.isFinite(minObjectCutoffMs)) await this.mediaStore.evictBefore(minObjectCutoffMs - 1 * day);
|
|
22214
|
+
if (this.sensorEventStore && Number.isFinite(minObjectCutoffMs)) try {
|
|
22215
|
+
const sensorDeleted = await this.sensorEventStore.evictBefore(minObjectCutoffMs);
|
|
20765
22216
|
if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
|
|
20766
22217
|
deleted: sensorDeleted,
|
|
20767
|
-
cutoffMs:
|
|
22218
|
+
cutoffMs: minObjectCutoffMs
|
|
20768
22219
|
} });
|
|
20769
22220
|
} catch (err) {
|
|
20770
22221
|
this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
|
|
@@ -20795,8 +22246,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
20795
22246
|
} catch (err) {
|
|
20796
22247
|
this.ctx.logger.debug("plate buffer prune failed", { meta: { error: String(err) } });
|
|
20797
22248
|
}
|
|
20798
|
-
if (this.objectEmbeddingStore) try {
|
|
20799
|
-
const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(
|
|
22249
|
+
if (this.objectEmbeddingStore && Number.isFinite(minObjectCutoffMs)) try {
|
|
22250
|
+
const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(minObjectCutoffMs);
|
|
20800
22251
|
if (deletedEmbIds.length > 0) this.ctx.logger.info("object embedding retention prune", { meta: { deleted: deletedEmbIds.length } });
|
|
20801
22252
|
} catch (err) {
|
|
20802
22253
|
this.ctx.logger.debug("object embedding prune failed", { meta: { error: String(err) } });
|
|
@@ -21462,6 +22913,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21462
22913
|
return this.queryFacade.deleteDeviceEvents(input);
|
|
21463
22914
|
}
|
|
21464
22915
|
/** The events ops-log rows (newest-first), optionally scoped to one camera. */
|
|
22916
|
+
async relocateMedia(input) {
|
|
22917
|
+
if (!this.mediaRelocate) throw new Error("media relocation unavailable");
|
|
22918
|
+
return { jobId: this.mediaRelocate.start(input) };
|
|
22919
|
+
}
|
|
22920
|
+
async getMediaRelocateStatus() {
|
|
22921
|
+
return this.mediaRelocate?.list() ?? [];
|
|
22922
|
+
}
|
|
22923
|
+
async cancelMediaRelocate(input) {
|
|
22924
|
+
return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
|
|
22925
|
+
}
|
|
21465
22926
|
async listOpsLog(input) {
|
|
21466
22927
|
return this.queryFacade.listOpsLog(input);
|
|
21467
22928
|
}
|
|
@@ -21541,13 +23002,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21541
23002
|
try {
|
|
21542
23003
|
const total = await sweepTrackRetention({
|
|
21543
23004
|
listDeviceIds: () => trackStore.listDeviceIds(),
|
|
21544
|
-
|
|
21545
|
-
|
|
23005
|
+
resolveCutoffMs: async (deviceId, nowMs) => (await this.deviceRetentionCutoffs(deviceId, nowMs)).trackCutoffMs,
|
|
23006
|
+
pruneTracksBefore: async (deviceId, cutoffMs) => {
|
|
23007
|
+
const counts = await this.pruneTracksBefore({
|
|
23008
|
+
deviceId,
|
|
23009
|
+
cutoffMs
|
|
23010
|
+
});
|
|
23011
|
+
if (counts.tracks > 0) this.eventsOpsLog?.append({
|
|
23012
|
+
op: "prune",
|
|
23013
|
+
reason: "retention",
|
|
23014
|
+
deviceId,
|
|
23015
|
+
itemsAffected: counts.tracks,
|
|
23016
|
+
bytesReclaimed: 0,
|
|
23017
|
+
detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
|
|
23018
|
+
});
|
|
23019
|
+
return counts;
|
|
21546
23020
|
},
|
|
21547
|
-
pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
|
|
21548
|
-
deviceId,
|
|
21549
|
-
cutoffMs
|
|
21550
|
-
}),
|
|
21551
23021
|
now: () => Date.now(),
|
|
21552
23022
|
onError: (deviceId, err) => {
|
|
21553
23023
|
this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
|
|
@@ -21641,7 +23111,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
21641
23111
|
const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
|
|
21642
23112
|
const baseSections = schema ? require_dist.hydrateSchema({
|
|
21643
23113
|
...schema,
|
|
21644
|
-
sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
|
|
23114
|
+
sections: retagRetentionSection(retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))))
|
|
21645
23115
|
}, raw).sections : [];
|
|
21646
23116
|
const liveStatsSection = {
|
|
21647
23117
|
id: "live-stats",
|
|
@@ -21697,5 +23167,6 @@ exports.default = PipelineAnalyticsAddon;
|
|
|
21697
23167
|
exports.ncActions = ncActions;
|
|
21698
23168
|
exports.pickCleanMedia = pickCleanMedia;
|
|
21699
23169
|
exports.retagDetectionSections = retagDetectionSections;
|
|
23170
|
+
exports.retagRetentionSection = retagRetentionSection;
|
|
21700
23171
|
exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
|
|
21701
23172
|
exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;
|