@camstack/addon-notifiers 1.2.7 → 1.2.8

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/addon.js CHANGED
@@ -2,7 +2,33 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
+ //#region \0rolldown/runtime.js
6
+ var __create = Object.create;
7
+ var __defProp = Object.defineProperty;
8
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
9
+ var __getOwnPropNames = Object.getOwnPropertyNames;
10
+ var __getProtoOf = Object.getPrototypeOf;
11
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
14
+ key = keys[i];
15
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
16
+ get: ((k) => from[k]).bind(null, key),
17
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
18
+ });
19
+ }
20
+ return to;
21
+ };
22
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
23
+ value: mod,
24
+ enumerable: true
25
+ }) : target, mod));
26
+ //#endregion
5
27
  let node_zlib = require("node:zlib");
28
+ let node_fs = require("node:fs");
29
+ let node_path = require("node:path");
30
+ node_path = __toESM(node_path);
31
+ let node_url = require("node:url");
6
32
  //#region ../types/dist/event-category-BLcNejAE.mjs
7
33
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
8
34
  EventCategory["SystemBoot"] = "system.boot";
@@ -7304,35 +7330,22 @@ function buildCapturingReply(envelope) {
7304
7330
  */
7305
7331
  var RecordingWeekdaySchema = number().int().min(0).max(6);
7306
7332
  var HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
7307
- var RecordingScheduleSchema = discriminatedUnion("kind", [object({ kind: literal("always") }), object({
7308
- kind: literal("timeOfDay"),
7309
- start: string().regex(HHMM),
7310
- end: string().regex(HHMM),
7311
- /** Restrict to these weekdays; omit = every day. */
7312
- days: array(RecordingWeekdaySchema).optional()
7313
- })]);
7314
- var RecordingModeSchema = _enum([
7315
- "continuous",
7316
- "onMotion",
7317
- "onAudioThreshold"
7318
- ]);
7319
7333
  /**
7320
- * First-class, authoritative per-camera storage mode — the explicit choice the
7321
- * UI reads directly (never inferred from `rules`):
7322
- * - `off` — not recording.
7323
- * - `events` — record only around triggers (motion / audio threshold),
7324
- * with pre/post-buffer.
7325
- * - `continuous` — record 24/7 within the schedule.
7334
+ * DERIVED per-camera storage summary — the single field cheap consumers read
7335
+ * (the viewer's status dot, the camera list) instead of walking `bands`:
7336
+ * - `off` — no band covers the camera (or it is disabled).
7337
+ * - `events` — every band records around triggers only.
7338
+ * - `continuous` — at least one band records continuously.
7326
7339
  *
7327
- * `mode` compiles one-way to the internal `rules[]` consumed by the policy
7328
- * engine (see `compileRules`); `rules[]` is never authored directly anymore.
7340
+ * NEVER authored: the recorder stamps it from the authoritative `bands` on
7341
+ * every save (`activeModeForConfig`). Writing it has no effect.
7329
7342
  */
7330
7343
  var RecordingStorageModeSchema = _enum([
7331
7344
  "off",
7332
7345
  "events",
7333
7346
  "continuous"
7334
7347
  ]);
7335
- /** Which detectors trigger an `events`-mode recording. */
7348
+ /** Which detectors trigger an `events`-mode band. */
7336
7349
  var RecordingTriggersSchema = object({
7337
7350
  motion: boolean().optional(),
7338
7351
  audioThresholdDbfs: number().optional()
@@ -7368,18 +7381,6 @@ var RecordingBandSchema = object({
7368
7381
  preBufferSec: number().min(0).optional(),
7369
7382
  postBufferSec: number().min(0).optional()
7370
7383
  });
7371
- var RecordingRuleSchema = object({
7372
- schedule: RecordingScheduleSchema,
7373
- mode: RecordingModeSchema,
7374
- /** Seconds of footage to retain BEFORE a trigger (applied at keep/discard). */
7375
- preBufferSec: number().min(0).default(0),
7376
- /** Keep recording until this many seconds after the last trigger. */
7377
- postBufferSec: number().min(0).default(0),
7378
- /** Each new trigger restarts the post-buffer window. */
7379
- resetTimeoutOnNewEvent: boolean().default(true),
7380
- /** onAudioThreshold only — dBFS level that counts as a trigger. */
7381
- thresholdDbfs: number().optional()
7382
- });
7383
7384
  /**
7384
7385
  * Per-device retention overrides. Every field is optional; an unset or `0`
7385
7386
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -7413,40 +7414,28 @@ var ScrubThumbnailPresetSchema = _enum([
7413
7414
  /**
7414
7415
  * The full per-camera recording intent — the wire shape of a RecordingTarget.
7415
7416
  *
7416
- * `mode` is the authoritative storage choice; `schedule`/`triggers`/`pre`/`post`
7417
- * are its mode-specific parameters. `rules` is a DEPRECATED authoring input kept
7418
- * only for transition + migration (`migrateRulesToMode`); the policy engine
7419
- * consumes the compiled output of `compileRules(config)`, never `rules` directly.
7417
+ * `bands` is the ONLY authored recording intent: what to record, when, and on
7418
+ * which trigger. `mode` is a derived summary the recorder stamps on save; every
7419
+ * other field is a storage knob (profiles, segment length, retention, scrub).
7420
+ *
7421
+ * STRICT on purpose: the legacy authoring surface (`schedule`/`schedules`/
7422
+ * `triggers`/`preBufferSec`/`postBufferSec`/`rules`) was retired 2026-07-30.
7423
+ * A stale caller must fail loudly — silently stripping its legacy intent would
7424
+ * persist a band-less config, i.e. silently stop recording the camera.
7420
7425
  */
7421
7426
  var RecordingConfigSchema = object({
7422
7427
  enabled: boolean(),
7423
- /** Authoritative storage mode. Absent on legacy targets derived once via
7424
- * `migrateRulesToMode`, then persisted. */
7428
+ /** DERIVED summary of `bands`, stamped by the recorder on every save.
7429
+ * Authoring it has no effect — see {@link RecordingStorageModeSchema}. */
7425
7430
  mode: RecordingStorageModeSchema.optional(),
7426
7431
  profiles: array(CamProfileSchema).optional(),
7427
7432
  segmentSeconds: number().int().positive().optional(),
7428
- /** Shared recording time-bands for `events` & `continuous` — record only when
7429
- * the wall-clock falls inside one of these windows. Omit or empty = always.
7430
- * `continuous` compiles to one rule per band; `events` to band × trigger. */
7431
- schedules: array(RecordingScheduleSchema).optional(),
7432
- /** Legacy single-band predecessor of `schedules`. Read-compat only — it is
7433
- * normalized into `schedules` on read and never written going forward. (Not
7434
- * tagged `@deprecated`: the normalization paths must read it cast-free.) */
7435
- schedule: RecordingScheduleSchema.optional(),
7436
- /** `events`-mode only — which detectors trigger a recording. */
7437
- triggers: RecordingTriggersSchema.optional(),
7438
- /** `events`-mode only — seconds retained before / after a trigger. */
7439
- preBufferSec: number().min(0).optional(),
7440
- postBufferSec: number().min(0).optional(),
7441
- /** DEPRECATED authoring input; retained for migration/transition. */
7442
- rules: array(RecordingRuleSchema).optional(),
7443
7433
  /**
7444
- * AUTHORITATIVE mode-per-band recording model (recorder). When present it
7445
- * is the single source of truth; the legacy `mode`/`schedules`/`schedule`/
7446
- * `triggers`/`rules` fields above are kept for READ-COMPAT only and are
7447
- * derived into bands once via `migrateConfigToBands`.
7434
+ * AUTHORITATIVE mode-per-band recording model the single source of truth
7435
+ * the recorder's band engine consumes. An empty array = record nothing;
7436
+ * "off" is the absence of a covering band, never a band value.
7448
7437
  */
7449
- bands: array(RecordingBandSchema).optional(),
7438
+ bands: array(RecordingBandSchema).default([]),
7450
7439
  retention: RecordingRetentionSchema.optional(),
7451
7440
  /**
7452
7441
  * Per-camera scrub-thumbnail fidelity preset (resolution + JPEG quality for
@@ -7454,8 +7443,15 @@ var RecordingConfigSchema = object({
7454
7443
  * windows only — existing sheets are immutable, and each window's index
7455
7444
  * carries its own tile dims so mixed-preset history renders correctly.
7456
7445
  */
7457
- scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7458
- });
7446
+ scrubThumbnails: ScrubThumbnailPresetSchema.optional(),
7447
+ /**
7448
+ * OPT-IN thumbnail-strip generation for this camera: every keyframe of the
7449
+ * low recording saved as a JPEG (the fast-drag scrub depth), a derived
7450
+ * cache that eviction reclaims with the footage. Absent/false = no strips
7451
+ * are written and scrub reads exact keyframes at every velocity.
7452
+ */
7453
+ stripsEnabled: boolean().optional()
7454
+ }).strict();
7459
7455
  /**
7460
7456
  * Ops-log — the durable, append-only operations audit shared by the
7461
7457
  * recordings and events management surfaces.
@@ -7474,7 +7470,8 @@ var OpsLogOpSchema = _enum([
7474
7470
  "prune",
7475
7471
  "manual-delete",
7476
7472
  "rescan",
7477
- "retention-run"
7473
+ "retention-run",
7474
+ "relocate"
7478
7475
  ]);
7479
7476
  /** Why the operation ran. */
7480
7477
  var OpsLogReasonSchema = _enum([
@@ -7513,6 +7510,55 @@ var OpsLogQueryInputSchema = object({
7513
7510
  limit: number().int().min(1).max(1e3).optional()
7514
7511
  });
7515
7512
  /**
7513
+ * Entity-relocation job state (storage entity-routing spec, Phase 4).
7514
+ *
7515
+ * One shape shared by the recorder's `relocateFootage` (segments + strips) and
7516
+ * pipeline-analytics' `relocateMedia` (event media blobs) so the admin Data
7517
+ * page renders both movers with one component. Jobs are in-RAM (a restart
7518
+ * forgets them — re-running is safe by construction: copy-if-absent, delete
7519
+ * after verify) and each completed/failed run also lands one durable ops-log
7520
+ * row on the owning addon's surface.
7521
+ */
7522
+ var RelocateJobStateSchema = _enum([
7523
+ "running",
7524
+ "done",
7525
+ "failed",
7526
+ "cancelled"
7527
+ ]);
7528
+ var RelocateJobSchema = object({
7529
+ jobId: string(),
7530
+ state: RelocateJobStateSchema,
7531
+ /** Source location — for media relocation this is informational ('*': rows
7532
+ * move from wherever they are to the target). */
7533
+ fromLocationId: string(),
7534
+ toLocationId: string(),
7535
+ /** Scoped device, or null = every device. */
7536
+ deviceId: number().nullable(),
7537
+ /** What the job moves (owner-addon specific: segments/strips or media). */
7538
+ entities: array(string()),
7539
+ filesMoved: number().int(),
7540
+ bytesMoved: number().int(),
7541
+ /** Total files discovered up front; null while (or when) unknown. */
7542
+ filesTotal: number().int().nullable(),
7543
+ startedAt: number(),
7544
+ finishedAt: number().nullable(),
7545
+ error: string().nullable()
7546
+ });
7547
+ var RelocateFootageInputSchema = object({
7548
+ deviceId: number().optional(),
7549
+ fromLocationId: string(),
7550
+ toLocationId: string(),
7551
+ entities: array(_enum(["segments", "strips"])).optional(),
7552
+ /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7553
+ * never allowed to starve live writers. */
7554
+ throttleMbps: number().min(1).max(1e3).optional()
7555
+ });
7556
+ var RelocateMediaInputSchema = object({
7557
+ deviceId: number().optional(),
7558
+ toLocationId: string(),
7559
+ throttleMbps: number().min(1).max(1e3).optional()
7560
+ });
7561
+ /**
7516
7562
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
7517
7563
  * storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
7518
7564
  * so the persisted record schema and the consumer-facing cap can both consume it
@@ -7562,6 +7608,13 @@ var StorageLocationSchema = object({
7562
7608
  nodeId: string().optional(),
7563
7609
  isDefault: boolean().default(false),
7564
7610
  isSystem: boolean().default(false),
7611
+ /** COMPUTED at read time by the orchestrator (statfs of the backing volume
7612
+ * for node-local locations it can reach) — never persisted, absent when the
7613
+ * volume is remote/unreachable. The single capacity truth every UI reads. */
7614
+ capacity: object({
7615
+ totalBytes: number(),
7616
+ availableBytes: number()
7617
+ }).nullable().optional(),
7565
7618
  createdAt: number(),
7566
7619
  updatedAt: number()
7567
7620
  });
@@ -8329,7 +8382,8 @@ var NcTaxonomyEntrySchema = object({
8329
8382
  /** Macro/category parent for grouping ('car' → 'vehicle'); null for a top. */
8330
8383
  parentKind: string().nullable()
8331
8384
  });
8332
- object({
8385
+ /** The complete NC picker taxonomy — three grouped buckets. */
8386
+ var NcTaxonomySchema = object({
8333
8387
  videoClasses: array(NcTaxonomyEntrySchema),
8334
8388
  audioKinds: array(NcTaxonomyEntrySchema),
8335
8389
  labels: array(NcTaxonomyEntrySchema)
@@ -9052,9 +9106,20 @@ var DEGRADE_ORDER = {
9052
9106
  "markdown"
9053
9107
  ]
9054
9108
  };
9055
- /** Pick the best target format the kind supports for a given source format. */
9109
+ /**
9110
+ * Pick the best target format the kind supports for a given source format.
9111
+ *
9112
+ * TOLERANT of a source outside the union. `NotificationSchema.format` carries
9113
+ * `.default('text')`, but that default only runs where the schema is PARSED —
9114
+ * the tRPC router. A cap call arriving from another addon (`ctx.api`) skips
9115
+ * that parse, so `format` reaches here `undefined`, and the bare
9116
+ * `DEGRADE_ORDER[source]` threw `is not iterable`. That broke the one promise
9117
+ * the degrade path makes ("never throws — degradation is reported, never
9118
+ * surfaced as an error") and dead-lettered every Notification-Center delivery
9119
+ * for a day (2026-07-30). An unknown source degrades like `text`.
9120
+ */
9056
9121
  function resolveFormat(source, supported) {
9057
- for (const candidate of DEGRADE_ORDER[source]) if (supported.includes(candidate)) return candidate;
9122
+ for (const candidate of DEGRADE_ORDER[source] ?? DEGRADE_ORDER.text) if (supported.includes(candidate)) return candidate;
9058
9123
  return supported[0] ?? "text";
9059
9124
  }
9060
9125
  /** Transcode a body between formats. */
@@ -9099,7 +9164,7 @@ function resolveLevel(levels, n) {
9099
9164
  })[0]);
9100
9165
  }
9101
9166
  function splitBody(body, maxLen) {
9102
- if (maxLen <= 0 || body.length <= maxLen) return [body];
9167
+ if (!Number.isFinite(maxLen) || maxLen <= 0 || body.length <= maxLen) return [body];
9103
9168
  const parts = [];
9104
9169
  for (let i = 0; i < body.length; i += maxLen) parts.push(body.slice(i, i + maxLen));
9105
9170
  return parts;
@@ -9182,6 +9247,7 @@ function prepareNotification(caps, n) {
9182
9247
  renderedAs
9183
9248
  };
9184
9249
  }
9250
+ new Set(["devices", "classes"]);
9185
9251
  /**
9186
9252
  * Shared geometry vocabulary for on-frame shape caps — privacy-mask,
9187
9253
  * motion-zones, and the detection zones/lines editor all speak this one
@@ -9498,12 +9564,60 @@ var NcRuleTargetSchema = object({
9498
9564
  * - `keyFrame` — the clean scene frame (no subject box).
9499
9565
  * - `none` — no attachment.
9500
9566
  */
9501
- var NcMediaPolicySchema = object({ attach: _enum([
9502
- "best",
9503
- "best-matching",
9504
- "keyFrame",
9505
- "none"
9506
- ]).default("best") });
9567
+ /**
9568
+ * WHAT THE PICTURE SHOWS — chosen explicitly, because the implicit ladders
9569
+ * conflate it with the selection strategy and betray the request: asking for
9570
+ * the clean scene frame on an object-event owner used to start at
9571
+ * `fullFrameBoxed`, i.e. the annotated frame (2026-07-30).
9572
+ * - `cropped` — the subject, tight. What "who is at the door" wants.
9573
+ * - `full` — the clean scene, no annotation. What "what is going on" wants.
9574
+ * - `boxed` — the scene WITH the detection boxes drawn, for verifying what
9575
+ * the pipeline actually saw.
9576
+ */
9577
+ var NcMediaFrameSchema = _enum([
9578
+ "cropped",
9579
+ "full",
9580
+ "boxed"
9581
+ ]);
9582
+ var NcMediaPolicySchema = object({
9583
+ attach: _enum([
9584
+ "best",
9585
+ "best-matching",
9586
+ "keyFrame",
9587
+ "none"
9588
+ ]).default("best"),
9589
+ /** What the still shows. Absent = `cropped` (the historical `best` shape). */
9590
+ frame: NcMediaFrameSchema.optional(),
9591
+ /** Crop the attached still to the rule's condition-zone bbox (padded) —
9592
+ * "show me the ZONE", not the whole scene or the subject crop. */
9593
+ zoneCrop: boolean().optional(),
9594
+ /**
9595
+ * Also attach a short GIF cut from the stream broker's clip ring around the
9596
+ * event — NOT from the recording, so the camera does not have to be
9597
+ * recording, and the window sits AROUND the moment instead of a segment
9598
+ * behind it. Fail-closed: a window the ring does not cover contributes no
9599
+ * gif, never a failed notification.
9600
+ */
9601
+ gif: boolean().optional(),
9602
+ /**
9603
+ * Also attach a short MP4 CLIP of the same cut. Same source and the same
9604
+ * fail-closed rule as `gif`. Prefer it where the backend takes video
9605
+ * (telegram, discord, zentik, webhook); backends that do not are handled by
9606
+ * the degrade engine, which drops the video and keeps the still.
9607
+ */
9608
+ clip: boolean().optional(),
9609
+ /** Seconds of footage BEFORE / AFTER the event instant. Defaults 3 / 7. */
9610
+ clipPreRollSec: number().int().min(0).max(30).optional(),
9611
+ clipPostRollSec: number().int().min(0).max(30).optional(),
9612
+ /**
9613
+ * Which stream profile the footage is cut from. Absent = the CHEAPEST
9614
+ * assigned profile: a notification is watched on a phone, so the 4K
9615
+ * rendition would burn CPU to produce a file the client downscales anyway.
9616
+ * A profile that is not assigned falls back to the cheapest, and the render
9617
+ * reports which one actually ran.
9618
+ */
9619
+ profile: CamProfileSchema.optional()
9620
+ });
9507
9621
  /** Throttle — cooldown survives restarts (rebuilt from the outbox on boot). */
9508
9622
  var NcThrottleSchema = object({
9509
9623
  cooldownSec: number().int().min(0).max(86400).default(60),
@@ -9517,7 +9631,19 @@ var NcRuleInputSchema = object({
9517
9631
  delivery: NcDeliverySchema,
9518
9632
  conditions: NcConditionsSchema.default({}),
9519
9633
  schedule: NcScheduleSchema.optional(),
9520
- targets: array(NcRuleTargetSchema).min(1),
9634
+ /** May be empty when `targetUsers` addresses at least one user — the
9635
+ * "at least one addressee" invariant is enforced by the provider, because
9636
+ * a cross-field refine here would break `NcRulePatchSchema.partial()`. */
9637
+ targets: array(NcRuleTargetSchema),
9638
+ /**
9639
+ * USERS this rule addresses in addition to `targets` (Phase 4). At fire
9640
+ * time each user fans out to the personal targets they own
9641
+ * (`config.ownerUserId`), filtered by that user's `allowedDevices` for the
9642
+ * firing camera — a user is never notified about a device they cannot open.
9643
+ * Per-user opt-outs (`disabledTargetIds`) still apply to the fanned-out
9644
+ * targets.
9645
+ */
9646
+ targetUsers: array(string()).optional(),
9521
9647
  media: NcMediaPolicySchema.default({ attach: "best" }),
9522
9648
  throttle: NcThrottleSchema.default({
9523
9649
  cooldownSec: 60,
@@ -9730,7 +9856,10 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
9730
9856
  }), object({ results: array(NcTestResultSchema) }), {
9731
9857
  kind: "mutation",
9732
9858
  auth: "admin"
9733
- }), method(object({}), object({ catalog: array(NcConditionDescriptorSchema) })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
9859
+ }), method(object({}), object({
9860
+ catalog: array(NcConditionDescriptorSchema),
9861
+ taxonomy: NcTaxonomySchema.optional()
9862
+ })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
9734
9863
  /**
9735
9864
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
9736
9865
  *
@@ -10467,6 +10596,28 @@ method(object({
10467
10596
  }), object({ success: literal(true) }), {
10468
10597
  kind: "mutation",
10469
10598
  auth: "admin"
10599
+ }), method(object({
10600
+ deviceId: number(),
10601
+ /** Absent = the LOWEST assigned profile — a notification attachment is
10602
+ * watched on a phone, and the cheap rendition is the right default. */
10603
+ profile: CamProfileSchema.optional(),
10604
+ aroundMs: number(),
10605
+ preRollSec: number().min(0).max(20).default(3),
10606
+ postRollSec: number().min(0).max(20).default(5),
10607
+ format: _enum(["gif", "mp4"]).default("gif"),
10608
+ maxWidth: number().int().min(120).max(1920).default(480),
10609
+ /** GIF only — MP4 keeps the source cadence. */
10610
+ fps: number().int().min(1).max(15).default(5)
10611
+ }), object({
10612
+ base64: string(),
10613
+ mime: string(),
10614
+ bytes: number().int(),
10615
+ /** The profile actually rendered (what the default resolved to). */
10616
+ profile: CamProfileSchema,
10617
+ durationMs: number()
10618
+ }), {
10619
+ kind: "mutation",
10620
+ auth: "admin"
10470
10621
  }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
10471
10622
  probed: boolean(),
10472
10623
  summary: string()
@@ -17033,6 +17184,36 @@ var TargetKindSchema = object({
17033
17184
  icon: string(),
17034
17185
  /** Stamped by each provider so the concat-fanned catalog stays routable. */
17035
17186
  addonId: string(),
17187
+ /**
17188
+ * URL of the kind's bundled BRAND icon, served by the providing addon over
17189
+ * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
17190
+ * when the addon bundles no icon for that kind — the client then falls back
17191
+ * to a neutral glyph rather than rendering the raw `icon` NAME as text.
17192
+ *
17193
+ * Root-relative on purpose: it resolves against whatever origin serves a web
17194
+ * client, and a native client joins it onto its own hub base.
17195
+ *
17196
+ * DECLARED here deliberately. It used to travel as an undeclared passthrough
17197
+ * field that survived only because the runtime cap-router forwards provider
17198
+ * output verbatim — so every consumer had to re-declare it by hand to stop
17199
+ * its own Zod parse from stripping it, and the whole arrangement would have
17200
+ * broken silently the moment output validation was tightened anywhere.
17201
+ */
17202
+ iconUrl: string().optional(),
17203
+ /**
17204
+ * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
17205
+ *
17206
+ * The server knows this and therefore says it, because the client cannot
17207
+ * safely guess: a React-Native client renders SVG and raster through two
17208
+ * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
17209
+ * not decode SVG on iOS/Android), so without this it silently fell back to a
17210
+ * placeholder glyph for every vector icon while the web build looked fine.
17211
+ *
17212
+ * Absent when {@link iconUrl} is absent, or for a legacy provider that has
17213
+ * not been updated — a client that cannot determine the type should prefer
17214
+ * its raster path, which is the safe default for an unknown image.
17215
+ */
17216
+ iconMediaType: string().optional(),
17036
17217
  configSchema: ConfigSchemaPassthrough,
17037
17218
  supportsDiscovery: boolean(),
17038
17219
  caps: TargetKindCapsSchema
@@ -17778,6 +17959,15 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17778
17959
  }), method(object({ deviceId: number() }), EventPruneCountsSchema, {
17779
17960
  kind: "mutation",
17780
17961
  auth: "admin"
17962
+ }), method(RelocateMediaInputSchema, object({ jobId: string() }), {
17963
+ kind: "mutation",
17964
+ auth: "admin"
17965
+ }), method(object({}), array(RelocateJobSchema).readonly(), {
17966
+ kind: "query",
17967
+ auth: "admin"
17968
+ }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
17969
+ kind: "mutation",
17970
+ auth: "admin"
17781
17971
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
17782
17972
  kind: "query",
17783
17973
  auth: "admin"
@@ -20257,6 +20447,17 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
20257
20447
  */
20258
20448
  priority: number()
20259
20449
  })).readonly() });
20450
+ /**
20451
+ * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
20452
+ * AUTO (resolved from the candidate ranking at send time); `resolved` reports
20453
+ * what AUTO currently picks, so the UI can show the effective value either way.
20454
+ */
20455
+ var NotificationEndpointSchema = object({
20456
+ /** The operator's explicit choice, or null for AUTO. */
20457
+ baseUrl: string().nullable(),
20458
+ /** What the ranking currently resolves to (null when nothing is reachable). */
20459
+ resolved: string().nullable()
20460
+ });
20260
20461
  var AllowedAddressesSchema = object({
20261
20462
  /**
20262
20463
  * Allowlist of interface addresses operators have explicitly opted
@@ -20279,7 +20480,7 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
20279
20480
  * to avoid mixed-content blocks in the browser. The public
20280
20481
  * tunnel always emits `https://` regardless. */
20281
20482
  scheme: _enum(["http", "https"]).optional()
20282
- }), GetConnectionEndpointsResultSchema), method(_void(), AllowedAddressesSchema), method(AllowedAddressesSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), AllowedAddressesSchema, { kind: "mutation" });
20483
+ }), GetConnectionEndpointsResultSchema), method(_void(), NotificationEndpointSchema), method(object({ baseUrl: string().nullable() }), NotificationEndpointSchema, { kind: "mutation" }), method(_void(), AllowedAddressesSchema), method(AllowedAddressesSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), AllowedAddressesSchema, { kind: "mutation" });
20283
20484
  /**
20284
20485
  * mesh-network — collection cap for mesh-VPN providers.
20285
20486
  *
@@ -21078,7 +21279,12 @@ var RecordingDeviceUsageSchema = object({
21078
21279
  var RecordingLocationUsageSchema = object({
21079
21280
  /** StorageLocation id; null for the legacy/degraded single-root fallback. */
21080
21281
  locationId: string().nullable(),
21081
- /** Bytes of recordings stored on this location. */
21282
+ /** Every location id sharing this row's PHYSICAL volume (aliases). One row
21283
+ * is emitted per physical disk (2026-07-29): two locations on one root
21284
+ * previously rendered as two identical "disks" with a nonsensical used
21285
+ * split — hydrate attribution across aliases is arbitrary by nature. */
21286
+ locationIds: array(string()).optional(),
21287
+ /** Bytes of recordings stored on this PHYSICAL volume (all aliases). */
21082
21288
  usedBytes: number(),
21083
21289
  /** Free bytes on the location's volume; null when capacity is unknown (remote). */
21084
21290
  availableBytes: number().nullable(),
@@ -21190,6 +21396,44 @@ method(object({
21190
21396
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
21191
21397
  kind: "query",
21192
21398
  auth: "admin"
21399
+ }), method(object({
21400
+ deviceId: number(),
21401
+ aroundMs: number(),
21402
+ preRollSec: number().min(0).max(30).default(2),
21403
+ postRollSec: number().min(0).max(30).default(5),
21404
+ maxWidth: number().int().min(120).max(1280).default(480),
21405
+ fps: number().int().min(1).max(15).default(5)
21406
+ }), object({
21407
+ gifBase64: string(),
21408
+ fromMs: number(),
21409
+ toMs: number()
21410
+ }), {
21411
+ kind: "mutation",
21412
+ auth: "admin"
21413
+ }), method(object({
21414
+ deviceId: number(),
21415
+ aroundMs: number(),
21416
+ preRollSec: number().min(0).max(30).default(3),
21417
+ postRollSec: number().min(0).max(30).default(7),
21418
+ maxWidth: number().int().min(160).max(1920).default(640)
21419
+ }), object({
21420
+ clipBase64: string(),
21421
+ mime: string(),
21422
+ fromMs: number(),
21423
+ toMs: number(),
21424
+ bytes: number().int()
21425
+ }), {
21426
+ kind: "mutation",
21427
+ auth: "admin"
21428
+ }), method(RelocateFootageInputSchema, object({ jobId: string() }), {
21429
+ kind: "mutation",
21430
+ auth: "admin"
21431
+ }), method(object({}), array(RelocateJobSchema).readonly(), {
21432
+ kind: "query",
21433
+ auth: "admin"
21434
+ }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
21435
+ kind: "mutation",
21436
+ auth: "admin"
21193
21437
  });
21194
21438
  /**
21195
21439
  * `recordingExport` cap — render a footage time range into a single downloadable
@@ -23675,6 +23919,12 @@ Object.freeze({
23675
23919
  addonId: null,
23676
23920
  access: "view"
23677
23921
  },
23922
+ "localNetwork.getNotificationEndpoint": {
23923
+ capName: "local-network",
23924
+ capScope: "system",
23925
+ addonId: null,
23926
+ access: "view"
23927
+ },
23678
23928
  "localNetwork.getPreferred": {
23679
23929
  capName: "local-network",
23680
23930
  capScope: "system",
@@ -23699,6 +23949,12 @@ Object.freeze({
23699
23949
  addonId: null,
23700
23950
  access: "create"
23701
23951
  },
23952
+ "localNetwork.setNotificationEndpoint": {
23953
+ capName: "local-network",
23954
+ capScope: "system",
23955
+ addonId: null,
23956
+ access: "create"
23957
+ },
23702
23958
  "lockControl.lock": {
23703
23959
  capName: "lock-control",
23704
23960
  capScope: "device",
@@ -24341,6 +24597,12 @@ Object.freeze({
24341
24597
  addonId: null,
24342
24598
  access: "create"
24343
24599
  },
24600
+ "pipelineAnalytics.cancelMediaRelocate": {
24601
+ capName: "pipeline-analytics",
24602
+ capScope: "device",
24603
+ addonId: null,
24604
+ access: "create"
24605
+ },
24344
24606
  "pipelineAnalytics.clearTracks": {
24345
24607
  capName: "pipeline-analytics",
24346
24608
  capScope: "device",
@@ -24395,6 +24657,12 @@ Object.freeze({
24395
24657
  addonId: null,
24396
24658
  access: "view"
24397
24659
  },
24660
+ "pipelineAnalytics.getMediaRelocateStatus": {
24661
+ capName: "pipeline-analytics",
24662
+ capScope: "device",
24663
+ addonId: null,
24664
+ access: "view"
24665
+ },
24398
24666
  "pipelineAnalytics.getMotionEvents": {
24399
24667
  capName: "pipeline-analytics",
24400
24668
  capScope: "device",
@@ -24467,6 +24735,12 @@ Object.freeze({
24467
24735
  addonId: null,
24468
24736
  access: "create"
24469
24737
  },
24738
+ "pipelineAnalytics.relocateMedia": {
24739
+ capName: "pipeline-analytics",
24740
+ capScope: "device",
24741
+ addonId: null,
24742
+ access: "create"
24743
+ },
24470
24744
  "pipelineAnalytics.searchObjectEvents": {
24471
24745
  capName: "pipeline-analytics",
24472
24746
  capScope: "device",
@@ -25229,6 +25503,12 @@ Object.freeze({
25229
25503
  addonId: null,
25230
25504
  access: "create"
25231
25505
  },
25506
+ "recording.cancelRelocate": {
25507
+ capName: "recording",
25508
+ capScope: "system",
25509
+ addonId: null,
25510
+ access: "create"
25511
+ },
25232
25512
  "recording.deleteFootprint": {
25233
25513
  capName: "recording",
25234
25514
  capScope: "system",
@@ -25259,6 +25539,12 @@ Object.freeze({
25259
25539
  addonId: null,
25260
25540
  access: "view"
25261
25541
  },
25542
+ "recording.getRelocateStatus": {
25543
+ capName: "recording",
25544
+ capScope: "system",
25545
+ addonId: null,
25546
+ access: "view"
25547
+ },
25262
25548
  "recording.getStorageUsage": {
25263
25549
  capName: "recording",
25264
25550
  capScope: "system",
@@ -25289,6 +25575,24 @@ Object.freeze({
25289
25575
  addonId: null,
25290
25576
  access: "view"
25291
25577
  },
25578
+ "recording.relocateFootage": {
25579
+ capName: "recording",
25580
+ capScope: "system",
25581
+ addonId: null,
25582
+ access: "create"
25583
+ },
25584
+ "recording.renderClip": {
25585
+ capName: "recording",
25586
+ capScope: "system",
25587
+ addonId: null,
25588
+ access: "create"
25589
+ },
25590
+ "recording.renderGif": {
25591
+ capName: "recording",
25592
+ capScope: "system",
25593
+ addonId: null,
25594
+ access: "create"
25595
+ },
25292
25596
  "recording.rescanStorage": {
25293
25597
  capName: "recording",
25294
25598
  capScope: "system",
@@ -25883,6 +26187,12 @@ Object.freeze({
25883
26187
  addonId: null,
25884
26188
  access: "create"
25885
26189
  },
26190
+ "streamBroker.renderPreBufferClip": {
26191
+ capName: "stream-broker",
26192
+ capScope: "system",
26193
+ addonId: null,
26194
+ access: "create"
26195
+ },
25886
26196
  "streamBroker.restartProfile": {
25887
26197
  capName: "stream-broker",
25888
26198
  capScope: "system",
@@ -26577,7 +26887,7 @@ var NOTIFIER_ICONS = {
26577
26887
  /** The addon id — also the `/addon/<id>/…` route prefix. Mirrors `NOTIFIERS_ADDON_ID`. */
26578
26888
  var NOTIFIERS_ROUTE_ID = "notifiers";
26579
26889
  /** One day; icons are immutable per addon version. */
26580
- var CACHE_CONTROL = "public, max-age=86400, immutable";
26890
+ var CACHE_CONTROL$1 = "public, max-age=86400, immutable";
26581
26891
  var ICON_KINDS = new Set(Object.keys(NOTIFIER_ICONS));
26582
26892
  /** `true` when a bundled brand icon exists for `kind`. */
26583
26893
  function hasIcon(kind) {
@@ -26591,6 +26901,15 @@ function hasIcon(kind) {
26591
26901
  function iconUrlFor(kind) {
26592
26902
  return hasIcon(kind) ? `/addon/${NOTIFIERS_ROUTE_ID}/icons/${kind}` : void 0;
26593
26903
  }
26904
+ /**
26905
+ * The media type `iconUrlFor(kind)` serves, or `undefined` when no icon is
26906
+ * bundled. The client needs it because SVG and raster render through different
26907
+ * components on React Native, and it cannot be guessed from the URL — the route
26908
+ * path deliberately carries no file extension.
26909
+ */
26910
+ function iconMediaTypeFor(kind) {
26911
+ return hasIcon(kind) ? NOTIFIER_ICONS[kind].contentType : void 0;
26912
+ }
26594
26913
  /** Pre-encode one bundled icon into every content coding. */
26595
26914
  function encodeIcon(icon) {
26596
26915
  const identity = typeof icon.body === "string" ? Buffer.from(icon.body, "utf8") : Buffer.from(icon.body);
@@ -26626,15 +26945,143 @@ function iconRoute(kind) {
26626
26945
  description: `Official brand icon (${encoded.contentType}) for the '${kind}' notifier kind.`,
26627
26946
  handler: async (request, reply) => {
26628
26947
  const coding = negotiateEncoding(String(request.headers["accept-encoding"] ?? ""));
26629
- reply.code(200).type(encoded.contentType).header("cache-control", CACHE_CONTROL).header("vary", "accept-encoding");
26948
+ reply.code(200).type(encoded.contentType).header("cache-control", CACHE_CONTROL$1).header("vary", "accept-encoding");
26630
26949
  if (coding !== null) reply.header("content-encoding", coding);
26631
26950
  reply.send(coding !== null ? encoded[coding] : encoded.identity);
26632
26951
  }
26633
26952
  };
26634
26953
  }
26635
- /** Build the `addon-routes` provider that serves the bundled icons. */
26636
- function createIconRouteProvider() {
26637
- return buildAddonRouteProvider(NOTIFIERS_ROUTE_ID, Object.keys(NOTIFIER_ICONS).map(iconRoute));
26954
+ /**
26955
+ * The icon routes, for composition into the addon's SINGLE `addon-routes`
26956
+ * provider. Not a provider of its own: a provider owns one route id and matches
26957
+ * paths within it, so the icons and the test samples must share one route set
26958
+ * (see `sample-routes.ts`).
26959
+ */
26960
+ function iconRoutes() {
26961
+ return Object.keys(NOTIFIER_ICONS).map(iconRoute);
26962
+ }
26963
+ //#endregion
26964
+ //#region src/sample-routes.ts
26965
+ /**
26966
+ * Serves the bundled test-notification sample media over the addon's own HTTP
26967
+ * surface via the `addon-routes` cap. One public GET route per sample:
26968
+ *
26969
+ * GET /addon/notifiers/samples/<name> → the bundled sample bytes
26970
+ *
26971
+ * WHY THIS EXISTS
26972
+ * ───────────────
26973
+ * The test panel used to attach `https://example.com/sample.jpg` — the IANA
26974
+ * reserved documentation domain, which serves no media. Every "does my target
26975
+ * deliver an image / gif / video" test was therefore broken by construction and
26976
+ * had never verified anything. The samples have to live somewhere the addon can
26977
+ * actually fetch, on an install with no internet, so they ship with the addon.
26978
+ *
26979
+ * The bytes are read from `assets/samples/` (not embedded in TS): a 128 KB mp4
26980
+ * as a base64 literal would inflate the bundle by ~170 KB of source. Path
26981
+ * resolution mirrors `addon-benchmark`'s `resolveFixturesDir` — `import.meta.url`
26982
+ * always points at the compiled bundle under `dist/`, so walking up one
26983
+ * directory lands on the package root whether the addon runs from source (tsx)
26984
+ * or from its built bundle.
26985
+ *
26986
+ * CONTENT-ENCODING IS OURS, DELIBERATELY
26987
+ * ──────────────────────────────────────
26988
+ * Same hazard `icon-routes.ts` documents: the hub pipes an addon route's reply
26989
+ * through its global `@fastify/compress`, and on the forked-addon UDS route
26990
+ * bridge that compressor emits an EMPTY brotli stream for a compressible body
26991
+ * over 1 KiB. The compressor skips a response that already declares a
26992
+ * non-identity `content-encoding`, so we negotiate and stamp it ourselves.
26993
+ *
26994
+ * Media types here are all ALREADY compressed (JPEG/GIF/MP4/MP3), so we serve
26995
+ * identity bytes and stamp `content-encoding: identity` — re-compressing them
26996
+ * wastes CPU for no gain, and the explicit header is what keeps the hub's
26997
+ * compressor off a body it would corrupt.
26998
+ */
26999
+ /** Samples are immutable per addon version. */
27000
+ var CACHE_CONTROL = "public, max-age=86400, immutable";
27001
+ /**
27002
+ * The bundled set. Keyed by the `AttachmentMediaType` each one exercises, so a
27003
+ * caps-driven test panel can ask for "the sample for this media type" without
27004
+ * knowing filenames. `icon` deliberately reuses the image sample — an icon slot
27005
+ * wants a small raster, and shipping a fifth file to say the same thing is
27006
+ * waste.
27007
+ */
27008
+ var SAMPLES = [
27009
+ {
27010
+ name: "image",
27011
+ file: "image.jpg",
27012
+ contentType: "image/jpeg"
27013
+ },
27014
+ {
27015
+ name: "gif",
27016
+ file: "animation.gif",
27017
+ contentType: "image/gif"
27018
+ },
27019
+ {
27020
+ name: "video",
27021
+ file: "clip.mp4",
27022
+ contentType: "video/mp4"
27023
+ },
27024
+ {
27025
+ name: "audio",
27026
+ file: "tone.mp3",
27027
+ contentType: "audio/mpeg"
27028
+ },
27029
+ {
27030
+ name: "icon",
27031
+ file: "image.jpg",
27032
+ contentType: "image/jpeg"
27033
+ }
27034
+ ];
27035
+ new Set(SAMPLES.map((s) => s.name));
27036
+ /** Resolve the addon's bundled samples directory (see the file header). */
27037
+ function resolveSamplesDir() {
27038
+ const here = (0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href);
27039
+ const pkgRoot = node_path.default.resolve(node_path.default.dirname(here), "..");
27040
+ return node_path.default.join(pkgRoot, "assets", "samples");
27041
+ }
27042
+ /**
27043
+ * Read every sample eagerly so a missing asset fails at BOOT with a clear
27044
+ * error, rather than on the first operator click with a 500 that reads as a
27045
+ * broken notifier.
27046
+ */
27047
+ function loadSamples() {
27048
+ const dir = resolveSamplesDir();
27049
+ const out = /* @__PURE__ */ new Map();
27050
+ for (const sample of SAMPLES) {
27051
+ const bytes = (0, node_fs.readFileSync)(node_path.default.join(dir, sample.file));
27052
+ out.set(sample.name, {
27053
+ contentType: sample.contentType,
27054
+ bytes
27055
+ });
27056
+ }
27057
+ return out;
27058
+ }
27059
+ /** One static GET route serving a single bundled sample. */
27060
+ function sampleRoute(sample, loaded) {
27061
+ return {
27062
+ method: "GET",
27063
+ path: `/samples/${sample.name}`,
27064
+ access: "public",
27065
+ description: `Bundled ${sample.contentType} test sample for the '${sample.name}' attachment slot.`,
27066
+ handler: async (_request, reply) => {
27067
+ reply.code(200).type(loaded.contentType).header("cache-control", CACHE_CONTROL).header("content-encoding", "identity").header("content-length", String(loaded.bytes.byteLength));
27068
+ reply.send(loaded.bytes);
27069
+ }
27070
+ };
27071
+ }
27072
+ /**
27073
+ * The sample routes, for composition into the addon's SINGLE `addon-routes`
27074
+ * provider. Deliberately not a provider of its own: a provider owns one route
27075
+ * id and matches paths within it, so two providers sharing `notifiers` would
27076
+ * collide — the icons and the samples must travel in one route set.
27077
+ */
27078
+ function sampleRoutes() {
27079
+ const loaded = loadSamples();
27080
+ return SAMPLES.map((sample) => {
27081
+ const bytes = loaded.get(sample.name);
27082
+ if (bytes === void 0) throw new Error(`notifiers: sample '${sample.name}' failed to load`);
27083
+ return sampleRoute(sample, bytes);
27084
+ });
26638
27085
  }
26639
27086
  //#endregion
26640
27087
  //#region src/nc-target-actions.ts
@@ -29223,6 +29670,7 @@ function createNotificationOutputProvider(deps) {
29223
29670
  };
29224
29671
  }
29225
29672
  const iconUrlFor = deps.iconUrlFor;
29673
+ const iconMediaTypeFor = deps.iconMediaTypeFor;
29226
29674
  return {
29227
29675
  listTargetKinds: async () => deps.adapters.map((a) => {
29228
29676
  const descriptor = {
@@ -29230,10 +29678,16 @@ function createNotificationOutputProvider(deps) {
29230
29678
  addonId
29231
29679
  };
29232
29680
  const iconUrl = iconUrlFor?.(a.descriptor.kind);
29233
- return iconUrl !== void 0 ? {
29681
+ if (iconUrl === void 0) return descriptor;
29682
+ const iconMediaType = iconMediaTypeFor?.(a.descriptor.kind);
29683
+ return iconMediaType !== void 0 ? {
29684
+ ...descriptor,
29685
+ iconUrl,
29686
+ iconMediaType
29687
+ } : {
29234
29688
  ...descriptor,
29235
29689
  iconUrl
29236
- } : descriptor;
29690
+ };
29237
29691
  }),
29238
29692
  listTargets: async () => {
29239
29693
  return (await store.list()).map((target) => {
@@ -29504,9 +29958,10 @@ var NotifiersAddon = class extends BaseAddon {
29504
29958
  store,
29505
29959
  adapters: ADAPTERS,
29506
29960
  logger: this.ctx.logger.child("notification-output"),
29507
- iconUrlFor
29961
+ iconUrlFor,
29962
+ iconMediaTypeFor
29508
29963
  });
29509
- const iconRouteProvider = createIconRouteProvider();
29964
+ const routeProvider = buildAddonRouteProvider(NOTIFIERS_ADDON_ID, [...iconRoutes(), ...sampleRoutes()]);
29510
29965
  const ncHandlers = makeNcTargetHandlers({
29511
29966
  provider,
29512
29967
  logger: this.ctx.logger.child("nc-target-actions")
@@ -29518,7 +29973,7 @@ var NotifiersAddon = class extends BaseAddon {
29518
29973
  provider
29519
29974
  }, {
29520
29975
  capability: addonRoutesCapability,
29521
- provider: iconRouteProvider
29976
+ provider: routeProvider
29522
29977
  }],
29523
29978
  customActions: ncTargetActions,
29524
29979
  actionHandlers: ncHandlers