@camstack/addon-post-analysis 1.2.83 → 1.2.85

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-Owxednhs.js");
5
+ const require_dist = require("../dist-Crk9pYee.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -1586,11 +1586,32 @@ var NC_ALARM_DECLARATION = {
1586
1586
  * retention="pruned by NcArtifactStore's shelf bounds (14 d / 2000 rows / 2 GiB) — the index row falls with its bytes, on the producer node only (see prune(null) gate)"
1587
1587
  */
1588
1588
  var NC_ARTIFACTS_COLLECTION = "notification-center:artifacts";
1589
- var NcArtifactKindSchema = require_dist._enum([
1589
+ /**
1590
+ * What an artefact IS. The schema lives in the ADDON, not in `packages/types`,
1591
+ * for the same reason the summary rule's does: it ships on `camstack deploy`
1592
+ * and never waits for a framework train.
1593
+ *
1594
+ * TWO groups:
1595
+ * - **Gallery tiles** — what the operator browses (timelapse, gif, mp4, mosaic).
1596
+ * - **Posters** — companion stills (`timelapse-preview`, `event-still`). They
1597
+ * exist so a video/gif tile can name its thumbnail via `posterArtifactId`.
1598
+ * They are not a filter chip and they are omitted from an unfiltered list.
1599
+ */
1600
+ var NC_GALLERY_ARTIFACT_KINDS = [
1590
1601
  "timelapse",
1591
- "timelapse-preview",
1602
+ "event-gif",
1603
+ "event-clip",
1592
1604
  "summary-mosaic"
1593
- ]);
1605
+ ];
1606
+ var NC_POSTER_ARTIFACT_KINDS = ["timelapse-preview", "event-still"];
1607
+ var NC_ARTIFACT_KINDS = [...NC_GALLERY_ARTIFACT_KINDS, ...NC_POSTER_ARTIFACT_KINDS];
1608
+ var NcArtifactKindSchema = require_dist._enum(NC_ARTIFACT_KINDS);
1609
+ /** Kind a notification attachment is filed under in the gallery. */
1610
+ function artifactKindForMediaType(mediaType) {
1611
+ if (mediaType === "gif") return "event-gif";
1612
+ if (mediaType === "video") return "event-clip";
1613
+ return "event-still";
1614
+ }
1594
1615
  /**
1595
1616
  * How the viewer should DRAW it. Derived from the mime once, here, so no client
1596
1617
  * has to carry a mime table — and so a GIF that degraded to a JPEG is drawn as
@@ -1949,7 +1970,7 @@ var NcArtifactIndex = class {
1949
1970
  }
1950
1971
  buildFilter(query, viewer) {
1951
1972
  const whereIn = { ...this.visibility(viewer).whereIn };
1952
- if (query.kinds !== void 0 && query.kinds.length > 0) whereIn["kind"] = [...query.kinds];
1973
+ whereIn["kind"] = query.kinds !== void 0 && query.kinds.length > 0 ? [...query.kinds] : [...NC_GALLERY_ARTIFACT_KINDS];
1953
1974
  if (query.deviceIds !== void 0 && query.deviceIds.length > 0) whereIn["deviceId"] = [...query.deviceIds];
1954
1975
  const whereBetween = {
1955
1976
  capturedAt: [query.fromMs ?? TIME_MIN, query.toMs ?? TIME_MAX],
@@ -7769,10 +7790,24 @@ function matchesSystemEvent(condition, subject) {
7769
7790
  if (!condition.kinds.includes(subject.kind)) return false;
7770
7791
  if (condition.deviceIds !== void 0 && (subject.deviceId === void 0 || !condition.deviceIds.includes(subject.deviceId))) return false;
7771
7792
  if (condition.deviceTypes !== void 0 && (subject.deviceType === void 0 || !condition.deviceTypes.includes(subject.deviceType))) return false;
7772
- if (condition.nodeIds !== void 0 && (subject.nodeId === void 0 || !condition.nodeIds.includes(subject.nodeId))) return false;
7773
- if (condition.packageNames !== void 0 && (subject.packageName === void 0 || !condition.packageNames.includes(subject.packageName))) return false;
7793
+ if (condition.nodeIds !== void 0) {
7794
+ if (!subjectNodes(subject).some((id) => condition.nodeIds.includes(id))) return false;
7795
+ }
7796
+ if (condition.packageNames !== void 0) {
7797
+ if (!subjectPackages(subject).some((name) => condition.packageNames.includes(name))) return false;
7798
+ }
7774
7799
  return true;
7775
7800
  }
7801
+ function subjectNodes(subject) {
7802
+ const ids = [...subject.nodeIds ?? []];
7803
+ if (subject.nodeId !== void 0 && !ids.includes(subject.nodeId)) ids.push(subject.nodeId);
7804
+ return ids;
7805
+ }
7806
+ function subjectPackages(subject) {
7807
+ const names = [...subject.packageNames ?? []];
7808
+ if (subject.packageName !== void 0 && !names.includes(subject.packageName)) names.push(subject.packageName);
7809
+ return names;
7810
+ }
7776
7811
  function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
7777
7812
  if (subject.kind === "system-event") {
7778
7813
  if (rule.delivery !== "system-event") return fail("delivery");
@@ -8229,12 +8264,30 @@ var en_default = {
8229
8264
  "one": "Camera {{deviceName}} has been analysed for {{minutes}} minute with no detection at all",
8230
8265
  "other": "Camera {{deviceName}} has been analysed for {{minutes}} minutes with no detection at all"
8231
8266
  },
8232
- "system.addon-update-available.title": "Addon update available",
8233
- "system.addon-update-available.body": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}})",
8267
+ "system.addon-update-available.title": {
8268
+ "one": "{{count}} addon update available",
8269
+ "other": "{{count}} addon updates available"
8270
+ },
8271
+ "system.addon-update-available.body": "{{list}}",
8234
8272
  "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
8235
- "system.server-update-available.title": "Server update available",
8273
+ "system.server-update-available.title": {
8274
+ "one": "Server update available on {{count}} node",
8275
+ "other": "Server update available on {{count}} nodes"
8276
+ },
8236
8277
  "system.server-update-available.body": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}})",
8237
8278
  "system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
8279
+ "system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on {{nodes}}",
8280
+ "system.addon-updated.title": {
8281
+ "one": "{{count}} addon updated",
8282
+ "other": "{{count}} addons updated"
8283
+ },
8284
+ "system.addon-updated.body": "{{list}}",
8285
+ "system.server-updated.title": "Server updated",
8286
+ "system.server-updated.body": "{{packageName}} on {{nodeId}} is now {{toVersion}} (was {{fromVersion}})",
8287
+ "system.server-updated.body.plain": "{{packageName}} on {{nodeId}} was updated",
8288
+ "system.export-completed.title": "Export ready",
8289
+ "system.export-completed.body": "The footage export is ready",
8290
+ "system.export-completed.body.titled": "{{title}} is ready",
8238
8291
  "system.camera-online.title": "Device online",
8239
8292
  "system.camera-online.body": "Device {{deviceName}} is online",
8240
8293
  "system.camera-offline.title": "Device offline",
@@ -8423,12 +8476,30 @@ var it_default = {
8423
8476
  "one": "La camera {{deviceName}} è analizzata da {{minutes}} minuto senza alcun rilevamento",
8424
8477
  "other": "La camera {{deviceName}} è analizzata da {{minutes}} minuti senza alcun rilevamento"
8425
8478
  },
8426
- "system.addon-update-available.title": "Aggiornamento addon disponibile",
8427
- "system.addon-update-available.body": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}})",
8479
+ "system.addon-update-available.title": {
8480
+ "one": "{{count}} aggiornamento addon disponibile",
8481
+ "other": "{{count}} aggiornamenti addon disponibili"
8482
+ },
8483
+ "system.addon-update-available.body": "{{list}}",
8428
8484
  "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
8429
- "system.server-update-available.title": "Aggiornamento server disponibile",
8485
+ "system.server-update-available.title": {
8486
+ "one": "Aggiornamento server disponibile su {{count}} nodo",
8487
+ "other": "Aggiornamento server disponibile su {{count}} nodi"
8488
+ },
8430
8489
  "system.server-update-available.body": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}})",
8431
8490
  "system.server-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
8491
+ "system.server-update-available.body.onNodes": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) su {{nodes}}",
8492
+ "system.addon-updated.title": {
8493
+ "one": "{{count}} addon aggiornato",
8494
+ "other": "{{count}} addon aggiornati"
8495
+ },
8496
+ "system.addon-updated.body": "{{list}}",
8497
+ "system.server-updated.title": "Server aggiornato",
8498
+ "system.server-updated.body": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}} (era {{fromVersion}})",
8499
+ "system.server-updated.body.plain": "{{packageName}} sul nodo {{nodeId}} è stato aggiornato",
8500
+ "system.export-completed.title": "Export pronto",
8501
+ "system.export-completed.body": "L'export del filmato è pronto",
8502
+ "system.export-completed.body.titled": "{{title}} è pronto",
8432
8503
  "system.camera-online.title": "Dispositivo online",
8433
8504
  "system.camera-online.body": "Il dispositivo {{deviceName}} è online",
8434
8505
  "system.camera-offline.title": "Dispositivo offline",
@@ -9155,7 +9226,7 @@ var NcDispatcher = class {
9155
9226
  vars: { body: rendered },
9156
9227
  frozen: rendered
9157
9228
  }) : rendered;
9158
- const attachments = [...await this.withArtifactUrls(await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
9229
+ const attachments = [...await this.withArtifactUrls(entry, await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
9159
9230
  const params = pickParams(entry.payload.params);
9160
9231
  const actions = await this.mintActions(entry, target);
9161
9232
  return {
@@ -9216,11 +9287,39 @@ var NcDispatcher = class {
9216
9287
  }
9217
9288
  });
9218
9289
  }
9219
- async withArtifactUrls(attachments) {
9290
+ async withArtifactUrls(entry, attachments) {
9291
+ const index = this.deps.indexArtifact;
9220
9292
  const publish = this.deps.publishArtifact;
9221
- if (publish === void 0) return [...attachments];
9293
+ if (index === void 0 && publish === void 0) return [...attachments];
9222
9294
  const out = [];
9295
+ let posterId = null;
9296
+ const deviceId = entry.payload.subject.deviceId;
9297
+ const common = {
9298
+ deviceId: deviceId > 0 ? deviceId : null,
9299
+ deviceIds: deviceId > 0 ? [deviceId] : [],
9300
+ capturedAt: entry.payload.subject.timestamp,
9301
+ ruleId: entry.ruleId,
9302
+ ruleName: entry.payload.ruleName
9303
+ };
9223
9304
  for (const att of attachments) {
9305
+ if (index !== void 0) {
9306
+ const kind = artifactKindForMediaType(att.mediaType);
9307
+ const meta = {
9308
+ ...common,
9309
+ kind,
9310
+ ...posterId !== null && kind !== "event-still" ? { posterArtifactId: posterId } : {}
9311
+ };
9312
+ const pub = await index(att.bytes, att.mime, meta).catch(() => ({
9313
+ url: null,
9314
+ artifactId: null
9315
+ }));
9316
+ if (kind === "event-still" && pub.artifactId !== null) posterId = pub.artifactId;
9317
+ out.push(pub.url === null ? { ...att } : {
9318
+ ...att,
9319
+ url: pub.url
9320
+ });
9321
+ continue;
9322
+ }
9224
9323
  const url = await publish(att.bytes, att.mime).catch(() => null);
9225
9324
  out.push(url === null ? { ...att } : {
9226
9325
  ...att,
@@ -9834,6 +9933,16 @@ function stringField(data, key) {
9834
9933
  const value = data[key];
9835
9934
  return typeof value === "string" && value.length > 0 ? value : void 0;
9836
9935
  }
9936
+ function stringList(data, key) {
9937
+ const raw = data[key];
9938
+ if (!Array.isArray(raw)) return [];
9939
+ const ids = [];
9940
+ for (const item of raw) {
9941
+ if (typeof item === "string" && item.length > 0 && !ids.includes(item)) ids.push(item);
9942
+ if (ids.length >= 20) break;
9943
+ }
9944
+ return ids;
9945
+ }
9837
9946
  function numberField(data, key) {
9838
9947
  const value = data[key];
9839
9948
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
@@ -9977,6 +10086,8 @@ function systemSubject(input) {
9977
10086
  ...input.deviceType !== void 0 ? { deviceType: input.deviceType } : {},
9978
10087
  ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {},
9979
10088
  ...input.packageName !== void 0 ? { packageName: input.packageName } : {},
10089
+ ...input.nodeIds !== void 0 ? { nodeIds: input.nodeIds } : {},
10090
+ ...input.packageNames !== void 0 ? { packageNames: input.packageNames } : {},
9980
10091
  ...input.textCount !== void 0 ? { textCount: input.textCount } : {},
9981
10092
  ...input.alarmSource !== void 0 ? { alarmSource: input.alarmSource } : {},
9982
10093
  data
@@ -10153,34 +10264,189 @@ function incomingFromSystemEvent(event, lookup) {
10153
10264
  ...identity !== void 0 ? { deviceType: identity.type } : {}
10154
10265
  });
10155
10266
  }
10156
- case "update.available": {
10157
- const target = stringField(data, "target");
10158
- const packageName = stringField(data, "packageName");
10159
- const currentVersion = stringField(data, "currentVersion");
10160
- const latestVersion = stringField(data, "latestVersion");
10161
- if (target !== "addon" && target !== "server" || packageName === void 0 || currentVersion === void 0 || latestVersion === void 0) return null;
10162
- const addon = target === "addon";
10163
- const nodeId = stringField(data, "nodeId");
10164
- const nodeScope = nodeId ?? "hub";
10165
- const kind = addon ? "addon-update-available" : "server-update-available";
10166
- return systemSubject({
10167
- facts,
10168
- kind,
10169
- subject: addon ? `package:${nodeScope}:${packageName}` : `package:${packageName}`,
10170
- titleKey: `system.${kind}.title`,
10171
- bodyKey: nodeId !== void 0 ? `system.${kind}.body.onNode` : `system.${kind}.body`,
10172
- textParams: {
10173
- packageName,
10174
- currentVersion,
10175
- latestVersion,
10176
- ...nodeId !== void 0 ? { nodeId } : {}
10177
- },
10267
+ case "update.available": return incomingFromUpdateAvailable(facts, data);
10268
+ case "addon.updated": {
10269
+ const update = parseAddonUpdateComplete(data);
10270
+ return update === null ? null : incomingFromAddonUpdates(facts, [update]);
10271
+ }
10272
+ case "system.restart-completed": return incomingFromServerUpdated(facts, data);
10273
+ case "recording.export.completed": return incomingFromExportCompleted(facts, data);
10274
+ default: return null;
10275
+ }
10276
+ }
10277
+ function incomingFromUpdateAvailable(facts, data) {
10278
+ const target = stringField(data, "target");
10279
+ if (target !== "addon" && target !== "server") return null;
10280
+ const packages = updatePackages(data);
10281
+ if (packages.length === 0) return null;
10282
+ const addon = target === "addon";
10283
+ const kind = addon ? "addon-update-available" : "server-update-available";
10284
+ const nodeIds = unique(packages.map((pkg) => pkg.nodeId).filter((id) => id !== void 0));
10285
+ const packageNames = unique(packages.map((pkg) => pkg.packageName));
10286
+ const signature = packages.map((pkg) => `${pkg.nodeId ?? ""}:${pkg.packageName}@${pkg.latestVersion}`).toSorted().join(",");
10287
+ const list = packages.map(formatAvailableLine).join("\n");
10288
+ const nodes = nodeIds.join(", ");
10289
+ const head = packages[0];
10290
+ const oneNode = nodeIds.length === 1 ? nodeIds[0] : void 0;
10291
+ return systemSubject({
10292
+ facts,
10293
+ kind,
10294
+ subject: addon ? `packages:addon:${signature}` : `packages:server:${signature}`,
10295
+ titleKey: `system.${kind}.title`,
10296
+ bodyKey: !addon && oneNode !== void 0 ? `system.${kind}.body.onNode` : !addon && nodeIds.length > 1 ? `system.${kind}.body.onNodes` : `system.${kind}.body`,
10297
+ textParams: {
10298
+ list,
10299
+ packageName: head.packageName,
10300
+ currentVersion: head.currentVersion,
10301
+ latestVersion: head.latestVersion,
10302
+ nodes,
10303
+ count: String(addon ? packages.length : nodeIds.length > 0 ? nodeIds.length : 1),
10304
+ ...oneNode !== void 0 ? { nodeId: oneNode } : {}
10305
+ },
10306
+ textCount: addon ? packages.length : nodeIds.length > 0 ? nodeIds.length : 1,
10307
+ packageName: head.packageName,
10308
+ packageNames,
10309
+ ...oneNode !== void 0 ? { nodeId: oneNode } : {},
10310
+ ...nodeIds.length > 0 ? { nodeIds } : {}
10311
+ });
10312
+ }
10313
+ /** Version-bearing `addon.updated` only — settings saves carry neither. */
10314
+ function parseAddonUpdateComplete(data) {
10315
+ const packageName = stringField(data, "packageName");
10316
+ const fromVersion = stringField(data, "fromVersion");
10317
+ const toVersion = stringField(data, "toVersion");
10318
+ if (packageName === void 0 || fromVersion === void 0 || toVersion === void 0) return null;
10319
+ const nodeId = stringField(data, "agent") ?? stringField(data, "nodeId");
10320
+ return {
10321
+ packageName,
10322
+ fromVersion,
10323
+ toVersion,
10324
+ ...nodeId !== void 0 ? { nodeId } : {}
10325
+ };
10326
+ }
10327
+ function incomingFromAddonUpdates(facts, updates) {
10328
+ if (updates.length === 0) return null;
10329
+ const packageNames = unique(updates.map((update) => update.packageName));
10330
+ const nodeIds = unique(updates.map((update) => update.nodeId).filter((id) => id !== void 0));
10331
+ const signature = updates.map((update) => `${update.nodeId ?? ""}:${update.packageName}@${update.toVersion}`).toSorted().join(",");
10332
+ const list = updates.map((update) => update.nodeId !== void 0 ? `${update.packageName} ${update.fromVersion} → ${update.toVersion} (${update.nodeId})` : `${update.packageName} ${update.fromVersion} → ${update.toVersion}`).join("\n");
10333
+ const head = updates[0];
10334
+ return systemSubject({
10335
+ facts,
10336
+ kind: "addon-updated",
10337
+ subject: `packages:addon-updated:${signature}`,
10338
+ titleKey: "system.addon-updated.title",
10339
+ bodyKey: "system.addon-updated.body",
10340
+ textParams: {
10341
+ list,
10342
+ count: String(packageNames.length)
10343
+ },
10344
+ textCount: packageNames.length,
10345
+ packageName: head.packageName,
10346
+ packageNames,
10347
+ ...head.nodeId !== void 0 ? { nodeId: head.nodeId } : {},
10348
+ ...nodeIds.length > 0 ? { nodeIds } : {}
10349
+ });
10350
+ }
10351
+ function incomingFromServerUpdated(facts, data) {
10352
+ const kind = stringField(data, "kind");
10353
+ const requestedBy = stringField(data, "requestedBy");
10354
+ if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:"))) return null;
10355
+ const packageName = stringField(data, "packageName") ?? "@camstack/server";
10356
+ const fromVersion = stringField(data, "fromVersion");
10357
+ const toVersion = stringField(data, "toVersion") ?? versionFromRequestedBy(requestedBy);
10358
+ const nodeId = stringField(data, "nodeId") ?? "hub";
10359
+ const bodyKey = fromVersion !== void 0 && toVersion !== void 0 ? "system.server-updated.body" : "system.server-updated.body.plain";
10360
+ return systemSubject({
10361
+ facts,
10362
+ kind: "server-updated",
10363
+ subject: `server-updated:${nodeId}:${toVersion ?? "unknown"}`,
10364
+ titleKey: "system.server-updated.title",
10365
+ bodyKey,
10366
+ textParams: {
10367
+ packageName,
10368
+ nodeId,
10369
+ ...fromVersion !== void 0 ? { fromVersion } : {},
10370
+ ...toVersion !== void 0 ? { toVersion } : {}
10371
+ },
10372
+ packageName,
10373
+ nodeId,
10374
+ nodeIds: [nodeId]
10375
+ });
10376
+ }
10377
+ function incomingFromExportCompleted(facts, data) {
10378
+ const exportId = stringField(data, "exportId");
10379
+ if (exportId === void 0) return null;
10380
+ const deviceId = numberField(data, "deviceId");
10381
+ const title = stringField(data, "title");
10382
+ const bytes = numberField(data, "fileBytes");
10383
+ const notifyTargetIds = stringList(data, "notifyTargetIds");
10384
+ const incoming = systemSubject({
10385
+ facts,
10386
+ kind: "export-completed",
10387
+ subject: `export:${exportId}`,
10388
+ titleKey: "system.export-completed.title",
10389
+ bodyKey: title !== void 0 ? "system.export-completed.body.titled" : "system.export-completed.body",
10390
+ textParams: {
10391
+ exportId,
10392
+ ...title !== void 0 ? { title } : {},
10393
+ ...bytes !== void 0 ? { bytes: String(bytes) } : {},
10394
+ ...deviceId !== void 0 ? { deviceId: String(deviceId) } : {}
10395
+ },
10396
+ ...deviceId !== void 0 ? { deviceId } : {}
10397
+ });
10398
+ if (notifyTargetIds.length === 0) return incoming;
10399
+ return {
10400
+ ...incoming,
10401
+ controls: {
10402
+ directTargetIds: notifyTargetIds,
10403
+ bypassMute: true
10404
+ }
10405
+ };
10406
+ }
10407
+ function updatePackages(data) {
10408
+ const raw = data["packages"];
10409
+ if (Array.isArray(raw) && raw.length > 0) {
10410
+ const rows = [];
10411
+ for (const item of raw) {
10412
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
10413
+ const row = item;
10414
+ const packageName = stringField(row, "packageName");
10415
+ const currentVersion = stringField(row, "currentVersion");
10416
+ const latestVersion = stringField(row, "latestVersion");
10417
+ if (packageName === void 0 || currentVersion === void 0 || latestVersion === void 0) continue;
10418
+ const nodeId = stringField(row, "nodeId");
10419
+ rows.push({
10178
10420
  packageName,
10421
+ currentVersion,
10422
+ latestVersion,
10179
10423
  ...nodeId !== void 0 ? { nodeId } : {}
10180
10424
  });
10181
10425
  }
10182
- default: return null;
10426
+ if (rows.length > 0) return rows;
10183
10427
  }
10428
+ const packageName = stringField(data, "packageName");
10429
+ const currentVersion = stringField(data, "currentVersion");
10430
+ const latestVersion = stringField(data, "latestVersion");
10431
+ if (packageName === void 0 || currentVersion === void 0 || latestVersion === void 0) return [];
10432
+ const nodeId = stringField(data, "nodeId");
10433
+ return [{
10434
+ packageName,
10435
+ currentVersion,
10436
+ latestVersion,
10437
+ ...nodeId !== void 0 ? { nodeId } : {}
10438
+ }];
10439
+ }
10440
+ function formatAvailableLine(pkg) {
10441
+ const line = `${pkg.packageName} ${pkg.latestVersion} (current ${pkg.currentVersion})`;
10442
+ return pkg.nodeId !== void 0 ? `${line} on ${pkg.nodeId}` : line;
10443
+ }
10444
+ function versionFromRequestedBy(requestedBy) {
10445
+ if (requestedBy === void 0) return void 0;
10446
+ return /@([^@\s]+)$/.exec(requestedBy)?.[1];
10447
+ }
10448
+ function unique(values) {
10449
+ return [...new Set(values)];
10184
10450
  }
10185
10451
  /**
10186
10452
  * The COMBINED row's two keys, or null when this transition is not one.
@@ -10896,6 +11162,67 @@ function recordToRow$2(subject, data) {
10896
11162
  updatedAt
10897
11163
  };
10898
11164
  }
11165
+ function isDeviceLivenessKind(kind) {
11166
+ return kind === "device-online" || kind === "device-offline" || kind === "camera-online" || kind === "camera-offline";
11167
+ }
11168
+ function livenessStateOfKind(kind) {
11169
+ return kind.endsWith("-offline") ? "offline" : "online";
11170
+ }
11171
+ var DeviceLivenessHoldoff = class {
11172
+ flush;
11173
+ pending = /* @__PURE__ */ new Map();
11174
+ lastNotified = /* @__PURE__ */ new Map();
11175
+ timers = /* @__PURE__ */ new Map();
11176
+ delayMs;
11177
+ schedule;
11178
+ cancel;
11179
+ constructor(flush, options = {}) {
11180
+ this.flush = flush;
11181
+ this.delayMs = options.delayMs ?? 3e5;
11182
+ this.schedule = options.schedule ?? setTimeout;
11183
+ this.cancel = options.cancel ?? clearTimeout;
11184
+ }
11185
+ /** The seeded (or last delivered) state — a bounce back to this is silence. */
11186
+ remember(subject, state) {
11187
+ this.lastNotified.set(subject, state);
11188
+ }
11189
+ /** Directory-seeded rows never pass through {@link remember}; first flip does. */
11190
+ rememberIfAbsent(subject, state) {
11191
+ if (!this.lastNotified.has(subject)) this.lastNotified.set(subject, state);
11192
+ }
11193
+ /** A real ledger flip. Starts / resets the trailing timer. */
11194
+ arm(subject, incoming) {
11195
+ this.pending.set(subject, incoming);
11196
+ const existing = this.timers.get(subject);
11197
+ if (existing !== void 0) this.cancel(existing);
11198
+ if (this.delayMs <= 0) {
11199
+ this.timers.delete(subject);
11200
+ this.fire(subject);
11201
+ return;
11202
+ }
11203
+ const id = this.schedule(() => {
11204
+ this.timers.delete(subject);
11205
+ this.fire(subject);
11206
+ }, this.delayMs);
11207
+ this.timers.set(subject, id);
11208
+ }
11209
+ dispose() {
11210
+ for (const id of this.timers.values()) this.cancel(id);
11211
+ this.timers.clear();
11212
+ this.pending.clear();
11213
+ }
11214
+ fire(subject) {
11215
+ const incoming = this.pending.get(subject);
11216
+ this.pending.delete(subject);
11217
+ if (incoming === void 0) return;
11218
+ const kind = incoming.subject.systemEvent?.kind;
11219
+ if (kind === void 0) return;
11220
+ const state = livenessStateOfKind(kind);
11221
+ if (this.lastNotified.get(subject) === state) return;
11222
+ this.lastNotified.set(subject, state);
11223
+ this.flush(incoming);
11224
+ }
11225
+ };
10899
11226
  //#endregion
10900
11227
  //#region src/notification-center/occupancy-watcher.ts
10901
11228
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
@@ -17793,6 +18120,36 @@ var TimelapseStore = class {
17793
18120
  return rule;
17794
18121
  }
17795
18122
  };
18123
+ var AddonUpdateCoalescer = class {
18124
+ flush;
18125
+ pending = /* @__PURE__ */ new Map();
18126
+ timer = null;
18127
+ delayMs;
18128
+ schedule;
18129
+ cancel;
18130
+ constructor(flush, options = {}) {
18131
+ this.flush = flush;
18132
+ this.delayMs = options.delayMs ?? 3e4;
18133
+ this.schedule = options.schedule ?? setTimeout;
18134
+ this.cancel = options.cancel ?? clearTimeout;
18135
+ }
18136
+ observe(update) {
18137
+ this.pending.set(update.packageName, update);
18138
+ if (this.timer !== null) this.cancel(this.timer);
18139
+ this.timer = this.schedule(() => {
18140
+ this.timer = null;
18141
+ const batch = [...this.pending.values()];
18142
+ this.pending.clear();
18143
+ if (batch.length > 0) this.flush(batch);
18144
+ }, this.delayMs);
18145
+ }
18146
+ dispose() {
18147
+ if (this.timer === null) return;
18148
+ this.cancel(this.timer);
18149
+ this.timer = null;
18150
+ this.pending.clear();
18151
+ }
18152
+ };
17796
18153
  //#endregion
17797
18154
  //#region src/notification-center/zone-owner-cache.ts
17798
18155
  var NcZoneOwnershipCache = class {
@@ -17902,6 +18259,15 @@ var TIMELAPSE_LOOKAHEAD_MS = 9 * 864e5;
17902
18259
  */
17903
18260
  var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
17904
18261
  /**
18262
+ * Synthetic rule id for an export's on-screen picker.
18263
+ *
18264
+ * The operator named these targets on the export screen, not in a rule. The
18265
+ * row still lives in the outbox so history, retry and language rendering are
18266
+ * the same path as every other notification. A matching `export-completed`
18267
+ * rule that already reached a target is skipped so one export is one push.
18268
+ */
18269
+ var NC_EXPORT_NOTIFY_RULE_ID = "nc-export-notify";
18270
+ /**
17905
18271
  * Which alarm buttons a notification of this kind should carry, or `null` for
17906
18272
  * every notification that is not about the panel.
17907
18273
  *
@@ -18349,6 +18715,10 @@ var NotificationCenter = class NotificationCenter {
18349
18715
  * seeds, only a real flip notifies.
18350
18716
  */
18351
18717
  liveness;
18718
+ /** Batches `addon.updated` (version-bearing) into one notification per 30 s. */
18719
+ addonUpdates;
18720
+ /** Trailing 5 min hold-off for camera liveness flaps (plugin restart). */
18721
+ livenessHoldoff;
18352
18722
  /**
18353
18723
  * Serialized evaluation chain. The persist hooks are fire-and-forget for
18354
18724
  * the frame path, but two concurrent evaluations of the same device
@@ -18651,6 +19021,16 @@ var NotificationCenter = class NotificationCenter {
18651
19021
  store: deps.store,
18652
19022
  logger: this.logger.child("liveness")
18653
19023
  });
19024
+ this.addonUpdates = new AddonUpdateCoalescer((updates) => {
19025
+ const incoming = incomingFromAddonUpdates({
19026
+ eventId: `addon-updated:${updates.map((u) => u.packageName).join(",")}`,
19027
+ timestamp: this.now(),
19028
+ category: "addon.updated",
19029
+ data: { packages: updates }
19030
+ }, updates);
19031
+ if (incoming !== null) this.consumeEvent(incoming);
19032
+ });
19033
+ this.livenessHoldoff = new DeviceLivenessHoldoff((event) => this.consumeEvent(event), { delayMs: deps.livenessHoldoffMs ?? (process.env.VITEST ? 0 : 3e5) });
18654
19034
  this.actionRunner = deps.actuate !== void 0 && deps.isCapDeviceScoped !== void 0 ? new NcRuleActionRunner({
18655
19035
  logger: this.logger.child("actions"),
18656
19036
  actuate: (input) => this.actuateWithAttribution(input),
@@ -18909,6 +19289,8 @@ var NotificationCenter = class NotificationCenter {
18909
19289
  this.timelapseScheduler?.stop();
18910
19290
  this.summaryProducer?.stop();
18911
19291
  this.audioStills?.clear();
19292
+ this.addonUpdates.dispose();
19293
+ this.livenessHoldoff.dispose();
18912
19294
  this.evaluationActive = false;
18913
19295
  }
18914
19296
  /**
@@ -19383,6 +19765,11 @@ var NotificationCenter = class NotificationCenter {
19383
19765
  */
19384
19766
  onSystemEvent(event) {
19385
19767
  if (!this.evaluationActive) return;
19768
+ if (event.category === "addon.updated") {
19769
+ const update = parseAddonUpdateComplete(event.data);
19770
+ if (update !== null) this.addonUpdates.observe(update);
19771
+ return;
19772
+ }
19386
19773
  const incoming = incomingFromSystemEvent(event, this.deviceDirectory.lookup());
19387
19774
  if (incoming === null) {
19388
19775
  const dropped = discardedSystemEvent(event);
@@ -19398,11 +19785,22 @@ var NotificationCenter = class NotificationCenter {
19398
19785
  const system = incoming.subject.systemEvent;
19399
19786
  const observation = system !== void 0 ? ledgeredLiveness(system) : null;
19400
19787
  if (observation !== null) {
19788
+ const previous = this.liveness.stateOf(observation.subject);
19401
19789
  const verdict = this.liveness.observe(observation, this.now());
19790
+ if (verdict === "seeded") {
19791
+ this.livenessHoldoff.remember(observation.subject, observation.state);
19792
+ this.reportLivenessDrop(incoming, verdict);
19793
+ return;
19794
+ }
19402
19795
  if (verdict !== "flip") {
19403
19796
  this.reportLivenessDrop(incoming, verdict);
19404
19797
  return;
19405
19798
  }
19799
+ if (previous !== void 0) this.livenessHoldoff.rememberIfAbsent(observation.subject, previous);
19800
+ if (isDeviceLivenessKind(observation.kind)) {
19801
+ this.livenessHoldoff.arm(observation.subject, incoming);
19802
+ return;
19803
+ }
19406
19804
  } else if (system !== void 0 && (system.kind.startsWith("device-") || system.kind.startsWith("stream-"))) {
19407
19805
  if (this.lastSystemLiveness.get(system.subject) === system.kind) return;
19408
19806
  this.lastSystemLiveness.set(system.subject, system.kind);
@@ -19895,6 +20293,55 @@ var NotificationCenter = class NotificationCenter {
19895
20293
  });
19896
20294
  }
19897
20295
  /**
20296
+ * Deliver to targets the producer named (`controls.directTargetIds`).
20297
+ *
20298
+ * Skips ids a matching rule already reached so one export is one push.
20299
+ * Absent/empty is a no-op — every other producer omits the control.
20300
+ */
20301
+ async enqueuePickerTargets(event, reached, outcomes) {
20302
+ const ids = event.controls?.directTargetIds;
20303
+ if (ids === void 0 || ids.length === 0) return;
20304
+ const targets = [...new Set(ids)].filter((id) => !reached.has(id));
20305
+ if (targets.length === 0) return;
20306
+ const subject = event.subject;
20307
+ const entries = targets.map((targetId) => ({
20308
+ ruleId: NC_EXPORT_NOTIFY_RULE_ID,
20309
+ targetId,
20310
+ deviceId: subject.deviceId,
20311
+ recordKind: "system-event",
20312
+ recordId: subject.recordId,
20313
+ payload: {
20314
+ ruleName: "export",
20315
+ delivery: "system-event",
20316
+ priority: 2,
20317
+ media: "none",
20318
+ subject: {
20319
+ deviceId: subject.deviceId,
20320
+ className: "export",
20321
+ zones: [],
20322
+ timestamp: subject.timestamp,
20323
+ ...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
20324
+ }
20325
+ }
20326
+ }));
20327
+ const inserted = await this.outbox.enqueue(entries);
20328
+ outcomes.push({
20329
+ ruleId: NC_EXPORT_NOTIFY_RULE_ID,
20330
+ ruleName: "export",
20331
+ matched: true,
20332
+ cooldown: "n/a",
20333
+ enqueued: inserted,
20334
+ targetIds: entries.map((entry) => entry.targetId)
20335
+ });
20336
+ this.logger.info("export picker notification enqueued", {
20337
+ tags: { deviceId: subject.deviceId },
20338
+ meta: {
20339
+ targets: targets.length,
20340
+ inserted
20341
+ }
20342
+ });
20343
+ }
20344
+ /**
19898
20345
  * The COMBINED notification's second half: withdraw what it replaced.
19899
20346
  *
19900
20347
  * Only for the targets the alarm actually reached, and only while their rows
@@ -20019,6 +20466,8 @@ var NotificationCenter = class NotificationCenter {
20019
20466
  }
20020
20467
  });
20021
20468
  if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
20469
+ await this.enqueuePickerTargets(event, /* @__PURE__ */ new Set(), outcomes);
20470
+ if (outcomes.some((outcome) => outcome.enqueued > 0)) return report({ candidates: 0 });
20022
20471
  return report({ droppedBy: "no-candidate-rule" });
20023
20472
  }
20024
20473
  const now = this.now();
@@ -20215,6 +20664,8 @@ var NotificationCenter = class NotificationCenter {
20215
20664
  }
20216
20665
  if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
20217
20666
  if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
20667
+ const reached = new Set(outcomes.flatMap((outcome) => outcome.targetIds));
20668
+ await this.enqueuePickerTargets(event, reached, outcomes);
20218
20669
  return report({ candidates: candidates.length });
20219
20670
  }
20220
20671
  /**
@@ -21434,6 +21885,9 @@ function systemEventSubject(kind, scope) {
21434
21885
  if (kind === "node-inference-unavailable") return `node-inference:${scope.nodeId ?? "hub"}`;
21435
21886
  if (kind === "detection-blind") return `detection-blind:${scope.deviceId ?? 0}`;
21436
21887
  if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
21888
+ if (kind === "addon-updated") return `packages:addon-updated:${scope.packageName ?? "pkg"}`;
21889
+ if (kind === "server-updated") return `server-updated:${scope.nodeId ?? "hub"}`;
21890
+ if (kind === "export-completed") return `export:${scope.deviceId ?? 0}`;
21437
21891
  return `alarm:${scope.deviceId ?? 0}`;
21438
21892
  }
21439
21893
  /**
@@ -21545,7 +21999,13 @@ function planRuleTest(input) {
21545
21999
  profile: "low",
21546
22000
  seconds: "30",
21547
22001
  devices: "cuda:0",
21548
- minutes: "15"
22002
+ minutes: "15",
22003
+ list: "@camstack/addon-pipeline 1.0.0 (current 0.0.0)",
22004
+ nodes: "hub",
22005
+ fromVersion: "0.0.0",
22006
+ toVersion: "0.0.1",
22007
+ title: "Export",
22008
+ count: "1"
21549
22009
  },
21550
22010
  ...systemScope.deviceId !== void 0 ? { deviceId: systemScope.deviceId } : {},
21551
22011
  ...c.systemEvent?.deviceTypes?.[0] !== void 0 ? { deviceType: c.systemEvent.deviceTypes[0] } : {},
@@ -28414,7 +28874,123 @@ var OrphanAuditActionResultSchema = require_dist.object({
28414
28874
  });
28415
28875
  var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
28416
28876
  //#endregion
28877
+ //#region src/pipeline-analytics/videoclip-visits.ts
28878
+ /**
28879
+ * Videoclip visits — the grain a clip should have on an events-mode camera.
28880
+ *
28881
+ * Disk writes one fMP4 per ffmpeg roll (~8–12 s). The recorder calendar merges
28882
+ * only gaps ≤ 5 s, so a single pantry walk shows up as several availability
28883
+ * ranges with ~8 s GOP holes between them. Analytics then mints one 15 s clip
28884
+ * per motion heartbeat. A *visit* is the keep-window those fragments belong
28885
+ * to: cluster availability with {@link VISIT_MERGE_GAP_MS}, annotate with
28886
+ * overlapping events. No second copy of the media; the files stay the source
28887
+ * of truth.
28888
+ */
28889
+ /** Synthetic event-id slot in `analytics:<start>:<end>:<id>` for a visit clip. */
28890
+ var VISIT_EVENT_ID = "visit";
28891
+ function clusterFootageVisits(ranges, gapMs = require_dist.VISIT_MERGE_GAP_MS) {
28892
+ if (ranges.length === 0) return [];
28893
+ const sorted = [...ranges].toSorted((a, b) => a.startMs - b.startMs);
28894
+ const visits = [];
28895
+ let pieces = [sorted[0]];
28896
+ let endMs = sorted[0].endMs;
28897
+ for (let i = 1; i < sorted.length; i++) {
28898
+ const next = sorted[i];
28899
+ if (next.startMs <= endMs + gapMs) {
28900
+ pieces.push(next);
28901
+ endMs = Math.max(endMs, next.endMs);
28902
+ continue;
28903
+ }
28904
+ visits.push(finishVisit(pieces, endMs));
28905
+ pieces = [next];
28906
+ endMs = next.endMs;
28907
+ }
28908
+ visits.push(finishVisit(pieces, endMs));
28909
+ return visits;
28910
+ }
28911
+ function finishVisit(pieces, endMs) {
28912
+ const holes = [];
28913
+ for (let i = 1; i < pieces.length; i++) {
28914
+ const prevEnd = pieces[i - 1].endMs;
28915
+ const nextStart = pieces[i].startMs;
28916
+ if (nextStart > prevEnd) holes.push({
28917
+ startMs: prevEnd,
28918
+ endMs: nextStart
28919
+ });
28920
+ }
28921
+ return {
28922
+ startMs: pieces[0].startMs,
28923
+ endMs,
28924
+ ranges: pieces,
28925
+ holes
28926
+ };
28927
+ }
28928
+ var KIND_ORDER = [
28929
+ "object",
28930
+ "motion",
28931
+ "audio"
28932
+ ];
28933
+ function eventOverlapsVisit(event, visit, pad) {
28934
+ if (event.timestamp >= visit.startMs && event.timestamp <= visit.endMs) return true;
28935
+ const windowStart = event.timestamp - pad.preMs;
28936
+ const windowEnd = event.timestamp + pad.postMs;
28937
+ return windowStart < visit.endMs && windowEnd > visit.startMs;
28938
+ }
28939
+ function dominantKind(attached) {
28940
+ for (const kind of KIND_ORDER) if (attached.some((a) => a.kind === kind)) return kind;
28941
+ return "motion";
28942
+ }
28943
+ function visitsToClips(visits, events, pad) {
28944
+ const tagged = [
28945
+ ...events.object.map((event) => ({
28946
+ kind: "object",
28947
+ event
28948
+ })),
28949
+ ...events.motion.map((event) => ({
28950
+ kind: "motion",
28951
+ event
28952
+ })),
28953
+ ...events.audio.map((event) => ({
28954
+ kind: "audio",
28955
+ event
28956
+ }))
28957
+ ];
28958
+ return visits.map((visit) => {
28959
+ const attached = tagged.filter((t) => eventOverlapsVisit(t.event, visit, pad));
28960
+ const kind = dominantKind(attached);
28961
+ const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl) ?? attached.find((a) => a.event.mediaUrl);
28962
+ return {
28963
+ id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
28964
+ source: "analytics",
28965
+ kind,
28966
+ timeRange: {
28967
+ startMs: visit.startMs,
28968
+ endMs: visit.endMs
28969
+ },
28970
+ thumbnail: withThumb?.event.mediaUrl,
28971
+ eventIds: attached.map((a) => a.event.id),
28972
+ holes: [...visit.holes]
28973
+ };
28974
+ }).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
28975
+ }
28976
+ //#endregion
28417
28977
  //#region src/pipeline-analytics/videoclips-provider.ts
28978
+ /**
28979
+ * Default `videoclips` provider — composes recorded footage visits with the
28980
+ * analytics event log. A clip is a time-WINDOW over existing footage, never a
28981
+ * separate file: `getClipPlayback` resolves to
28982
+ * `recording.getPlaybackManifest(startMs, endMs)`.
28983
+ *
28984
+ * When availability is present the grain is a *visit* (events-mode keep-window
28985
+ * clustered at `VISIT_MERGE_GAP_MS`), not one clip per motion heartbeat. The
28986
+ * clip id still encodes the window (`analytics:<startMs>:<endMs>:visit`) so
28987
+ * playback is self-contained. Without availability (or when that read fails)
28988
+ * the provider falls back to per-event padded windows.
28989
+ *
28990
+ * Pure helpers (`eventsToClips` / `clusterFootageVisits` / `clipIdFor` /
28991
+ * `parseClipId`) are unit-tested in isolation; the provider factory wires
28992
+ * injected fetch/resolve deps so the compose logic is testable without I/O.
28993
+ */
28418
28994
  var SOURCE = "analytics";
28419
28995
  function clipIdFor(eventId, startMs, endMs) {
28420
28996
  return `${SOURCE}:${startMs}:${endMs}:${eventId}`;
@@ -28457,36 +29033,29 @@ function eventsToClips(events, pad) {
28457
29033
  ...events.audio.map((e) => make(e, "audio"))
28458
29034
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
28459
29035
  }
28460
- /** Keep only clips whose window overlaps at least one recorded range — a clip
28461
- * is a window over footage, so a window with nothing behind it is unplayable
28462
- * and must not be listed (a camera that never recorded showed 15 dead audio
28463
- * "clips" in the viewer ribbon, every tap a 500). Pure. */
28464
- function filterClipsByFootage(clips, ranges) {
28465
- return clips.filter((c) => ranges.some((r) => c.timeRange.startMs < r.endMs && c.timeRange.endMs > r.startMs));
28466
- }
28467
29036
  function createVideoclipsProvider(deps) {
28468
29037
  return {
28469
29038
  listClips: async ({ deviceId, since, until, limit }) => {
28470
- const clips = eventsToClips(await deps.fetchEvents({
29039
+ const events = await deps.fetchEvents({
28471
29040
  deviceId,
28472
29041
  since,
28473
29042
  until,
28474
- limit
28475
- }), deps.defaultPad);
28476
- let filtered = clips;
29043
+ ...deps.fetchAvailability ? {} : { limit }
29044
+ });
29045
+ let clips = eventsToClips(events, deps.defaultPad);
28477
29046
  if (deps.fetchAvailability) try {
28478
- filtered = filterClipsByFootage(clips, await deps.fetchAvailability({
29047
+ clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
28479
29048
  deviceId,
28480
29049
  since,
28481
29050
  until
28482
- }));
29051
+ }), require_dist.VISIT_MERGE_GAP_MS), events, deps.defaultPad);
28483
29052
  } catch (err) {
28484
29053
  deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
28485
29054
  deviceId,
28486
29055
  error: err instanceof Error ? err.message : String(err)
28487
29056
  });
28488
29057
  }
28489
- return limit != null ? filtered.slice(0, limit) : filtered;
29058
+ return limit != null ? clips.slice(0, limit) : clips;
28490
29059
  },
28491
29060
  getClipPlayback: async ({ deviceId, clipId }) => {
28492
29061
  const parsed = parseClipId(clipId);
@@ -35615,7 +36184,8 @@ async function rankKeyEvents(candidates, options, peakLookup) {
35615
36184
  windowMs: t.lastSeen - t.firstSeen,
35616
36185
  ...t.markForTrain !== void 0 ? { markForTrain: t.markForTrain } : {},
35617
36186
  ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
35618
- ...t.debug !== void 0 ? { debug: t.debug } : {}
36187
+ ...t.debug !== void 0 ? { debug: t.debug } : {},
36188
+ ...t.favourited !== void 0 ? { favourited: t.favourited } : {}
35619
36189
  });
35620
36190
  }
35621
36191
  scored.sort((a, b) => b.importance - a.importance);
@@ -37258,7 +37828,8 @@ async function applyTrackFlags(deps, input) {
37258
37828
  }
37259
37829
  const patch = {
37260
37830
  ...input.flags.markForTrain !== void 0 ? { markForTrain: input.flags.markForTrain } : {},
37261
- ...input.flags.debug !== void 0 ? { debug: input.flags.debug } : {}
37831
+ ...input.flags.debug !== void 0 ? { debug: input.flags.debug } : {},
37832
+ ...input.flags.favourited !== void 0 ? { favourited: input.flags.favourited } : {}
37262
37833
  };
37263
37834
  if (Object.keys(patch).length > 0) await store.setFlags(input.trackId, patch);
37264
37835
  const retrainStatus = patch.markForTrain === void 0 ? current.retrainStatus : patch.markForTrain ? "staging" : "none";
@@ -37266,6 +37837,7 @@ async function applyTrackFlags(deps, input) {
37266
37837
  trackId: input.trackId,
37267
37838
  markForTrain: retrainStatus === "staging",
37268
37839
  debug: patch.debug ?? current.debug,
37840
+ favourited: patch.favourited ?? current.favourited,
37269
37841
  retrainStatus
37270
37842
  };
37271
37843
  }
@@ -41309,6 +41881,10 @@ var TRACKS_COLUMNS = [
41309
41881
  name: "debug",
41310
41882
  type: "BOOLEAN"
41311
41883
  },
41884
+ {
41885
+ name: "favourited",
41886
+ type: "BOOLEAN"
41887
+ },
41312
41888
  {
41313
41889
  name: "hasFace",
41314
41890
  type: "BOOLEAN"
@@ -41402,6 +41978,7 @@ function cloneTrack(t) {
41402
41978
  markForTrain: t.retrainStatus === "staging"
41403
41979
  } : {},
41404
41980
  ...t.debug !== void 0 ? { debug: t.debug } : {},
41981
+ ...t.favourited !== void 0 ? { favourited: t.favourited } : {},
41405
41982
  ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
41406
41983
  ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
41407
41984
  ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {}
@@ -41747,11 +42324,13 @@ var TrackStore = class {
41747
42324
  const nextStatus = patch.markForTrain === void 0 ? void 0 : patch.markForTrain ? "staging" : "none";
41748
42325
  if (nextStatus !== void 0) data["retrainStatus"] = nextStatus;
41749
42326
  if (patch.debug !== void 0) data["debug"] = patch.debug;
42327
+ if (patch.favourited !== void 0) data["favourited"] = patch.favourited;
41750
42328
  if (Object.keys(data).length === 0) return;
41751
42329
  const active = this.active.get(trackId);
41752
42330
  if (active) {
41753
42331
  if (nextStatus !== void 0) active.retrainStatus = nextStatus;
41754
42332
  if (patch.debug !== void 0) active.debug = patch.debug;
42333
+ if (patch.favourited !== void 0) active.favourited = patch.favourited;
41755
42334
  }
41756
42335
  try {
41757
42336
  await this.store.update.mutate({
@@ -41892,13 +42471,15 @@ var TrackStore = class {
41892
42471
  const active = this.active.get(trackId);
41893
42472
  if (active) return {
41894
42473
  retrainStatus: active.retrainStatus ?? "none",
41895
- debug: active.debug === true
42474
+ debug: active.debug === true,
42475
+ favourited: active.favourited === true
41896
42476
  };
41897
42477
  const persisted = await this.getPersistedByTrackId(trackId);
41898
42478
  if (!persisted) return null;
41899
42479
  return {
41900
42480
  retrainStatus: persisted.retrainStatus ?? "none",
41901
- debug: persisted.debug === true
42481
+ debug: persisted.debug === true,
42482
+ favourited: persisted.favourited === true
41902
42483
  };
41903
42484
  }
41904
42485
  /**
@@ -42033,6 +42614,7 @@ var TrackStore = class {
42033
42614
  filter: {
42034
42615
  where: { deviceId },
42035
42616
  whereIn: { retrainStatus: ["none", "trained"] },
42617
+ whereNot: { favourited: true },
42036
42618
  whereBetween: { lastSeen: [0, cutoffMs - 1] },
42037
42619
  orderBy: {
42038
42620
  field: "lastSeen",
@@ -42202,6 +42784,40 @@ var TrackStore = class {
42202
42784
  }
42203
42785
  }
42204
42786
  /**
42787
+ * How many of this device's tracks are FAVOURITED — optionally only those
42788
+ * older than a cutoff, i.e. the rows a sweep just declined to touch because
42789
+ * of the boolean pin (independent of `retrainStatus`).
42790
+ *
42791
+ * Mirrors {@link countStaging}: the sweep log must name held work or a
42792
+ * favourited-only camera reads as "retention did nothing". Best-effort — a
42793
+ * failure reports 0 rather than disturbing the caller.
42794
+ */
42795
+ async countFavourited(query) {
42796
+ const { deviceId, olderThanMs } = query;
42797
+ try {
42798
+ return await this.store.count.query({
42799
+ collection: TRACKS_COLLECTION,
42800
+ filter: {
42801
+ where: {
42802
+ deviceId,
42803
+ favourited: true
42804
+ },
42805
+ ...olderThanMs !== void 0 ? { whereBetween: { lastSeen: [0, olderThanMs - 1] } } : {}
42806
+ }
42807
+ });
42808
+ } catch (err) {
42809
+ this.logger.warn("TrackStore.countFavourited failed", {
42810
+ tags: { deviceId },
42811
+ meta: {
42812
+ deviceId,
42813
+ olderThanMs,
42814
+ error: String(err)
42815
+ }
42816
+ });
42817
+ return 0;
42818
+ }
42819
+ }
42820
+ /**
42205
42821
  * The SUBSET of `trackIds` that still have a persisted row — the orphan
42206
42822
  * audit's liveness probe.
42207
42823
  *
@@ -42574,6 +43190,7 @@ var TrackStore = class {
42574
43190
  ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
42575
43191
  ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
42576
43192
  ...t.debug !== void 0 ? { debug: t.debug } : {},
43193
+ ...t.favourited !== void 0 ? { favourited: t.favourited } : {},
42577
43194
  ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
42578
43195
  ...t.hasRider !== void 0 ? { hasRider: t.hasRider } : {},
42579
43196
  ...t.closedReason !== void 0 ? { closedReason: t.closedReason } : {},
@@ -42611,6 +43228,7 @@ var TrackStore = class {
42611
43228
  const audioLabels = data["audioLabels"];
42612
43229
  const retrainParsed = require_dist.RetrainStatusSchema.safeParse(data["retrainStatus"]);
42613
43230
  const debug = data["debug"];
43231
+ const favourited = data["favourited"];
42614
43232
  const hasFace = data["hasFace"];
42615
43233
  /** Same read as `hasFace` — NULL / missing stays ABSENT, never `false`. */
42616
43234
  const hasRider = data["hasRider"];
@@ -42650,6 +43268,7 @@ var TrackStore = class {
42650
43268
  markForTrain: retrainParsed.data === "staging"
42651
43269
  } : {},
42652
43270
  ...typeof debug === "boolean" ? { debug } : {},
43271
+ ...typeof favourited === "boolean" ? { favourited } : {},
42653
43272
  ...typeof hasFace === "boolean" ? { hasFace } : {},
42654
43273
  ...typeof hasRider === "boolean" ? { hasRider } : {},
42655
43274
  ...closedReason !== void 0 ? { closedReason } : {},
@@ -44672,7 +45291,10 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
44672
45291
  require_dist.EventCategory.WorkerOffline,
44673
45292
  require_dist.EventCategory.PipelineNodeInferenceUnavailable,
44674
45293
  require_dist.EventCategory.PipelineDetectionBlind,
44675
- require_dist.EventCategory.UpdateAvailable
45294
+ require_dist.EventCategory.UpdateAvailable,
45295
+ require_dist.EventCategory.AddonUpdated,
45296
+ require_dist.EventCategory.SystemRestartCompleted,
45297
+ require_dist.EventCategory.RecordingExportCompleted
44676
45298
  ];
44677
45299
  /**
44678
45300
  * Install the addon's bus subscriptions. Identical set + relative order to the
@@ -50771,6 +51393,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
50771
51393
  },
50772
51394
  renderEventMedia: (req) => this.renderEventMedia(req),
50773
51395
  publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
51396
+ indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {
51397
+ url: null,
51398
+ artifactId: null
51399
+ },
50774
51400
  listTargets: async () => {
50775
51401
  return (await api.notificationOutput.listTargets.query({})).map((t) => {
50776
51402
  const owner = t.config["ownerUserId"];
@@ -55431,6 +56057,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
55431
56057
  deleted: counts.tracks
55432
56058
  }
55433
56059
  });
56060
+ const favourited = await this.trackStore?.countFavourited({
56061
+ deviceId,
56062
+ olderThanMs: cutoffMs
56063
+ });
56064
+ if (favourited !== void 0 && favourited > 0) this.ctx.logger.info("track retention held favourited tracks", {
56065
+ tags: { deviceId },
56066
+ meta: {
56067
+ deviceId,
56068
+ favourited,
56069
+ cutoffMs,
56070
+ deleted: counts.tracks
56071
+ }
56072
+ });
55434
56073
  if (counts.tracks > 0) this.eventsOpsLog?.append({
55435
56074
  op: "prune",
55436
56075
  reason: "retention",