@camstack/addon-notifiers 1.2.7 → 1.2.9

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.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
2
- //#region ../types/dist/event-category-BLcNejAE.mjs
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ //#region ../types/dist/event-category-Bz24uP1U.mjs
3
6
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4
7
  EventCategory["SystemBoot"] = "system.boot";
5
8
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -270,6 +273,19 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
270
273
  */
271
274
  EventCategory["DeviceStateChanged"] = "device.state-changed";
272
275
  /**
276
+ * Frame occupancy for a camera CHANGED — a tracked object was gained or
277
+ * lost. Carries `{ deviceId, totalObjects, byClass, zones }`.
278
+ *
279
+ * Emitted only on a change, so a steady scene is silent. It exists so a
280
+ * client can stop polling `zoneAnalytics.getCurrentSnapshot`: that was the
281
+ * one live badge with no push signal at all, and it cost a request every
282
+ * four seconds per visible camera.
283
+ *
284
+ * Like every event it is telemetry and may be dropped ([D8]) — a consumer
285
+ * keeps a slow reconcile rather than trusting it alone.
286
+ */
287
+ EventCategory["ZoneAnalyticsOccupancyChanged"] = "zone-analytics.occupancy-changed";
288
+ /**
273
289
  * Cap event fired by every device that registers the `battery`
274
290
  * capability. Mirrors the cap definition's `onStatusChanged`. Carries
275
291
  * `{ deviceId, status: BatteryStatus }`. Subscribers (alert center,
@@ -7300,35 +7316,22 @@ function buildCapturingReply(envelope) {
7300
7316
  */
7301
7317
  var RecordingWeekdaySchema = number().int().min(0).max(6);
7302
7318
  var HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
7303
- var RecordingScheduleSchema = discriminatedUnion("kind", [object({ kind: literal("always") }), object({
7304
- kind: literal("timeOfDay"),
7305
- start: string().regex(HHMM),
7306
- end: string().regex(HHMM),
7307
- /** Restrict to these weekdays; omit = every day. */
7308
- days: array(RecordingWeekdaySchema).optional()
7309
- })]);
7310
- var RecordingModeSchema = _enum([
7311
- "continuous",
7312
- "onMotion",
7313
- "onAudioThreshold"
7314
- ]);
7315
7319
  /**
7316
- * First-class, authoritative per-camera storage mode — the explicit choice the
7317
- * UI reads directly (never inferred from `rules`):
7318
- * - `off` — not recording.
7319
- * - `events` — record only around triggers (motion / audio threshold),
7320
- * with pre/post-buffer.
7321
- * - `continuous` — record 24/7 within the schedule.
7320
+ * DERIVED per-camera storage summary — the single field cheap consumers read
7321
+ * (the viewer's status dot, the camera list) instead of walking `bands`:
7322
+ * - `off` — no band covers the camera (or it is disabled).
7323
+ * - `events` — every band records around triggers only.
7324
+ * - `continuous` — at least one band records continuously.
7322
7325
  *
7323
- * `mode` compiles one-way to the internal `rules[]` consumed by the policy
7324
- * engine (see `compileRules`); `rules[]` is never authored directly anymore.
7326
+ * NEVER authored: the recorder stamps it from the authoritative `bands` on
7327
+ * every save (`activeModeForConfig`). Writing it has no effect.
7325
7328
  */
7326
7329
  var RecordingStorageModeSchema = _enum([
7327
7330
  "off",
7328
7331
  "events",
7329
7332
  "continuous"
7330
7333
  ]);
7331
- /** Which detectors trigger an `events`-mode recording. */
7334
+ /** Which detectors trigger an `events`-mode band. */
7332
7335
  var RecordingTriggersSchema = object({
7333
7336
  motion: boolean().optional(),
7334
7337
  audioThresholdDbfs: number().optional()
@@ -7364,18 +7367,6 @@ var RecordingBandSchema = object({
7364
7367
  preBufferSec: number().min(0).optional(),
7365
7368
  postBufferSec: number().min(0).optional()
7366
7369
  });
7367
- var RecordingRuleSchema = object({
7368
- schedule: RecordingScheduleSchema,
7369
- mode: RecordingModeSchema,
7370
- /** Seconds of footage to retain BEFORE a trigger (applied at keep/discard). */
7371
- preBufferSec: number().min(0).default(0),
7372
- /** Keep recording until this many seconds after the last trigger. */
7373
- postBufferSec: number().min(0).default(0),
7374
- /** Each new trigger restarts the post-buffer window. */
7375
- resetTimeoutOnNewEvent: boolean().default(true),
7376
- /** onAudioThreshold only — dBFS level that counts as a trigger. */
7377
- thresholdDbfs: number().optional()
7378
- });
7379
7370
  /**
7380
7371
  * Per-device retention overrides. Every field is optional; an unset or `0`
7381
7372
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -7409,40 +7400,28 @@ var ScrubThumbnailPresetSchema = _enum([
7409
7400
  /**
7410
7401
  * The full per-camera recording intent — the wire shape of a RecordingTarget.
7411
7402
  *
7412
- * `mode` is the authoritative storage choice; `schedule`/`triggers`/`pre`/`post`
7413
- * are its mode-specific parameters. `rules` is a DEPRECATED authoring input kept
7414
- * only for transition + migration (`migrateRulesToMode`); the policy engine
7415
- * consumes the compiled output of `compileRules(config)`, never `rules` directly.
7403
+ * `bands` is the ONLY authored recording intent: what to record, when, and on
7404
+ * which trigger. `mode` is a derived summary the recorder stamps on save; every
7405
+ * other field is a storage knob (profiles, segment length, retention, scrub).
7406
+ *
7407
+ * STRICT on purpose: the legacy authoring surface (`schedule`/`schedules`/
7408
+ * `triggers`/`preBufferSec`/`postBufferSec`/`rules`) was retired 2026-07-30.
7409
+ * A stale caller must fail loudly — silently stripping its legacy intent would
7410
+ * persist a band-less config, i.e. silently stop recording the camera.
7416
7411
  */
7417
7412
  var RecordingConfigSchema = object({
7418
7413
  enabled: boolean(),
7419
- /** Authoritative storage mode. Absent on legacy targets derived once via
7420
- * `migrateRulesToMode`, then persisted. */
7414
+ /** DERIVED summary of `bands`, stamped by the recorder on every save.
7415
+ * Authoring it has no effect — see {@link RecordingStorageModeSchema}. */
7421
7416
  mode: RecordingStorageModeSchema.optional(),
7422
7417
  profiles: array(CamProfileSchema).optional(),
7423
7418
  segmentSeconds: number().int().positive().optional(),
7424
- /** Shared recording time-bands for `events` & `continuous` — record only when
7425
- * the wall-clock falls inside one of these windows. Omit or empty = always.
7426
- * `continuous` compiles to one rule per band; `events` to band × trigger. */
7427
- schedules: array(RecordingScheduleSchema).optional(),
7428
- /** Legacy single-band predecessor of `schedules`. Read-compat only — it is
7429
- * normalized into `schedules` on read and never written going forward. (Not
7430
- * tagged `@deprecated`: the normalization paths must read it cast-free.) */
7431
- schedule: RecordingScheduleSchema.optional(),
7432
- /** `events`-mode only — which detectors trigger a recording. */
7433
- triggers: RecordingTriggersSchema.optional(),
7434
- /** `events`-mode only — seconds retained before / after a trigger. */
7435
- preBufferSec: number().min(0).optional(),
7436
- postBufferSec: number().min(0).optional(),
7437
- /** DEPRECATED authoring input; retained for migration/transition. */
7438
- rules: array(RecordingRuleSchema).optional(),
7439
7419
  /**
7440
- * AUTHORITATIVE mode-per-band recording model (recorder). When present it
7441
- * is the single source of truth; the legacy `mode`/`schedules`/`schedule`/
7442
- * `triggers`/`rules` fields above are kept for READ-COMPAT only and are
7443
- * derived into bands once via `migrateConfigToBands`.
7420
+ * AUTHORITATIVE mode-per-band recording model the single source of truth
7421
+ * the recorder's band engine consumes. An empty array = record nothing;
7422
+ * "off" is the absence of a covering band, never a band value.
7444
7423
  */
7445
- bands: array(RecordingBandSchema).optional(),
7424
+ bands: array(RecordingBandSchema).default([]),
7446
7425
  retention: RecordingRetentionSchema.optional(),
7447
7426
  /**
7448
7427
  * Per-camera scrub-thumbnail fidelity preset (resolution + JPEG quality for
@@ -7450,8 +7429,15 @@ var RecordingConfigSchema = object({
7450
7429
  * windows only — existing sheets are immutable, and each window's index
7451
7430
  * carries its own tile dims so mixed-preset history renders correctly.
7452
7431
  */
7453
- scrubThumbnails: ScrubThumbnailPresetSchema.optional()
7454
- });
7432
+ scrubThumbnails: ScrubThumbnailPresetSchema.optional(),
7433
+ /**
7434
+ * OPT-IN thumbnail-strip generation for this camera: every keyframe of the
7435
+ * low recording saved as a JPEG (the fast-drag scrub depth), a derived
7436
+ * cache that eviction reclaims with the footage. Absent/false = no strips
7437
+ * are written and scrub reads exact keyframes at every velocity.
7438
+ */
7439
+ stripsEnabled: boolean().optional()
7440
+ }).strict();
7455
7441
  /**
7456
7442
  * Ops-log — the durable, append-only operations audit shared by the
7457
7443
  * recordings and events management surfaces.
@@ -7470,7 +7456,8 @@ var OpsLogOpSchema = _enum([
7470
7456
  "prune",
7471
7457
  "manual-delete",
7472
7458
  "rescan",
7473
- "retention-run"
7459
+ "retention-run",
7460
+ "relocate"
7474
7461
  ]);
7475
7462
  /** Why the operation ran. */
7476
7463
  var OpsLogReasonSchema = _enum([
@@ -7509,6 +7496,55 @@ var OpsLogQueryInputSchema = object({
7509
7496
  limit: number().int().min(1).max(1e3).optional()
7510
7497
  });
7511
7498
  /**
7499
+ * Entity-relocation job state (storage entity-routing spec, Phase 4).
7500
+ *
7501
+ * One shape shared by the recorder's `relocateFootage` (segments + strips) and
7502
+ * pipeline-analytics' `relocateMedia` (event media blobs) so the admin Data
7503
+ * page renders both movers with one component. Jobs are in-RAM (a restart
7504
+ * forgets them — re-running is safe by construction: copy-if-absent, delete
7505
+ * after verify) and each completed/failed run also lands one durable ops-log
7506
+ * row on the owning addon's surface.
7507
+ */
7508
+ var RelocateJobStateSchema = _enum([
7509
+ "running",
7510
+ "done",
7511
+ "failed",
7512
+ "cancelled"
7513
+ ]);
7514
+ var RelocateJobSchema = object({
7515
+ jobId: string(),
7516
+ state: RelocateJobStateSchema,
7517
+ /** Source location — for media relocation this is informational ('*': rows
7518
+ * move from wherever they are to the target). */
7519
+ fromLocationId: string(),
7520
+ toLocationId: string(),
7521
+ /** Scoped device, or null = every device. */
7522
+ deviceId: number().nullable(),
7523
+ /** What the job moves (owner-addon specific: segments/strips or media). */
7524
+ entities: array(string()),
7525
+ filesMoved: number().int(),
7526
+ bytesMoved: number().int(),
7527
+ /** Total files discovered up front; null while (or when) unknown. */
7528
+ filesTotal: number().int().nullable(),
7529
+ startedAt: number(),
7530
+ finishedAt: number().nullable(),
7531
+ error: string().nullable()
7532
+ });
7533
+ var RelocateFootageInputSchema = object({
7534
+ deviceId: number().optional(),
7535
+ fromLocationId: string(),
7536
+ toLocationId: string(),
7537
+ entities: array(_enum(["segments", "strips"])).optional(),
7538
+ /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7539
+ * never allowed to starve live writers. */
7540
+ throttleMbps: number().min(1).max(1e3).optional()
7541
+ });
7542
+ var RelocateMediaInputSchema = object({
7543
+ deviceId: number().optional(),
7544
+ toLocationId: string(),
7545
+ throttleMbps: number().min(1).max(1e3).optional()
7546
+ });
7547
+ /**
7512
7548
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
7513
7549
  * storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
7514
7550
  * so the persisted record schema and the consumer-facing cap can both consume it
@@ -7558,6 +7594,13 @@ var StorageLocationSchema = object({
7558
7594
  nodeId: string().optional(),
7559
7595
  isDefault: boolean().default(false),
7560
7596
  isSystem: boolean().default(false),
7597
+ /** COMPUTED at read time by the orchestrator (statfs of the backing volume
7598
+ * for node-local locations it can reach) — never persisted, absent when the
7599
+ * volume is remote/unreachable. The single capacity truth every UI reads. */
7600
+ capacity: object({
7601
+ totalBytes: number(),
7602
+ availableBytes: number()
7603
+ }).nullable().optional(),
7561
7604
  createdAt: number(),
7562
7605
  updatedAt: number()
7563
7606
  });
@@ -8325,7 +8368,8 @@ var NcTaxonomyEntrySchema = object({
8325
8368
  /** Macro/category parent for grouping ('car' → 'vehicle'); null for a top. */
8326
8369
  parentKind: string().nullable()
8327
8370
  });
8328
- object({
8371
+ /** The complete NC picker taxonomy — three grouped buckets. */
8372
+ var NcTaxonomySchema = object({
8329
8373
  videoClasses: array(NcTaxonomyEntrySchema),
8330
8374
  audioKinds: array(NcTaxonomyEntrySchema),
8331
8375
  labels: array(NcTaxonomyEntrySchema)
@@ -9048,9 +9092,20 @@ var DEGRADE_ORDER = {
9048
9092
  "markdown"
9049
9093
  ]
9050
9094
  };
9051
- /** Pick the best target format the kind supports for a given source format. */
9095
+ /**
9096
+ * Pick the best target format the kind supports for a given source format.
9097
+ *
9098
+ * TOLERANT of a source outside the union. `NotificationSchema.format` carries
9099
+ * `.default('text')`, but that default only runs where the schema is PARSED —
9100
+ * the tRPC router. A cap call arriving from another addon (`ctx.api`) skips
9101
+ * that parse, so `format` reaches here `undefined`, and the bare
9102
+ * `DEGRADE_ORDER[source]` threw `is not iterable`. That broke the one promise
9103
+ * the degrade path makes ("never throws — degradation is reported, never
9104
+ * surfaced as an error") and dead-lettered every Notification-Center delivery
9105
+ * for a day (2026-07-30). An unknown source degrades like `text`.
9106
+ */
9052
9107
  function resolveFormat(source, supported) {
9053
- for (const candidate of DEGRADE_ORDER[source]) if (supported.includes(candidate)) return candidate;
9108
+ for (const candidate of DEGRADE_ORDER[source] ?? DEGRADE_ORDER.text) if (supported.includes(candidate)) return candidate;
9054
9109
  return supported[0] ?? "text";
9055
9110
  }
9056
9111
  /** Transcode a body between formats. */
@@ -9095,7 +9150,7 @@ function resolveLevel(levels, n) {
9095
9150
  })[0]);
9096
9151
  }
9097
9152
  function splitBody(body, maxLen) {
9098
- if (maxLen <= 0 || body.length <= maxLen) return [body];
9153
+ if (!Number.isFinite(maxLen) || maxLen <= 0 || body.length <= maxLen) return [body];
9099
9154
  const parts = [];
9100
9155
  for (let i = 0; i < body.length; i += maxLen) parts.push(body.slice(i, i + maxLen));
9101
9156
  return parts;
@@ -9178,6 +9233,7 @@ function prepareNotification(caps, n) {
9178
9233
  renderedAs
9179
9234
  };
9180
9235
  }
9236
+ new Set(["devices", "classes"]);
9181
9237
  /**
9182
9238
  * Shared geometry vocabulary for on-frame shape caps — privacy-mask,
9183
9239
  * motion-zones, and the detection zones/lines editor all speak this one
@@ -9336,6 +9392,29 @@ var NcOccupancyConditionSchema = object({
9336
9392
  count: number().int().min(0).default(1),
9337
9393
  sustainSeconds: number().int().min(0).max(3600).default(15)
9338
9394
  });
9395
+ /**
9396
+ * Which zone-crossing DIRECTION a rule accepts (`ObjectEvent.zoneCrossing`).
9397
+ *
9398
+ * The values are not symmetric, and deliberately so — the absent value has to
9399
+ * mean exactly what every rule authored before this condition existed already
9400
+ * does:
9401
+ * - `enter` — entries and every NON-crossing record (movement state,
9402
+ * package, sensor). Exits are rejected. **This is the absent behaviour**:
9403
+ * an operator who never asked for exits must not start receiving them.
9404
+ * - `exit` — ONLY an exit crossing. A record that is not a crossing at all
9405
+ * fails closed, because "the car left the drive" is a question about a
9406
+ * boundary, not about a detection.
9407
+ * - `any` — no direction filter; entries, exits and non-crossings alike.
9408
+ *
9409
+ * A rule asking for a direction should normally also scope `zones`, which the
9410
+ * engine evaluates against the crossed zone as well as the current membership
9411
+ * (an exit's membership no longer contains the zone it just left).
9412
+ */
9413
+ var NcCrossingSchema = _enum([
9414
+ "enter",
9415
+ "exit",
9416
+ "any"
9417
+ ]);
9339
9418
  /** Admin-zone membership condition (zone IDs as stamped by the ZoneEngine). */
9340
9419
  var NcZoneConditionSchema = object({
9341
9420
  ids: array(string().min(1)).min(1),
@@ -9360,6 +9439,13 @@ var NcConditionsSchema = object({
9360
9439
  /** Veto zones — any hit fails the rule. */
9361
9440
  zonesExclude: array(string().min(1)).optional(),
9362
9441
  /**
9442
+ * Zone-crossing direction. IMMEDIATE only — a crossing is a per-event fact
9443
+ * and a closed track carries none, so a `track-end` rule asking for one
9444
+ * fails closed (use `zones`, which tests `zonesVisited`). ABSENT = `enter`,
9445
+ * which is exactly today's behaviour. See {@link NcCrossingSchema}.
9446
+ */
9447
+ crossing: NcCrossingSchema.optional(),
9448
+ /**
9363
9449
  * Exact (case-insensitive) match on the record's collapsed `label`
9364
9450
  * (identity name / plate text / subclass).
9365
9451
  */
@@ -9494,17 +9580,85 @@ var NcRuleTargetSchema = object({
9494
9580
  * - `keyFrame` — the clean scene frame (no subject box).
9495
9581
  * - `none` — no attachment.
9496
9582
  */
9497
- var NcMediaPolicySchema = object({ attach: _enum([
9498
- "best",
9499
- "best-matching",
9500
- "keyFrame",
9501
- "none"
9502
- ]).default("best") });
9583
+ /**
9584
+ * WHAT THE PICTURE SHOWS — chosen explicitly, because the implicit ladders
9585
+ * conflate it with the selection strategy and betray the request: asking for
9586
+ * the clean scene frame on an object-event owner used to start at
9587
+ * `fullFrameBoxed`, i.e. the annotated frame (2026-07-30).
9588
+ * - `cropped` — the subject, tight. What "who is at the door" wants.
9589
+ * - `full` — the clean scene, no annotation. What "what is going on" wants.
9590
+ * - `boxed` — the scene WITH the detection boxes drawn, for verifying what
9591
+ * the pipeline actually saw.
9592
+ */
9593
+ var NcMediaFrameSchema = _enum([
9594
+ "cropped",
9595
+ "full",
9596
+ "boxed"
9597
+ ]);
9598
+ var NcMediaPolicySchema = object({
9599
+ attach: _enum([
9600
+ "best",
9601
+ "best-matching",
9602
+ "keyFrame",
9603
+ "none"
9604
+ ]).default("best"),
9605
+ /** What the still shows. Absent = `cropped` (the historical `best` shape). */
9606
+ frame: NcMediaFrameSchema.optional(),
9607
+ /** Crop the attached still to the rule's condition-zone bbox (padded) —
9608
+ * "show me the ZONE", not the whole scene or the subject crop. */
9609
+ zoneCrop: boolean().optional(),
9610
+ /**
9611
+ * Also attach a short GIF cut from the stream broker's clip ring around the
9612
+ * event — NOT from the recording, so the camera does not have to be
9613
+ * recording, and the window sits AROUND the moment instead of a segment
9614
+ * behind it. Fail-closed: a window the ring does not cover contributes no
9615
+ * gif, never a failed notification.
9616
+ */
9617
+ gif: boolean().optional(),
9618
+ /**
9619
+ * Also attach a short MP4 CLIP of the same cut. Same source and the same
9620
+ * fail-closed rule as `gif`. Prefer it where the backend takes video
9621
+ * (telegram, discord, zentik, webhook); backends that do not are handled by
9622
+ * the degrade engine, which drops the video and keeps the still.
9623
+ */
9624
+ clip: boolean().optional(),
9625
+ /** Seconds of footage BEFORE / AFTER the event instant. Defaults 3 / 7. */
9626
+ clipPreRollSec: number().int().min(0).max(30).optional(),
9627
+ clipPostRollSec: number().int().min(0).max(30).optional(),
9628
+ /**
9629
+ * Which stream profile the footage is cut from. Absent = the CHEAPEST
9630
+ * assigned profile: a notification is watched on a phone, so the 4K
9631
+ * rendition would burn CPU to produce a file the client downscales anyway.
9632
+ * A profile that is not assigned falls back to the cheapest, and the render
9633
+ * reports which one actually ran.
9634
+ */
9635
+ profile: CamProfileSchema.optional()
9636
+ });
9637
+ /**
9638
+ * Cooldown GRANULARITY over the subject's class — how much a fired
9639
+ * notification suppresses.
9640
+ * - `shared` (default, and the absent value) — one window for the whole
9641
+ * rule/scope: a cat silences the next dog for `cooldownSec`.
9642
+ * - `per-class` — an independent window per detected class, so cat→dog fires
9643
+ * at once and cat→cat still waits.
9644
+ *
9645
+ * AUDIO subjects are ALWAYS per-class regardless of this setting: a scream
9646
+ * must not be swallowed by a bark's window (the precedent this generalizes —
9647
+ * see `cooldownKey` in the rule engine).
9648
+ */
9649
+ var NcThrottleGranularitySchema = _enum(["shared", "per-class"]);
9503
9650
  /** Throttle — cooldown survives restarts (rebuilt from the outbox on boot). */
9504
9651
  var NcThrottleSchema = object({
9505
9652
  cooldownSec: number().int().min(0).max(86400).default(60),
9506
9653
  /** `rule` = one shared cooldown; `rule-device` = per-camera cooldown. */
9507
- scope: _enum(["rule", "rule-device"]).default("rule-device")
9654
+ scope: _enum(["rule", "rule-device"]).default("rule-device"),
9655
+ /**
9656
+ * Class granularity of the cooldown key. Optional rather than defaulted:
9657
+ * a Zod default does NOT run on the addon→addon cap path, so a persisted
9658
+ * rule authored before this field simply carries none — and the engine
9659
+ * reads absent as `shared`, the pre-existing behaviour.
9660
+ */
9661
+ granularity: NcThrottleGranularitySchema.optional()
9508
9662
  });
9509
9663
  /** Client-supplied rule fields (server stamps id/createdBy/createdAt/updatedAt). */
9510
9664
  var NcRuleInputSchema = object({
@@ -9513,7 +9667,19 @@ var NcRuleInputSchema = object({
9513
9667
  delivery: NcDeliverySchema,
9514
9668
  conditions: NcConditionsSchema.default({}),
9515
9669
  schedule: NcScheduleSchema.optional(),
9516
- targets: array(NcRuleTargetSchema).min(1),
9670
+ /** May be empty when `targetUsers` addresses at least one user — the
9671
+ * "at least one addressee" invariant is enforced by the provider, because
9672
+ * a cross-field refine here would break `NcRulePatchSchema.partial()`. */
9673
+ targets: array(NcRuleTargetSchema),
9674
+ /**
9675
+ * USERS this rule addresses in addition to `targets` (Phase 4). At fire
9676
+ * time each user fans out to the personal targets they own
9677
+ * (`config.ownerUserId`), filtered by that user's `allowedDevices` for the
9678
+ * firing camera — a user is never notified about a device they cannot open.
9679
+ * Per-user opt-outs (`disabledTargetIds`) still apply to the fanned-out
9680
+ * targets.
9681
+ */
9682
+ targetUsers: array(string()).optional(),
9517
9683
  media: NcMediaPolicySchema.default({ attach: "best" }),
9518
9684
  throttle: NcThrottleSchema.default({
9519
9685
  cooldownSec: 60,
@@ -9600,6 +9766,7 @@ var NcConditionDescriptorSchema = object({
9600
9766
  "schedule",
9601
9767
  "plateMatcher",
9602
9768
  "packagePhase",
9769
+ "crossingSelect",
9603
9770
  "polygonDraw",
9604
9771
  "occupancy"
9605
9772
  ]),
@@ -9726,7 +9893,10 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
9726
9893
  }), object({ results: array(NcTestResultSchema) }), {
9727
9894
  kind: "mutation",
9728
9895
  auth: "admin"
9729
- }), method(object({}), object({ catalog: array(NcConditionDescriptorSchema) })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
9896
+ }), method(object({}), object({
9897
+ catalog: array(NcConditionDescriptorSchema),
9898
+ taxonomy: NcTaxonomySchema.optional()
9899
+ })), method(object({ filter: NcHistoryFilterSchema.default({ limit: 100 }) }), object({ entries: array(NcHistoryEntrySchema) }), { auth: "admin" });
9730
9900
  /**
9731
9901
  * TimelapseRule — the STANDALONE scheduled timelapse producer's rule model.
9732
9902
  *
@@ -10463,6 +10633,28 @@ method(object({
10463
10633
  }), object({ success: literal(true) }), {
10464
10634
  kind: "mutation",
10465
10635
  auth: "admin"
10636
+ }), method(object({
10637
+ deviceId: number(),
10638
+ /** Absent = the LOWEST assigned profile — a notification attachment is
10639
+ * watched on a phone, and the cheap rendition is the right default. */
10640
+ profile: CamProfileSchema.optional(),
10641
+ aroundMs: number(),
10642
+ preRollSec: number().min(0).max(20).default(3),
10643
+ postRollSec: number().min(0).max(20).default(5),
10644
+ format: _enum(["gif", "mp4"]).default("gif"),
10645
+ maxWidth: number().int().min(120).max(1920).default(480),
10646
+ /** GIF only — MP4 keeps the source cadence. */
10647
+ fps: number().int().min(1).max(15).default(5)
10648
+ }), object({
10649
+ base64: string(),
10650
+ mime: string(),
10651
+ bytes: number().int(),
10652
+ /** The profile actually rendered (what the default resolved to). */
10653
+ profile: CamProfileSchema,
10654
+ durationMs: number()
10655
+ }), {
10656
+ kind: "mutation",
10657
+ auth: "admin"
10466
10658
  }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
10467
10659
  probed: boolean(),
10468
10660
  summary: string()
@@ -16098,7 +16290,10 @@ method(object({
16098
16290
  }), method(object({
16099
16291
  deviceId: number(),
16100
16292
  caps: array(string()).readonly().optional()
16101
- }), record(string(), unknown().nullable()));
16293
+ }), record(string(), unknown().nullable())), method(object({
16294
+ deviceIds: array(number()).readonly(),
16295
+ caps: array(string()).readonly().optional()
16296
+ }), record(string(), record(string(), unknown().nullable())));
16102
16297
  method(object({ deviceId: number() }), record(string(), record(string(), unknown()))), method(object({
16103
16298
  deviceId: number(),
16104
16299
  capName: string()
@@ -17029,6 +17224,36 @@ var TargetKindSchema = object({
17029
17224
  icon: string(),
17030
17225
  /** Stamped by each provider so the concat-fanned catalog stays routable. */
17031
17226
  addonId: string(),
17227
+ /**
17228
+ * URL of the kind's bundled BRAND icon, served by the providing addon over
17229
+ * its own `addon-routes` surface (`/addon/<addonId>/icons/<kind>`). Absent
17230
+ * when the addon bundles no icon for that kind — the client then falls back
17231
+ * to a neutral glyph rather than rendering the raw `icon` NAME as text.
17232
+ *
17233
+ * Root-relative on purpose: it resolves against whatever origin serves a web
17234
+ * client, and a native client joins it onto its own hub base.
17235
+ *
17236
+ * DECLARED here deliberately. It used to travel as an undeclared passthrough
17237
+ * field that survived only because the runtime cap-router forwards provider
17238
+ * output verbatim — so every consumer had to re-declare it by hand to stop
17239
+ * its own Zod parse from stripping it, and the whole arrangement would have
17240
+ * broken silently the moment output validation was tightened anywhere.
17241
+ */
17242
+ iconUrl: string().optional(),
17243
+ /**
17244
+ * Media type of {@link iconUrl} (`image/svg+xml`, `image/png`, …).
17245
+ *
17246
+ * The server knows this and therefore says it, because the client cannot
17247
+ * safely guess: a React-Native client renders SVG and raster through two
17248
+ * DIFFERENT components (`react-native-svg` vs `expo-image` — expo-image does
17249
+ * not decode SVG on iOS/Android), so without this it silently fell back to a
17250
+ * placeholder glyph for every vector icon while the web build looked fine.
17251
+ *
17252
+ * Absent when {@link iconUrl} is absent, or for a legacy provider that has
17253
+ * not been updated — a client that cannot determine the type should prefer
17254
+ * its raster path, which is the safe default for an unknown image.
17255
+ */
17256
+ iconMediaType: string().optional(),
17032
17257
  configSchema: ConfigSchemaPassthrough,
17033
17258
  supportsDiscovery: boolean(),
17034
17259
  caps: TargetKindCapsSchema
@@ -17490,6 +17715,29 @@ var MotionEventSchema = object({
17490
17715
  * Absent on legacy rows ⇒ treat as `pipeline`.
17491
17716
  */
17492
17717
  var DetectionSourceSchema = _enum(["pipeline", "onboard"]);
17718
+ /**
17719
+ * The confirmed zone crossing that produced an object event. Present ONLY on
17720
+ * an event emitted BY a crossing (`zone.enter` / `zone.exit`); a movement-state
17721
+ * event (`object.entering` / `leaving` / `stationary` / `loitering`) and an
17722
+ * appearance event carry none, so a rule asking for a direction fails closed
17723
+ * on them.
17724
+ *
17725
+ * Exactly ONE crossing per event: the emitter turns each confirmed crossing
17726
+ * into its own event, so a frame in which a track enters A while leaving B
17727
+ * produces two events with two directions — never one ambiguous row.
17728
+ *
17729
+ * `zoneId` is load-bearing for an EXIT: the event's `zones` list is the
17730
+ * membership the box has NOW, and by definition it no longer contains the zone
17731
+ * that was just left. Without the id here, a zone-scoped rule could never match
17732
+ * the exit it asked for.
17733
+ */
17734
+ var ZoneCrossingSchema = object({
17735
+ direction: _enum(["enter", "exit"]),
17736
+ /** Admin zone id crossed. */
17737
+ zoneId: string(),
17738
+ /** Zone display name at crossing time (falls back to the id). */
17739
+ zoneName: string().optional()
17740
+ });
17493
17741
  var ObjectEventSchema = object({
17494
17742
  ...BaseEventFields,
17495
17743
  kind: literal("object"),
@@ -17516,6 +17764,12 @@ var ObjectEventSchema = object({
17516
17764
  zones: array(string()).readonly().optional(),
17517
17765
  /** Omitted in slim projection. */
17518
17766
  state: TrackStateSchema.optional(),
17767
+ /**
17768
+ * The zone crossing this event IS, when it is one. Absent on every other
17769
+ * event kind (movement state, appearance, package) — see
17770
+ * {@link ZoneCrossingSchema}. Omitted in slim projection.
17771
+ */
17772
+ zoneCrossing: ZoneCrossingSchema.optional(),
17519
17773
  /** Detection-frame dimensions in pixels — let consumers normalize the
17520
17774
  * pixel-space `bbox` onto a displayed image. Omitted in slim projection. */
17521
17775
  frameWidth: number().optional(),
@@ -17774,6 +18028,15 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17774
18028
  }), method(object({ deviceId: number() }), EventPruneCountsSchema, {
17775
18029
  kind: "mutation",
17776
18030
  auth: "admin"
18031
+ }), method(RelocateMediaInputSchema, object({ jobId: string() }), {
18032
+ kind: "mutation",
18033
+ auth: "admin"
18034
+ }), method(object({}), array(RelocateJobSchema).readonly(), {
18035
+ kind: "query",
18036
+ auth: "admin"
18037
+ }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
18038
+ kind: "mutation",
18039
+ auth: "admin"
17777
18040
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
17778
18041
  kind: "query",
17779
18042
  auth: "admin"
@@ -20253,6 +20516,17 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
20253
20516
  */
20254
20517
  priority: number()
20255
20518
  })).readonly() });
20519
+ /**
20520
+ * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
20521
+ * AUTO (resolved from the candidate ranking at send time); `resolved` reports
20522
+ * what AUTO currently picks, so the UI can show the effective value either way.
20523
+ */
20524
+ var NotificationEndpointSchema = object({
20525
+ /** The operator's explicit choice, or null for AUTO. */
20526
+ baseUrl: string().nullable(),
20527
+ /** What the ranking currently resolves to (null when nothing is reachable). */
20528
+ resolved: string().nullable()
20529
+ });
20256
20530
  var AllowedAddressesSchema = object({
20257
20531
  /**
20258
20532
  * Allowlist of interface addresses operators have explicitly opted
@@ -20275,7 +20549,7 @@ method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(obje
20275
20549
  * to avoid mixed-content blocks in the browser. The public
20276
20550
  * tunnel always emits `https://` regardless. */
20277
20551
  scheme: _enum(["http", "https"]).optional()
20278
- }), GetConnectionEndpointsResultSchema), method(_void(), AllowedAddressesSchema), method(AllowedAddressesSchema, object({ success: literal(true) }), { kind: "mutation" }), method(_void(), AllowedAddressesSchema, { kind: "mutation" });
20552
+ }), 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" });
20279
20553
  /**
20280
20554
  * mesh-network — collection cap for mesh-VPN providers.
20281
20555
  *
@@ -21074,7 +21348,12 @@ var RecordingDeviceUsageSchema = object({
21074
21348
  var RecordingLocationUsageSchema = object({
21075
21349
  /** StorageLocation id; null for the legacy/degraded single-root fallback. */
21076
21350
  locationId: string().nullable(),
21077
- /** Bytes of recordings stored on this location. */
21351
+ /** Every location id sharing this row's PHYSICAL volume (aliases). One row
21352
+ * is emitted per physical disk (2026-07-29): two locations on one root
21353
+ * previously rendered as two identical "disks" with a nonsensical used
21354
+ * split — hydrate attribution across aliases is arbitrary by nature. */
21355
+ locationIds: array(string()).optional(),
21356
+ /** Bytes of recordings stored on this PHYSICAL volume (all aliases). */
21078
21357
  usedBytes: number(),
21079
21358
  /** Free bytes on the location's volume; null when capacity is unknown (remote). */
21080
21359
  availableBytes: number().nullable(),
@@ -21186,6 +21465,44 @@ method(object({
21186
21465
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
21187
21466
  kind: "query",
21188
21467
  auth: "admin"
21468
+ }), method(object({
21469
+ deviceId: number(),
21470
+ aroundMs: number(),
21471
+ preRollSec: number().min(0).max(30).default(2),
21472
+ postRollSec: number().min(0).max(30).default(5),
21473
+ maxWidth: number().int().min(120).max(1280).default(480),
21474
+ fps: number().int().min(1).max(15).default(5)
21475
+ }), object({
21476
+ gifBase64: string(),
21477
+ fromMs: number(),
21478
+ toMs: number()
21479
+ }), {
21480
+ kind: "mutation",
21481
+ auth: "admin"
21482
+ }), method(object({
21483
+ deviceId: number(),
21484
+ aroundMs: number(),
21485
+ preRollSec: number().min(0).max(30).default(3),
21486
+ postRollSec: number().min(0).max(30).default(7),
21487
+ maxWidth: number().int().min(160).max(1920).default(640)
21488
+ }), object({
21489
+ clipBase64: string(),
21490
+ mime: string(),
21491
+ fromMs: number(),
21492
+ toMs: number(),
21493
+ bytes: number().int()
21494
+ }), {
21495
+ kind: "mutation",
21496
+ auth: "admin"
21497
+ }), method(RelocateFootageInputSchema, object({ jobId: string() }), {
21498
+ kind: "mutation",
21499
+ auth: "admin"
21500
+ }), method(object({}), array(RelocateJobSchema).readonly(), {
21501
+ kind: "query",
21502
+ auth: "admin"
21503
+ }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
21504
+ kind: "mutation",
21505
+ auth: "admin"
21189
21506
  });
21190
21507
  /**
21191
21508
  * `recordingExport` cap — render a footage time range into a single downloadable
@@ -22777,6 +23094,12 @@ Object.freeze({
22777
23094
  addonId: null,
22778
23095
  access: "view"
22779
23096
  },
23097
+ "deviceManager.getDeviceStatusAggregateBatch": {
23098
+ capName: "device-manager",
23099
+ capScope: "system",
23100
+ addonId: null,
23101
+ access: "view"
23102
+ },
22780
23103
  "deviceManager.getLinkedDevices": {
22781
23104
  capName: "device-manager",
22782
23105
  capScope: "system",
@@ -23671,6 +23994,12 @@ Object.freeze({
23671
23994
  addonId: null,
23672
23995
  access: "view"
23673
23996
  },
23997
+ "localNetwork.getNotificationEndpoint": {
23998
+ capName: "local-network",
23999
+ capScope: "system",
24000
+ addonId: null,
24001
+ access: "view"
24002
+ },
23674
24003
  "localNetwork.getPreferred": {
23675
24004
  capName: "local-network",
23676
24005
  capScope: "system",
@@ -23695,6 +24024,12 @@ Object.freeze({
23695
24024
  addonId: null,
23696
24025
  access: "create"
23697
24026
  },
24027
+ "localNetwork.setNotificationEndpoint": {
24028
+ capName: "local-network",
24029
+ capScope: "system",
24030
+ addonId: null,
24031
+ access: "create"
24032
+ },
23698
24033
  "lockControl.lock": {
23699
24034
  capName: "lock-control",
23700
24035
  capScope: "device",
@@ -24337,6 +24672,12 @@ Object.freeze({
24337
24672
  addonId: null,
24338
24673
  access: "create"
24339
24674
  },
24675
+ "pipelineAnalytics.cancelMediaRelocate": {
24676
+ capName: "pipeline-analytics",
24677
+ capScope: "device",
24678
+ addonId: null,
24679
+ access: "create"
24680
+ },
24340
24681
  "pipelineAnalytics.clearTracks": {
24341
24682
  capName: "pipeline-analytics",
24342
24683
  capScope: "device",
@@ -24391,6 +24732,12 @@ Object.freeze({
24391
24732
  addonId: null,
24392
24733
  access: "view"
24393
24734
  },
24735
+ "pipelineAnalytics.getMediaRelocateStatus": {
24736
+ capName: "pipeline-analytics",
24737
+ capScope: "device",
24738
+ addonId: null,
24739
+ access: "view"
24740
+ },
24394
24741
  "pipelineAnalytics.getMotionEvents": {
24395
24742
  capName: "pipeline-analytics",
24396
24743
  capScope: "device",
@@ -24463,6 +24810,12 @@ Object.freeze({
24463
24810
  addonId: null,
24464
24811
  access: "create"
24465
24812
  },
24813
+ "pipelineAnalytics.relocateMedia": {
24814
+ capName: "pipeline-analytics",
24815
+ capScope: "device",
24816
+ addonId: null,
24817
+ access: "create"
24818
+ },
24466
24819
  "pipelineAnalytics.searchObjectEvents": {
24467
24820
  capName: "pipeline-analytics",
24468
24821
  capScope: "device",
@@ -25225,6 +25578,12 @@ Object.freeze({
25225
25578
  addonId: null,
25226
25579
  access: "create"
25227
25580
  },
25581
+ "recording.cancelRelocate": {
25582
+ capName: "recording",
25583
+ capScope: "system",
25584
+ addonId: null,
25585
+ access: "create"
25586
+ },
25228
25587
  "recording.deleteFootprint": {
25229
25588
  capName: "recording",
25230
25589
  capScope: "system",
@@ -25255,6 +25614,12 @@ Object.freeze({
25255
25614
  addonId: null,
25256
25615
  access: "view"
25257
25616
  },
25617
+ "recording.getRelocateStatus": {
25618
+ capName: "recording",
25619
+ capScope: "system",
25620
+ addonId: null,
25621
+ access: "view"
25622
+ },
25258
25623
  "recording.getStorageUsage": {
25259
25624
  capName: "recording",
25260
25625
  capScope: "system",
@@ -25285,6 +25650,24 @@ Object.freeze({
25285
25650
  addonId: null,
25286
25651
  access: "view"
25287
25652
  },
25653
+ "recording.relocateFootage": {
25654
+ capName: "recording",
25655
+ capScope: "system",
25656
+ addonId: null,
25657
+ access: "create"
25658
+ },
25659
+ "recording.renderClip": {
25660
+ capName: "recording",
25661
+ capScope: "system",
25662
+ addonId: null,
25663
+ access: "create"
25664
+ },
25665
+ "recording.renderGif": {
25666
+ capName: "recording",
25667
+ capScope: "system",
25668
+ addonId: null,
25669
+ access: "create"
25670
+ },
25288
25671
  "recording.rescanStorage": {
25289
25672
  capName: "recording",
25290
25673
  capScope: "system",
@@ -25879,6 +26262,12 @@ Object.freeze({
25879
26262
  addonId: null,
25880
26263
  access: "create"
25881
26264
  },
26265
+ "streamBroker.renderPreBufferClip": {
26266
+ capName: "stream-broker",
26267
+ capScope: "system",
26268
+ addonId: null,
26269
+ access: "create"
26270
+ },
25882
26271
  "streamBroker.restartProfile": {
25883
26272
  capName: "stream-broker",
25884
26273
  capScope: "system",
@@ -26573,7 +26962,7 @@ var NOTIFIER_ICONS = {
26573
26962
  /** The addon id — also the `/addon/<id>/…` route prefix. Mirrors `NOTIFIERS_ADDON_ID`. */
26574
26963
  var NOTIFIERS_ROUTE_ID = "notifiers";
26575
26964
  /** One day; icons are immutable per addon version. */
26576
- var CACHE_CONTROL = "public, max-age=86400, immutable";
26965
+ var CACHE_CONTROL$1 = "public, max-age=86400, immutable";
26577
26966
  var ICON_KINDS = new Set(Object.keys(NOTIFIER_ICONS));
26578
26967
  /** `true` when a bundled brand icon exists for `kind`. */
26579
26968
  function hasIcon(kind) {
@@ -26587,6 +26976,15 @@ function hasIcon(kind) {
26587
26976
  function iconUrlFor(kind) {
26588
26977
  return hasIcon(kind) ? `/addon/${NOTIFIERS_ROUTE_ID}/icons/${kind}` : void 0;
26589
26978
  }
26979
+ /**
26980
+ * The media type `iconUrlFor(kind)` serves, or `undefined` when no icon is
26981
+ * bundled. The client needs it because SVG and raster render through different
26982
+ * components on React Native, and it cannot be guessed from the URL — the route
26983
+ * path deliberately carries no file extension.
26984
+ */
26985
+ function iconMediaTypeFor(kind) {
26986
+ return hasIcon(kind) ? NOTIFIER_ICONS[kind].contentType : void 0;
26987
+ }
26590
26988
  /** Pre-encode one bundled icon into every content coding. */
26591
26989
  function encodeIcon(icon) {
26592
26990
  const identity = typeof icon.body === "string" ? Buffer.from(icon.body, "utf8") : Buffer.from(icon.body);
@@ -26622,15 +27020,143 @@ function iconRoute(kind) {
26622
27020
  description: `Official brand icon (${encoded.contentType}) for the '${kind}' notifier kind.`,
26623
27021
  handler: async (request, reply) => {
26624
27022
  const coding = negotiateEncoding(String(request.headers["accept-encoding"] ?? ""));
26625
- reply.code(200).type(encoded.contentType).header("cache-control", CACHE_CONTROL).header("vary", "accept-encoding");
27023
+ reply.code(200).type(encoded.contentType).header("cache-control", CACHE_CONTROL$1).header("vary", "accept-encoding");
26626
27024
  if (coding !== null) reply.header("content-encoding", coding);
26627
27025
  reply.send(coding !== null ? encoded[coding] : encoded.identity);
26628
27026
  }
26629
27027
  };
26630
27028
  }
26631
- /** Build the `addon-routes` provider that serves the bundled icons. */
26632
- function createIconRouteProvider() {
26633
- return buildAddonRouteProvider(NOTIFIERS_ROUTE_ID, Object.keys(NOTIFIER_ICONS).map(iconRoute));
27029
+ /**
27030
+ * The icon routes, for composition into the addon's SINGLE `addon-routes`
27031
+ * provider. Not a provider of its own: a provider owns one route id and matches
27032
+ * paths within it, so the icons and the test samples must share one route set
27033
+ * (see `sample-routes.ts`).
27034
+ */
27035
+ function iconRoutes() {
27036
+ return Object.keys(NOTIFIER_ICONS).map(iconRoute);
27037
+ }
27038
+ //#endregion
27039
+ //#region src/sample-routes.ts
27040
+ /**
27041
+ * Serves the bundled test-notification sample media over the addon's own HTTP
27042
+ * surface via the `addon-routes` cap. One public GET route per sample:
27043
+ *
27044
+ * GET /addon/notifiers/samples/<name> → the bundled sample bytes
27045
+ *
27046
+ * WHY THIS EXISTS
27047
+ * ───────────────
27048
+ * The test panel used to attach `https://example.com/sample.jpg` — the IANA
27049
+ * reserved documentation domain, which serves no media. Every "does my target
27050
+ * deliver an image / gif / video" test was therefore broken by construction and
27051
+ * had never verified anything. The samples have to live somewhere the addon can
27052
+ * actually fetch, on an install with no internet, so they ship with the addon.
27053
+ *
27054
+ * The bytes are read from `assets/samples/` (not embedded in TS): a 128 KB mp4
27055
+ * as a base64 literal would inflate the bundle by ~170 KB of source. Path
27056
+ * resolution mirrors `addon-benchmark`'s `resolveFixturesDir` — `import.meta.url`
27057
+ * always points at the compiled bundle under `dist/`, so walking up one
27058
+ * directory lands on the package root whether the addon runs from source (tsx)
27059
+ * or from its built bundle.
27060
+ *
27061
+ * CONTENT-ENCODING IS OURS, DELIBERATELY
27062
+ * ──────────────────────────────────────
27063
+ * Same hazard `icon-routes.ts` documents: the hub pipes an addon route's reply
27064
+ * through its global `@fastify/compress`, and on the forked-addon UDS route
27065
+ * bridge that compressor emits an EMPTY brotli stream for a compressible body
27066
+ * over 1 KiB. The compressor skips a response that already declares a
27067
+ * non-identity `content-encoding`, so we negotiate and stamp it ourselves.
27068
+ *
27069
+ * Media types here are all ALREADY compressed (JPEG/GIF/MP4/MP3), so we serve
27070
+ * identity bytes and stamp `content-encoding: identity` — re-compressing them
27071
+ * wastes CPU for no gain, and the explicit header is what keeps the hub's
27072
+ * compressor off a body it would corrupt.
27073
+ */
27074
+ /** Samples are immutable per addon version. */
27075
+ var CACHE_CONTROL = "public, max-age=86400, immutable";
27076
+ /**
27077
+ * The bundled set. Keyed by the `AttachmentMediaType` each one exercises, so a
27078
+ * caps-driven test panel can ask for "the sample for this media type" without
27079
+ * knowing filenames. `icon` deliberately reuses the image sample — an icon slot
27080
+ * wants a small raster, and shipping a fifth file to say the same thing is
27081
+ * waste.
27082
+ */
27083
+ var SAMPLES = [
27084
+ {
27085
+ name: "image",
27086
+ file: "image.jpg",
27087
+ contentType: "image/jpeg"
27088
+ },
27089
+ {
27090
+ name: "gif",
27091
+ file: "animation.gif",
27092
+ contentType: "image/gif"
27093
+ },
27094
+ {
27095
+ name: "video",
27096
+ file: "clip.mp4",
27097
+ contentType: "video/mp4"
27098
+ },
27099
+ {
27100
+ name: "audio",
27101
+ file: "tone.mp3",
27102
+ contentType: "audio/mpeg"
27103
+ },
27104
+ {
27105
+ name: "icon",
27106
+ file: "image.jpg",
27107
+ contentType: "image/jpeg"
27108
+ }
27109
+ ];
27110
+ new Set(SAMPLES.map((s) => s.name));
27111
+ /** Resolve the addon's bundled samples directory (see the file header). */
27112
+ function resolveSamplesDir() {
27113
+ const here = fileURLToPath(import.meta.url);
27114
+ const pkgRoot = path.resolve(path.dirname(here), "..");
27115
+ return path.join(pkgRoot, "assets", "samples");
27116
+ }
27117
+ /**
27118
+ * Read every sample eagerly so a missing asset fails at BOOT with a clear
27119
+ * error, rather than on the first operator click with a 500 that reads as a
27120
+ * broken notifier.
27121
+ */
27122
+ function loadSamples() {
27123
+ const dir = resolveSamplesDir();
27124
+ const out = /* @__PURE__ */ new Map();
27125
+ for (const sample of SAMPLES) {
27126
+ const bytes = readFileSync(path.join(dir, sample.file));
27127
+ out.set(sample.name, {
27128
+ contentType: sample.contentType,
27129
+ bytes
27130
+ });
27131
+ }
27132
+ return out;
27133
+ }
27134
+ /** One static GET route serving a single bundled sample. */
27135
+ function sampleRoute(sample, loaded) {
27136
+ return {
27137
+ method: "GET",
27138
+ path: `/samples/${sample.name}`,
27139
+ access: "public",
27140
+ description: `Bundled ${sample.contentType} test sample for the '${sample.name}' attachment slot.`,
27141
+ handler: async (_request, reply) => {
27142
+ reply.code(200).type(loaded.contentType).header("cache-control", CACHE_CONTROL).header("content-encoding", "identity").header("content-length", String(loaded.bytes.byteLength));
27143
+ reply.send(loaded.bytes);
27144
+ }
27145
+ };
27146
+ }
27147
+ /**
27148
+ * The sample routes, for composition into the addon's SINGLE `addon-routes`
27149
+ * provider. Deliberately not a provider of its own: a provider owns one route
27150
+ * id and matches paths within it, so two providers sharing `notifiers` would
27151
+ * collide — the icons and the samples must travel in one route set.
27152
+ */
27153
+ function sampleRoutes() {
27154
+ const loaded = loadSamples();
27155
+ return SAMPLES.map((sample) => {
27156
+ const bytes = loaded.get(sample.name);
27157
+ if (bytes === void 0) throw new Error(`notifiers: sample '${sample.name}' failed to load`);
27158
+ return sampleRoute(sample, bytes);
27159
+ });
26634
27160
  }
26635
27161
  //#endregion
26636
27162
  //#region src/nc-target-actions.ts
@@ -29219,6 +29745,7 @@ function createNotificationOutputProvider(deps) {
29219
29745
  };
29220
29746
  }
29221
29747
  const iconUrlFor = deps.iconUrlFor;
29748
+ const iconMediaTypeFor = deps.iconMediaTypeFor;
29222
29749
  return {
29223
29750
  listTargetKinds: async () => deps.adapters.map((a) => {
29224
29751
  const descriptor = {
@@ -29226,10 +29753,16 @@ function createNotificationOutputProvider(deps) {
29226
29753
  addonId
29227
29754
  };
29228
29755
  const iconUrl = iconUrlFor?.(a.descriptor.kind);
29229
- return iconUrl !== void 0 ? {
29756
+ if (iconUrl === void 0) return descriptor;
29757
+ const iconMediaType = iconMediaTypeFor?.(a.descriptor.kind);
29758
+ return iconMediaType !== void 0 ? {
29759
+ ...descriptor,
29760
+ iconUrl,
29761
+ iconMediaType
29762
+ } : {
29230
29763
  ...descriptor,
29231
29764
  iconUrl
29232
- } : descriptor;
29765
+ };
29233
29766
  }),
29234
29767
  listTargets: async () => {
29235
29768
  return (await store.list()).map((target) => {
@@ -29500,9 +30033,10 @@ var NotifiersAddon = class extends BaseAddon {
29500
30033
  store,
29501
30034
  adapters: ADAPTERS,
29502
30035
  logger: this.ctx.logger.child("notification-output"),
29503
- iconUrlFor
30036
+ iconUrlFor,
30037
+ iconMediaTypeFor
29504
30038
  });
29505
- const iconRouteProvider = createIconRouteProvider();
30039
+ const routeProvider = buildAddonRouteProvider(NOTIFIERS_ADDON_ID, [...iconRoutes(), ...sampleRoutes()]);
29506
30040
  const ncHandlers = makeNcTargetHandlers({
29507
30041
  provider,
29508
30042
  logger: this.ctx.logger.child("nc-target-actions")
@@ -29514,7 +30048,7 @@ var NotifiersAddon = class extends BaseAddon {
29514
30048
  provider
29515
30049
  }, {
29516
30050
  capability: addonRoutesCapability,
29517
- provider: iconRouteProvider
30051
+ provider: routeProvider
29518
30052
  }],
29519
30053
  customActions: ncTargetActions,
29520
30054
  actionHandlers: ncHandlers