@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
|
@@ -1,6 +1,346 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
import {
|
|
1
|
+
import { A as errMsg, B as number, D as subKindsOf, E as plateGalleryCapability, F as nodePin, H as string, I as _enum, L as array, M as DeviceType, N as createEvent, O as videoclipsCapability, P as hydrateSchema, R as boolean, S as faceGalleryCapability, T as pipelineAnalyticsCapability, U as EventCategory, V as object, _ as buildEventKindDescriptor, a as NC_CONDITION_CATALOG, b as defineCustomActions, c as NcRuleInputSchema, d as NcTaxonomySchema, f as OpsLogEntrySchema, g as audioMetricsCapability, h as addonWidgetsSourceCapability, i as MACRO_LABELS, j as BaseAddon, k as zoneAnalyticsCapability, l as NcRulePatchSchema, m as TimelapseRuleSchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as TimelapseRuleInputSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as cosineSimilarity, w as notificationRulesCapability, y as customAction, z as literal } from "../dist-C41w6Xvl.mjs";
|
|
2
|
+
import { promises } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
5
|
import sharp from "sharp";
|
|
6
|
+
//#region src/notification-center/artifact-url.ts
|
|
7
|
+
/**
|
|
8
|
+
* Signed, externally-reachable URLs for notification artifacts.
|
|
9
|
+
*
|
|
10
|
+
* WHY this exists: the dispatcher only ever produced attachment BYTES, and the
|
|
11
|
+
* degrade engine drops a bytes-only attachment for a `mode:'url'` target
|
|
12
|
+
* (`attachment:noUrl`) — so WhatsApp and gotify received no media at all,
|
|
13
|
+
* silently. A URL also lets an oversized attachment degrade to a link instead
|
|
14
|
+
* of being dropped on `maxBytes`.
|
|
15
|
+
*
|
|
16
|
+
* Two decisions the shape depends on:
|
|
17
|
+
*
|
|
18
|
+
* - **Signed, not authenticated.** The fetcher is a notifier BACKEND (or the
|
|
19
|
+
* recipient's phone) — it holds no session and no admin token. The route is
|
|
20
|
+
* therefore `access:'public'` and the authority is the signature: an
|
|
21
|
+
* unguessable HMAC over `(id, exp)` with a per-install secret, expiring on
|
|
22
|
+
* its own. Nothing else about the install is reachable through it.
|
|
23
|
+
* - **The base URL is CHOSEN, never assumed.** A notification carrying
|
|
24
|
+
* `http://127.0.0.1:...` is useless (the Alexa `hubUrl` bug, again). The
|
|
25
|
+
* ranked endpoint list the `local-network` cap already computes — public
|
|
26
|
+
* tunnel > mesh > LAN, loopback last — is the authority, and loopback is
|
|
27
|
+
* refused outright rather than shipped as a broken link.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Pick the base URL an artifact link should use: the LOWEST-priority-number
|
|
31
|
+
* endpoint that is not loopback (the list is already ranked public > mesh >
|
|
32
|
+
* LAN > loopback). `null` when only loopback is available — a link nobody
|
|
33
|
+
* outside the host could open is worse than no link, because the notification
|
|
34
|
+
* would arrive advertising media that 404s.
|
|
35
|
+
*/
|
|
36
|
+
function pickArtifactBaseUrl(endpoints) {
|
|
37
|
+
return endpoints.filter((e) => e.kind !== "loopback").toSorted((a, b) => a.priority - b.priority)[0]?.baseUrl ?? null;
|
|
38
|
+
}
|
|
39
|
+
/** Sign `(id, exp)` — the exact string the verifier recomputes. */
|
|
40
|
+
function signArtifact(secret, id, expMs) {
|
|
41
|
+
return createHmac("sha256", secret).update(`${id}:${expMs}`).digest("hex");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Build the fully-qualified, signed URL for an artifact.
|
|
45
|
+
* `baseUrl` comes from {@link pickArtifactBaseUrl}, `routePrefix` from the
|
|
46
|
+
* data-plane handle (`/addon/<addonId>/nc-artifact`).
|
|
47
|
+
*/
|
|
48
|
+
function buildArtifactUrl(input) {
|
|
49
|
+
const sig = signArtifact(input.secret, input.id, input.expMs);
|
|
50
|
+
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}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Verify a request's `(id, exp, sig)`. Constant-time on the signature so a
|
|
54
|
+
* public route cannot be probed for it byte by byte, and expiry-checked before
|
|
55
|
+
* the compare so an expired link is rejected even with a valid signature.
|
|
56
|
+
*/
|
|
57
|
+
function verifyArtifactSignature(input) {
|
|
58
|
+
if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
|
|
59
|
+
const expected = signArtifact(input.secret, input.id, input.exp);
|
|
60
|
+
const a = Buffer.from(expected, "utf8");
|
|
61
|
+
const b = Buffer.from(input.sig, "utf8");
|
|
62
|
+
if (a.length !== b.length) return false;
|
|
63
|
+
return timingSafeEqual(a, b);
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/notification-center/artifact-plane.ts
|
|
67
|
+
/**
|
|
68
|
+
* The hub's default HTTPS API port — the same constant `derive-hub-url.ts`
|
|
69
|
+
* documents. Only used for the LAN candidates; an operator who runs the hub on
|
|
70
|
+
* another port sets `CAMSTACK_HUB_PUBLIC_URL`, which wins outright.
|
|
71
|
+
*/
|
|
72
|
+
var HUB_API_PORT = 4443;
|
|
73
|
+
/**
|
|
74
|
+
* Ranking of the public sources. All NEGATIVE: `local-network` gives its
|
|
75
|
+
* preferred LAN interface priority `0`, so a public candidate has to sit below
|
|
76
|
+
* zero to outrank it.
|
|
77
|
+
*/
|
|
78
|
+
var PRIORITY_MARKED = -400;
|
|
79
|
+
var PRIORITY_CONFIGURED = -300;
|
|
80
|
+
var PRIORITY_CONNECTED = -200;
|
|
81
|
+
var PRIORITY_EXTERNAL_LIST = -100;
|
|
82
|
+
/**
|
|
83
|
+
* Build the ranked candidate list, in the operator's own order: an explicitly
|
|
84
|
+
* configured public origin, then the connected external ingress (tunnels),
|
|
85
|
+
* then the LAN addresses. Every source is best-effort — one that throws
|
|
86
|
+
* contributes nothing rather than costing the whole list.
|
|
87
|
+
*/
|
|
88
|
+
async function collectArtifactEndpoints(sources) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const marked = sources.markedBaseUrl;
|
|
91
|
+
if (marked !== void 0 && marked !== "") out.push({
|
|
92
|
+
baseUrl: marked,
|
|
93
|
+
kind: "public",
|
|
94
|
+
priority: PRIORITY_MARKED
|
|
95
|
+
});
|
|
96
|
+
const configured = sources.configuredPublicUrl;
|
|
97
|
+
if (configured !== void 0 && configured !== "" && !/127\.0\.0\.1|localhost/.test(configured)) out.push({
|
|
98
|
+
baseUrl: configured,
|
|
99
|
+
kind: "public",
|
|
100
|
+
priority: PRIORITY_CONFIGURED
|
|
101
|
+
});
|
|
102
|
+
try {
|
|
103
|
+
const connected = await sources.getConnected();
|
|
104
|
+
if (connected !== null && connected.protocol === "https") out.push({
|
|
105
|
+
baseUrl: connected.url,
|
|
106
|
+
kind: "public",
|
|
107
|
+
priority: PRIORITY_CONNECTED
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
sources.logger.debug("connected endpoint lookup failed", { meta: { error: String(err) } });
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const external = await sources.listExternal();
|
|
114
|
+
for (const [i, e] of external.entries()) {
|
|
115
|
+
if (e.protocol !== "https") continue;
|
|
116
|
+
out.push({
|
|
117
|
+
baseUrl: e.url,
|
|
118
|
+
kind: "public",
|
|
119
|
+
priority: PRIORITY_EXTERNAL_LIST + i
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
sources.logger.debug("external endpoint lookup failed", { meta: { error: String(err) } });
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
out.push(...await sources.listLan(HUB_API_PORT));
|
|
127
|
+
} catch (err) {
|
|
128
|
+
sources.logger.debug("LAN endpoint lookup failed", { meta: { error: String(err) } });
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
var DEFAULT_TTL_MS = 24 * 36e5;
|
|
133
|
+
var DEFAULT_ENDPOINT_CACHE_MS = 6e4;
|
|
134
|
+
var NcArtifactPlane = class {
|
|
135
|
+
deps;
|
|
136
|
+
now;
|
|
137
|
+
cachedBaseUrl = null;
|
|
138
|
+
/** Last base URL announced in the log — so the line appears on CHANGE only. */
|
|
139
|
+
lastLoggedBaseUrl = null;
|
|
140
|
+
cachedAt = 0;
|
|
141
|
+
constructor(deps) {
|
|
142
|
+
this.deps = deps;
|
|
143
|
+
this.now = deps.now ?? (() => Date.now());
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Store bytes and mint a signed, externally-reachable URL for them.
|
|
147
|
+
* Returns `null` when no usable base URL exists (loopback only) — the caller
|
|
148
|
+
* then ships bytes alone, exactly as before. Never throws: an artifact URL is
|
|
149
|
+
* an ENHANCEMENT, and failing to mint one must not cost the notification.
|
|
150
|
+
*/
|
|
151
|
+
async publish(bytes, mime) {
|
|
152
|
+
try {
|
|
153
|
+
const baseUrl = await this.resolveBaseUrl();
|
|
154
|
+
if (baseUrl === null) return null;
|
|
155
|
+
const artifact = await this.deps.store.put(bytes, mime);
|
|
156
|
+
return buildArtifactUrl({
|
|
157
|
+
baseUrl,
|
|
158
|
+
routePrefix: this.deps.routePrefix,
|
|
159
|
+
id: artifact.id,
|
|
160
|
+
secret: this.deps.secret,
|
|
161
|
+
expMs: this.now() + (this.deps.ttlMs ?? DEFAULT_TTL_MS)
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
this.deps.logger.debug("artifact publish failed — shipping bytes only", { meta: { error: String(err) } });
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** The data-plane handler for `<prefix>/<id>?exp=&sig=`. */
|
|
169
|
+
handler = async (req, res) => {
|
|
170
|
+
const url = new URL(req.url ?? "/", "http://placeholder");
|
|
171
|
+
const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
|
|
172
|
+
if (!verifyArtifactSignature({
|
|
173
|
+
secret: this.deps.secret,
|
|
174
|
+
id,
|
|
175
|
+
exp: Number(url.searchParams.get("exp")),
|
|
176
|
+
sig: url.searchParams.get("sig") ?? "",
|
|
177
|
+
nowMs: this.now()
|
|
178
|
+
})) {
|
|
179
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
180
|
+
res.end("not found");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const found = await this.deps.store.read(id);
|
|
184
|
+
if (found === null) {
|
|
185
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
186
|
+
res.end("not found");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
res.writeHead(200, {
|
|
190
|
+
"content-type": found.mime,
|
|
191
|
+
"content-length": String(found.bytes.byteLength),
|
|
192
|
+
"cache-control": "private, max-age=3600"
|
|
193
|
+
});
|
|
194
|
+
res.end(found.bytes);
|
|
195
|
+
};
|
|
196
|
+
/** The chosen base URL, cached briefly (the interface list barely moves). */
|
|
197
|
+
async resolveBaseUrl() {
|
|
198
|
+
const ttl = this.deps.endpointCacheMs ?? DEFAULT_ENDPOINT_CACHE_MS;
|
|
199
|
+
if (this.cachedBaseUrl !== null && this.now() - this.cachedAt < ttl) return this.cachedBaseUrl;
|
|
200
|
+
const picked = pickArtifactBaseUrl(await this.deps.listEndpoints());
|
|
201
|
+
if (picked === null) {
|
|
202
|
+
this.deps.logger.debug("no externally-reachable endpoint — artifacts ship as bytes only");
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (picked !== this.lastLoggedBaseUrl) {
|
|
206
|
+
this.deps.logger.info("artifact links will use", { meta: { baseUrl: picked } });
|
|
207
|
+
this.lastLoggedBaseUrl = picked;
|
|
208
|
+
}
|
|
209
|
+
this.cachedBaseUrl = picked;
|
|
210
|
+
this.cachedAt = this.now();
|
|
211
|
+
return picked;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/notification-center/artifact-store.ts
|
|
216
|
+
/**
|
|
217
|
+
* NcArtifactStore — the bytes behind a signed notification-artifact URL.
|
|
218
|
+
*
|
|
219
|
+
* A notification attachment is transient by nature: it exists to be fetched
|
|
220
|
+
* once, by a notifier backend or the recipient's phone, within minutes of the
|
|
221
|
+
* event. So this is a BOUNDED SCRATCH, not a media library — the durable copy
|
|
222
|
+
* of an event's media already lives in the media store, and duplicating it here
|
|
223
|
+
* permanently would grow without limit for no benefit.
|
|
224
|
+
*
|
|
225
|
+
* Two bounds, both enforced on every write (age first, then count): whichever
|
|
226
|
+
* bites first, the oldest artifacts go. A sweep on boot clears whatever a crash
|
|
227
|
+
* left behind — the directory is disposable by construction, so a lost file is
|
|
228
|
+
* a 404 on one link, never a corrupt state.
|
|
229
|
+
*/
|
|
230
|
+
var DEFAULT_MAX_AGE_MS = 6 * 36e5;
|
|
231
|
+
var DEFAULT_MAX_ENTRIES = 500;
|
|
232
|
+
/** `image/jpeg` → `jpg`, so a fetched link has a name a client will accept. */
|
|
233
|
+
function extensionFor(mime) {
|
|
234
|
+
if (mime === "image/jpeg") return "jpg";
|
|
235
|
+
if (mime === "image/gif") return "gif";
|
|
236
|
+
if (mime === "video/mp4") return "mp4";
|
|
237
|
+
if (mime === "image/png") return "png";
|
|
238
|
+
return "bin";
|
|
239
|
+
}
|
|
240
|
+
var NcArtifactStore = class {
|
|
241
|
+
options;
|
|
242
|
+
now;
|
|
243
|
+
maxAgeMs;
|
|
244
|
+
maxEntries;
|
|
245
|
+
/** id → mime, so the route can answer with the right content-type without
|
|
246
|
+
* re-deriving it from the extension on every request. */
|
|
247
|
+
mimeById = /* @__PURE__ */ new Map();
|
|
248
|
+
constructor(options) {
|
|
249
|
+
this.options = options;
|
|
250
|
+
this.now = options.now ?? (() => Date.now());
|
|
251
|
+
this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
252
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
253
|
+
}
|
|
254
|
+
/** Create the directory and sweep whatever a previous run left behind. */
|
|
255
|
+
async start() {
|
|
256
|
+
await promises.mkdir(this.options.dir, { recursive: true });
|
|
257
|
+
await this.sweep();
|
|
258
|
+
}
|
|
259
|
+
/** Persist bytes and return the artifact handle (never throws on sweep). */
|
|
260
|
+
async put(bytes, mime) {
|
|
261
|
+
const id = `${this.now()}-${randomUUID()}`;
|
|
262
|
+
const file = path.join(this.options.dir, `${id}.${extensionFor(mime)}`);
|
|
263
|
+
await promises.mkdir(this.options.dir, { recursive: true });
|
|
264
|
+
await promises.writeFile(file, bytes);
|
|
265
|
+
this.mimeById.set(id, mime);
|
|
266
|
+
await this.sweep();
|
|
267
|
+
return {
|
|
268
|
+
id,
|
|
269
|
+
mime,
|
|
270
|
+
bytes: bytes.byteLength
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/** Read an artifact's bytes + mime, or null when it is gone (expired/swept). */
|
|
274
|
+
async read(id) {
|
|
275
|
+
if (!/^[0-9]+-[0-9a-f-]{36}$/i.test(id)) return null;
|
|
276
|
+
const found = (await this.list()).find((e) => e.id === id);
|
|
277
|
+
if (!found) return null;
|
|
278
|
+
try {
|
|
279
|
+
return {
|
|
280
|
+
bytes: await promises.readFile(found.file),
|
|
281
|
+
mime: this.mimeById.get(id) ?? mimeFromExtension(found.file)
|
|
282
|
+
};
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Delete every artifact (operator cleanup / shutdown). */
|
|
288
|
+
async clear() {
|
|
289
|
+
await promises.rm(this.options.dir, {
|
|
290
|
+
recursive: true,
|
|
291
|
+
force: true
|
|
292
|
+
}).catch(() => {});
|
|
293
|
+
this.mimeById.clear();
|
|
294
|
+
}
|
|
295
|
+
/** Age- then count-bounded prune. Best-effort: a failed unlink is logged once. */
|
|
296
|
+
async sweep() {
|
|
297
|
+
const entries = await this.list();
|
|
298
|
+
const cutoff = this.now() - this.maxAgeMs;
|
|
299
|
+
const doomed = entries.filter((e) => e.mtimeMs < cutoff);
|
|
300
|
+
const survivors = entries.filter((e) => e.mtimeMs >= cutoff).toSorted((a, b) => b.mtimeMs - a.mtimeMs);
|
|
301
|
+
doomed.push(...survivors.slice(this.maxEntries));
|
|
302
|
+
for (const e of doomed) try {
|
|
303
|
+
await promises.rm(e.file, { force: true });
|
|
304
|
+
this.mimeById.delete(e.id);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
this.options.logger.debug("artifact sweep failed to unlink", { meta: {
|
|
307
|
+
file: e.file,
|
|
308
|
+
error: String(err)
|
|
309
|
+
} });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async list() {
|
|
313
|
+
let names;
|
|
314
|
+
try {
|
|
315
|
+
names = await promises.readdir(this.options.dir);
|
|
316
|
+
} catch {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const out = [];
|
|
320
|
+
for (const name of names) {
|
|
321
|
+
const file = path.join(this.options.dir, name);
|
|
322
|
+
try {
|
|
323
|
+
const st = await promises.stat(file);
|
|
324
|
+
if (!st.isFile()) continue;
|
|
325
|
+
out.push({
|
|
326
|
+
id: name.slice(0, name.lastIndexOf(".")),
|
|
327
|
+
file,
|
|
328
|
+
mtimeMs: st.mtimeMs
|
|
329
|
+
});
|
|
330
|
+
} catch {}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
function mimeFromExtension(file) {
|
|
336
|
+
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
337
|
+
if (ext === "jpg") return "image/jpeg";
|
|
338
|
+
if (ext === "gif") return "image/gif";
|
|
339
|
+
if (ext === "mp4") return "video/mp4";
|
|
340
|
+
if (ext === "png") return "image/png";
|
|
341
|
+
return "application/octet-stream";
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
4
344
|
//#region src/pipeline-analytics/videoclips-provider.ts
|
|
5
345
|
var SOURCE = "analytics";
|
|
6
346
|
function clipIdFor(eventId, startMs, endMs) {
|
|
@@ -4157,6 +4497,42 @@ function attachmentKindPreference(policy, ownerKind) {
|
|
|
4157
4497
|
];
|
|
4158
4498
|
}
|
|
4159
4499
|
/**
|
|
4500
|
+
* The kind ladder for an EXPLICIT frame choice. Strict by design — each option
|
|
4501
|
+
* stays inside its own family, because a rule that asked for the clean scene
|
|
4502
|
+
* and received the boxed one (or vice versa) is not "degrading gracefully", it
|
|
4503
|
+
* is answering a different question.
|
|
4504
|
+
*
|
|
4505
|
+
* `boxed` is the one exception: with no annotated frame stored, the CLEAN
|
|
4506
|
+
* scene is the honest fallback (same picture, no annotation) — never a subject
|
|
4507
|
+
* crop, which shows something else entirely.
|
|
4508
|
+
*/
|
|
4509
|
+
function framePreference(frame, ownerKind) {
|
|
4510
|
+
if (frame === "cropped") return ownerKind === "track" ? [
|
|
4511
|
+
"thumbnail",
|
|
4512
|
+
"thumbnailSmall",
|
|
4513
|
+
"crop"
|
|
4514
|
+
] : [
|
|
4515
|
+
"crop",
|
|
4516
|
+
"thumbnail",
|
|
4517
|
+
"thumbnailSmall"
|
|
4518
|
+
];
|
|
4519
|
+
if (frame === "boxed") return [
|
|
4520
|
+
"fullFrameBoxed",
|
|
4521
|
+
"keyFrame",
|
|
4522
|
+
"keyFrameSmall",
|
|
4523
|
+
"fullFrame"
|
|
4524
|
+
];
|
|
4525
|
+
return ownerKind === "track" ? [
|
|
4526
|
+
"keyFrame",
|
|
4527
|
+
"keyFrameSmall",
|
|
4528
|
+
"firstFrame"
|
|
4529
|
+
] : [
|
|
4530
|
+
"fullFrame",
|
|
4531
|
+
"keyFrame",
|
|
4532
|
+
"keyFrameSmall"
|
|
4533
|
+
];
|
|
4534
|
+
}
|
|
4535
|
+
/**
|
|
4160
4536
|
* Derive the `best-matching` media signal from a matched rule's condition
|
|
4161
4537
|
* summary ({@link NcEvaluation.matchedOn}). Identity takes priority over plate
|
|
4162
4538
|
* (D-3 ordering: a face rule that ALSO plate-matched attaches the face crop).
|
|
@@ -4185,6 +4561,20 @@ function bestMatchingKindPreference(signal, ownerKind) {
|
|
|
4185
4561
|
//#endregion
|
|
4186
4562
|
//#region src/notification-center/dispatcher.ts
|
|
4187
4563
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
4564
|
+
/** GIF and MP4 are the same cut in two containers — one render request each. */
|
|
4565
|
+
var FOOTAGE_FORMATS = [{
|
|
4566
|
+
flag: "mediaGif",
|
|
4567
|
+
format: "gif",
|
|
4568
|
+
mediaType: "gif",
|
|
4569
|
+
mime: "image/gif",
|
|
4570
|
+
name: "event.gif"
|
|
4571
|
+
}, {
|
|
4572
|
+
flag: "mediaClip",
|
|
4573
|
+
format: "mp4",
|
|
4574
|
+
mediaType: "video",
|
|
4575
|
+
mime: "video/mp4",
|
|
4576
|
+
name: "event.mp4"
|
|
4577
|
+
}];
|
|
4188
4578
|
var NcDispatcher = class {
|
|
4189
4579
|
deps;
|
|
4190
4580
|
targetCache = null;
|
|
@@ -4254,14 +4644,19 @@ var NcDispatcher = class {
|
|
|
4254
4644
|
async resolveTarget(targetId) {
|
|
4255
4645
|
const cached = this.cachedTarget(targetId);
|
|
4256
4646
|
if (cached !== null) return cached;
|
|
4647
|
+
let targets;
|
|
4257
4648
|
try {
|
|
4258
|
-
|
|
4259
|
-
this.targetCache = new Map(targets.map((t) => [t.id, t]));
|
|
4260
|
-
this.targetCacheAt = this.now();
|
|
4649
|
+
targets = await this.deps.listTargets();
|
|
4261
4650
|
} catch (err) {
|
|
4262
4651
|
this.deps.logger.warn("target catalog refresh failed", { meta: { error: String(err) } });
|
|
4263
4652
|
throw err instanceof Error ? err : new Error(String(err));
|
|
4264
4653
|
}
|
|
4654
|
+
if (targets.length === 0) {
|
|
4655
|
+
this.deps.logger.warn("target catalog came back EMPTY — treating as transient", { meta: { targetId } });
|
|
4656
|
+
throw new Error(`target catalog empty (transient) while resolving ${targetId}`);
|
|
4657
|
+
}
|
|
4658
|
+
this.targetCache = new Map(targets.map((t) => [t.id, t]));
|
|
4659
|
+
this.targetCacheAt = this.now();
|
|
4265
4660
|
return this.targetCache.get(targetId) ?? null;
|
|
4266
4661
|
}
|
|
4267
4662
|
cachedTarget(targetId) {
|
|
@@ -4275,7 +4670,7 @@ var NcDispatcher = class {
|
|
|
4275
4670
|
const vars = buildTemplateVars(entry, deviceName);
|
|
4276
4671
|
const title = renderTemplate(entry.payload.template?.title, vars) ?? entry.payload.ruleName;
|
|
4277
4672
|
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName);
|
|
4278
|
-
const
|
|
4673
|
+
const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
|
|
4279
4674
|
const params = pickParams(entry.payload.params);
|
|
4280
4675
|
return {
|
|
4281
4676
|
body,
|
|
@@ -4285,7 +4680,7 @@ var NcDispatcher = class {
|
|
|
4285
4680
|
tag: entry.ruleId,
|
|
4286
4681
|
deviceId: subject.deviceId,
|
|
4287
4682
|
...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
|
|
4288
|
-
...
|
|
4683
|
+
...attachments.length > 0 ? { attachments } : {}
|
|
4289
4684
|
};
|
|
4290
4685
|
}
|
|
4291
4686
|
/**
|
|
@@ -4297,9 +4692,142 @@ var NcDispatcher = class {
|
|
|
4297
4692
|
* explains the fired condition (`faceCrop`/`plateCrop`, both event-owned)
|
|
4298
4693
|
* then degrades to the plain `best` → `keyFrame` ladders.
|
|
4299
4694
|
*/
|
|
4300
|
-
|
|
4301
|
-
|
|
4695
|
+
/**
|
|
4696
|
+
* Mint a signed URL for each attachment, alongside the bytes it already
|
|
4697
|
+
* carries. Best-effort per attachment: a failed mint leaves that one
|
|
4698
|
+
* bytes-only rather than costing the notification. Without this a url-mode
|
|
4699
|
+
* target (WhatsApp, gotify) silently received NO media — the degrade engine
|
|
4700
|
+
* drops a bytes-only attachment for them (`attachment:noUrl`).
|
|
4701
|
+
*/
|
|
4702
|
+
/**
|
|
4703
|
+
* A failed footage render is best-effort — ship the still, skip the video.
|
|
4704
|
+
*
|
|
4705
|
+
* There is nothing to wait for: the clip ring only grows FORWARD, so a window
|
|
4706
|
+
* it does not already cover will never be covered by retrying. (An earlier
|
|
4707
|
+
* revision deferred delivery here, when clips were cut from finalized
|
|
4708
|
+
* recording segments; retrying under the pre-buffer would just age the
|
|
4709
|
+
* pre-roll out of the ring.)
|
|
4710
|
+
*/
|
|
4711
|
+
notePendingFootage(entry, err, what) {
|
|
4712
|
+
this.deps.logger.debug(`${what} attachment render failed`, {
|
|
4713
|
+
tags: { deviceId: entry.payload.subject.deviceId },
|
|
4714
|
+
meta: {
|
|
4715
|
+
error: String(err),
|
|
4716
|
+
action: "send-without-video"
|
|
4717
|
+
}
|
|
4718
|
+
});
|
|
4719
|
+
}
|
|
4720
|
+
async withArtifactUrls(attachments) {
|
|
4721
|
+
const publish = this.deps.publishArtifact;
|
|
4722
|
+
if (publish === void 0) return [...attachments];
|
|
4723
|
+
const out = [];
|
|
4724
|
+
for (const att of attachments) {
|
|
4725
|
+
const url = await publish(att.bytes, att.mime).catch(() => null);
|
|
4726
|
+
out.push(url === null ? { ...att } : {
|
|
4727
|
+
...att,
|
|
4728
|
+
url
|
|
4729
|
+
});
|
|
4730
|
+
}
|
|
4731
|
+
return out;
|
|
4732
|
+
}
|
|
4733
|
+
/** The full attachment list: the policy still (zone-cropped when the rule
|
|
4734
|
+
* froze zone ids) plus the footage cut from the broker's clip ring when
|
|
4735
|
+
* requested. Each part is best-effort — a failed crop falls back to the
|
|
4736
|
+
* uncropped still, a failed render just omits the video. */
|
|
4737
|
+
async resolveAttachments(entry) {
|
|
4738
|
+
const out = [];
|
|
4739
|
+
const zoneIdsWanted = entry.payload.mediaZoneIds;
|
|
4740
|
+
const still = await this.resolveAttachment(entry, zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0);
|
|
4741
|
+
if (still !== null) {
|
|
4742
|
+
const zoneIds = zoneIdsWanted;
|
|
4743
|
+
if (zoneIds !== void 0 && zoneIds.length > 0) {
|
|
4744
|
+
const cropped = await this.zoneCrop(entry.payload.subject.deviceId, zoneIds, still.bytes);
|
|
4745
|
+
out.push(cropped !== null ? {
|
|
4746
|
+
...still,
|
|
4747
|
+
bytes: cropped,
|
|
4748
|
+
name: "zone.jpg"
|
|
4749
|
+
} : still);
|
|
4750
|
+
} else out.push(still);
|
|
4751
|
+
}
|
|
4752
|
+
for (const want of FOOTAGE_FORMATS) {
|
|
4753
|
+
if (entry.payload[want.flag] !== true || !this.deps.renderFootage) continue;
|
|
4754
|
+
try {
|
|
4755
|
+
const rendered = await this.deps.renderFootage({
|
|
4756
|
+
deviceId: entry.payload.subject.deviceId,
|
|
4757
|
+
aroundMs: entry.payload.subject.timestamp,
|
|
4758
|
+
format: want.format,
|
|
4759
|
+
...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
|
|
4760
|
+
...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
|
|
4761
|
+
...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
|
|
4762
|
+
});
|
|
4763
|
+
if (rendered !== null && rendered.byteLength > 0) {
|
|
4764
|
+
const bytes = new Uint8Array(rendered.byteLength);
|
|
4765
|
+
bytes.set(rendered);
|
|
4766
|
+
out.push({
|
|
4767
|
+
mediaType: want.mediaType,
|
|
4768
|
+
bytes,
|
|
4769
|
+
mime: want.mime,
|
|
4770
|
+
name: want.name
|
|
4771
|
+
});
|
|
4772
|
+
}
|
|
4773
|
+
} catch (err) {
|
|
4774
|
+
this.notePendingFootage(entry, err, want.format === "gif" ? "gif" : "clip");
|
|
4775
|
+
}
|
|
4776
|
+
}
|
|
4777
|
+
return out;
|
|
4778
|
+
}
|
|
4779
|
+
/** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
|
|
4780
|
+
* pixel rect via sharp metadata). Null on any failure — caller falls back
|
|
4781
|
+
* to the uncropped still. */
|
|
4782
|
+
async zoneCrop(deviceId, zoneIds, jpeg) {
|
|
4783
|
+
try {
|
|
4784
|
+
const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
|
|
4785
|
+
if (points.length === 0) return null;
|
|
4786
|
+
const { default: sharp } = await import("sharp");
|
|
4787
|
+
const img = sharp(Buffer.from(jpeg));
|
|
4788
|
+
const meta = await img.metadata();
|
|
4789
|
+
const W = meta.width ?? 0;
|
|
4790
|
+
const H = meta.height ?? 0;
|
|
4791
|
+
if (W === 0 || H === 0) return null;
|
|
4792
|
+
let minX = 1;
|
|
4793
|
+
let minY = 1;
|
|
4794
|
+
let maxX = 0;
|
|
4795
|
+
let maxY = 0;
|
|
4796
|
+
for (const p of points) {
|
|
4797
|
+
if (p.x < minX) minX = p.x;
|
|
4798
|
+
if (p.y < minY) minY = p.y;
|
|
4799
|
+
if (p.x > maxX) maxX = p.x;
|
|
4800
|
+
if (p.y > maxY) maxY = p.y;
|
|
4801
|
+
}
|
|
4802
|
+
if (maxX <= minX || maxY <= minY) return null;
|
|
4803
|
+
const padX = (maxX - minX) * .1;
|
|
4804
|
+
const padY = (maxY - minY) * .1;
|
|
4805
|
+
const left = Math.max(0, Math.floor((minX - padX) * W));
|
|
4806
|
+
const top = Math.max(0, Math.floor((minY - padY) * H));
|
|
4807
|
+
const width = Math.min(W - left, Math.ceil((maxX - minX + 2 * padX) * W));
|
|
4808
|
+
const height = Math.min(H - top, Math.ceil((maxY - minY + 2 * padY) * H));
|
|
4809
|
+
if (width < 16 || height < 16) return null;
|
|
4810
|
+
const outBuf = await img.extract({
|
|
4811
|
+
left,
|
|
4812
|
+
top,
|
|
4813
|
+
width,
|
|
4814
|
+
height
|
|
4815
|
+
}).jpeg({ quality: 82 }).toBuffer();
|
|
4816
|
+
const bytes = new Uint8Array(outBuf.byteLength);
|
|
4817
|
+
bytes.set(outBuf);
|
|
4818
|
+
return bytes;
|
|
4819
|
+
} catch (err) {
|
|
4820
|
+
this.deps.logger.debug("zone crop failed — attaching uncropped still", { meta: {
|
|
4821
|
+
deviceId,
|
|
4822
|
+
error: String(err)
|
|
4823
|
+
} });
|
|
4824
|
+
return null;
|
|
4825
|
+
}
|
|
4826
|
+
}
|
|
4827
|
+
async resolveAttachment(entry, policyOverride) {
|
|
4828
|
+
const policy = policyOverride ?? entry.payload.media;
|
|
4302
4829
|
if (policy === "none") return null;
|
|
4830
|
+
const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
|
|
4303
4831
|
const subject = entry.payload.subject;
|
|
4304
4832
|
const signal = policy === "best-matching" ? matchSignal(entry.payload.matchedOn) : null;
|
|
4305
4833
|
const owners = [];
|
|
@@ -4311,11 +4839,11 @@ var NcDispatcher = class {
|
|
|
4311
4839
|
kind: "track",
|
|
4312
4840
|
id: subject.trackId
|
|
4313
4841
|
});
|
|
4314
|
-
if (policy === "keyFrame") owners.reverse();
|
|
4842
|
+
if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
|
|
4315
4843
|
for (const owner of owners) try {
|
|
4316
4844
|
const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
|
|
4317
4845
|
if (files.length === 0) continue;
|
|
4318
|
-
const preference = policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
4846
|
+
const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
|
|
4319
4847
|
for (const kind of preference) {
|
|
4320
4848
|
const file = files.find((f) => f.kind === kind);
|
|
4321
4849
|
if (file === void 0) continue;
|
|
@@ -4637,7 +5165,7 @@ var NC_OCCUPANCY_INDEXES = [{
|
|
|
4637
5165
|
}];
|
|
4638
5166
|
/** Query cap — a per-(device, zone, class) key set is small; this is a
|
|
4639
5167
|
* generous ceiling that still bounds a pathological read. */
|
|
4640
|
-
var LOAD_LIMIT = 1e5;
|
|
5168
|
+
var LOAD_LIMIT$1 = 1e5;
|
|
4641
5169
|
var OccupancyStore = class {
|
|
4642
5170
|
cache = /* @__PURE__ */ new Map();
|
|
4643
5171
|
store;
|
|
@@ -4664,7 +5192,7 @@ var OccupancyStore = class {
|
|
|
4664
5192
|
try {
|
|
4665
5193
|
const records = await this.store.query.query({
|
|
4666
5194
|
collection: NC_OCCUPANCY_COLLECTION,
|
|
4667
|
-
filter: { limit: LOAD_LIMIT }
|
|
5195
|
+
filter: { limit: LOAD_LIMIT$1 }
|
|
4668
5196
|
});
|
|
4669
5197
|
this.cache.clear();
|
|
4670
5198
|
let skipped = 0;
|
|
@@ -5423,25 +5951,273 @@ var NcRuleStore = class {
|
|
|
5423
5951
|
this.byId.delete(ruleId);
|
|
5424
5952
|
try {
|
|
5425
5953
|
await this.store.delete.mutate({
|
|
5426
|
-
collection: NC_RULES_COLLECTION,
|
|
5954
|
+
collection: NC_RULES_COLLECTION,
|
|
5955
|
+
key: ruleId
|
|
5956
|
+
});
|
|
5957
|
+
} catch (err) {
|
|
5958
|
+
this.logger.warn("notification rule delete failed", { meta: {
|
|
5959
|
+
ruleId,
|
|
5960
|
+
error: String(err)
|
|
5961
|
+
} });
|
|
5962
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
5963
|
+
}
|
|
5964
|
+
}
|
|
5965
|
+
async persist(rule) {
|
|
5966
|
+
await this.store.set.mutate({
|
|
5967
|
+
collection: NC_RULES_COLLECTION,
|
|
5968
|
+
key: rule.id,
|
|
5969
|
+
value: {
|
|
5970
|
+
name: rule.name,
|
|
5971
|
+
enabled: rule.enabled,
|
|
5972
|
+
delivery: rule.delivery,
|
|
5973
|
+
updatedAt: rule.updatedAt,
|
|
5974
|
+
rule
|
|
5975
|
+
}
|
|
5976
|
+
});
|
|
5977
|
+
}
|
|
5978
|
+
};
|
|
5979
|
+
//#endregion
|
|
5980
|
+
//#region src/notification-center/timelapse/timelapse-store.ts
|
|
5981
|
+
var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
|
|
5982
|
+
var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
5983
|
+
{
|
|
5984
|
+
name: "id",
|
|
5985
|
+
type: "TEXT",
|
|
5986
|
+
primaryKey: true,
|
|
5987
|
+
notNull: true
|
|
5988
|
+
},
|
|
5989
|
+
{
|
|
5990
|
+
name: "name",
|
|
5991
|
+
type: "TEXT",
|
|
5992
|
+
notNull: true
|
|
5993
|
+
},
|
|
5994
|
+
{
|
|
5995
|
+
name: "enabled",
|
|
5996
|
+
type: "BOOLEAN",
|
|
5997
|
+
notNull: true
|
|
5998
|
+
},
|
|
5999
|
+
{
|
|
6000
|
+
name: "updatedAt",
|
|
6001
|
+
type: "INTEGER",
|
|
6002
|
+
notNull: true
|
|
6003
|
+
},
|
|
6004
|
+
(
|
|
6005
|
+
/** The FULL rule object (Zod-validated on read) — scalars above are
|
|
6006
|
+
* indexed projections only. */
|
|
6007
|
+
{
|
|
6008
|
+
name: "rule",
|
|
6009
|
+
type: "JSON",
|
|
6010
|
+
notNull: true
|
|
6011
|
+
})
|
|
6012
|
+
];
|
|
6013
|
+
var NC_TIMELAPSE_RULES_INDEXES = [{
|
|
6014
|
+
name: "idx_nc_timelapse_rules_enabled",
|
|
6015
|
+
columns: ["enabled"]
|
|
6016
|
+
}];
|
|
6017
|
+
/** Query cap — the rule set is operator-authored and tiny; a generous ceiling. */
|
|
6018
|
+
var LOAD_LIMIT = 1e4;
|
|
6019
|
+
/**
|
|
6020
|
+
* Resolve the three-way `template` patch signal onto a merged rule, immutably:
|
|
6021
|
+
* `undefined` (key absent) leaves it as-is, `null` DROPS the key, an object
|
|
6022
|
+
* replaces it. Keeping `null` out of the persisted rule is what lets
|
|
6023
|
+
* `TimelapseRuleSchema` stay a plain `.optional()`.
|
|
6024
|
+
*/
|
|
6025
|
+
function applyTemplatePatch(merged, template) {
|
|
6026
|
+
if (template === void 0) return merged;
|
|
6027
|
+
if (template !== null) return {
|
|
6028
|
+
...merged,
|
|
6029
|
+
template
|
|
6030
|
+
};
|
|
6031
|
+
const { template: _cleared, ...withoutTemplate } = merged;
|
|
6032
|
+
return withoutTemplate;
|
|
6033
|
+
}
|
|
6034
|
+
var TimelapseStore = class {
|
|
6035
|
+
byId = /* @__PURE__ */ new Map();
|
|
6036
|
+
store;
|
|
6037
|
+
logger;
|
|
6038
|
+
now;
|
|
6039
|
+
newId;
|
|
6040
|
+
constructor(deps) {
|
|
6041
|
+
this.store = deps.store;
|
|
6042
|
+
this.logger = deps.logger;
|
|
6043
|
+
this.now = deps.now ?? (() => Date.now());
|
|
6044
|
+
this.newId = deps.newId ?? (() => randomUUID());
|
|
6045
|
+
}
|
|
6046
|
+
static async declare(store) {
|
|
6047
|
+
await store.declareCollection.mutate({
|
|
6048
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6049
|
+
columns: [...NC_TIMELAPSE_RULES_COLUMNS],
|
|
6050
|
+
indexes: [...NC_TIMELAPSE_RULES_INDEXES]
|
|
6051
|
+
});
|
|
6052
|
+
}
|
|
6053
|
+
/**
|
|
6054
|
+
* (Re)hydrate the FULL rule set from the store — called at boot and on the
|
|
6055
|
+
* periodic refresh tick. Replaces the cache wholesale; a row whose JSON no
|
|
6056
|
+
* longer validates is skipped with a warning (a degraded rule must never
|
|
6057
|
+
* crash the scheduler).
|
|
6058
|
+
*/
|
|
6059
|
+
async load() {
|
|
6060
|
+
try {
|
|
6061
|
+
const rows = await this.store.query.query({
|
|
6062
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
6063
|
+
filter: { limit: LOAD_LIMIT }
|
|
6064
|
+
});
|
|
6065
|
+
this.byId.clear();
|
|
6066
|
+
let skipped = 0;
|
|
6067
|
+
for (const row of rows) {
|
|
6068
|
+
const parsed = TimelapseRuleSchema.safeParse(row.data["rule"]);
|
|
6069
|
+
if (!parsed.success) {
|
|
6070
|
+
skipped += 1;
|
|
6071
|
+
continue;
|
|
6072
|
+
}
|
|
6073
|
+
this.byId.set(parsed.data.id, parsed.data);
|
|
6074
|
+
}
|
|
6075
|
+
this.logger.debug("timelapse rules loaded", { meta: {
|
|
6076
|
+
rules: this.byId.size,
|
|
6077
|
+
...skipped > 0 ? { skippedInvalid: skipped } : {}
|
|
6078
|
+
} });
|
|
6079
|
+
} catch (err) {
|
|
6080
|
+
this.logger.warn("timelapse rules load failed", { meta: { error: String(err) } });
|
|
6081
|
+
}
|
|
6082
|
+
}
|
|
6083
|
+
/** Every rule, newest-first (admin path). */
|
|
6084
|
+
list() {
|
|
6085
|
+
return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
6086
|
+
}
|
|
6087
|
+
/** The scheduler's read: only rules that should be ticked. */
|
|
6088
|
+
listEnabled() {
|
|
6089
|
+
return this.list().filter((r) => r.enabled);
|
|
6090
|
+
}
|
|
6091
|
+
get(ruleId) {
|
|
6092
|
+
return this.byId.get(ruleId) ?? null;
|
|
6093
|
+
}
|
|
6094
|
+
/**
|
|
6095
|
+
* Rules visible to `userId`: their OWN personal rules (`ownerUserId ===
|
|
6096
|
+
* userId`) plus every admin/global rule (`ownerUserId` absent). Never another
|
|
6097
|
+
* user's personal rows. Newest-first (inherits {@link list}).
|
|
6098
|
+
*
|
|
6099
|
+
* The caller identity is server-derived; an absent/undefined caller must be
|
|
6100
|
+
* resolved to a fail-closed value by the bridge action BEFORE calling here —
|
|
6101
|
+
* this store never treats a missing caller as admin/global.
|
|
6102
|
+
*/
|
|
6103
|
+
listForOwner(userId) {
|
|
6104
|
+
return this.list().filter((r) => r.ownerUserId === void 0 || r.ownerUserId === userId);
|
|
6105
|
+
}
|
|
6106
|
+
/**
|
|
6107
|
+
* Mutation gate for a NON-admin caller: true only for a PERSONAL rule this
|
|
6108
|
+
* user owns. A global rule (no `ownerUserId`) returns false — global rules
|
|
6109
|
+
* are admin-only, and the bridge action grants admins the mutation without
|
|
6110
|
+
* consulting this check. An unknown rule id is false (fail-closed).
|
|
6111
|
+
*/
|
|
6112
|
+
isOwnedBy(ruleId, userId) {
|
|
6113
|
+
const rule = this.byId.get(ruleId);
|
|
6114
|
+
return rule?.ownerUserId !== void 0 && rule.ownerUserId === userId;
|
|
6115
|
+
}
|
|
6116
|
+
/**
|
|
6117
|
+
* Create a rule. `createdBy` is the SERVER-injected caller userId;
|
|
6118
|
+
* `ownerUserId` is the server-derived owner (omit for an admin/global rule).
|
|
6119
|
+
* Neither is ever read from `input`.
|
|
6120
|
+
*
|
|
6121
|
+
* The input is re-parsed through {@link TimelapseRuleInputSchema} BEFORE the
|
|
6122
|
+
* spread — that schema carries no ownership/provenance keys, so it strips any
|
|
6123
|
+
* that rode in on the blob. Without it, an `ownerUserId` on `input` would
|
|
6124
|
+
* survive whenever the `ownerUserId` ARGUMENT is omitted (the admin/global
|
|
6125
|
+
* path): a `TimelapseRule` is structurally assignable to `TimelapseRuleInput`,
|
|
6126
|
+
* so a future "duplicate rule" action (`create(existingRule, caller)`) would
|
|
6127
|
+
* compile cleanly and silently clone the ORIGINAL owner.
|
|
6128
|
+
*/
|
|
6129
|
+
async create(input, createdBy, ownerUserId) {
|
|
6130
|
+
const now = this.now();
|
|
6131
|
+
const rule = TimelapseRuleSchema.parse({
|
|
6132
|
+
...TimelapseRuleInputSchema.parse(input),
|
|
6133
|
+
id: this.newId(),
|
|
6134
|
+
...ownerUserId !== void 0 ? { ownerUserId } : {},
|
|
6135
|
+
createdBy,
|
|
6136
|
+
createdAt: now,
|
|
6137
|
+
updatedAt: now
|
|
6138
|
+
});
|
|
6139
|
+
await this.persist(rule);
|
|
6140
|
+
this.byId.set(rule.id, rule);
|
|
6141
|
+
return rule;
|
|
6142
|
+
}
|
|
6143
|
+
/**
|
|
6144
|
+
* Apply a partial patch. Immutable: returns the NEW rule object. Identity,
|
|
6145
|
+
* ownership and generation state are re-pinned from the existing rule AFTER
|
|
6146
|
+
* the spread — the patch schema carries none of them, and this makes a
|
|
6147
|
+
* hand-built (unparsed) patch object equally unable to re-own a rule.
|
|
6148
|
+
*
|
|
6149
|
+
* `template` is the one clearable field: an absent key leaves it unchanged,
|
|
6150
|
+
* an explicit `null` CLEARS it (the persisted rule loses the key — `null`
|
|
6151
|
+
* never reaches {@link TimelapseRuleSchema}). See the patch schema's wire
|
|
6152
|
+
* note.
|
|
6153
|
+
*/
|
|
6154
|
+
async update(ruleId, patch) {
|
|
6155
|
+
const existing = this.byId.get(ruleId);
|
|
6156
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
6157
|
+
const { template, ...rest } = patch;
|
|
6158
|
+
const merged = {
|
|
6159
|
+
...existing,
|
|
6160
|
+
...rest,
|
|
6161
|
+
id: existing.id,
|
|
6162
|
+
ownerUserId: existing.ownerUserId,
|
|
6163
|
+
lastGeneratedAt: existing.lastGeneratedAt,
|
|
6164
|
+
createdBy: existing.createdBy,
|
|
6165
|
+
createdAt: existing.createdAt,
|
|
6166
|
+
updatedAt: this.now()
|
|
6167
|
+
};
|
|
6168
|
+
return this.write(applyTemplatePatch(merged, template));
|
|
6169
|
+
}
|
|
6170
|
+
async setEnabled(ruleId, enabled) {
|
|
6171
|
+
return this.update(ruleId, { enabled });
|
|
6172
|
+
}
|
|
6173
|
+
/**
|
|
6174
|
+
* Record a successful generation. `at` is the generation epoch-ms — the
|
|
6175
|
+
* durable state behind the 1-hour re-generation guard. Does NOT bump
|
|
6176
|
+
* `updatedAt` (generation is not an edit of the rule definition).
|
|
6177
|
+
*/
|
|
6178
|
+
async markGenerated(ruleId, at) {
|
|
6179
|
+
const existing = this.byId.get(ruleId);
|
|
6180
|
+
if (!existing) throw new Error(`timelapse rule not found: ${ruleId}`);
|
|
6181
|
+
return this.write({
|
|
6182
|
+
...existing,
|
|
6183
|
+
lastGeneratedAt: at
|
|
6184
|
+
});
|
|
6185
|
+
}
|
|
6186
|
+
/** Idempotent delete — unknown ids are a no-op. */
|
|
6187
|
+
async delete(ruleId) {
|
|
6188
|
+
this.byId.delete(ruleId);
|
|
6189
|
+
try {
|
|
6190
|
+
await this.store.delete.mutate({
|
|
6191
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
5427
6192
|
key: ruleId
|
|
5428
6193
|
});
|
|
5429
6194
|
} catch (err) {
|
|
5430
|
-
this.logger.warn("
|
|
6195
|
+
this.logger.warn("timelapse rule delete failed", { meta: {
|
|
5431
6196
|
ruleId,
|
|
5432
6197
|
error: String(err)
|
|
5433
6198
|
} });
|
|
5434
6199
|
throw err instanceof Error ? err : new Error(String(err));
|
|
5435
6200
|
}
|
|
5436
6201
|
}
|
|
6202
|
+
/**
|
|
6203
|
+
* Re-validate a merged candidate, persist it, then cache it. Re-validating
|
|
6204
|
+
* means a patch can never persist a rule that would be skipped at the next
|
|
6205
|
+
* `load()`; provenance/ownership survive because they are spread from the
|
|
6206
|
+
* existing rule and never present on a patch.
|
|
6207
|
+
*/
|
|
6208
|
+
async write(candidate) {
|
|
6209
|
+
const rule = TimelapseRuleSchema.parse(candidate);
|
|
6210
|
+
await this.persist(rule);
|
|
6211
|
+
this.byId.set(rule.id, rule);
|
|
6212
|
+
return rule;
|
|
6213
|
+
}
|
|
5437
6214
|
async persist(rule) {
|
|
5438
6215
|
await this.store.set.mutate({
|
|
5439
|
-
collection:
|
|
6216
|
+
collection: NC_TIMELAPSE_RULES_COLLECTION,
|
|
5440
6217
|
key: rule.id,
|
|
5441
6218
|
value: {
|
|
5442
6219
|
name: rule.name,
|
|
5443
6220
|
enabled: rule.enabled,
|
|
5444
|
-
delivery: rule.delivery,
|
|
5445
6221
|
updatedAt: rule.updatedAt,
|
|
5446
6222
|
rule
|
|
5447
6223
|
}
|
|
@@ -5543,7 +6319,7 @@ function outboxEntryToHistory(entry) {
|
|
|
5543
6319
|
}
|
|
5544
6320
|
};
|
|
5545
6321
|
}
|
|
5546
|
-
var NotificationCenter = class {
|
|
6322
|
+
var NotificationCenter = class NotificationCenter {
|
|
5547
6323
|
logger;
|
|
5548
6324
|
rules;
|
|
5549
6325
|
outbox;
|
|
@@ -5605,11 +6381,19 @@ var NotificationCenter = class {
|
|
|
5605
6381
|
get ruleStore() {
|
|
5606
6382
|
return this.rules;
|
|
5607
6383
|
}
|
|
5608
|
-
/**
|
|
6384
|
+
/**
|
|
6385
|
+
* Declare every Notification Center collection (idempotent, boot-time).
|
|
6386
|
+
*
|
|
6387
|
+
* EVERY store the module can touch belongs here, including ones whose
|
|
6388
|
+
* feature is not wired yet: an UNDECLARED collection answers 412 on first
|
|
6389
|
+
* use and takes the whole runner down with it. The timelapse store shipped
|
|
6390
|
+
* without this line and was one enable away from doing exactly that.
|
|
6391
|
+
*/
|
|
5609
6392
|
static async declare(store) {
|
|
5610
6393
|
await NcRuleStore.declare(store);
|
|
5611
6394
|
await NcOutbox.declare(store);
|
|
5612
6395
|
await OccupancyStore.declare(store);
|
|
6396
|
+
await TimelapseStore.declare(store);
|
|
5613
6397
|
}
|
|
5614
6398
|
/**
|
|
5615
6399
|
* Load rules (every node — the cap provider serves CRUD from any node).
|
|
@@ -5794,6 +6578,7 @@ var NotificationCenter = class {
|
|
|
5794
6578
|
listRules: async () => ({ rules: [...this.rules.list()] }),
|
|
5795
6579
|
getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
|
|
5796
6580
|
createRule: async ({ rule, caller }) => {
|
|
6581
|
+
NotificationCenter.assertHasAddressee(rule.targets, rule.targetUsers);
|
|
5797
6582
|
await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
|
|
5798
6583
|
const created = await this.rules.create(rule, caller.userId);
|
|
5799
6584
|
this.logger.info("notification rule created", { meta: {
|
|
@@ -5805,6 +6590,10 @@ var NotificationCenter = class {
|
|
|
5805
6590
|
},
|
|
5806
6591
|
updateRule: async ({ ruleId, patch, caller }) => {
|
|
5807
6592
|
if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
|
|
6593
|
+
if (patch.targets !== void 0 || patch.targetUsers !== void 0) {
|
|
6594
|
+
const existing = this.rules.get(ruleId);
|
|
6595
|
+
NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
|
|
6596
|
+
}
|
|
5808
6597
|
const { disabledTargetIds: _optOut, ...safePatch } = patch;
|
|
5809
6598
|
const updated = await this.rules.update(ruleId, safePatch);
|
|
5810
6599
|
this.logger.info("notification rule updated", { meta: {
|
|
@@ -5822,7 +6611,10 @@ var NotificationCenter = class {
|
|
|
5822
6611
|
return { success: true };
|
|
5823
6612
|
},
|
|
5824
6613
|
testRule: async ({ rule, lookbackMinutes }) => ({ results: [...await this.dryRun(rule, lookbackMinutes)] }),
|
|
5825
|
-
getConditionCatalog: async () => ({
|
|
6614
|
+
getConditionCatalog: async () => ({
|
|
6615
|
+
catalog: [...NC_CONDITION_CATALOG],
|
|
6616
|
+
taxonomy: NC_TAXONOMY
|
|
6617
|
+
}),
|
|
5826
6618
|
getHistory: async ({ filter }) => {
|
|
5827
6619
|
const entries = await this.outbox.queryHistory({
|
|
5828
6620
|
...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
|
|
@@ -5863,26 +6655,76 @@ var NotificationCenter = class {
|
|
|
5863
6655
|
async evaluateAndEnqueue(subject, kind) {
|
|
5864
6656
|
const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
|
|
5865
6657
|
const candidates = this.rules.listEnabled(delivery);
|
|
5866
|
-
if (candidates.length === 0)
|
|
6658
|
+
if (candidates.length === 0) {
|
|
6659
|
+
this.logger.debug("no enabled rule for this trigger", {
|
|
6660
|
+
tags: { deviceId: subject.deviceId },
|
|
6661
|
+
meta: {
|
|
6662
|
+
delivery,
|
|
6663
|
+
kind,
|
|
6664
|
+
rulesLoaded: this.rules.list().length
|
|
6665
|
+
}
|
|
6666
|
+
});
|
|
6667
|
+
return;
|
|
6668
|
+
}
|
|
5867
6669
|
const now = this.now();
|
|
5868
6670
|
for (const rule of candidates) {
|
|
5869
6671
|
const evaluation = evaluateRule(rule, subject);
|
|
5870
|
-
if (!evaluation.matched)
|
|
6672
|
+
if (!evaluation.matched) {
|
|
6673
|
+
this.logger.debug("rule did not match", {
|
|
6674
|
+
tags: { deviceId: subject.deviceId },
|
|
6675
|
+
meta: {
|
|
6676
|
+
ruleId: rule.id,
|
|
6677
|
+
rule: rule.name,
|
|
6678
|
+
kind,
|
|
6679
|
+
failed: evaluation.failedCondition,
|
|
6680
|
+
classes: subject.classNames
|
|
6681
|
+
}
|
|
6682
|
+
});
|
|
6683
|
+
continue;
|
|
6684
|
+
}
|
|
5871
6685
|
const key = cooldownKey(rule, subject);
|
|
5872
|
-
if (isCoolingDown(rule, this.lastFiredAt.get(key), now))
|
|
5873
|
-
|
|
6686
|
+
if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) {
|
|
6687
|
+
this.logger.debug("rule matched but is cooling down", {
|
|
6688
|
+
tags: { deviceId: subject.deviceId },
|
|
6689
|
+
meta: {
|
|
6690
|
+
ruleId: rule.id,
|
|
6691
|
+
rule: rule.name,
|
|
6692
|
+
key
|
|
6693
|
+
}
|
|
6694
|
+
});
|
|
6695
|
+
continue;
|
|
6696
|
+
}
|
|
6697
|
+
this.logger.info("rule matched — enqueueing", {
|
|
6698
|
+
tags: { deviceId: subject.deviceId },
|
|
6699
|
+
meta: {
|
|
6700
|
+
ruleId: rule.id,
|
|
6701
|
+
rule: rule.name,
|
|
6702
|
+
kind,
|
|
6703
|
+
targets: rule.targets.length
|
|
6704
|
+
}
|
|
6705
|
+
});
|
|
6706
|
+
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6707
|
+
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
|
|
5874
6708
|
}
|
|
5875
6709
|
}
|
|
5876
|
-
buildEntries(rule, subject, kind, matchedOn) {
|
|
6710
|
+
buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
|
|
5877
6711
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
5878
6712
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
5879
|
-
|
|
6713
|
+
const direct = new Set(rule.targets.map((t) => t.targetId));
|
|
6714
|
+
return [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))].filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
|
|
5880
6715
|
const payload = {
|
|
5881
6716
|
ruleName: rule.name,
|
|
5882
6717
|
delivery: rule.delivery,
|
|
5883
6718
|
priority: rule.priority,
|
|
5884
6719
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
5885
6720
|
media: rule.media.attach,
|
|
6721
|
+
...rule.media.zoneCrop === true && rule.conditions.zones !== void 0 ? { mediaZoneIds: [...rule.conditions.zones.ids] } : {},
|
|
6722
|
+
...rule.media.frame !== void 0 ? { mediaFrame: rule.media.frame } : {},
|
|
6723
|
+
...rule.media.profile !== void 0 ? { mediaProfile: rule.media.profile } : {},
|
|
6724
|
+
...rule.media.gif === true ? { mediaGif: true } : {},
|
|
6725
|
+
...rule.media.clip === true ? { mediaClip: true } : {},
|
|
6726
|
+
...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
|
|
6727
|
+
...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
|
|
5886
6728
|
...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
|
|
5887
6729
|
...target.params !== void 0 ? { params: target.params } : {},
|
|
5888
6730
|
subject: {
|
|
@@ -5908,6 +6750,63 @@ var NotificationCenter = class {
|
|
|
5908
6750
|
};
|
|
5909
6751
|
});
|
|
5910
6752
|
}
|
|
6753
|
+
/** rule→user fan-out caches (60 s users/targets; device routes immutable). */
|
|
6754
|
+
userGrantsCache = null;
|
|
6755
|
+
userGrantsAt = 0;
|
|
6756
|
+
ownedTargetsCache = null;
|
|
6757
|
+
ownedTargetsAt = 0;
|
|
6758
|
+
deviceRouteCache = /* @__PURE__ */ new Map();
|
|
6759
|
+
userFanoutUnavailableLogged = false;
|
|
6760
|
+
/**
|
|
6761
|
+
* The personal targets a rule's `targetUsers` resolve to for THIS device:
|
|
6762
|
+
* each addressed user contributes the enabled targets they own — but only
|
|
6763
|
+
* when their `allowedDevices` grant covers the firing camera (admin users
|
|
6764
|
+
* always pass). A user must never be notified about a device they cannot
|
|
6765
|
+
* open.
|
|
6766
|
+
*/
|
|
6767
|
+
async resolveUserTargets(rule, deviceId) {
|
|
6768
|
+
const users = rule.targetUsers;
|
|
6769
|
+
if (users === void 0 || users.length === 0) return [];
|
|
6770
|
+
const { listUsers, getDeviceRoute } = this.deps;
|
|
6771
|
+
if (listUsers === void 0 || getDeviceRoute === void 0) {
|
|
6772
|
+
if (!this.userFanoutUnavailableLogged) {
|
|
6773
|
+
this.userFanoutUnavailableLogged = true;
|
|
6774
|
+
this.logger.warn("rule addresses users but the user fan-out deps are not wired", { meta: { ruleId: rule.id } });
|
|
6775
|
+
}
|
|
6776
|
+
return [];
|
|
6777
|
+
}
|
|
6778
|
+
const now = this.now();
|
|
6779
|
+
if (this.userGrantsCache === null || now - this.userGrantsAt > 6e4) {
|
|
6780
|
+
this.userGrantsCache = await listUsers();
|
|
6781
|
+
this.userGrantsAt = now;
|
|
6782
|
+
}
|
|
6783
|
+
if (this.ownedTargetsCache === null || now - this.ownedTargetsAt > 6e4) {
|
|
6784
|
+
const targets = await this.deps.dispatcher.listTargets();
|
|
6785
|
+
const owned = /* @__PURE__ */ new Map();
|
|
6786
|
+
for (const t of targets) {
|
|
6787
|
+
if (t.ownerUserId === void 0 || !t.enabled) continue;
|
|
6788
|
+
const list = owned.get(t.ownerUserId) ?? [];
|
|
6789
|
+
list.push(t.id);
|
|
6790
|
+
owned.set(t.ownerUserId, list);
|
|
6791
|
+
}
|
|
6792
|
+
this.ownedTargetsCache = owned;
|
|
6793
|
+
this.ownedTargetsAt = now;
|
|
6794
|
+
}
|
|
6795
|
+
if (!this.deviceRouteCache.has(deviceId)) this.deviceRouteCache.set(deviceId, await getDeviceRoute(deviceId));
|
|
6796
|
+
const route = this.deviceRouteCache.get(deviceId) ?? null;
|
|
6797
|
+
const out = [];
|
|
6798
|
+
for (const userId of users) {
|
|
6799
|
+
const user = this.userGrantsCache.find((u) => u.id === userId);
|
|
6800
|
+
if (user === void 0) continue;
|
|
6801
|
+
if (!user.isAdmin) {
|
|
6802
|
+
if (route === null) continue;
|
|
6803
|
+
const grant = user.allowedDevices[route.addonId];
|
|
6804
|
+
if (!(grant === "*" || Array.isArray(grant) && grant.includes(route.stableId))) continue;
|
|
6805
|
+
}
|
|
6806
|
+
for (const targetId of this.ownedTargetsCache.get(userId) ?? []) out.push({ targetId });
|
|
6807
|
+
}
|
|
6808
|
+
return out;
|
|
6809
|
+
}
|
|
5911
6810
|
/** Rebuild the cooldown map from persisted outbox rows (restart-proof). */
|
|
5912
6811
|
async seedCooldowns() {
|
|
5913
6812
|
const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
|
|
@@ -6021,10 +6920,21 @@ var NotificationCenter = class {
|
|
|
6021
6920
|
this.drainTicks += 1;
|
|
6022
6921
|
if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) await this.outbox.setWatermark(this.now());
|
|
6023
6922
|
}
|
|
6923
|
+
/** The "at least one addressee" invariant lives here, not in Zod: the
|
|
6924
|
+
* schema allows empty `targets` (a users-only rule) and a cross-field
|
|
6925
|
+
* refine would break `NcRulePatchSchema.partial()`. */
|
|
6926
|
+
static assertHasAddressee(targets, targetUsers) {
|
|
6927
|
+
if (targets.length === 0 && (targetUsers?.length ?? 0) === 0) throw new Error("a rule needs at least one delivery target or user");
|
|
6928
|
+
}
|
|
6024
6929
|
/** Rule-save referential check: every targetId must resolve in the
|
|
6025
6930
|
* live notification-output catalog (spec §2.3 save-time validation). */
|
|
6026
6931
|
async validateTargetRefs(targetIds) {
|
|
6932
|
+
if (targetIds.length === 0) return;
|
|
6027
6933
|
const targets = await this.deps.dispatcher.listTargets();
|
|
6934
|
+
if (targets.length === 0) {
|
|
6935
|
+
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] } });
|
|
6936
|
+
return;
|
|
6937
|
+
}
|
|
6028
6938
|
const known = new Set(targets.map((t) => t.id));
|
|
6029
6939
|
for (const id of targetIds) if (!known.has(id)) throw new Error(`unknown notification target: ${id}`);
|
|
6030
6940
|
}
|
|
@@ -6139,15 +7049,28 @@ function makeNcActionHandlers(deps) {
|
|
|
6139
7049
|
if (rule === null) throw new Error(`forbidden: rule not found: ${ruleId}`);
|
|
6140
7050
|
return rule;
|
|
6141
7051
|
};
|
|
6142
|
-
|
|
7052
|
+
/**
|
|
7053
|
+
* A rule the caller may EDIT. Ownership binds non-admins; an ADMIN
|
|
7054
|
+
* administers every rule — global ones (`ownerUserId: undefined`, what the
|
|
7055
|
+
* admin UI creates) and, for support, a user's personal rule.
|
|
7056
|
+
*
|
|
7057
|
+
* Without the admin bypass the comparison `rule.ownerUserId === userId` is
|
|
7058
|
+
* false for EVERY caller on a global rule, so the viewer refused to let an
|
|
7059
|
+
* admin edit an admin rule (operator report, 2026-07-30). Same bypass shape
|
|
7060
|
+
* as `assertTargetsOwned`.
|
|
7061
|
+
*/
|
|
7062
|
+
const assertRuleEditable = (ruleId, caller) => {
|
|
6143
7063
|
const rule = assertOwnsRule(ruleId);
|
|
6144
|
-
if (
|
|
7064
|
+
if (caller.isAdmin) return rule;
|
|
7065
|
+
if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
|
|
6145
7066
|
return rule;
|
|
6146
7067
|
};
|
|
6147
|
-
/** A rule the caller may SEE — his own personal rule
|
|
6148
|
-
|
|
7068
|
+
/** A rule the caller may SEE — his own personal rule, a global/admin rule, or
|
|
7069
|
+
* (for an admin) any rule at all. */
|
|
7070
|
+
const assertRuleVisible = (ruleId, caller) => {
|
|
6149
7071
|
const rule = assertOwnsRule(ruleId);
|
|
6150
|
-
if (
|
|
7072
|
+
if (caller.isAdmin) return rule;
|
|
7073
|
+
if (rule.ownerUserId !== void 0 && rule.ownerUserId !== caller.userId) throw new Error(`forbidden: rule not owned: ${ruleId}`);
|
|
6151
7074
|
return rule;
|
|
6152
7075
|
};
|
|
6153
7076
|
const assertTargetsOwned = async (targetIds, caller) => {
|
|
@@ -6158,9 +7081,9 @@ function makeNcActionHandlers(deps) {
|
|
|
6158
7081
|
return {
|
|
6159
7082
|
"nc.listRules": async (_input, caller) => {
|
|
6160
7083
|
const c = requireCaller(caller);
|
|
6161
|
-
return { rules: deps.ruleStore.listForOwner(c.userId).map((r) => ({
|
|
7084
|
+
return { rules: (c.isAdmin ? deps.ruleStore.list() : deps.ruleStore.listForOwner(c.userId)).map((r) => ({
|
|
6162
7085
|
...r,
|
|
6163
|
-
readOnly: r.ownerUserId !== c.userId
|
|
7086
|
+
readOnly: !c.isAdmin && r.ownerUserId !== c.userId
|
|
6164
7087
|
})) };
|
|
6165
7088
|
},
|
|
6166
7089
|
"nc.getConditionCatalog": async () => ({
|
|
@@ -6183,7 +7106,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6183
7106
|
},
|
|
6184
7107
|
"nc.updateRule": async (input, caller) => {
|
|
6185
7108
|
const c = requireCaller(caller);
|
|
6186
|
-
|
|
7109
|
+
assertRuleEditable(input.ruleId, c);
|
|
6187
7110
|
const patch = NcRulePatchSchema.parse(input.patch);
|
|
6188
7111
|
if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
|
|
6189
7112
|
const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
|
|
@@ -6196,7 +7119,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6196
7119
|
},
|
|
6197
7120
|
"nc.deleteRule": async (input, caller) => {
|
|
6198
7121
|
const c = requireCaller(caller);
|
|
6199
|
-
|
|
7122
|
+
assertRuleEditable(input.ruleId, c);
|
|
6200
7123
|
await deps.ruleStore.delete(input.ruleId);
|
|
6201
7124
|
deps.logger.info("nc rule deleted", { meta: {
|
|
6202
7125
|
ruleId: input.ruleId,
|
|
@@ -6206,7 +7129,7 @@ function makeNcActionHandlers(deps) {
|
|
|
6206
7129
|
},
|
|
6207
7130
|
"nc.setRuleTargetEnabled": async (input, caller) => {
|
|
6208
7131
|
const c = requireCaller(caller);
|
|
6209
|
-
assertRuleVisible(input.ruleId, c
|
|
7132
|
+
assertRuleVisible(input.ruleId, c);
|
|
6210
7133
|
await assertTargetsOwned([input.targetId], c);
|
|
6211
7134
|
await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
|
|
6212
7135
|
return { success: true };
|
|
@@ -8858,7 +9781,15 @@ var MEDIA_COLUMNS = [
|
|
|
8858
9781
|
name: "sizeBytes",
|
|
8859
9782
|
type: "INTEGER",
|
|
8860
9783
|
notNull: true
|
|
8861
|
-
}
|
|
9784
|
+
},
|
|
9785
|
+
(
|
|
9786
|
+
/** Storage location holding the blob. NULL = the default `eventMedia`
|
|
9787
|
+
* location (every pre-Phase-3 row, and every write until multi-location
|
|
9788
|
+
* events exist) — the relocate mover stamps real ids as it moves blobs. */
|
|
9789
|
+
{
|
|
9790
|
+
name: "locationId",
|
|
9791
|
+
type: "TEXT"
|
|
9792
|
+
})
|
|
8862
9793
|
];
|
|
8863
9794
|
var MEDIA_INDEXES = [{
|
|
8864
9795
|
name: "idx_media_owner",
|
|
@@ -8867,11 +9798,18 @@ var MEDIA_INDEXES = [{
|
|
|
8867
9798
|
name: "idx_media_device_ts",
|
|
8868
9799
|
columns: ["deviceId", "timestamp"]
|
|
8869
9800
|
}];
|
|
9801
|
+
/** The storage location of a media row/record: its stamped `locationId`, or
|
|
9802
|
+
* the default `eventMedia` location for NULL (pre-multi-location) rows. */
|
|
9803
|
+
var DEFAULT_MEDIA_LOCATION = "eventMedia";
|
|
9804
|
+
function mediaRowLocation(data) {
|
|
9805
|
+
const id = data?.["locationId"];
|
|
9806
|
+
return typeof id === "string" && id.length > 0 ? id : DEFAULT_MEDIA_LOCATION;
|
|
9807
|
+
}
|
|
8870
9808
|
function buildKey(params) {
|
|
8871
9809
|
return isSingleInstanceKind(params.kind) ? `${params.ownerKind}:${params.ownerId}:${params.kind}` : `${params.ownerKind}:${params.ownerId}:${params.kind}:${params.timestamp}`;
|
|
8872
9810
|
}
|
|
8873
9811
|
function buildPath(params) {
|
|
8874
|
-
const base =
|
|
9812
|
+
const base = `${params.deviceId}/events/${params.ownerKind}/${params.ownerId}`;
|
|
8875
9813
|
return isSingleInstanceKind(params.kind) ? `${base}/${params.kind}.jpg` : `${base}/${params.kind}-${params.timestamp}.jpg`;
|
|
8876
9814
|
}
|
|
8877
9815
|
var MediaStore = class {
|
|
@@ -8902,7 +9840,8 @@ var MediaStore = class {
|
|
|
8902
9840
|
kind: params.kind,
|
|
8903
9841
|
timestamp: params.timestamp,
|
|
8904
9842
|
path,
|
|
8905
|
-
sizeBytes: params.data.length
|
|
9843
|
+
sizeBytes: params.data.length,
|
|
9844
|
+
locationId: null
|
|
8906
9845
|
};
|
|
8907
9846
|
try {
|
|
8908
9847
|
await this.storage.write({
|
|
@@ -8959,10 +9898,11 @@ var MediaStore = class {
|
|
|
8959
9898
|
const newKey = await this.put(params);
|
|
8960
9899
|
for (const row of existing) {
|
|
8961
9900
|
if (row.id === newKey) continue;
|
|
8962
|
-
const
|
|
9901
|
+
const rowData = row.data;
|
|
9902
|
+
const path = String(rowData["path"] ?? "");
|
|
8963
9903
|
if (path) try {
|
|
8964
9904
|
await this.storage.delete({
|
|
8965
|
-
location:
|
|
9905
|
+
location: mediaRowLocation(rowData),
|
|
8966
9906
|
relativePath: path
|
|
8967
9907
|
});
|
|
8968
9908
|
} catch {}
|
|
@@ -9030,7 +9970,7 @@ var MediaStore = class {
|
|
|
9030
9970
|
key,
|
|
9031
9971
|
kind,
|
|
9032
9972
|
base64: (await this.storage.read({
|
|
9033
|
-
location:
|
|
9973
|
+
location: mediaRowLocation(data),
|
|
9034
9974
|
relativePath: path
|
|
9035
9975
|
})).toString("base64"),
|
|
9036
9976
|
sizeBytes,
|
|
@@ -9050,10 +9990,11 @@ var MediaStore = class {
|
|
|
9050
9990
|
key
|
|
9051
9991
|
});
|
|
9052
9992
|
if (!row) return;
|
|
9053
|
-
const
|
|
9993
|
+
const data = row;
|
|
9994
|
+
const path = String(data["path"] ?? "");
|
|
9054
9995
|
if (path) try {
|
|
9055
9996
|
await this.storage.delete({
|
|
9056
|
-
location:
|
|
9997
|
+
location: mediaRowLocation(data),
|
|
9057
9998
|
relativePath: path
|
|
9058
9999
|
});
|
|
9059
10000
|
} catch {}
|
|
@@ -9092,7 +10033,7 @@ var MediaStore = class {
|
|
|
9092
10033
|
const kind = String(data["kind"]);
|
|
9093
10034
|
try {
|
|
9094
10035
|
const buf = await this.storage.read({
|
|
9095
|
-
location:
|
|
10036
|
+
location: mediaRowLocation(data),
|
|
9096
10037
|
relativePath: path
|
|
9097
10038
|
});
|
|
9098
10039
|
files.push({
|
|
@@ -9127,10 +10068,11 @@ var MediaStore = class {
|
|
|
9127
10068
|
} }
|
|
9128
10069
|
});
|
|
9129
10070
|
for (const row of rows) {
|
|
9130
|
-
const
|
|
10071
|
+
const rowData = row.data;
|
|
10072
|
+
const path = String(rowData["path"] ?? "");
|
|
9131
10073
|
try {
|
|
9132
10074
|
if (path) await this.storage.delete({
|
|
9133
|
-
location:
|
|
10075
|
+
location: mediaRowLocation(rowData),
|
|
9134
10076
|
relativePath: path
|
|
9135
10077
|
});
|
|
9136
10078
|
} catch {}
|
|
@@ -9226,14 +10168,14 @@ var MediaStore = class {
|
|
|
9226
10168
|
kind,
|
|
9227
10169
|
timestamp,
|
|
9228
10170
|
data: await this.storage.read({
|
|
9229
|
-
location:
|
|
10171
|
+
location: mediaRowLocation(meta),
|
|
9230
10172
|
relativePath: oldPath
|
|
9231
10173
|
})
|
|
9232
10174
|
};
|
|
9233
10175
|
const newKey = await this.put(newParams);
|
|
9234
10176
|
try {
|
|
9235
10177
|
await this.storage.delete({
|
|
9236
|
-
location:
|
|
10178
|
+
location: mediaRowLocation(meta),
|
|
9237
10179
|
relativePath: oldPath
|
|
9238
10180
|
});
|
|
9239
10181
|
} catch (err) {
|
|
@@ -9303,10 +10245,11 @@ var MediaStore = class {
|
|
|
9303
10245
|
if (rows.length === 0) break;
|
|
9304
10246
|
let deletedInPage = 0;
|
|
9305
10247
|
for (const row of rows) {
|
|
9306
|
-
const
|
|
10248
|
+
const rowData = row.data;
|
|
10249
|
+
const path = String(rowData["path"] ?? "");
|
|
9307
10250
|
try {
|
|
9308
10251
|
if (path) await this.storage.delete({
|
|
9309
|
-
location:
|
|
10252
|
+
location: mediaRowLocation(rowData),
|
|
9310
10253
|
relativePath: path
|
|
9311
10254
|
});
|
|
9312
10255
|
} catch {}
|
|
@@ -9862,6 +10805,7 @@ var EventStore = class {
|
|
|
9862
10805
|
const rows = await this.store.query.query({
|
|
9863
10806
|
collection,
|
|
9864
10807
|
filter: {
|
|
10808
|
+
...params.deviceId !== void 0 ? { where: { deviceId: params.deviceId } } : {},
|
|
9865
10809
|
whereBetween: { timestamp: [0, cutoffMs] },
|
|
9866
10810
|
limit: 500
|
|
9867
10811
|
}
|
|
@@ -10262,6 +11206,183 @@ function stripNulls(data) {
|
|
|
10262
11206
|
return out;
|
|
10263
11207
|
}
|
|
10264
11208
|
//#endregion
|
|
11209
|
+
//#region src/pipeline-analytics/location-aware-media-storage.ts
|
|
11210
|
+
/**
|
|
11211
|
+
* Location-aware blob storage for event media (entity-routing spec, Phase 3).
|
|
11212
|
+
*
|
|
11213
|
+
* Media rows may carry a `locationId` (stamped by the relocate mover when a
|
|
11214
|
+
* blob is moved off the default location). The write-rate bypass provider
|
|
11215
|
+
* only knows the default media root — this wrapper routes any OTHER location
|
|
11216
|
+
* id through a resolver (the storage cap's `resolve`, cached forever: a
|
|
11217
|
+
* location's root only changes via operator reconfig, which restarts us) and
|
|
11218
|
+
* does direct fs I/O against that root, keeping the bypass's
|
|
11219
|
+
* no-RPC-per-blob property for every location.
|
|
11220
|
+
*/
|
|
11221
|
+
function createLocationAwareMediaStorage(deps) {
|
|
11222
|
+
const roots = /* @__PURE__ */ new Map();
|
|
11223
|
+
const rootOf = async (locationId) => {
|
|
11224
|
+
const cached = roots.get(locationId);
|
|
11225
|
+
if (cached !== void 0) return cached;
|
|
11226
|
+
const root = await deps.resolveRoot(locationId);
|
|
11227
|
+
roots.set(locationId, root);
|
|
11228
|
+
return root;
|
|
11229
|
+
};
|
|
11230
|
+
const absOf = async (locationId, relativePath) => path.join(await rootOf(locationId), relativePath);
|
|
11231
|
+
return {
|
|
11232
|
+
write: async (input) => {
|
|
11233
|
+
if (input.location === deps.defaultLocation) return deps.base.write(input);
|
|
11234
|
+
const abs = await absOf(input.location, input.relativePath);
|
|
11235
|
+
await promises.mkdir(path.dirname(abs), { recursive: true });
|
|
11236
|
+
await promises.writeFile(abs, input.data);
|
|
11237
|
+
},
|
|
11238
|
+
read: async (input) => {
|
|
11239
|
+
if (input.location === deps.defaultLocation) return deps.base.read(input);
|
|
11240
|
+
return promises.readFile(await absOf(input.location, input.relativePath));
|
|
11241
|
+
},
|
|
11242
|
+
delete: async (input) => {
|
|
11243
|
+
if (input.location === deps.defaultLocation) return deps.base.delete(input);
|
|
11244
|
+
await promises.rm(await absOf(input.location, input.relativePath), { force: true });
|
|
11245
|
+
}
|
|
11246
|
+
};
|
|
11247
|
+
}
|
|
11248
|
+
//#endregion
|
|
11249
|
+
//#region src/pipeline-analytics/media-relocate-engine.ts
|
|
11250
|
+
var PAGE_SIZE = 200;
|
|
11251
|
+
var DEFAULT_THROTTLE_MBPS = 40;
|
|
11252
|
+
function snapshot(j) {
|
|
11253
|
+
return {
|
|
11254
|
+
jobId: j.jobId,
|
|
11255
|
+
state: j.state,
|
|
11256
|
+
fromLocationId: j.fromLocationId,
|
|
11257
|
+
toLocationId: j.toLocationId,
|
|
11258
|
+
deviceId: j.deviceId,
|
|
11259
|
+
entities: ["media"],
|
|
11260
|
+
filesMoved: j.filesMoved,
|
|
11261
|
+
bytesMoved: j.bytesMoved,
|
|
11262
|
+
filesTotal: null,
|
|
11263
|
+
startedAt: j.startedAt,
|
|
11264
|
+
finishedAt: j.finishedAt,
|
|
11265
|
+
error: j.error
|
|
11266
|
+
};
|
|
11267
|
+
}
|
|
11268
|
+
var MediaRelocateEngine = class {
|
|
11269
|
+
deps;
|
|
11270
|
+
jobs = /* @__PURE__ */ new Map();
|
|
11271
|
+
constructor(deps) {
|
|
11272
|
+
this.deps = deps;
|
|
11273
|
+
}
|
|
11274
|
+
list() {
|
|
11275
|
+
return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
|
|
11276
|
+
}
|
|
11277
|
+
cancel(jobId) {
|
|
11278
|
+
const job = this.jobs.get(jobId);
|
|
11279
|
+
if (!job || job.state !== "running") return false;
|
|
11280
|
+
job.cancelRequested = true;
|
|
11281
|
+
return true;
|
|
11282
|
+
}
|
|
11283
|
+
start(input) {
|
|
11284
|
+
for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
|
|
11285
|
+
const job = {
|
|
11286
|
+
jobId: this.deps.newId(),
|
|
11287
|
+
state: "running",
|
|
11288
|
+
fromLocationId: "*",
|
|
11289
|
+
toLocationId: input.toLocationId,
|
|
11290
|
+
deviceId: input.deviceId ?? null,
|
|
11291
|
+
filesMoved: 0,
|
|
11292
|
+
bytesMoved: 0,
|
|
11293
|
+
startedAt: this.deps.now(),
|
|
11294
|
+
finishedAt: null,
|
|
11295
|
+
error: null,
|
|
11296
|
+
cancelRequested: false
|
|
11297
|
+
};
|
|
11298
|
+
this.jobs.set(job.jobId, job);
|
|
11299
|
+
this.run(job, input.throttleMbps ?? DEFAULT_THROTTLE_MBPS);
|
|
11300
|
+
return job.jobId;
|
|
11301
|
+
}
|
|
11302
|
+
async run(job, throttleMbps) {
|
|
11303
|
+
const sleep = this.deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
11304
|
+
const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
|
|
11305
|
+
try {
|
|
11306
|
+
await this.deps.resolveTargetRoot(job.toLocationId);
|
|
11307
|
+
let cursor = 0;
|
|
11308
|
+
let seenAtCursor = /* @__PURE__ */ new Set();
|
|
11309
|
+
for (;;) {
|
|
11310
|
+
if (job.cancelRequested) break;
|
|
11311
|
+
const fresh = (await this.deps.store.query.query({
|
|
11312
|
+
collection: MEDIA_COLLECTION,
|
|
11313
|
+
filter: {
|
|
11314
|
+
...job.deviceId !== null ? { where: { deviceId: job.deviceId } } : {},
|
|
11315
|
+
whereBetween: { timestamp: [cursor, Number.MAX_SAFE_INTEGER] },
|
|
11316
|
+
orderBy: {
|
|
11317
|
+
field: "timestamp",
|
|
11318
|
+
direction: "asc"
|
|
11319
|
+
},
|
|
11320
|
+
limit: PAGE_SIZE
|
|
11321
|
+
}
|
|
11322
|
+
})).filter((r) => !seenAtCursor.has(r.id));
|
|
11323
|
+
if (fresh.length === 0) break;
|
|
11324
|
+
for (const row of fresh) {
|
|
11325
|
+
if (job.cancelRequested) break;
|
|
11326
|
+
const data = row.data;
|
|
11327
|
+
const from = mediaRowLocation(data);
|
|
11328
|
+
if (from === job.toLocationId) continue;
|
|
11329
|
+
const relativePath = String(data["path"] ?? "");
|
|
11330
|
+
if (relativePath.length === 0) continue;
|
|
11331
|
+
try {
|
|
11332
|
+
const bytes = await this.deps.storage.read({
|
|
11333
|
+
location: from,
|
|
11334
|
+
relativePath
|
|
11335
|
+
});
|
|
11336
|
+
await this.deps.storage.write({
|
|
11337
|
+
location: job.toLocationId,
|
|
11338
|
+
relativePath,
|
|
11339
|
+
data: bytes
|
|
11340
|
+
});
|
|
11341
|
+
await this.deps.store.set.mutate({
|
|
11342
|
+
collection: MEDIA_COLLECTION,
|
|
11343
|
+
key: row.id,
|
|
11344
|
+
value: {
|
|
11345
|
+
...data,
|
|
11346
|
+
locationId: job.toLocationId
|
|
11347
|
+
}
|
|
11348
|
+
});
|
|
11349
|
+
await this.deps.storage.delete({
|
|
11350
|
+
location: from,
|
|
11351
|
+
relativePath
|
|
11352
|
+
});
|
|
11353
|
+
job.filesMoved++;
|
|
11354
|
+
job.bytesMoved += bytes.length;
|
|
11355
|
+
await sleep(bytes.length / bytesPerMs);
|
|
11356
|
+
} catch (err) {
|
|
11357
|
+
this.deps.logger.debug("media relocate row failed", { meta: {
|
|
11358
|
+
key: row.id,
|
|
11359
|
+
error: String(err)
|
|
11360
|
+
} });
|
|
11361
|
+
}
|
|
11362
|
+
}
|
|
11363
|
+
const last = fresh[fresh.length - 1];
|
|
11364
|
+
const lastTs = Number(last.data["timestamp"] ?? cursor);
|
|
11365
|
+
if (lastTs === cursor) for (const r of fresh) seenAtCursor.add(r.id);
|
|
11366
|
+
else {
|
|
11367
|
+
cursor = lastTs;
|
|
11368
|
+
seenAtCursor = new Set(fresh.filter((r) => Number(r.data["timestamp"]) === lastTs).map((r) => r.id));
|
|
11369
|
+
}
|
|
11370
|
+
}
|
|
11371
|
+
job.state = job.cancelRequested ? "cancelled" : "done";
|
|
11372
|
+
} catch (err) {
|
|
11373
|
+
job.state = "failed";
|
|
11374
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
11375
|
+
this.deps.logger.warn("media relocate job failed", { meta: {
|
|
11376
|
+
jobId: job.jobId,
|
|
11377
|
+
error: job.error
|
|
11378
|
+
} });
|
|
11379
|
+
} finally {
|
|
11380
|
+
job.finishedAt = this.deps.now();
|
|
11381
|
+
this.deps.onFinished?.(snapshot(job));
|
|
11382
|
+
}
|
|
11383
|
+
}
|
|
11384
|
+
};
|
|
11385
|
+
//#endregion
|
|
10265
11386
|
//#region src/pipeline-analytics/store/sensor-event-store.ts
|
|
10266
11387
|
var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
|
|
10267
11388
|
var SENSOR_EVENT_COLUMNS = [
|
|
@@ -11388,7 +12509,7 @@ var EventMediaDispatcher = class {
|
|
|
11388
12509
|
if (sn.rollingLastFrame && boxed) lastFrameWritten = await this.replaceKind(deviceId, sn.trackId, "lastFrame", sn.timestamp, boxed);
|
|
11389
12510
|
let thumbnailWritten = false;
|
|
11390
12511
|
if (sn.bestThumbnail) {
|
|
11391
|
-
const variants = await this.cropSubjectVariants(frameHandle, fw, fh, sn.bbox);
|
|
12512
|
+
const variants = await this.cropSubjectVariants(deviceId, frameHandle, fw, fh, sn.bbox);
|
|
11392
12513
|
if (variants) {
|
|
11393
12514
|
thumbnailWritten = await this.replaceKind(deviceId, sn.trackId, "thumbnail", sn.timestamp, variants.thumbnail);
|
|
11394
12515
|
await this.replaceKind(deviceId, sn.trackId, "thumbnailSmall", sn.timestamp, variants.thumbnailSmall);
|
|
@@ -11490,12 +12611,15 @@ var EventMediaDispatcher = class {
|
|
|
11490
12611
|
* per-frame retry lands a real native crop later. Never a local resize
|
|
11491
12612
|
* upscale of a ≤640 tile (a blurred lie).
|
|
11492
12613
|
*/
|
|
11493
|
-
async cropSubjectVariants(frameHandle, fw, fh, bbox) {
|
|
12614
|
+
async cropSubjectVariants(deviceId, frameHandle, fw, fh, bbox) {
|
|
11494
12615
|
if (!this.deps.getNativeCropJpeg) {
|
|
11495
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11496
|
-
|
|
11497
|
-
|
|
11498
|
-
|
|
12616
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12617
|
+
tags: { deviceId },
|
|
12618
|
+
meta: {
|
|
12619
|
+
shmId: frameHandle.shmId,
|
|
12620
|
+
reason: "no-native-cap"
|
|
12621
|
+
}
|
|
12622
|
+
});
|
|
11499
12623
|
return null;
|
|
11500
12624
|
}
|
|
11501
12625
|
try {
|
|
@@ -11503,12 +12627,19 @@ var EventMediaDispatcher = class {
|
|
|
11503
12627
|
W: fw,
|
|
11504
12628
|
H: fh
|
|
11505
12629
|
});
|
|
12630
|
+
const askedAt = Date.now();
|
|
11506
12631
|
const slab = await this.deps.getNativeCropJpeg(frameHandle, layout.fetch);
|
|
11507
12632
|
if (!slab) {
|
|
11508
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11509
|
-
|
|
11510
|
-
|
|
11511
|
-
|
|
12633
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12634
|
+
tags: { deviceId },
|
|
12635
|
+
meta: {
|
|
12636
|
+
shmId: frameHandle.shmId,
|
|
12637
|
+
handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
|
|
12638
|
+
handleNodeId: frameHandle.nodeId,
|
|
12639
|
+
roundTripMs: Date.now() - askedAt,
|
|
12640
|
+
reason: "native-miss"
|
|
12641
|
+
}
|
|
12642
|
+
});
|
|
11512
12643
|
return null;
|
|
11513
12644
|
}
|
|
11514
12645
|
const thumbnail = await composeWideCentralSquareThumbnail(slab, layout);
|
|
@@ -11517,10 +12648,14 @@ var EventMediaDispatcher = class {
|
|
|
11517
12648
|
thumbnailSmall: await deriveThumbnailSmall(thumbnail)
|
|
11518
12649
|
};
|
|
11519
12650
|
} catch (err) {
|
|
11520
|
-
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
11521
|
-
|
|
11522
|
-
|
|
11523
|
-
|
|
12651
|
+
this.deps.logger.warn("native subject crop miss — will retry, no upscale", {
|
|
12652
|
+
tags: { deviceId },
|
|
12653
|
+
meta: {
|
|
12654
|
+
shmId: frameHandle.shmId,
|
|
12655
|
+
reason: "threw",
|
|
12656
|
+
error: err instanceof Error ? err.message : String(err)
|
|
12657
|
+
}
|
|
12658
|
+
});
|
|
11524
12659
|
return null;
|
|
11525
12660
|
}
|
|
11526
12661
|
}
|
|
@@ -13581,37 +14716,7 @@ var AnalyticsQueryFacade = class {
|
|
|
13581
14716
|
}));
|
|
13582
14717
|
}
|
|
13583
14718
|
};
|
|
13584
|
-
|
|
13585
|
-
//#region src/pipeline-analytics/track-retention-sweep.ts
|
|
13586
|
-
/**
|
|
13587
|
-
* Periodic track retention sweep (design §6) — the piece that makes retention
|
|
13588
|
-
* actually BOUNDED. Without it, `pruneTracksBefore` only runs when something
|
|
13589
|
-
* calls it; this sweep ages persisted tracks out on the provider's existing
|
|
13590
|
-
* retention interval.
|
|
13591
|
-
*
|
|
13592
|
-
* `retentionMs` is a PER-DEVICE setting (`trackRetentionDays`, default 7 days;
|
|
13593
|
-
* `0` = keep forever → the device is skipped). Enrolled gallery is exempt by the
|
|
13594
|
-
* cascade store layer (design §4), not here.
|
|
13595
|
-
*
|
|
13596
|
-
* Kept as a pure, dependency-injected function so the loop is unit-testable
|
|
13597
|
-
* without booting the addon.
|
|
13598
|
-
*/
|
|
13599
|
-
var DAY_MS = 1440 * 60 * 1e3;
|
|
13600
|
-
/** Per-device track retention setting. Default 7 days; 0 = keep forever. */
|
|
13601
|
-
var TrackRetentionSettingsSchema = object({ trackRetentionDays: number().min(0).default(7) });
|
|
13602
|
-
var TRACK_RETENTION_DEFAULT_DAYS = TrackRetentionSettingsSchema.parse({}).trackRetentionDays;
|
|
13603
|
-
/** Resolve `trackRetentionDays` from a raw per-device store blob — an invalid or
|
|
13604
|
-
* missing value falls back to the default (parse never throws). */
|
|
13605
|
-
function resolveTrackRetentionDays(raw) {
|
|
13606
|
-
const parsed = TrackRetentionSettingsSchema.shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
|
|
13607
|
-
return parsed.success ? parsed.data : TRACK_RETENTION_DEFAULT_DAYS;
|
|
13608
|
-
}
|
|
13609
|
-
/** Cutoff timestamp for a retention window. `null` when retention is disabled
|
|
13610
|
-
* (`retentionDays <= 0` → keep forever, the sweep skips the device). */
|
|
13611
|
-
function trackRetentionCutoff(nowMs, retentionDays) {
|
|
13612
|
-
if (retentionDays <= 0) return null;
|
|
13613
|
-
return nowMs - retentionDays * DAY_MS;
|
|
13614
|
-
}
|
|
14719
|
+
var TRACK_RETENTION_DEFAULT_DAYS = object({ trackRetentionDays: number().min(0).default(7) }).parse({}).trackRetentionDays;
|
|
13615
14720
|
/**
|
|
13616
14721
|
* Sweep every device with persisted tracks: skip retention-disabled devices,
|
|
13617
14722
|
* prune the rest at `now − retentionMs`. Per-device ISOLATED — one bad device
|
|
@@ -13622,7 +14727,7 @@ async function sweepTrackRetention(deps) {
|
|
|
13622
14727
|
const nowMs = deps.now();
|
|
13623
14728
|
let totalTracks = 0;
|
|
13624
14729
|
for (const deviceId of devices) try {
|
|
13625
|
-
const cutoffMs =
|
|
14730
|
+
const cutoffMs = await deps.resolveCutoffMs(deviceId, nowMs);
|
|
13626
14731
|
if (cutoffMs === null) continue;
|
|
13627
14732
|
const counts = await deps.pruneTracksBefore(deviceId, cutoffMs);
|
|
13628
14733
|
totalTracks += counts.tracks;
|
|
@@ -14187,6 +15292,19 @@ function retagDetectionSections(sections) {
|
|
|
14187
15292
|
} : s);
|
|
14188
15293
|
}
|
|
14189
15294
|
/**
|
|
15295
|
+
* Re-home the analytics `retention` section onto the recorder's `recording`
|
|
15296
|
+
* top-tab (operator ask, 2026-07-29): footage and analytics retention are ONE
|
|
15297
|
+
* unified policy since the follow-recordings default, so their controls
|
|
15298
|
+
* belong on ONE tab. Same pure-retag mechanism as the detection sections —
|
|
15299
|
+
* `DeviceDetail` folds matching-tab sections together, no admin-ui change.
|
|
15300
|
+
*/
|
|
15301
|
+
function retagRetentionSection(sections) {
|
|
15302
|
+
return sections.map((s) => s.id === "retention" ? {
|
|
15303
|
+
...s,
|
|
15304
|
+
tab: "recording"
|
|
15305
|
+
} : s);
|
|
15306
|
+
}
|
|
15307
|
+
/**
|
|
14190
15308
|
* Fields that live ONLY on the global settings page and must never surface in a
|
|
14191
15309
|
* per-device contribution. The face-recognition `enabled` switch is the GLOBAL
|
|
14192
15310
|
* master kill for the whole subsystem — per-camera face production is governed
|
|
@@ -14365,9 +15483,23 @@ function buildGlobalSettingsSchema() {
|
|
|
14365
15483
|
{
|
|
14366
15484
|
id: "retention",
|
|
14367
15485
|
title: "Retention",
|
|
14368
|
-
description: "How long analytics history is kept
|
|
15486
|
+
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.",
|
|
14369
15487
|
columns: 3,
|
|
14370
15488
|
fields: [
|
|
15489
|
+
{
|
|
15490
|
+
type: "select",
|
|
15491
|
+
key: "retentionMode",
|
|
15492
|
+
label: "Mode",
|
|
15493
|
+
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.",
|
|
15494
|
+
default: "follow-recordings",
|
|
15495
|
+
options: [{
|
|
15496
|
+
value: "follow-recordings",
|
|
15497
|
+
label: "Follow recordings (default)"
|
|
15498
|
+
}, {
|
|
15499
|
+
value: "custom",
|
|
15500
|
+
label: "Custom windows"
|
|
15501
|
+
}]
|
|
15502
|
+
},
|
|
14371
15503
|
{
|
|
14372
15504
|
type: "number",
|
|
14373
15505
|
key: "trackRetentionDays",
|
|
@@ -14902,6 +16034,86 @@ async function encodeKeyFrameVariants(native) {
|
|
|
14902
16034
|
};
|
|
14903
16035
|
}
|
|
14904
16036
|
//#endregion
|
|
16037
|
+
//#region src/pipeline-analytics/retention-policy.ts
|
|
16038
|
+
/**
|
|
16039
|
+
* Unified analytics retention policy (storage entity-routing spec, Phase 1).
|
|
16040
|
+
*
|
|
16041
|
+
* Two modes, per device:
|
|
16042
|
+
*
|
|
16043
|
+
* - `follow-recordings` (the DEFAULT): tracks and events live exactly as long
|
|
16044
|
+
* as the camera's retained footage — ONE cutoff, the instant of the oldest
|
|
16045
|
+
* segment still on disk. That follows the recorder's age retention AND its
|
|
16046
|
+
* disk-pressure eviction automatically, with no second policy to keep in
|
|
16047
|
+
* sync. A camera with no footage at all (recording off / brand new) falls
|
|
16048
|
+
* back to the custom day windows below, so analytics stay bounded either
|
|
16049
|
+
* way.
|
|
16050
|
+
*
|
|
16051
|
+
* - `custom`: the per-kind day windows (tracks / motion / object / audio),
|
|
16052
|
+
* exactly the pre-unification behaviour — except the knobs are now actually
|
|
16053
|
+
* READ (they were exposed in settings and silently ignored by the sweep,
|
|
16054
|
+
* which used hardcoded 14/30/7).
|
|
16055
|
+
*
|
|
16056
|
+
* Follow-mode floor: events younger than {@link FOLLOW_FLOOR_DAYS} are never
|
|
16057
|
+
* pruned, even when footage is younger (a camera that STARTED recording
|
|
16058
|
+
* yesterday must not nuke its whole event history back to the first segment).
|
|
16059
|
+
*/
|
|
16060
|
+
var DAY_MS = 1440 * 60 * 1e3;
|
|
16061
|
+
/** Per-device analytics retention settings as stored in the device blob.
|
|
16062
|
+
* `retentionMode` absent = follow-recordings (the new default). The day
|
|
16063
|
+
* windows keep their historical defaults and double as the no-footage
|
|
16064
|
+
* fallback in follow mode. */
|
|
16065
|
+
var RetentionSettingsSchema = object({
|
|
16066
|
+
retentionMode: _enum(["follow-recordings", "custom"]).default("follow-recordings"),
|
|
16067
|
+
trackRetentionDays: number().min(0).default(7),
|
|
16068
|
+
retentionMotionDays: number().min(1).default(14),
|
|
16069
|
+
retentionObjectDays: number().min(1).default(30),
|
|
16070
|
+
retentionAudioDays: number().min(1).default(7)
|
|
16071
|
+
});
|
|
16072
|
+
/** Resolve the settings from a raw device-store blob — invalid or missing
|
|
16073
|
+
* fields fall back to defaults, never throw. */
|
|
16074
|
+
function resolveRetentionSettings(raw) {
|
|
16075
|
+
const parsed = RetentionSettingsSchema.safeParse(raw);
|
|
16076
|
+
if (parsed.success) return parsed.data;
|
|
16077
|
+
const shape = RetentionSettingsSchema.shape;
|
|
16078
|
+
const field = (k) => shape[k].safeParse(raw[k]).success ? shape[k].parse(raw[k]) : RetentionSettingsSchema.parse({})[k];
|
|
16079
|
+
return {
|
|
16080
|
+
retentionMode: field("retentionMode"),
|
|
16081
|
+
trackRetentionDays: field("trackRetentionDays"),
|
|
16082
|
+
retentionMotionDays: field("retentionMotionDays"),
|
|
16083
|
+
retentionObjectDays: field("retentionObjectDays"),
|
|
16084
|
+
retentionAudioDays: field("retentionAudioDays")
|
|
16085
|
+
};
|
|
16086
|
+
}
|
|
16087
|
+
/**
|
|
16088
|
+
* Follow-mode cutoff: the oldest retained footage instant, floored so
|
|
16089
|
+
* anything younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage
|
|
16090
|
+
* (none on disk) → `null`, the caller falls back to custom windows.
|
|
16091
|
+
*/
|
|
16092
|
+
function followCutoffMs(nowMs, earliestFootageMs) {
|
|
16093
|
+
if (earliestFootageMs === null) return null;
|
|
16094
|
+
return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS);
|
|
16095
|
+
}
|
|
16096
|
+
/** Compute the effective cutoffs for one device at `nowMs`. */
|
|
16097
|
+
function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
|
|
16098
|
+
if (settings.retentionMode === "follow-recordings") {
|
|
16099
|
+
const cutoff = followCutoffMs(nowMs, earliestFootageMs);
|
|
16100
|
+
if (cutoff !== null) return {
|
|
16101
|
+
trackCutoffMs: cutoff,
|
|
16102
|
+
motionCutoffMs: cutoff,
|
|
16103
|
+
objectCutoffMs: cutoff,
|
|
16104
|
+
audioCutoffMs: cutoff,
|
|
16105
|
+
effectiveMode: "follow-recordings"
|
|
16106
|
+
};
|
|
16107
|
+
}
|
|
16108
|
+
return {
|
|
16109
|
+
trackCutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS,
|
|
16110
|
+
motionCutoffMs: nowMs - settings.retentionMotionDays * DAY_MS,
|
|
16111
|
+
objectCutoffMs: nowMs - settings.retentionObjectDays * DAY_MS,
|
|
16112
|
+
audioCutoffMs: nowMs - settings.retentionAudioDays * DAY_MS,
|
|
16113
|
+
effectiveMode: "custom"
|
|
16114
|
+
};
|
|
16115
|
+
}
|
|
16116
|
+
//#endregion
|
|
14905
16117
|
//#region src/pipeline-analytics/store/identity-store.ts
|
|
14906
16118
|
/**
|
|
14907
16119
|
* IdentityStore — per-person identity registry for face recognition.
|
|
@@ -18288,6 +19500,17 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
|
|
|
18288
19500
|
* Absent / empty / non-string all fall back to the hub default — the exact
|
|
18289
19501
|
* narrowing the old raw read applied inline. */
|
|
18290
19502
|
var PostProcessingNodeIdSchema = string().min(1);
|
|
19503
|
+
/**
|
|
19504
|
+
* Footage-attachment window + geometry, used when the rule states none. The
|
|
19505
|
+
* window is CENTRED on the event, so the recipient sees the approach and what
|
|
19506
|
+
* followed rather than one side of it.
|
|
19507
|
+
*/
|
|
19508
|
+
var NC_FOOTAGE_PRE_ROLL_SEC = 3;
|
|
19509
|
+
var NC_FOOTAGE_POST_ROLL_SEC = 5;
|
|
19510
|
+
var NC_FOOTAGE_MAX_WIDTH = 480;
|
|
19511
|
+
var NC_FOOTAGE_FPS = 5;
|
|
19512
|
+
/** Per-install HMAC secret behind the signed artifact links (minted once). */
|
|
19513
|
+
var NcArtifactSecretSchema = string();
|
|
18291
19514
|
var EmbeddingEnabledSchema = boolean();
|
|
18292
19515
|
var SILENCE_FLOOR_DBFS = -55;
|
|
18293
19516
|
var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
@@ -18359,6 +19582,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18359
19582
|
eventStore = null;
|
|
18360
19583
|
/** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
|
|
18361
19584
|
eventsOpsLog = null;
|
|
19585
|
+
/** Event-media relocation engine (entity-routing Phase 4). */
|
|
19586
|
+
mediaRelocate = null;
|
|
18362
19587
|
/** Per-camera history of LINKED-device sensor state changes (Part B). */
|
|
18363
19588
|
sensorEventStore = null;
|
|
18364
19589
|
/** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
|
|
@@ -18417,6 +19642,88 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18417
19642
|
retentionSweepTimer = null;
|
|
18418
19643
|
/** Handle for the event-media data-plane listener (dispose on shutdown). */
|
|
18419
19644
|
eventMediaDataPlane = null;
|
|
19645
|
+
/** The NC artifact plane (signed public links for notification media) and its
|
|
19646
|
+
* data-plane handle. Null until served / when the facility is absent. */
|
|
19647
|
+
ncArtifactPlane = null;
|
|
19648
|
+
ncArtifactDataPlane = null;
|
|
19649
|
+
/** The operator's marked notification endpoint, or null (AUTO / unavailable). */
|
|
19650
|
+
async markedNotificationEndpoint() {
|
|
19651
|
+
try {
|
|
19652
|
+
return (await this.ctx.api.localNetwork.getNotificationEndpoint.query()).baseUrl ?? void 0;
|
|
19653
|
+
} catch {
|
|
19654
|
+
return;
|
|
19655
|
+
}
|
|
19656
|
+
}
|
|
19657
|
+
/**
|
|
19658
|
+
* Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
|
|
19659
|
+
* each link (see `notification-center/artifact-url.ts`). It exists because
|
|
19660
|
+
* attachments used to carry BYTES only, and the degrade engine drops a
|
|
19661
|
+
* bytes-only attachment for a url-mode backend — WhatsApp and gotify were
|
|
19662
|
+
* receiving no media at all.
|
|
19663
|
+
*
|
|
19664
|
+
* Best-effort at every step: no facility, no secret or no reachable base URL
|
|
19665
|
+
* ⇒ no plane, and the dispatcher keeps shipping bytes exactly as before.
|
|
19666
|
+
*/
|
|
19667
|
+
async serveNcArtifactPlane() {
|
|
19668
|
+
try {
|
|
19669
|
+
const secretState = this.state("ncArtifactSecret", NcArtifactSecretSchema, "");
|
|
19670
|
+
let secret = await secretState.get();
|
|
19671
|
+
if (secret === "") {
|
|
19672
|
+
secret = randomUUID().replace(/-/g, "");
|
|
19673
|
+
await secretState.set(secret);
|
|
19674
|
+
}
|
|
19675
|
+
const store = new NcArtifactStore({
|
|
19676
|
+
dir: path.join(this.ctx.dataDir, "nc-artifacts"),
|
|
19677
|
+
logger: this.ctx.logger.child("nc-artifacts")
|
|
19678
|
+
});
|
|
19679
|
+
await store.start();
|
|
19680
|
+
const plane = new NcArtifactPlane({
|
|
19681
|
+
store,
|
|
19682
|
+
secret,
|
|
19683
|
+
logger: this.ctx.logger.child("nc-artifacts"),
|
|
19684
|
+
routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
|
|
19685
|
+
listEndpoints: async () => collectArtifactEndpoints({
|
|
19686
|
+
markedBaseUrl: await this.markedNotificationEndpoint(),
|
|
19687
|
+
configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
|
|
19688
|
+
getConnected: async () => {
|
|
19689
|
+
const status = await this.ctx.api.networkAccess.getStatus.query();
|
|
19690
|
+
this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
|
|
19691
|
+
connected: status.connected,
|
|
19692
|
+
url: status.endpoint?.url ?? null,
|
|
19693
|
+
protocol: status.endpoint?.protocol ?? null
|
|
19694
|
+
} });
|
|
19695
|
+
return status.connected && status.endpoint !== null ? {
|
|
19696
|
+
url: status.endpoint.url,
|
|
19697
|
+
protocol: status.endpoint.protocol
|
|
19698
|
+
} : null;
|
|
19699
|
+
},
|
|
19700
|
+
listExternal: async () => {
|
|
19701
|
+
return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
|
|
19702
|
+
url: e.url,
|
|
19703
|
+
protocol: e.protocol
|
|
19704
|
+
}));
|
|
19705
|
+
},
|
|
19706
|
+
listLan: async (port) => {
|
|
19707
|
+
return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
|
|
19708
|
+
baseUrl: e.baseUrl,
|
|
19709
|
+
kind: e.kind,
|
|
19710
|
+
priority: e.priority
|
|
19711
|
+
}));
|
|
19712
|
+
},
|
|
19713
|
+
logger: this.ctx.logger
|
|
19714
|
+
})
|
|
19715
|
+
});
|
|
19716
|
+
this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
|
|
19717
|
+
prefix: "nc-artifact",
|
|
19718
|
+
access: "public",
|
|
19719
|
+
handler: plane.handler
|
|
19720
|
+
}) ?? null;
|
|
19721
|
+
this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
|
|
19722
|
+
this.ctx.logger.info("nc-artifact data-plane served", { meta: { served: this.ncArtifactPlane !== null } });
|
|
19723
|
+
} catch (err) {
|
|
19724
|
+
this.ctx.logger.warn("nc-artifact data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
19725
|
+
}
|
|
19726
|
+
}
|
|
18420
19727
|
/** Public base URL for event thumbnails: `/addon/<addonId>/event-media`.
|
|
18421
19728
|
* Set once the data-plane is registered; null until then (e.g. no
|
|
18422
19729
|
* dataPlane facility in the current environment). */
|
|
@@ -18720,12 +20027,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18720
20027
|
let storage = this.ctx.kernel.storage;
|
|
18721
20028
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
18722
20029
|
if (mediaRoot) {
|
|
18723
|
-
const { FilesystemStorageProvider } = await import("../node-
|
|
18724
|
-
storage = new FilesystemStorageProvider(mediaRoot);
|
|
20030
|
+
const { FilesystemStorageProvider } = await import("../node-BmVBZiOw.mjs");
|
|
20031
|
+
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
18725
20032
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
18726
20033
|
}
|
|
18727
20034
|
if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
|
|
18728
|
-
return
|
|
20035
|
+
return createLocationAwareMediaStorage({
|
|
20036
|
+
base: storage,
|
|
20037
|
+
defaultLocation: "eventMedia",
|
|
20038
|
+
resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
|
|
20039
|
+
location: locationId,
|
|
20040
|
+
relativePath: ""
|
|
20041
|
+
})
|
|
20042
|
+
});
|
|
18729
20043
|
}
|
|
18730
20044
|
/** Constructs every SQLite-backed store plus the stationary/package-drop/
|
|
18731
20045
|
* sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
|
|
@@ -18773,6 +20087,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
18773
20087
|
logger: logger.child("MediaStore")
|
|
18774
20088
|
});
|
|
18775
20089
|
this.mediaStore = mediaStore;
|
|
20090
|
+
this.mediaRelocate = new MediaRelocateEngine({
|
|
20091
|
+
store: api.settingsStore,
|
|
20092
|
+
storage,
|
|
20093
|
+
logger: logger.child("MediaRelocate"),
|
|
20094
|
+
now: () => Date.now(),
|
|
20095
|
+
newId: () => randomUUID(),
|
|
20096
|
+
resolveTargetRoot: (locationId) => api.storage.resolve.query({
|
|
20097
|
+
location: locationId,
|
|
20098
|
+
relativePath: ""
|
|
20099
|
+
}),
|
|
20100
|
+
onFinished: (job) => {
|
|
20101
|
+
this.eventsOpsLog?.append({
|
|
20102
|
+
op: "relocate",
|
|
20103
|
+
reason: "operator",
|
|
20104
|
+
deviceId: job.deviceId,
|
|
20105
|
+
itemsAffected: job.filesMoved,
|
|
20106
|
+
bytesReclaimed: 0,
|
|
20107
|
+
detail: `${job.state}: media → ${job.toLocationId} (${job.bytesMoved} bytes${job.error ? `; ${job.error}` : ""})`
|
|
20108
|
+
});
|
|
20109
|
+
}
|
|
20110
|
+
});
|
|
18776
20111
|
const eventStore = new EventStore({
|
|
18777
20112
|
store: api.settingsStore,
|
|
18778
20113
|
logger: logger.child("EventStore"),
|
|
@@ -19018,15 +20353,59 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19018
20353
|
this.notificationCenter = new NotificationCenter({
|
|
19019
20354
|
store: api.settingsStore,
|
|
19020
20355
|
logger: logger.child("NotificationCenter"),
|
|
20356
|
+
listUsers: async () => {
|
|
20357
|
+
return (await api.userManagement.listUsers.query()).map((u) => ({
|
|
20358
|
+
id: u.id,
|
|
20359
|
+
isAdmin: u.isAdmin,
|
|
20360
|
+
allowedDevices: u.allowedDevices
|
|
20361
|
+
}));
|
|
20362
|
+
},
|
|
20363
|
+
getDeviceRoute: async (deviceId) => {
|
|
20364
|
+
const device = await api.deviceManager.getDevice.query({ deviceId });
|
|
20365
|
+
return device === null ? null : {
|
|
20366
|
+
stableId: device.stableId,
|
|
20367
|
+
addonId: device.addonId
|
|
20368
|
+
};
|
|
20369
|
+
},
|
|
19021
20370
|
dispatcher: {
|
|
20371
|
+
getZonePolygons: async (deviceId, zoneIds) => {
|
|
20372
|
+
const zones = await api.zones.listZones.query({ deviceId });
|
|
20373
|
+
const wanted = new Set(zoneIds);
|
|
20374
|
+
return zones.filter((z) => wanted.has(z.id) && Array.isArray(z.polygon)).map((z) => (z.polygon ?? []).map((pt) => ({
|
|
20375
|
+
x: pt.x,
|
|
20376
|
+
y: pt.y
|
|
20377
|
+
})));
|
|
20378
|
+
},
|
|
20379
|
+
renderFootage: async (req) => {
|
|
20380
|
+
const res = await api.streamBroker.renderPreBufferClip.mutate({
|
|
20381
|
+
deviceId: req.deviceId,
|
|
20382
|
+
aroundMs: req.aroundMs,
|
|
20383
|
+
format: req.format,
|
|
20384
|
+
preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
|
|
20385
|
+
postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
|
|
20386
|
+
maxWidth: NC_FOOTAGE_MAX_WIDTH,
|
|
20387
|
+
fps: NC_FOOTAGE_FPS,
|
|
20388
|
+
...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
|
|
20389
|
+
});
|
|
20390
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
20391
|
+
if (buf.byteLength === 0) return null;
|
|
20392
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
20393
|
+
bytes.set(buf);
|
|
20394
|
+
return bytes;
|
|
20395
|
+
},
|
|
20396
|
+
publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
|
|
19022
20397
|
listTargets: async () => {
|
|
19023
|
-
return (await api.notificationOutput.listTargets.query({})).map((t) =>
|
|
19024
|
-
|
|
19025
|
-
|
|
19026
|
-
|
|
19027
|
-
|
|
19028
|
-
|
|
19029
|
-
|
|
20398
|
+
return (await api.notificationOutput.listTargets.query({})).map((t) => {
|
|
20399
|
+
const owner = t.config["ownerUserId"];
|
|
20400
|
+
return {
|
|
20401
|
+
id: t.id,
|
|
20402
|
+
addonId: t.addonId,
|
|
20403
|
+
name: t.name,
|
|
20404
|
+
kind: t.kind,
|
|
20405
|
+
enabled: t.enabled,
|
|
20406
|
+
...typeof owner === "string" && owner.length > 0 ? { ownerUserId: owner } : {}
|
|
20407
|
+
};
|
|
20408
|
+
});
|
|
19030
20409
|
},
|
|
19031
20410
|
send: async (input) => {
|
|
19032
20411
|
const { attachments, ...notification } = input.notification;
|
|
@@ -19080,6 +20459,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
19080
20459
|
handler
|
|
19081
20460
|
}) ?? null;
|
|
19082
20461
|
this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
|
|
20462
|
+
await this.serveNcArtifactPlane();
|
|
19083
20463
|
this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
|
|
19084
20464
|
} catch (err) {
|
|
19085
20465
|
this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
@@ -20724,42 +22104,112 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20724
22104
|
if (this.shuttingDown || !this.trackStore) return;
|
|
20725
22105
|
await this.trackCloser.sweep();
|
|
20726
22106
|
}
|
|
22107
|
+
/** Unified retention (Phase 1): earliest retained footage per device — the
|
|
22108
|
+
* follow-recordings cutoff source. Cached 10 min: availability answers in
|
|
22109
|
+
* ~70 ms but the sweep asks once per device per pass. `null` = no footage
|
|
22110
|
+
* (or recorder unreachable) → the policy falls back to the custom windows. */
|
|
22111
|
+
earliestFootageCache = /* @__PURE__ */ new Map();
|
|
22112
|
+
async earliestFootageMs(deviceId) {
|
|
22113
|
+
const cached = this.earliestFootageCache.get(deviceId);
|
|
22114
|
+
if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
|
|
22115
|
+
let value = null;
|
|
22116
|
+
try {
|
|
22117
|
+
const res = await this.ctx.api.recording.getAvailability.query({
|
|
22118
|
+
deviceId,
|
|
22119
|
+
fromMs: 0,
|
|
22120
|
+
toMs: Date.now()
|
|
22121
|
+
});
|
|
22122
|
+
let min = Number.POSITIVE_INFINITY;
|
|
22123
|
+
for (const r of res.ranges) if (r.startMs < min) min = r.startMs;
|
|
22124
|
+
value = Number.isFinite(min) ? min : null;
|
|
22125
|
+
} catch {
|
|
22126
|
+
value = null;
|
|
22127
|
+
}
|
|
22128
|
+
this.earliestFootageCache.set(deviceId, {
|
|
22129
|
+
at: Date.now(),
|
|
22130
|
+
value
|
|
22131
|
+
});
|
|
22132
|
+
return value;
|
|
22133
|
+
}
|
|
22134
|
+
/** The per-device effective cutoffs (mode + footage → numbers). */
|
|
22135
|
+
async deviceRetentionCutoffs(deviceId, nowMs) {
|
|
22136
|
+
const settings = resolveRetentionSettings(await this.ctx?.settings?.readDeviceStore(deviceId) ?? {});
|
|
22137
|
+
return resolveRetentionCutoffs(settings, nowMs, settings.retentionMode === "follow-recordings" ? await this.earliestFootageMs(deviceId) : null);
|
|
22138
|
+
}
|
|
22139
|
+
/** The devices one retention pass covers: every camera the hub knows plus
|
|
22140
|
+
* any device that still has persisted tracks (covers deleted cameras whose
|
|
22141
|
+
* history must keep aging out). */
|
|
22142
|
+
async retentionDeviceIds() {
|
|
22143
|
+
const ids = /* @__PURE__ */ new Set();
|
|
22144
|
+
try {
|
|
22145
|
+
const all = await this.ctx.api.deviceManager.listAll.query({});
|
|
22146
|
+
for (const d of all) ids.add(d.id);
|
|
22147
|
+
} catch {}
|
|
22148
|
+
try {
|
|
22149
|
+
for (const id of await this.trackStore?.listDeviceIds() ?? []) ids.add(id);
|
|
22150
|
+
} catch {}
|
|
22151
|
+
return [...ids];
|
|
22152
|
+
}
|
|
20727
22153
|
async sweepRetention() {
|
|
20728
22154
|
if (this.shuttingDown || !this.eventStore || !this.mediaStore) return;
|
|
20729
22155
|
const now = Date.now();
|
|
20730
22156
|
const day = 1440 * 60 * 1e3;
|
|
20731
|
-
const OBJECT_RETENTION_DAYS = 30;
|
|
20732
|
-
const motionCutoffMs = now - 14 * day;
|
|
20733
|
-
const objectCutoffMs = now - OBJECT_RETENTION_DAYS * day;
|
|
20734
|
-
const audioCutoffMs = now - 7 * day;
|
|
20735
22157
|
try {
|
|
20736
|
-
const
|
|
20737
|
-
|
|
20738
|
-
|
|
20739
|
-
|
|
20740
|
-
|
|
20741
|
-
|
|
20742
|
-
|
|
20743
|
-
|
|
20744
|
-
|
|
20745
|
-
|
|
20746
|
-
|
|
20747
|
-
|
|
20748
|
-
|
|
20749
|
-
|
|
20750
|
-
|
|
20751
|
-
|
|
20752
|
-
|
|
20753
|
-
|
|
20754
|
-
|
|
20755
|
-
|
|
22158
|
+
const deviceIds = await this.retentionDeviceIds();
|
|
22159
|
+
const evictedIds = [];
|
|
22160
|
+
let minObjectCutoffMs = Number.POSITIVE_INFINITY;
|
|
22161
|
+
for (const deviceId of deviceIds) {
|
|
22162
|
+
if (this.shuttingDown) return;
|
|
22163
|
+
try {
|
|
22164
|
+
const cutoffs = await this.deviceRetentionCutoffs(deviceId, now);
|
|
22165
|
+
if (cutoffs.objectCutoffMs !== null && cutoffs.objectCutoffMs < minObjectCutoffMs) minObjectCutoffMs = cutoffs.objectCutoffMs;
|
|
22166
|
+
if (cutoffs.motionCutoffMs === null && cutoffs.objectCutoffMs === null && cutoffs.audioCutoffMs === null) continue;
|
|
22167
|
+
const evicted = await this.eventStore.evictBefore({
|
|
22168
|
+
deviceId,
|
|
22169
|
+
motionCutoffMs: cutoffs.motionCutoffMs ?? 0,
|
|
22170
|
+
objectCutoffMs: cutoffs.objectCutoffMs ?? 0,
|
|
22171
|
+
audioCutoffMs: cutoffs.audioCutoffMs ?? 0
|
|
22172
|
+
});
|
|
22173
|
+
const ids = [
|
|
22174
|
+
...evicted.motion,
|
|
22175
|
+
...evicted.object,
|
|
22176
|
+
...evicted.audio
|
|
22177
|
+
];
|
|
22178
|
+
if (ids.length > 0) {
|
|
22179
|
+
evictedIds.push(...ids);
|
|
22180
|
+
this.eventsOpsLog?.append({
|
|
22181
|
+
op: "prune",
|
|
22182
|
+
reason: "retention",
|
|
22183
|
+
deviceId,
|
|
22184
|
+
itemsAffected: ids.length,
|
|
22185
|
+
bytesReclaimed: 0,
|
|
22186
|
+
detail: `age sweep (${cutoffs.effectiveMode}): ${evicted.motion.length} motion, ${evicted.object.length} object, ${evicted.audio.length} audio`
|
|
22187
|
+
});
|
|
22188
|
+
this.ctx.logger.info("analytics event eviction (age sweep)", {
|
|
22189
|
+
tags: { deviceId },
|
|
22190
|
+
meta: {
|
|
22191
|
+
motion: evicted.motion.length,
|
|
22192
|
+
object: evicted.object.length,
|
|
22193
|
+
audio: evicted.audio.length,
|
|
22194
|
+
mode: cutoffs.effectiveMode,
|
|
22195
|
+
objectCutoffMs: cutoffs.objectCutoffMs
|
|
22196
|
+
}
|
|
22197
|
+
});
|
|
22198
|
+
}
|
|
22199
|
+
} catch (err) {
|
|
22200
|
+
this.ctx.logger.debug("event retention sweep (device) failed", {
|
|
22201
|
+
tags: { deviceId },
|
|
22202
|
+
meta: { error: String(err) }
|
|
22203
|
+
});
|
|
22204
|
+
}
|
|
20756
22205
|
}
|
|
20757
|
-
await this.mediaStore.
|
|
20758
|
-
if (
|
|
20759
|
-
|
|
22206
|
+
if (evictedIds.length > 0) await this.mediaStore.deleteForEvents(evictedIds);
|
|
22207
|
+
if (Number.isFinite(minObjectCutoffMs)) await this.mediaStore.evictBefore(minObjectCutoffMs - 1 * day);
|
|
22208
|
+
if (this.sensorEventStore && Number.isFinite(minObjectCutoffMs)) try {
|
|
22209
|
+
const sensorDeleted = await this.sensorEventStore.evictBefore(minObjectCutoffMs);
|
|
20760
22210
|
if (sensorDeleted > 0) this.ctx.logger.info("sensor-event retention prune", { meta: {
|
|
20761
22211
|
deleted: sensorDeleted,
|
|
20762
|
-
cutoffMs:
|
|
22212
|
+
cutoffMs: minObjectCutoffMs
|
|
20763
22213
|
} });
|
|
20764
22214
|
} catch (err) {
|
|
20765
22215
|
this.ctx.logger.debug("sensor-event prune failed", { meta: { error: String(err) } });
|
|
@@ -20790,8 +22240,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
20790
22240
|
} catch (err) {
|
|
20791
22241
|
this.ctx.logger.debug("plate buffer prune failed", { meta: { error: String(err) } });
|
|
20792
22242
|
}
|
|
20793
|
-
if (this.objectEmbeddingStore) try {
|
|
20794
|
-
const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(
|
|
22243
|
+
if (this.objectEmbeddingStore && Number.isFinite(minObjectCutoffMs)) try {
|
|
22244
|
+
const deletedEmbIds = await this.objectEmbeddingStore.pruneAll(minObjectCutoffMs);
|
|
20795
22245
|
if (deletedEmbIds.length > 0) this.ctx.logger.info("object embedding retention prune", { meta: { deleted: deletedEmbIds.length } });
|
|
20796
22246
|
} catch (err) {
|
|
20797
22247
|
this.ctx.logger.debug("object embedding prune failed", { meta: { error: String(err) } });
|
|
@@ -21457,6 +22907,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21457
22907
|
return this.queryFacade.deleteDeviceEvents(input);
|
|
21458
22908
|
}
|
|
21459
22909
|
/** The events ops-log rows (newest-first), optionally scoped to one camera. */
|
|
22910
|
+
async relocateMedia(input) {
|
|
22911
|
+
if (!this.mediaRelocate) throw new Error("media relocation unavailable");
|
|
22912
|
+
return { jobId: this.mediaRelocate.start(input) };
|
|
22913
|
+
}
|
|
22914
|
+
async getMediaRelocateStatus() {
|
|
22915
|
+
return this.mediaRelocate?.list() ?? [];
|
|
22916
|
+
}
|
|
22917
|
+
async cancelMediaRelocate(input) {
|
|
22918
|
+
return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
|
|
22919
|
+
}
|
|
21460
22920
|
async listOpsLog(input) {
|
|
21461
22921
|
return this.queryFacade.listOpsLog(input);
|
|
21462
22922
|
}
|
|
@@ -21536,13 +22996,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21536
22996
|
try {
|
|
21537
22997
|
const total = await sweepTrackRetention({
|
|
21538
22998
|
listDeviceIds: () => trackStore.listDeviceIds(),
|
|
21539
|
-
|
|
21540
|
-
|
|
22999
|
+
resolveCutoffMs: async (deviceId, nowMs) => (await this.deviceRetentionCutoffs(deviceId, nowMs)).trackCutoffMs,
|
|
23000
|
+
pruneTracksBefore: async (deviceId, cutoffMs) => {
|
|
23001
|
+
const counts = await this.pruneTracksBefore({
|
|
23002
|
+
deviceId,
|
|
23003
|
+
cutoffMs
|
|
23004
|
+
});
|
|
23005
|
+
if (counts.tracks > 0) this.eventsOpsLog?.append({
|
|
23006
|
+
op: "prune",
|
|
23007
|
+
reason: "retention",
|
|
23008
|
+
deviceId,
|
|
23009
|
+
itemsAffected: counts.tracks,
|
|
23010
|
+
bytesReclaimed: 0,
|
|
23011
|
+
detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
|
|
23012
|
+
});
|
|
23013
|
+
return counts;
|
|
21541
23014
|
},
|
|
21542
|
-
pruneTracksBefore: (deviceId, cutoffMs) => this.pruneTracksBefore({
|
|
21543
|
-
deviceId,
|
|
21544
|
-
cutoffMs
|
|
21545
|
-
}),
|
|
21546
23015
|
now: () => Date.now(),
|
|
21547
23016
|
onError: (deviceId, err) => {
|
|
21548
23017
|
this.ctx.logger.debug("track retention sweep (device) failed", { meta: {
|
|
@@ -21636,7 +23105,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21636
23105
|
const raw = await this.ctx?.settings?.readDeviceStore(input.deviceId) ?? {};
|
|
21637
23106
|
const baseSections = schema ? hydrateSchema({
|
|
21638
23107
|
...schema,
|
|
21639
|
-
sections: retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections)))
|
|
23108
|
+
sections: retagRetentionSection(retagDetectionSections(stripGlobalOnlyFields(toAnalyticsDeviceSections(schema.sections))))
|
|
21640
23109
|
}, raw).sections : [];
|
|
21641
23110
|
const liveStatsSection = {
|
|
21642
23111
|
id: "live-stats",
|
|
@@ -21686,4 +23155,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
21686
23155
|
}
|
|
21687
23156
|
};
|
|
21688
23157
|
//#endregion
|
|
21689
|
-
export { DETECTION_PIPELINE_SECTION_IDS, ncActions as customActions, ncActions, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|
|
23158
|
+
export { DETECTION_PIPELINE_SECTION_IDS, ncActions as customActions, ncActions, PipelineAnalyticsAddon as default, pickCleanMedia, retagDetectionSections, retagRetentionSection, stripGlobalOnlyFields, toAnalyticsDeviceSections };
|