@camstack/addon-export-hap 1.2.128 → 1.2.132

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.
@@ -3,9 +3,9 @@ import { spawn } from "node:child_process";
3
3
  import { createHash, randomBytes, randomUUID } from "node:crypto";
4
4
  import * as path from "node:path";
5
5
  import { join } from "node:path";
6
- import { createWriteStream, readFileSync } from "node:fs";
6
+ import { createReadStream, createWriteStream, readFileSync } from "node:fs";
7
7
  import * as fs from "node:fs/promises";
8
- import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
8
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
9
9
  import { once } from "node:events";
10
10
  import { createSocket } from "node:dgram";
11
11
  import { networkInterfaces } from "node:os";
@@ -5399,7 +5399,7 @@ var ZodIssueCode = {
5399
5399
  var ZodFirstPartyTypeKind;
5400
5400
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5401
5401
  //#endregion
5402
- //#region ../types/dist/sleep-CiR4z7r9.mjs
5402
+ //#region ../types/dist/sleep-BVhJDJka.mjs
5403
5403
  /**
5404
5404
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5405
5405
  * window to float samples (D455).
@@ -7029,6 +7029,18 @@ function systemMethod(input, output, options) {
7029
7029
  systemOnly: true
7030
7030
  };
7031
7031
  }
7032
+ /**
7033
+ * A method a SOURCE of a collection cap may legitimately not serve — OPTIONAL
7034
+ * on `InferProvider`. The `providerOptional: true` literal is what
7035
+ * `InferProvider` keys on; see {@link CapabilityMethodSchema.providerOptional}
7036
+ * for when this is the honest answer and when it is a soft stub.
7037
+ */
7038
+ function optionalMethod(input, output, options) {
7039
+ return {
7040
+ ...method(input, output, options),
7041
+ providerOptional: true
7042
+ };
7043
+ }
7032
7044
  var StaticDirOutputSchema$1 = object({ staticDir: string() });
7033
7045
  var VersionOutputSchema$1 = object({ version: string() });
7034
7046
  method(_void(), StaticDirOutputSchema$1, { auth: "admin" }), method(_void(), VersionOutputSchema$1, { auth: "admin" });
@@ -8052,6 +8064,8 @@ var EVENT_OWNER_TYPES = [
8052
8064
  * nothing failing until a caller asked.
8053
8065
  */
8054
8066
  var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
8067
+ /** The same list as a Zod enum, for the cap input that carries it. */
8068
+ var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
8055
8069
  new Set(EVENT_OWNER_TYPES);
8056
8070
  var EncodeProfileSchema = object({
8057
8071
  video: object({
@@ -9665,20 +9679,86 @@ var StorageCleanupJobSchema = object({
9665
9679
  });
9666
9680
  var StorageCleanupStatusInputSchema = object({ jobId: string().optional() });
9667
9681
  /**
9668
- * The one typed state of a storage location. Authoritative Zod schema — the TS
9669
- * alias below is `z.infer<>` of it, never a second spelling.
9682
+ * The storage-location STATE MODEL (D385) — one typed state, one policy module.
9683
+ *
9684
+ * A location's state used to be split across two authorities: the typed
9685
+ * `enabled` field (THE write switch since D383) and an untyped `config.readOnly`
9686
+ * key. They did not mean the same thing — `enabled: false` was still evicted
9687
+ * under disk pressure while `config.readOnly` was deliberately excluded — and
9688
+ * neither name said which. Every consumer re-derived the difference, and the
9689
+ * three questions that actually matter were answered in six places.
9690
+ *
9691
+ * This module is the ONLY place in the repo allowed to interpret the state. It
9692
+ * answers three questions and nothing else:
9693
+ *
9694
+ * - may this location be WRITTEN to? {@link modeMayWrite}
9695
+ * - may this location be READ? {@link modeMayRead}
9696
+ * - what is its eviction policy? {@link evictionPolicyForMode}
9697
+ *
9698
+ * | mode | write | read | eviction |
9699
+ * | ---------- | ----- | ---- | ------------------------------ |
9700
+ * | `active` | yes | yes | `normal` (pressure + usage cap) |
9701
+ * | `readonly` | no | yes | `never` |
9702
+ * | `drain` | no | yes | `drain` (paced, until empty) |
9703
+ * | `disabled` | no | no | `never` |
9704
+ *
9705
+ * `scripts/check-storage-location-mode-single-owner.ts` fails the build when
9706
+ * anything outside this module reads `config['readOnly']` or compares `enabled`
9707
+ * directly. A rule nothing checks has already been broken somewhere.
9670
9708
  */
9671
- var StorageLocationModeSchema = _enum([
9709
+ var STORAGE_LOCATION_MODES = [
9672
9710
  "active",
9673
9711
  "readonly",
9674
9712
  "drain",
9675
9713
  "disabled"
9676
- ]);
9714
+ ];
9715
+ /**
9716
+ * The one typed state of a storage location. Authoritative Zod schema — the TS
9717
+ * alias below is `z.infer<>` of it, never a second spelling.
9718
+ */
9719
+ var StorageLocationModeSchema = _enum(STORAGE_LOCATION_MODES);
9677
9720
  _enum([
9678
9721
  "normal",
9679
9722
  "never",
9680
9723
  "drain"
9681
9724
  ]);
9725
+ /** Is this mode a write target? Only `active` is. */
9726
+ function modeMayWrite(mode) {
9727
+ return mode === "active";
9728
+ }
9729
+ /**
9730
+ * The mode a LEGACY row implies, or `null` when it implies nothing — the row is
9731
+ * already stamped, or it carried neither flag.
9732
+ *
9733
+ * Both legacy flags fold to `readonly`, which is the CONSERVATIVE direction: a
9734
+ * state change must never start deleting footage on its own, and it must never
9735
+ * make footage that was still being served disappear. `enabled: false` used to
9736
+ * leave the location evictable under pressure; folding it to `readonly` stops
9737
+ * that, which is a strictly safer answer than the one it replaces.
9738
+ */
9739
+ function legacyModeOf(location) {
9740
+ if (location.mode !== void 0) return null;
9741
+ if (location.config["readOnly"] === true) return "readonly";
9742
+ if (location.enabled === false) return "readonly";
9743
+ return null;
9744
+ }
9745
+ /**
9746
+ * The state of a location, stamped or folded. THE one interpretation: a row
9747
+ * that predates D385 is never ambiguous, and a stamped `mode` always wins over
9748
+ * whatever the legacy pair still says.
9749
+ */
9750
+ function resolveLocationMode(location) {
9751
+ return (isStorageLocationMode(location.mode) ? location.mode : void 0) ?? legacyModeOf(location) ?? "active";
9752
+ }
9753
+ /** Is this one of the four states? The stamped value crosses a wire, and a
9754
+ * value nobody defined must not be rendered as if it were a state. */
9755
+ function isStorageLocationMode(value) {
9756
+ return STORAGE_LOCATION_MODES.some((mode) => mode === value);
9757
+ }
9758
+ /** May this location be written to? */
9759
+ function mayWriteToLocation(location) {
9760
+ return modeMayWrite(resolveLocationMode(location));
9761
+ }
9682
9762
  /**
9683
9763
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
9684
9764
  * storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
@@ -21875,7 +21955,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
21875
21955
  ownerType: EventOwnerTypeSchema,
21876
21956
  eventId: number().int(),
21877
21957
  deviceId: number()
21878
- }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
21958
+ }), array(MediaFileInfoSchema).readonly()), method(object({
21959
+ deviceId: number(),
21960
+ ownerKind: MediaPresenceOwnerKindSchema,
21961
+ ownerIds: array(string()).max(5e3)
21962
+ }), array(string()).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
21879
21963
  kind: "mutation",
21880
21964
  auth: "admin"
21881
21965
  }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
@@ -24869,6 +24953,18 @@ method(VectorDeclareIndexInputSchema, _void(), {
24869
24953
  kind: "mutation",
24870
24954
  auth: "admin"
24871
24955
  }), method(VectorStatsInputSchema, VectorStatsResultSchema, { auth: "admin" });
24956
+ _enum([
24957
+ "queue-full",
24958
+ "camera-backoff",
24959
+ "sleeping",
24960
+ "camera-refused",
24961
+ "no-keyframe",
24962
+ "no-catalog-row",
24963
+ "unsupported",
24964
+ "unknown-device",
24965
+ "uid-missing"
24966
+ ]);
24967
+ _enum(["deferred", "final"]);
24872
24968
  var ClipSchema = object({
24873
24969
  /** Opaque, provider-namespaced id. The default provider encodes the time
24874
24970
  * window so `getClipPlayback` is self-contained (no event re-query). */
@@ -24943,31 +25039,454 @@ var ClipSchema = object({
24943
25039
  holes: array(object({
24944
25040
  startMs: number(),
24945
25041
  endMs: number()
24946
- })).optional()
25042
+ })).optional(),
25043
+ /**
25044
+ * A URL that MAY produce this clip's still, asked only for rows actually on
25045
+ * screen. The opposite of {@link ClipSchema.thumbnail}: that one is VOUCHED
25046
+ * (a JPEG is already on disk), this one is an offer. The route answers 200
25047
+ * with the image, or 204 with `x-camstack-reason` when it could not mint one
25048
+ * — a surface latches that refusal to the instant rather than retrying.
25049
+ *
25050
+ * Never both: a clip with a vouched `thumbnail` needs no mint.
25051
+ */
25052
+ thumbnailMint: string().optional(),
25053
+ /**
25054
+ * Why no still will be produced for this clip RIGHT NOW — set when the
25055
+ * provider already knows, so the surface draws the glyph and the reason
25056
+ * instead of firing a mint that cannot succeed.
25057
+ *
25058
+ * `sleeping` — a standalone battery camera; a read would be a wake (D549 4).
25059
+ * `camera-refused` — the camera answered the CoverPreview with a refusal.
25060
+ * `no-keyframe` — the window holds no decodable I-frame (an event that sits
25061
+ * inside no file is the measured case).
25062
+ * `unsupported` — this source cannot mint stills at all.
25063
+ */
25064
+ thumbnailUnavailable: object({ reason: _enum([
25065
+ "sleeping",
25066
+ "camera-refused",
25067
+ "no-keyframe",
25068
+ "unsupported"
25069
+ ]) }).optional(),
25070
+ /**
25071
+ * When the catalog this row came from was last CONFIRMED against the device.
25072
+ * Absent means "this row was read live". A persisted catalog served while a
25073
+ * camera sleeps carries the age it really has — a cached list is never drawn
25074
+ * as current (D549 13).
25075
+ */
25076
+ catalogAsOf: number().optional(),
25077
+ /**
25078
+ * The camera's OWN type strings for this clip, all of them, unmapped
25079
+ * (`md`, `people`, `dog_cat`, `sched`, …). Kept beside {@link labels}
25080
+ * because a firmware inventing a type must not vanish: the mapping into our
25081
+ * filter vocabulary is lossy on purpose and this is the lossless copy
25082
+ * (D549 20).
25083
+ */
25084
+ nativeTypes: array(string()).optional(),
25085
+ /**
25086
+ * What the subject DID — `crossline`, `intrude`, `loitering`. A behaviour
25087
+ * travels BESIDE a class, never instead of one, and the class filter ignores
25088
+ * it: "a person crossed a line" is still a person (D549 20).
25089
+ */
25090
+ behaviours: array(string()).optional(),
25091
+ /**
25092
+ * The same recording as two files — the sub twin (what the row and its
25093
+ * thumbnail are) and its main twin, matched at listing time so a quality
25094
+ * change never re-searches the camera (D549 15). `getClipPlayback`'s
25095
+ * `profile` picks between them: `low | mid` → sub, `high` → main.
25096
+ */
25097
+ streams: object({
25098
+ sub: object({
25099
+ id: string(),
25100
+ bytes: number().optional()
25101
+ }).optional(),
25102
+ main: object({
25103
+ id: string(),
25104
+ bytes: number().optional()
25105
+ }).optional()
25106
+ }).optional(),
25107
+ /** The camera says it holds a sub-stream copy of this recording. */
25108
+ supportSub: boolean().optional(),
25109
+ /**
25110
+ * Whether this row has bytes behind it. ABSENT means yes — every clip that
25111
+ * IS a file is playable, and only a source that lists EVENTS can produce a
25112
+ * row with nothing to play (the measured case: 1 of 29 hub events on 3628
25113
+ * fell inside no file). Such a row is shown, never dropped and never offered
25114
+ * as playable-then-failing (D549 19).
25115
+ */
25116
+ playable: boolean().optional(),
25117
+ /** Why {@link playable} is false, verbatim (`no-file-for-window`). */
25118
+ unplayableReason: string().optional(),
25119
+ /**
25120
+ * This clip is STILL BEING WRITTEN, so {@link ClipSchema.timeRange}`.endMs`
25121
+ * is not its end.
25122
+ *
25123
+ * Absent — the common case — means the row is closed and its `endMs` is the
25124
+ * end of the recording. Present and `true` means the source told us the file
25125
+ * has no end yet, and the `endMs` we carry is whatever the camera's index
25126
+ * entry happened to hold: on a Reolink E1 Outdoor PoE (592, measured
25127
+ * 2026-09-20) the newest file `…_192927_000000_…_0.mp4` reported an `endTime`
25128
+ * of 19:29:56 and STILL reported it eight minutes later, while the file went
25129
+ * on growing. A surface that drew 29 s there was lying about a clip that
25130
+ * plays for minutes, on the one row the operator looks at first.
25131
+ *
25132
+ * `endMs` is deliberately still a number: it is the best bound anything has
25133
+ * for a mint window or a byte fetch, and every consumer already requires it.
25134
+ * This flag says what it is WORTH, and a duration is not drawn from it.
25135
+ */
25136
+ inProgress: boolean().optional()
24947
25137
  });
24948
25138
  var ClipPlaybackSchema = object({
24949
- /** HLS master URL through the hub data-plane (Range + token in path). */
25139
+ /**
25140
+ * A media URL through the hub data-plane. {@link ClipPlaybackSchema.format}
25141
+ * says what kind — an HLS master playlist for a recording-derived clip, a
25142
+ * progressive MP4 for a native file the vendor addon muxed and serves with
25143
+ * `Range`. The URL is NOT a credential: both planes are registered
25144
+ * `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
25145
+ * the session cookie, so nothing is appended to it (D549 22).
25146
+ */
24950
25147
  playbackUrl: string(),
25148
+ /**
25149
+ * How to play {@link playbackUrl}. Absent means `hls` — the shape every
25150
+ * existing consumer already assumes. A player that branches on this is the
25151
+ * one change a native clip needs; a player that ignores it will hand an MP4
25152
+ * to hls.js and fail parsing it as a manifest.
25153
+ */
25154
+ format: _enum(["hls", "mp4"]).optional(),
25155
+ /**
25156
+ * Which twin was actually served. A clip that holds only one stream answers
25157
+ * with the one it has, and the surface SAYS so — a missing main twin is
25158
+ * never served silently as if it were the asked-for quality (D549 15).
25159
+ */
25160
+ served: CamProfileSchema.optional(),
24951
25161
  /** Optional LAN/remote alternates for the same clip. */
24952
25162
  playbackEndpoints: array(string()).optional(),
24953
25163
  token: string().optional()
24954
25164
  });
24955
- DeviceType.Camera, method(object({
24956
- deviceId: number(),
24957
- since: number(),
24958
- until: number(),
24959
- limit: number().int().positive().optional()
24960
- }), array(ClipSchema).readonly(), {
24961
- kind: "query",
24962
- auth: "admin"
24963
- }), method(object({
24964
- deviceId: number(),
24965
- clipId: string()
24966
- }), ClipPlaybackSchema, {
24967
- kind: "query",
24968
- auth: "admin"
25165
+ /**
25166
+ * Why a source cannot answer right now — per SOURCE, never fleet-wide.
25167
+ *
25168
+ * `ok` is the only state whose clip list may be read as complete. The other
25169
+ * three exist because an empty list from a sleeping camera reads as "this
25170
+ * camera has no recordings", which is the defect this whole line of work is
25171
+ * about: a battery camera nobody woke (`sleeping`), a camera that could not be
25172
+ * reached at all (`unreachable`, also what a provider that THREW reports), a
25173
+ * camera whose SD card is not mounted (`no-storage`, measured on 640 —
25174
+ * `HddInfo mount 0`, honestly nothing to list rather than "no clips"), and a
25175
+ * camera whose OWN index disagrees with its OWN calendar (`index-empty`,
25176
+ * measured on 618: the calendar marks 3–20 September, days 6–20 list zero
25177
+ * files on both streams and both filters, with 177 GB free). That last one is
25178
+ * a defect ON THE CAMERA, and the only honest thing a provider can do is say
25179
+ * which days it asked for and got nothing — "no clips" would be a lie about a
25180
+ * card that is full of them.
25181
+ */
25182
+ /**
25183
+ * The operator's authorisation to WAKE a sleeping camera for one clip read.
25184
+ *
25185
+ * ONE value, absent by default, and it is a `force`-shaped operator signal in
25186
+ * exactly the sense `snapshot-wake-gate.ts` uses the word: *"`snapshot.getSnapshot`'s
25187
+ * `force` flag, and nothing else… a background caller must never set it…
25188
+ * Stale but honest beats woken"*. A scheduler, a retry, a reconcile and a
25189
+ * prefetch never set it; a surface sets it only behind the same confirm the
25190
+ * "Wake and refresh" gesture uses, and it is refused below 15 % battery
25191
+ * exactly as that gesture is.
25192
+ *
25193
+ * The gate is decided BEFORE `getApi()`, because on UDP the login IS the wake
25194
+ * (D549) — a read that opened a session and then checked would have woken the
25195
+ * camera to find out it was not allowed to.
25196
+ *
25197
+ * **One authorised yes is one wake.** `next-natural` — take the clip the next
25198
+ * time the camera is awake for its own reasons — is deliberately not a member:
25199
+ * it was proposed, it is free on the battery, and the operator declined it on
25200
+ * 2026-09-20 (*"Quando un export viene richiesto si sveglia la camera."*,
25201
+ * D558 "Considered and not taken").
25202
+ */
25203
+ var ClipWakeSchema = _enum(["authorised"]);
25204
+ /**
25205
+ * Hard ceiling on ONE {@link videoclipsCapability.methods.readClipBytes} — the
25206
+ * same 50 MiB `RECORDING_EXPORT_MAX_READ_BYTES` uses, and for the same second
25207
+ * reason: the envelope is unary, so a base64 payload is held whole (~1.33× its
25208
+ * size) in the provider AND in the caller, on a hub this repo has already
25209
+ * OOM'd once (D9/D18).
25210
+ *
25211
+ * Measured clips sit far below it — 92 KB–2.29 MB for a sub twin, 455 KB for a
25212
+ * 16 s main clip — so the bound bites rarely. "Rarely" is not "never": a long
25213
+ * 4K main twin can exceed it, and above the bound the provider REFUSES with
25214
+ * the size in the message, never truncates. Half a video is worse than an
25215
+ * honest refusal.
25216
+ *
25217
+ * The clean follow-on is a CHUNKED read so a `high` twin of a long clip stops
25218
+ * being refusable at all. That is a later slice, named here so the bound is
25219
+ * not mistaken for a design ceiling.
25220
+ */
25221
+ var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
25222
+ /**
25223
+ * A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
25224
+ *
25225
+ * `bytes` is the DECODED length, so nobody infers it from the base64 length,
25226
+ * and `served` says which twin the caller actually got.
25227
+ */
25228
+ var ClipBytesSchema = object({
25229
+ base64: string(),
25230
+ contentType: string(),
25231
+ /** Suggested filename, extension included. */
25232
+ name: string(),
25233
+ bytes: number().int().nonnegative(),
25234
+ /**
25235
+ * Which twin was actually served — the same contract
25236
+ * {@link ClipPlaybackSchema.served} carries, and REQUIRED here because the
25237
+ * export record persists it: a row read a week later must say the same thing
25238
+ * the panel said at the moment of the tap. A missing main twin is never
25239
+ * served silently as if it were the asked-for quality (D549 15).
25240
+ */
25241
+ served: CamProfileSchema,
25242
+ /**
25243
+ * The DECODED duration of the delivered file, when the fetch measured one.
25244
+ *
25245
+ * A clip fetch verifies its own completion against the catalog row and
25246
+ * retries a materially short pass (D568); this is that measurement, carried
25247
+ * so a consumer can say the same thing rather than re-deriving it. Absent
25248
+ * when the producer did not measure — never zero, which would say the file
25249
+ * is empty.
25250
+ */
25251
+ durationMs: number().positive().optional()
25252
+ });
25253
+ var ClipSourceAvailabilitySchema = object({
25254
+ state: _enum([
25255
+ "ok",
25256
+ "sleeping",
25257
+ "unreachable",
25258
+ "no-storage",
25259
+ "index-empty"
25260
+ ]),
25261
+ /** Free text, shown verbatim. Names the camera's own refusal when there is one. */
25262
+ reason: string().optional(),
25263
+ /** When this source's catalog was last CONFIRMED. A cached list is never
25264
+ * drawn as current: the surface shows the age whenever it is older than the
25265
+ * refresh interval. */
25266
+ catalogAsOf: number().optional()
24969
25267
  });
24970
25268
  /**
25269
+ * One SOURCE of clips for a camera — a row of the picker, and the namespace
25270
+ * every clip id from it is prefixed with (`analytics`,
25271
+ * `native:reolink:onboard`, `hksv`, …).
25272
+ *
25273
+ * A provider lists the sources IT serves for that device, and answers for each
25274
+ * of them whether it can answer at all. A provider with nothing to offer on a
25275
+ * camera returns `[]` — it is not that camera's business.
25276
+ */
25277
+ var ClipSourceSchema = object({
25278
+ /** The value this source stamps on {@link ClipSchema.source}, and the prefix
25279
+ * of every clip id it mints. `getClipPlayback` routes on it. */
25280
+ source: string(),
25281
+ /** Operator-facing name of the source ("CamStack recordings", "SD card"). */
25282
+ label: string(),
25283
+ /**
25284
+ * The addon that SERVES this row.
25285
+ *
25286
+ * A surface cannot otherwise resolve a source to who answers it, and the
25287
+ * alternative — a `native:reolink:* → provider-reolink` table inside the
25288
+ * widget — is a second authority on provider identity living in the one
25289
+ * package with no business knowing it, wrong the day a third source appears
25290
+ * (D557). One addon may serve SEVERAL sources, which is why the per-device
25291
+ * switch is keyed by SOURCE and not by this (D555) — `addon-provider-reolink`
25292
+ * served a hub child two of them until the two views were measured to be one
25293
+ * store read twice (D555) and then read ONE way (D565).
25294
+ *
25295
+ * Optional for version skew only. The collection dispatcher stamps it from
25296
+ * the registry, so a row that travelled through the fan-out carries the
25297
+ * authoritative id whatever the provider filled in.
25298
+ */
25299
+ addonId: string().optional(),
25300
+ availability: ClipSourceAvailabilitySchema
25301
+ });
25302
+ var videoclipsCapability = {
25303
+ name: "videoclips",
25304
+ scope: "device",
25305
+ mode: "collection",
25306
+ kind: "wrapper",
25307
+ defaultActive: true,
25308
+ /** A clip is a window over a camera's footage — the cap is meaningless on a
25309
+ * sensor, a button or an event emitter, and the `defaultActive` auto-bind
25310
+ * reads this to decide which devices it may claim. */
25311
+ deviceTypes: [DeviceType.Camera],
25312
+ /**
25313
+ * The Clips section of a camera's device details is FRAMEWORK-DERIVED (D14):
25314
+ * the aggregator turns this declaration into the `type:'widget'` section and
25315
+ * `DeviceDetail.tsx` is never edited. `videoclips` is the first WRAPPER cap
25316
+ * to declare one — every previous `host/` widget cap is `deviceNative` — so
25317
+ * `device-config-widget-wrapped-binding.spec.ts` pins that a `kind:'wrapped'`
25318
+ * binding entry derives the same section a native one does.
25319
+ *
25320
+ * **It is a SECTION of the Recording tab, at the end of it — not a tab of
25321
+ * its own.** It shipped as a `clips` top-tab and the operator rejected the
25322
+ * placement: *"utilizzerei la stessa tab recordings, lì abbiamo già tutto il
25323
+ * necessario, una nuova sezione alla fine per le clips"*. The Recording tab
25324
+ * already holds the recorder's panel, the schedule bands and the unified
25325
+ * retention policy; footage the camera itself holds is the same question,
25326
+ * asked of a different store. `order: 100` puts it after all of them with
25327
+ * room left in front. The `clips` entry in `WELL_KNOWN_TABS` went with it —
25328
+ * a well-known id nobody declares is an invitation to mint the tab again.
25329
+ *
25330
+ * `topTab` stays: the browser owns a pane (a day's tiles, a source list and
25331
+ * a player) and the Config tab's inner bar has no room for one. The widget
25332
+ * itself is `host/clips-browser` in ui-library's `HOST_WIDGETS`, and because
25333
+ * the admin Recordings page renders every `location:'top-tab'` +
25334
+ * `tab:'recording'` section behind its camera picker
25335
+ * (`CameraRecordingSettingsSection`), this declaration lands the section on
25336
+ * BOTH surfaces with no second wiring.
25337
+ */
25338
+ deviceConfig: { ui: {
25339
+ kind: "widget",
25340
+ widgetId: "host/clips-browser",
25341
+ tab: "recording",
25342
+ topTab: true,
25343
+ label: "Clips",
25344
+ order: 100
25345
+ } },
25346
+ methods: {
25347
+ listClips: method(object({
25348
+ deviceId: number(),
25349
+ since: number(),
25350
+ until: number(),
25351
+ limit: number().int().positive().optional(),
25352
+ /**
25353
+ * WHICH provider to ask — the `addonId` a {@link ClipSourceSchema} row
25354
+ * carries, never a source id and never a list. **Required** (D554 amended).
25355
+ *
25356
+ * A provider the device is not bound to is refused by name rather than
25357
+ * answered by another one (D552's `rejectUnresolvedAddonPin` rule).
25358
+ *
25359
+ * It was optional, documented as "absent means the device's BOUND
25360
+ * provider, which is CamStack on every camera". No code implemented
25361
+ * that. Measured on the live hub 2026-09-20 — device 592, bound to
25362
+ * `recorder` AND `provider-reolink` — a bare call with `limit: 3`
25363
+ * answered SIX rows, three from each source, merged newest-first:
25364
+ * `device-collection-dispatch.ts` simply left the fan-out un-narrowed,
25365
+ * so absence bought the union this method exists not to be, and
25366
+ * `limit` meant `limit × sources`.
25367
+ *
25368
+ * There is nothing to restore the default to. `getBindings` answers a
25369
+ * collection cap with a DERIVED, PLURAL set (D554 amended, step 0);
25370
+ * `setWrapperActive` — the singleton authority that could name one —
25371
+ * throws for a collection cap by design. Naming CamStack here instead
25372
+ * would privilege one addon by id inside a surface whose premise is
25373
+ * that sources are peers, and would be wrong on the first camera with
25374
+ * no recorder binding. So absence is REFUSED, in the schema, where the
25375
+ * generated types make it unomittable rather than merely discouraged.
25376
+ *
25377
+ * The default belongs to the SURFACE, which has the `listSources` rows
25378
+ * and can say which one it picked (D569 § 1–3; the viewer already
25379
+ * always sends this).
25380
+ *
25381
+ * It replaced `sources?: string[]`, a VIEW filter over source ids that
25382
+ * assumed the answer was a fan-out over everything a camera has. The
25383
+ * operator settled otherwise on 2026-09-20 — *"l'utilizzatore è uno
25384
+ * solo"* — so the list is asked of one provider at a time and there is
25385
+ * nothing to filter out of it.
25386
+ */
25387
+ provider: string().min(1)
25388
+ }), array(ClipSchema).readonly(), {
25389
+ kind: "query",
25390
+ auth: "protected"
25391
+ }),
25392
+ /**
25393
+ * The sources this camera has, WITH the reason any of them cannot answer.
25394
+ *
25395
+ * Asked separately from `listClips` because an empty clip list is
25396
+ * ambiguous and this is the only place the ambiguity is resolved: every
25397
+ * bound provider contributes its own rows, and a provider that could not
25398
+ * be reached at all still produces one row saying so. A surface that draws
25399
+ * "no clips" without reading this is drawing a guess.
25400
+ */
25401
+ listSources: method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
25402
+ kind: "query",
25403
+ auth: "protected"
25404
+ }),
25405
+ getClipPlayback: method(object({
25406
+ deviceId: number(),
25407
+ clipId: string(),
25408
+ /**
25409
+ * Which twin to serve, on the ONE quality scale the system already has
25410
+ * (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
25411
+ * twin; both ids are already on the row so this never re-searches the
25412
+ * camera. Absent means the provider's own default (the sub file, which
25413
+ * every source is measured to hold).
25414
+ *
25415
+ * `auto` is deliberately NOT accepted here: a stored file has no
25416
+ * broker session, so the adaptive tier cannot be resolved for it. The
25417
+ * viewer resolves `auto` to a profile the same way live does, before
25418
+ * it calls (D549 15).
25419
+ */
25420
+ profile: CamProfileSchema.optional()
25421
+ }), ClipPlaybackSchema, {
25422
+ kind: "query",
25423
+ auth: "protected"
25424
+ }),
25425
+ /**
25426
+ * This clip's BYTES, base64, bounded — the by-handle read a clip EXPORT
25427
+ * pulls once (D558).
25428
+ *
25429
+ * `getClipPlayback` is the right answer for a player: it hands back a URL
25430
+ * on a plane the hub serves `access:'authenticated'`, which a browser and a
25431
+ * viewer session satisfy. It is the wrong answer for another ADDON. There
25432
+ * is no addon→addon byte transport in this framework — `AddonDataPlane`
25433
+ * only lets an addon SERVE, on `127.0.0.1` behind a per-listener secret
25434
+ * only the hub may present — so a recorder that wants a camera's clip
25435
+ * cannot fetch that URL. This method is the one seam that exists for it,
25436
+ * and it is deliberately the same shape (and the same bound) as
25437
+ * `recordingExport.readExportBytes`, which exists for the mirror-image
25438
+ * reason.
25439
+ *
25440
+ * Routing needs no `provider` pin: the id is source-prefixed and
25441
+ * self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
25442
+ * to the source that claims it — and an id nobody claims is REFUSED rather
25443
+ * than answered by another source.
25444
+ *
25445
+ * The producer reuses the fetch path it already has, completion rules
25446
+ * included: a clip is taken by cmd 5 and finished on a short idle window
25447
+ * whose result is PROVED against the catalog row's own span, retried once
25448
+ * when it comes up materially short, and served-and-named when it is still
25449
+ * short (D568). A second fetch with different completion rules is exactly
25450
+ * the second authority D558 refuses to create.
25451
+ *
25452
+ * Every refusal THROWS with its reason and none of them is silent — the
25453
+ * sleep gate (decided before `getApi()`, liftable only by
25454
+ * {@link ClipWakeSchema}), a catalog row nobody claims, a clip with no
25455
+ * bytes behind it, a mux that failed, and the size bound. The caller turns
25456
+ * that reason into an operator-facing one; a truncated file is never an
25457
+ * answer.
25458
+ */
25459
+ readClipBytes: optionalMethod(object({
25460
+ deviceId: number(),
25461
+ clipId: string().min(1),
25462
+ /**
25463
+ * Which twin to fetch, on the mapping D549 15 already fixed:
25464
+ * `low | mid` → the sub file, `high` → the main twin. `auto` is not
25465
+ * accepted here for the same reason it is not accepted by
25466
+ * `getClipPlayback` — a stored file has no broker session, so the
25467
+ * adaptive tier cannot be resolved for it.
25468
+ */
25469
+ profile: CamProfileSchema.optional(),
25470
+ /**
25471
+ * The CALLER's byte bound, so an over-size clip is refused before it is
25472
+ * read and encoded rather than after. Capped by
25473
+ * {@link VIDEOCLIPS_MAX_READ_BYTES} whatever is passed; absent means
25474
+ * that ceiling.
25475
+ */
25476
+ maxBytes: number().int().positive().optional(),
25477
+ /**
25478
+ * The operator's authorisation to wake a sleeping camera for this
25479
+ * read. Absent — the default — means a sleeping standalone battery
25480
+ * camera is REFUSED by name, before any session is opened.
25481
+ */
25482
+ wake: ClipWakeSchema.optional()
25483
+ }), ClipBytesSchema, {
25484
+ kind: "query",
25485
+ auth: "protected"
25486
+ })
25487
+ }
25488
+ };
25489
+ /**
24971
25490
  * Optional client-side hints sent at session creation to help the provider
24972
25491
  * pick the best native source. All fields optional — a viewer that knows
24973
25492
  * nothing still gets a sane default. (Relocated from the retired `webrtc`
@@ -30934,13 +31453,101 @@ var ExportStateSchema = _enum([
30934
31453
  "expired",
30935
31454
  "deleted"
30936
31455
  ]);
30937
- /** One export job / history row. */
31456
+ /**
31457
+ * WHAT an export is — the authority, as opposed to the four top-level fields
31458
+ * the Library sorts and labels on (D558 § 2.2).
31459
+ *
31460
+ * Read it through {@link exportSubjectOf}, never off the record directly: the
31461
+ * field is optional for the history rows written before it existed, and that
31462
+ * absence has exactly one interpreter.
31463
+ */
31464
+ var ExportSubjectSchema = discriminatedUnion("kind", [object({
31465
+ kind: literal("footage"),
31466
+ deviceId: number(),
31467
+ profiles: array(string()).min(1),
31468
+ fromMs: number(),
31469
+ toMs: number()
31470
+ }), object({
31471
+ kind: literal("clip"),
31472
+ deviceId: number(),
31473
+ provider: string().min(1),
31474
+ source: string().min(1),
31475
+ sourceLabel: string().min(1),
31476
+ clipId: string().min(1),
31477
+ /**
31478
+ * WHERE IN THE CATALOG to confirm this clip — the day window the surface was
31479
+ * already listing when the operator picked the segment.
31480
+ *
31481
+ * It is **not** a time range control and it never becomes one: the record's
31482
+ * `fromMs`/`toMs` come from the catalog ROW and from nothing a caller
31483
+ * supplied (D558 § 5.4.3), and a window that does not contain the clip is a
31484
+ * `catalog-miss`, not a silently wider search. It exists because
31485
+ * `videoclips.listClips` takes `since`/`until` and has no by-handle twin:
31486
+ * the catalog check D558 asks for is literally that call, and a call needs a
31487
+ * window. The surface has one — `ClipsBrowser`'s `dayWindow`, one local
31488
+ * wall-clock day, which is also the only width measured to be cheap (one
31489
+ * day on 592 lists 171 clips in 712 ms, a busy day 546 in 2.0 s; twenty days
31490
+ * of one child's events took 3.6–5.4 s).
31491
+ */
31492
+ catalogWindow: object({
31493
+ sinceMs: number(),
31494
+ untilMs: number()
31495
+ }),
31496
+ profile: _enum([
31497
+ "high",
31498
+ "mid",
31499
+ "low"
31500
+ ]),
31501
+ /**
31502
+ * The operator's authorisation to wake a sleeping camera for this export.
31503
+ * ONE definition, in the cap that owns the clip read ({@link ClipWakeSchema}),
31504
+ * because the gate that honours it is the clip provider's sleep gate — a
31505
+ * second enum here would be a second contract. Absent by default; never
31506
+ * settable by a scheduler or a retry.
31507
+ *
31508
+ * **One authorised yes is ONE wake.** A failed clip export is retried only by
31509
+ * an operator act that asks again; a queued job that outlived its wake fails
31510
+ * with a reason rather than waking on its turn; and this subject carries
31511
+ * exactly one profile precisely so one tap is never two fetches (D558 § 5.2).
31512
+ */
31513
+ wake: ClipWakeSchema.optional()
31514
+ })]);
31515
+ /**
31516
+ * One export job / history row.
31517
+ *
31518
+ * **`subject` is the AUTHORITY on what was exported. `deviceId`, `profile`,
31519
+ * `fromMs` and `toMs` are its PROJECTION** — kept top-level because the whole
31520
+ * Library sorts and labels on them (`library-items.ts` orders an export by
31521
+ * `fromMs`; `export-format.ts` draws `rangeLabel` from the pair), and a row
31522
+ * that did not fill them would sort under the epoch and render a blank range.
31523
+ * Write to the subject and read from the projection and the two will disagree;
31524
+ * the projection is derived at creation and never edited afterwards.
31525
+ *
31526
+ * And they mean DIFFERENT FACTS for the two kinds, which is the part a reader
31527
+ * who knows only the recording export will get wrong:
31528
+ *
31529
+ * | field | `kind:'footage'` | `kind:'clip'` |
31530
+ * | --- | --- | --- |
31531
+ * | `fromMs`/`toMs` | the stretch the operator ASKED for | the camera's own boundaries, always `clip.timeRange`, never anything a caller supplied |
31532
+ * | `profile` | the stream rendered | the twin actually SERVED (`subject.profile` is the one asked for) |
31533
+ *
31534
+ * Same type, different fact — the shape this repo keeps getting wrong (D385's
31535
+ * two authorities, D224's second copy).
31536
+ */
30938
31537
  var ExportRecordSchema = object({
30939
31538
  id: string(),
30940
31539
  deviceId: number(),
30941
31540
  profile: string(),
30942
31541
  fromMs: number(),
30943
31542
  toMs: number(),
31543
+ /**
31544
+ * What this export IS. Optional ONLY for the rows written before D558: the
31545
+ * store parses every row through this schema on every read, so a required
31546
+ * field would make the export AUDIT — which is the whole reason rows survive
31547
+ * file deletion — unreadable in one release. Absence means `footage`, and
31548
+ * {@link exportSubjectOf} is the one place that says so.
31549
+ */
31550
+ subject: ExportSubjectSchema.optional(),
30944
31551
  options: ExportOptionsSchema,
30945
31552
  state: ExportStateSchema,
30946
31553
  /** 0–100 while rendering; null otherwise. */
@@ -30957,6 +31564,18 @@ var ExportRecordSchema = object({
30957
31564
  /** Failure reason when state is 'failed'; null otherwise. */
30958
31565
  error: string().nullable()
30959
31566
  });
31567
+ _enum([
31568
+ "catalog-miss",
31569
+ "catalog-unreachable",
31570
+ "no-file-for-window",
31571
+ "clip-in-progress",
31572
+ "unsupported-option",
31573
+ "sleeping",
31574
+ "camera-refused",
31575
+ "fetch-failed",
31576
+ "too-large-to-transfer",
31577
+ "wake-expired"
31578
+ ]);
30960
31579
  /** Candidate download URLs (LAN first, then operator extra hosts). */
30961
31580
  var ExportDownloadSchema = object({
30962
31581
  url: string(),
@@ -30975,16 +31594,50 @@ var ExportBytesSchema = object({
30975
31594
  name: string(),
30976
31595
  bytes: number().int().nonnegative()
30977
31596
  });
31597
+ /** Canonical `profiles[]`, falling back to the legacy singular `profile`. */
31598
+ function resolveExportProfiles(input) {
31599
+ if (input.profiles !== void 0 && input.profiles.length > 0) return [...input.profiles];
31600
+ if (typeof input.profile === "string" && input.profile.length > 0) return [input.profile];
31601
+ return [];
31602
+ }
30978
31603
  method(object({
30979
31604
  deviceId: number(),
30980
31605
  /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
30981
31606
  profile: string().optional(),
30982
31607
  profiles: array(string()).min(1).optional(),
30983
- fromMs: number(),
30984
- toMs: number(),
31608
+ /** Footage only — a clip's boundaries are the camera's. */
31609
+ fromMs: number().optional(),
31610
+ toMs: number().optional(),
31611
+ /** What to export. Absent means the legacy flat footage request. */
31612
+ subject: ExportSubjectSchema.optional(),
30985
31613
  options: ExportOptionsSchema
30986
31614
  }).superRefine((v, ctx) => {
30987
- if ((v.profiles !== void 0 && v.profiles.length > 0 ? v.profiles : v.profile !== void 0 ? [v.profile] : []).length < 1) ctx.addIssue({
31615
+ if (v.subject?.kind === "clip") {
31616
+ if (v.subject.deviceId !== v.deviceId) ctx.addIssue({
31617
+ code: ZodIssueCode.custom,
31618
+ message: `subject.deviceId (${v.subject.deviceId}) must equal deviceId (${v.deviceId}) — the top-level field is what per-device scope enforcement reads`,
31619
+ path: ["subject", "deviceId"]
31620
+ });
31621
+ if (v.fromMs !== void 0 || v.toMs !== void 0) ctx.addIssue({
31622
+ code: ZodIssueCode.custom,
31623
+ message: "a clip export asks for no time range: the camera chose the boundaries and they are read from the catalog row",
31624
+ path: ["fromMs"]
31625
+ });
31626
+ return;
31627
+ }
31628
+ if (v.subject?.kind === "footage" && v.subject.deviceId !== v.deviceId) ctx.addIssue({
31629
+ code: ZodIssueCode.custom,
31630
+ message: `subject.deviceId (${v.subject.deviceId}) must equal deviceId (${v.deviceId})`,
31631
+ path: ["subject", "deviceId"]
31632
+ });
31633
+ const fromMs = v.subject?.kind === "footage" ? v.subject.fromMs : v.fromMs;
31634
+ const toMs = v.subject?.kind === "footage" ? v.subject.toMs : v.toMs;
31635
+ if (typeof fromMs !== "number" || typeof toMs !== "number") ctx.addIssue({
31636
+ code: ZodIssueCode.custom,
31637
+ message: "a footage export needs fromMs and toMs",
31638
+ path: ["fromMs"]
31639
+ });
31640
+ if ((v.subject?.kind === "footage" ? [...v.subject.profiles] : resolveExportProfiles(v)).length < 1) ctx.addIssue({
30988
31641
  code: ZodIssueCode.custom,
30989
31642
  message: "pass profiles[] (min 1) or legacy profile",
30990
31643
  path: ["profiles"]
@@ -36405,6 +37058,12 @@ Object.freeze({
36405
37058
  addonId: null,
36406
37059
  access: "view"
36407
37060
  },
37061
+ "pipelineAnalytics.ownersWithMedia": {
37062
+ capName: "pipeline-analytics",
37063
+ capScope: "device",
37064
+ addonId: null,
37065
+ access: "view"
37066
+ },
36408
37067
  "pipelineAnalytics.pauseForStorageMigration": {
36409
37068
  capName: "pipeline-analytics",
36410
37069
  capScope: "device",
@@ -38997,6 +39656,18 @@ Object.freeze({
38997
39656
  addonId: null,
38998
39657
  access: "view"
38999
39658
  },
39659
+ "videoclips.listSources": {
39660
+ capName: "videoclips",
39661
+ capScope: "device",
39662
+ addonId: null,
39663
+ access: "view"
39664
+ },
39665
+ "videoclips.readClipBytes": {
39666
+ capName: "videoclips",
39667
+ capScope: "device",
39668
+ addonId: null,
39669
+ access: "view"
39670
+ },
39000
39671
  "viewerUi.getStaticDir": {
39001
39672
  capName: "viewer-ui",
39002
39673
  capScope: "system",
@@ -40299,6 +40970,11 @@ Object.freeze({
40299
40970
  form: "single",
40300
40971
  optional: false
40301
40972
  }],
40973
+ "pipelineAnalytics.ownersWithMedia": [{
40974
+ name: "deviceId",
40975
+ form: "single",
40976
+ optional: false
40977
+ }],
40302
40978
  "pipelineAnalytics.proposeRetrainAnnotations": [{
40303
40979
  name: "deviceId",
40304
40980
  form: "single",
@@ -41005,6 +41681,16 @@ Object.freeze({
41005
41681
  form: "single",
41006
41682
  optional: false
41007
41683
  }],
41684
+ "videoclips.listSources": [{
41685
+ name: "deviceId",
41686
+ form: "single",
41687
+ optional: false
41688
+ }],
41689
+ "videoclips.readClipBytes": [{
41690
+ name: "deviceId",
41691
+ form: "single",
41692
+ optional: false
41693
+ }],
41008
41694
  "waterHeater.setAway": [{
41009
41695
  name: "deviceId",
41010
41696
  form: "single",
@@ -88769,6 +89455,410 @@ function firstExposedAccessorySetupUri(exposed, logger) {
88769
89455
  }
88770
89456
  }
88771
89457
  //#endregion
89458
+ //#region src/hksv/build-outcome.ts
89459
+ /**
89460
+ * Every camera's last build verdict, in this process.
89461
+ *
89462
+ * A `Map` behind a named type rather than a bare one, because the thing that
89463
+ * matters about it is what `get` returning `undefined` MEANS: not "recording is
89464
+ * fine", but "no accessory has been built for this camera since the addon
89465
+ * started".
89466
+ */
89467
+ var HksvBuildOutcomes = class {
89468
+ byDevice = /* @__PURE__ */ new Map();
89469
+ note(outcome) {
89470
+ this.byDevice.set(outcome.deviceId, outcome);
89471
+ }
89472
+ /** `null` when nothing has been established for this camera yet. */
89473
+ lastFor(deviceId) {
89474
+ return this.byDevice.get(deviceId) ?? null;
89475
+ }
89476
+ /** The camera left HomeKit: its verdict is not a fact about anything now. */
89477
+ forget(deviceId) {
89478
+ this.byDevice.delete(deviceId);
89479
+ }
89480
+ };
89481
+ //#endregion
89482
+ //#region src/hksv/clip-ffmpeg-run.ts
89483
+ function createBoundedFfmpegRunner(input) {
89484
+ return (args) => new Promise((resolve) => {
89485
+ let settled = false;
89486
+ const finish = (run) => {
89487
+ if (settled) return;
89488
+ settled = true;
89489
+ clearTimeout(timer);
89490
+ resolve(run);
89491
+ };
89492
+ const child = input.spawnFn(input.ffmpegBinaryPath, [...args], { stdio: [
89493
+ "ignore",
89494
+ "ignore",
89495
+ "pipe"
89496
+ ] });
89497
+ let stderr = "";
89498
+ child.stderr?.on("data", (chunk) => {
89499
+ if (stderr.length < 2e3) stderr += chunk.toString("utf8");
89500
+ });
89501
+ const timer = setTimeout(() => {
89502
+ child.kill("SIGKILL");
89503
+ finish({
89504
+ code: null,
89505
+ timedOut: true,
89506
+ spawnFailed: false,
89507
+ stderr
89508
+ });
89509
+ }, input.timeoutMs);
89510
+ timer.unref?.();
89511
+ child.on("error", (err) => {
89512
+ finish({
89513
+ code: null,
89514
+ timedOut: false,
89515
+ spawnFailed: true,
89516
+ stderr: err.message
89517
+ });
89518
+ });
89519
+ child.on("close", (code) => {
89520
+ finish({
89521
+ code,
89522
+ timedOut: false,
89523
+ spawnFailed: false,
89524
+ stderr
89525
+ });
89526
+ });
89527
+ });
89528
+ }
89529
+ //#endregion
89530
+ //#region src/hksv/clip-location.ts
89531
+ /** The declared id. Deployment-wide; see the docblock for why it is new. */
89532
+ var HOMEKIT_CLIPS_LOCATION_TYPE = "homekitClips";
89533
+ /** The class subtree under the resolved root. Never written at the root (D327). */
89534
+ var HOMEKIT_CLIPS_SUBTREE = "homekit-clips";
89535
+ /**
89536
+ * The cap calls, HERE and not in the addon.
89537
+ *
89538
+ * Deliberately co-located with the `mayWriteToLocation` filter below: a file
89539
+ * that enumerates storage locations must visibly decide whether it is choosing
89540
+ * a WRITE target (`scripts/check-storage-write-target-enabled.ts` enforces
89541
+ * exactly that, and it fired when the closure lived in the addon). Splitting
89542
+ * the enumeration from the decision is how the backup fan-out grew a second
89543
+ * enabled flag.
89544
+ */
89545
+ function createClipLocationPorts(api) {
89546
+ return {
89547
+ listLocations: () => api.storage.listLocations.query({}),
89548
+ resolvePath: (locationId) => api.storage.resolve.query({
89549
+ location: locationId,
89550
+ relativePath: ""
89551
+ })
89552
+ };
89553
+ }
89554
+ var HksvClipLocation = class {
89555
+ input;
89556
+ log;
89557
+ now;
89558
+ revalidateMs;
89559
+ cachedRoot = null;
89560
+ resolvedAt = Number.NEGATIVE_INFINITY;
89561
+ refusal = null;
89562
+ /** What the last warn said, so a per-clip refusal is not a per-clip log line. */
89563
+ announced = null;
89564
+ inFlight = null;
89565
+ constructor(input) {
89566
+ this.input = input;
89567
+ this.log = input.logger;
89568
+ this.now = input.now ?? Date.now;
89569
+ this.revalidateMs = input.revalidateMs ?? 6e4;
89570
+ }
89571
+ /** Why the last resolution produced no root. `null` once one succeeded. */
89572
+ get lastRefusal() {
89573
+ return this.refusal;
89574
+ }
89575
+ /** The doorbell rang (`StorageLocationsChanged`): drop the mirror. */
89576
+ invalidate() {
89577
+ this.resolvedAt = Number.NEGATIVE_INFINITY;
89578
+ }
89579
+ /**
89580
+ * The directory clips are written under, or `null` when there is none right
89581
+ * now. Never throws: the caller's correct move for every refusal is the same
89582
+ * — do not tee this clip, and leave HomeKit alone.
89583
+ */
89584
+ async root() {
89585
+ if (this.now() - this.resolvedAt < this.revalidateMs) return this.cachedRoot;
89586
+ const inflight = this.inFlight;
89587
+ if (inflight !== null) return inflight;
89588
+ const attempt = this.resolve();
89589
+ this.inFlight = attempt;
89590
+ try {
89591
+ return await attempt;
89592
+ } finally {
89593
+ this.inFlight = null;
89594
+ }
89595
+ }
89596
+ async resolve() {
89597
+ try {
89598
+ const rows = (await this.input.ports.listLocations()).filter((l) => l.type === HOMEKIT_CLIPS_LOCATION_TYPE);
89599
+ if (rows.length === 0) return this.refuse("no-location", {});
89600
+ if (rows.length > 1) this.warnOnce("hksv clip store: more than one HomeKit clips location exists — it is declared single", { ids: rows.map((l) => l.id) });
89601
+ const writable = rows.find((l) => mayWriteToLocation(l));
89602
+ if (writable === void 0) {
89603
+ const first = rows[0];
89604
+ return this.refuse("not-writable", {
89605
+ locationId: first?.id ?? null,
89606
+ mode: first === void 0 ? null : resolveLocationMode(first)
89607
+ });
89608
+ }
89609
+ const root = `${(await this.input.ports.resolvePath(writable.id)).replace(/\/+$/, "")}/${HOMEKIT_CLIPS_SUBTREE}`;
89610
+ this.cachedRoot = root;
89611
+ this.resolvedAt = this.now();
89612
+ this.refusal = null;
89613
+ if (this.announced !== null) {
89614
+ this.announced = null;
89615
+ this.log.info("hksv clip store: the clips location is writable again", { meta: {
89616
+ locationId: writable.id,
89617
+ root
89618
+ } });
89619
+ }
89620
+ return root;
89621
+ } catch (err) {
89622
+ return this.refuse("unreachable", { error: err instanceof Error ? err.message : String(err) });
89623
+ }
89624
+ }
89625
+ refuse(reason, meta) {
89626
+ this.cachedRoot = null;
89627
+ this.resolvedAt = this.now();
89628
+ this.refusal = reason;
89629
+ this.warnOnce("hksv clip store: no writable HomeKit clips location — clips are NOT being kept (HomeKit recording is unaffected)", {
89630
+ reason,
89631
+ ...meta
89632
+ });
89633
+ return null;
89634
+ }
89635
+ /**
89636
+ * One line per distinct situation. A refusal that reprinted on every clip
89637
+ * would drown the line that says it CHANGED, and the operator reads the log
89638
+ * to find out which of the two is happening.
89639
+ */
89640
+ warnOnce(message, meta) {
89641
+ const key = `${message}|${JSON.stringify(meta)}`;
89642
+ if (this.announced === key) return;
89643
+ this.announced = key;
89644
+ this.log.warn(message, { meta });
89645
+ }
89646
+ };
89647
+ //#endregion
89648
+ //#region src/hksv/clip-remux.ts
89649
+ /**
89650
+ * The faststart pass: the teed clip is made SEEKABLE, once, by the process that
89651
+ * minted it.
89652
+ *
89653
+ * ## Why the producer owes this
89654
+ *
89655
+ * The tee cuts like a pipe. ffmpeg writes `frag_keyframe+empty_moov` and never
89656
+ * returns to write a trailer, so on a teed clip `mvhd.duration` and
89657
+ * `mdhd.duration` are **0**, the `stbl` sample tables are EMPTY and there is no
89658
+ * `mfra`. Measured, on this hub's own files. The result decodes end to end — it
89659
+ * plays — but nothing can map a time to a byte in it, and a time-to-byte map is
89660
+ * the whole of what a scrub, a `Range` request and a stated duration are.
89661
+ *
89662
+ * The alternative was a remux in the CONSUMER, at play time. That puts an
89663
+ * ffmpeg per viewer in the broker's runner and buffers the whole file to write
89664
+ * a trailer, every time anybody drags — the cost D570 § 5(b) rejected, moved
89665
+ * one process along. Here it is one pass per clip, at the moment the clip is
89666
+ * born, measured at 40 ms and +0.7 % bytes. A clip is written once and read
89667
+ * many times; this is the side of that asymmetry the work belongs on.
89668
+ *
89669
+ * ## The constraint that outranks the feature
89670
+ *
89671
+ * Exactly as for the tee itself: **a clip that does not seek is a clip; a clip
89672
+ * the remux ate is a HomeKit recording the operator lost.** So the pass writes
89673
+ * a SIBLING and renames over the original only once it has vouched for it, it
89674
+ * never throws, and every way of failing has its own name on the record —
89675
+ * a missing binary, a deadline and a file ffmpeg blessed but did not fix are
89676
+ * three different operator actions.
89677
+ *
89678
+ * ## The vouch is a measurement, not an exit code
89679
+ *
89680
+ * `-movflags +faststart` exiting 0 is not evidence. {@link moovPrecedesMdat}
89681
+ * reads the top-level box order off the produced file and requires `moov`
89682
+ * ahead of the media — and requires the file to be PROGRESSIVE, because the
89683
+ * fragmented shape the tee already wrote is *also* moov-first and is exactly
89684
+ * what this pass exists to replace.
89685
+ */
89686
+ /** A remux that has not finished by here is not going to. */
89687
+ var CLIP_REMUX_TIMEOUT_MS = 3e4;
89688
+ /** The sibling the pass writes before it has earned the clip's own name. */
89689
+ var CLIP_REMUX_SUFFIX = ".faststart";
89690
+ /**
89691
+ * How much of the head is read back to judge the box order. The `moov` of a
89692
+ * short clip is a few hundred KB at most, and this only has to reach far enough
89693
+ * to meet the first `mdat` or `moof`.
89694
+ */
89695
+ var HEAD_BYTES = 1024 * 1024;
89696
+ function buildClipRemuxArgs(input) {
89697
+ return [
89698
+ "-hide_banner",
89699
+ "-loglevel",
89700
+ "error",
89701
+ "-nostdin",
89702
+ "-y",
89703
+ "-i",
89704
+ input.clipPath,
89705
+ "-c",
89706
+ "copy",
89707
+ "-movflags",
89708
+ "+faststart",
89709
+ "-f",
89710
+ "mp4",
89711
+ input.outPath
89712
+ ];
89713
+ }
89714
+ function createClipRemuxer(input) {
89715
+ return async ({ clipPath }) => {
89716
+ const outPath = `${clipPath}${CLIP_REMUX_SUFFIX}`;
89717
+ try {
89718
+ const run = await input.runner(buildClipRemuxArgs({
89719
+ clipPath,
89720
+ outPath
89721
+ }));
89722
+ if (run.spawnFailed) return await discard(outPath, "ffmpeg-missing");
89723
+ if (run.timedOut) return await discard(outPath, "timed-out");
89724
+ if (run.code !== 0) return await discard(outPath, "remux-failed");
89725
+ const size = await sizeOf(outPath);
89726
+ if (size === null || size === 0) return await discard(outPath, "remux-failed");
89727
+ const head = await readHead(outPath);
89728
+ if (!moovPrecedesMdat(head)) return await discard(outPath, "not-seekable");
89729
+ const durationMs = readMovieDurationMs(head);
89730
+ await rename(outPath, clipPath);
89731
+ return {
89732
+ ok: true,
89733
+ bytes: size,
89734
+ ...durationMs === null ? {} : { durationMs }
89735
+ };
89736
+ } catch {
89737
+ return await discard(outPath, "remux-failed");
89738
+ }
89739
+ };
89740
+ }
89741
+ async function discard(outPath, reason) {
89742
+ await rm(outPath, { force: true }).catch(() => void 0);
89743
+ return {
89744
+ ok: false,
89745
+ reason
89746
+ };
89747
+ }
89748
+ async function sizeOf(path) {
89749
+ try {
89750
+ return (await stat(path)).size;
89751
+ } catch {
89752
+ return null;
89753
+ }
89754
+ }
89755
+ async function readHead(path) {
89756
+ const buf = await readFile(path);
89757
+ return new Uint8Array(buf.buffer, buf.byteOffset, Math.min(buf.byteLength, HEAD_BYTES));
89758
+ }
89759
+ /**
89760
+ * Does this file carry a progressive index ahead of its media?
89761
+ *
89762
+ * Walks the TOP-LEVEL boxes only. Three verdicts collapse into `false`, and
89763
+ * each of them is a real file this store has held:
89764
+ *
89765
+ * - `moov` after `mdat` — a plain mux, seekable only once the whole file is
89766
+ * in hand, which over a `Range` reader means never.
89767
+ * - `moof` before any `mdat` — the FRAGMENTED shape the tee writes. Its
89768
+ * leading `moov` is the empty one `empty_moov` produced, so judging on
89769
+ * position alone would bless exactly the file this pass replaces.
89770
+ * - anything unreadable — a truncated header, or a box claiming a size that
89771
+ * does not advance the cursor. A corrupt file is never guessed at, and a
89772
+ * non-advancing size is how a scanner spins for ever.
89773
+ */
89774
+ function moovPrecedesMdat(bytes) {
89775
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
89776
+ let at = 0;
89777
+ let sawMoov = false;
89778
+ while (at + 8 <= bytes.byteLength) {
89779
+ const declared = view.getUint32(at);
89780
+ const type = String.fromCharCode(bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]);
89781
+ let size = declared;
89782
+ let header = 8;
89783
+ if (declared === 1) {
89784
+ if (at + 16 > bytes.byteLength) return false;
89785
+ const large = view.getBigUint64(at + 8);
89786
+ if (large > BigInt(Number.MAX_SAFE_INTEGER)) return false;
89787
+ size = Number(large);
89788
+ header = 16;
89789
+ }
89790
+ if (size < header) return false;
89791
+ if (type === "moov") sawMoov = true;
89792
+ if (type === "mdat") return sawMoov;
89793
+ if (type === "moof") return false;
89794
+ at += size;
89795
+ }
89796
+ return false;
89797
+ }
89798
+ /**
89799
+ * The movie duration, in ms, from the `mvhd` inside `moov`.
89800
+ *
89801
+ * `null`, never `0`, for every way of not knowing — no `moov`, no `mvhd`, a
89802
+ * truncated header, a zero timescale, or the **zero duration `empty_moov`
89803
+ * writes**, which is the case that matters: believing it would replace a
89804
+ * duration that is merely wrong with one that confidently says the clip is
89805
+ * empty (D393).
89806
+ *
89807
+ * Worth having because the alternative number is wrong in a specific,
89808
+ * measurable way: `endedAtMs - startedAtMs` is how long the TEE ran, and the
89809
+ * first fragments it wrote were the prebuffer — footage older than the tee
89810
+ * itself. On 615 that is 12.0 s claimed against 19.99 s held.
89811
+ */
89812
+ function readMovieDurationMs(bytes) {
89813
+ const moov = findTopLevelBox(bytes, "moov");
89814
+ if (moov === null) return null;
89815
+ const mvhd = findTopLevelBox(moov, "mvhd");
89816
+ if (mvhd === null || mvhd.byteLength < 4) return null;
89817
+ const view = new DataView(mvhd.buffer, mvhd.byteOffset, mvhd.byteLength);
89818
+ const version = mvhd[0];
89819
+ const timescaleAt = version === 1 ? 20 : 12;
89820
+ const durationAt = timescaleAt + 4;
89821
+ if (durationAt + (version === 1 ? 8 : 4) > mvhd.byteLength) return null;
89822
+ const timescale = view.getUint32(timescaleAt);
89823
+ if (timescale === 0) return null;
89824
+ const duration = version === 1 ? Number(view.getBigUint64(durationAt)) : view.getUint32(durationAt);
89825
+ if (duration <= 0 || !Number.isFinite(duration)) return null;
89826
+ return Math.round(duration / timescale * 1e3);
89827
+ }
89828
+ /**
89829
+ * The PAYLOAD — header stripped — of the first top-level box of `type`, or
89830
+ * `null`. Stripped, because the only reason to hold a box here is to walk its
89831
+ * children, and leaving the header on makes the first child look like the
89832
+ * parent.
89833
+ *
89834
+ * Deliberately the same walk as {@link moovPrecedesMdat} rather than a shared
89835
+ * generator: that one answers a question about ORDER and stops at the media,
89836
+ * this one descends. Both refuse a box that does not advance the cursor, which
89837
+ * is the property neither can do without.
89838
+ */
89839
+ function findTopLevelBox(bytes, type) {
89840
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
89841
+ let at = 0;
89842
+ while (at + 8 <= bytes.byteLength) {
89843
+ const declared = view.getUint32(at);
89844
+ const found = String.fromCharCode(bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]);
89845
+ let size = declared;
89846
+ let header = 8;
89847
+ if (declared === 1) {
89848
+ if (at + 16 > bytes.byteLength) return null;
89849
+ const large = view.getBigUint64(at + 8);
89850
+ if (large > BigInt(Number.MAX_SAFE_INTEGER)) return null;
89851
+ size = Number(large);
89852
+ header = 16;
89853
+ }
89854
+ if (size < header) return null;
89855
+ const end = Math.min(at + size, bytes.byteLength);
89856
+ if (found === type) return at + header >= end ? null : bytes.subarray(at + header, end);
89857
+ at += size;
89858
+ }
89859
+ return null;
89860
+ }
89861
+ //#endregion
88772
89862
  //#region src/hksv/clip-record.ts
88773
89863
  /**
88774
89864
  * What ONE teed HomeKit clip is, on disk.
@@ -88790,10 +89880,14 @@ function firstExposedAccessorySetupUri(exposed, logger) {
88790
89880
  * `deriveFragmentLengthMs` refuses a GOP longer than 8 s. A camera the tee
88791
89881
  * never writes for is not a gap in this store, it is a camera HKSV never
88792
89882
  * recorded; {@link HksvClipRecord} exists only for the ones it did.
88793
- * - **The duration is whatever iOS PULLED.** `durationMs` is the wall-clock
88794
- * span of the HDS stream (prebuffer replay plus however long the hub kept it
88795
- * open), not a window CamStack chose. A short clip is not a truncated one —
88796
- * {@link HksvClipRecord.truncated} is the only thing that says truncated.
89883
+ * - **The duration is whatever iOS PULLED.** Not a window CamStack chose, and
89884
+ * a short clip is not a truncated one — {@link HksvClipRecord.truncated} is
89885
+ * the only thing that says truncated. Since D578 it is READ FROM THE FILE
89886
+ * (`mvhd`, after the faststart pass), because the tee's own wall clock is
89887
+ * wrong in a specific direction: the clock starts when the tee does, and the
89888
+ * first fragments it writes are the PREBUFFER — footage older than the tee
89889
+ * itself. Measured on 615: 12.0 s claimed against 19.99 s held. The wall
89890
+ * clock remains the fallback for a file that could not be measured.
88797
89891
  *
88798
89892
  * ## `thumbnailFile` is VOUCHED
88799
89893
  *
@@ -88812,6 +89906,18 @@ var HksvThumbnailUnavailableReasonSchema = _enum([
88812
89906
  "no-init-segment",
88813
89907
  "not-attempted"
88814
89908
  ]);
89909
+ /**
89910
+ * Why a teed clip could not be made seekable. Named, never blank — a missing
89911
+ * binary, a deadline and a file ffmpeg blessed but did not fix are three
89912
+ * different operator actions, and "it does not seek" is none of them.
89913
+ */
89914
+ var HksvRemuxUnavailableReasonSchema = _enum([
89915
+ "remux-failed",
89916
+ "timed-out",
89917
+ "ffmpeg-missing",
89918
+ "not-seekable",
89919
+ "not-attempted"
89920
+ ]);
88815
89921
  /** Why a clip stopped short of the stream that fed it. */
88816
89922
  var HksvClipTruncationSchema = _enum([
88817
89923
  "size-cap",
@@ -88830,21 +89936,59 @@ var HksvClipRecordSchema = object({
88830
89936
  streamId: number().int(),
88831
89937
  startedAtMs: number().int(),
88832
89938
  endedAtMs: number().int(),
88833
- /** `endedAtMs - startedAtMs`. What iOS pulled, never a window we chose. */
89939
+ /**
89940
+ * What iOS pulled, never a window we chose — measured from the file's own
89941
+ * `mvhd` where the faststart pass could read it, and `endedAtMs -
89942
+ * startedAtMs` otherwise. See the docblock: the two differ by the prebuffer.
89943
+ */
88834
89944
  durationMs: number().int().nonnegative(),
88835
89945
  /** How much of the clip's head came from the prebuffer ring, by ARRIVAL age. */
88836
89946
  prebufferSpanMs: number().int().nonnegative(),
89947
+ /**
89948
+ * The size of {@link HksvClipRecord.file} AS IT NOW STANDS on disk — so on a
89949
+ * clip the faststart pass rewrote, the post-remux size, re-stat'd and never
89950
+ * carried over from the tee's own count. The pass costs about +0.7 %, and a
89951
+ * record stating the pre-remux figure would be wrong by exactly that for
89952
+ * ever, on the one field a `Range` reader and the disk accounting both use.
89953
+ */
88837
89954
  bytes: number().int().nonnegative(),
88838
89955
  fragments: number().int().nonnegative(),
88839
89956
  /** Basenames, relative to the clip's own device directory. */
88840
89957
  file: string().min(1),
88841
89958
  thumbnailFile: string().min(1).optional(),
88842
89959
  thumbnailUnavailable: object({ reason: HksvThumbnailUnavailableReasonSchema }).optional(),
89960
+ /**
89961
+ * The faststart pass ran and its output was VOUCHED: `moov` ahead of the
89962
+ * media, progressive, read back off the produced file. Present only when
89963
+ * that is true of {@link HksvClipRecord.file}.
89964
+ *
89965
+ * Three states, not two, and the third is the reason this is optional rather
89966
+ * than a boolean: `true` means it seeks, {@link
89967
+ * HksvClipRecord.remuxUnavailable} means it does not and says why, and
89968
+ * NEITHER means unknown — a sidecar written before this field existed cannot
89969
+ * be given one after the fact, and a reader must not read that silence as
89970
+ * "does not seek" (D393).
89971
+ */
89972
+ seekable: literal(true).optional(),
89973
+ remuxUnavailable: object({ reason: HksvRemuxUnavailableReasonSchema }).optional(),
88843
89974
  truncated: HksvClipTruncationSchema.optional(),
88844
89975
  /** iOS sent `ack` for this stream: HomeKit itself kept the clip. */
88845
89976
  acknowledgedByHomeKit: boolean(),
88846
89977
  width: number().int().positive(),
88847
89978
  height: number().int().positive(),
89979
+ /**
89980
+ * WHICH broker slot HomeKit recorded from, on the one quality scale the
89981
+ * system has (`high | mid | low`).
89982
+ *
89983
+ * Written by the tee from `pickRecordingSource`'s choice, because it is the
89984
+ * only thing that knows: the pixels do not say which profile produced them,
89985
+ * and a consumer that needs `served` (`getClipPlayback`, `readClipBytes`)
89986
+ * must never derive it from the resolution. Optional because a sidecar
89987
+ * written before this field existed cannot be given one after the fact — a
89988
+ * reader REFUSES to name a twin it was never told (D393), rather than
89989
+ * guessing one.
89990
+ */
89991
+ profile: CamProfileSchema.optional(),
88848
89992
  /** The advertised fragment length the source was cutting at. */
88849
89993
  fragmentMs: number().int().positive()
88850
89994
  });
@@ -88853,7 +89997,17 @@ var HksvClipRecordSchema = object({
88853
89997
  * else, so a pruner deleting a clip never has to guess which JPEG was its.
88854
89998
  */
88855
89999
  function clipFileNames(clipId) {
88856
- const stem = clipStem(clipId);
90000
+ return clipFileNamesForStem(clipStem(clipId));
90001
+ }
90002
+ /**
90003
+ * The same three names, from a stem a URL already carries.
90004
+ *
90005
+ * A data-plane route is handed the stem, not the id — `:` is legal on ext4 and
90006
+ * hostile in a path everywhere else, which is why {@link clipStem} exists — and
90007
+ * it must not re-derive the layout. One place mints these names, for both
90008
+ * callers.
90009
+ */
90010
+ function clipFileNamesForStem(stem) {
88857
90011
  return {
88858
90012
  clip: `${stem}.mp4`,
88859
90013
  thumbnail: `${stem}.jpg`,
@@ -88874,32 +90028,39 @@ function clipStem(clipId) {
88874
90028
  *
88875
90029
  * ## The owner
88876
90030
  *
88877
- * The **export-hap addon**, in its OWN data dir, and nothing else. Not the
88878
- * recorder's storage locations and not the `eventMedia` class:
90031
+ * The **export-hap addon**, on its own DECLARED storage location
90032
+ * (`homekitClips`, see `clip-location.ts`) — the operator picks the disk. Not
90033
+ * the `eventMedia` class and not `recordings`:
88879
90034
  *
88880
- * - `export-hap` is hub-only and runs in its own runner. The recorder's
88881
- * location machinery (D385/D386/D389) is another addon's, in another
88882
- * process, and reaching it would be a cross-runner write on the frame path
88883
- * of a live HomeKit session — exactly what D9/D18 forbid.
88884
90035
  * - A teed clip is a DERIVED artifact of a HomeKit session, with no track, no
88885
90036
  * detection and no event behind it. Filing it under `eventMedia` would put
88886
90037
  * it inside the post-analysis retention sweep, where its lifetime would be
88887
90038
  * decided by a policy written for a different question.
88888
- * - The precedent is D549 decision 13: Reolink's clip thumbnails live in the
88889
- * vendor addon's data dir under a per-device LRU, outside `eventMedia` and
88890
- * outside the sweep, for the same reason.
90039
+ * - `recordings` rows belong to the recorder's placement planner, its evictor
90040
+ * and its drain ratchet, and these files are not segments its index knows.
90041
+ * - The precedent for a class of its own is D549 decision 13: Reolink's clip
90042
+ * thumbnails sit outside `eventMedia` and outside the sweep for the same
90043
+ * reason.
88891
90044
  *
88892
- * Layout: `<dataDir>/hksv-clips/<deviceId>/<stem>.{mp4,jpg,json}`. One stem per
88893
- * clip, three extensions, so a pruner deleting a clip never has to guess which
88894
- * JPEG was its.
90045
+ * Layout: `<location root>/homekit-clips/<deviceId>/<stem>.{mp4,jpg,json}`. One
90046
+ * stem per clip, three extensions, so a pruner deleting a clip never has to
90047
+ * guess which JPEG was its; the `homekit-clips/` subtree is mandatory because
90048
+ * the location's seeded default SHARES the recordings root (D327).
88895
90049
  *
88896
- * ## The retention, and why these numbers
90050
+ * The first cut wrote to `<dataDir>/hksv-clips/`. It was defensible and gave
90051
+ * the operator no say, which was the first thing he asked for after seeing it.
90052
+ *
90053
+ * ## The retention: one preference, three rails
88897
90054
  *
88898
90055
  * HomeKit's own retention is unreadable — HAP has no read-back, so CamStack can
88899
90056
  * never ask "does iOS still have this one?" and reconcile. The store therefore
88900
90057
  * cannot mirror HomeKit's lifetime and does not pretend to: it keeps a bounded
88901
- * recent window and says, per clip, exactly when it started. Four bounds, all
88902
- * enforced on every completion:
90058
+ * recent window and says, per clip, exactly when it started.
90059
+ *
90060
+ * **The AGE is the operator's, per camera**
90061
+ * ({@link HksvClipStoreInput.maxAgeMsFor}, cascaded global-default plus
90062
+ * per-device override by the addon), defaulting to 14 days. The other three are
90063
+ * RAILS, not preferences, and stay fixed:
88903
90064
  *
88904
90065
  * - **{@link DEFAULT_CLIP_BOUNDS.maxClipBytes} per clip (256 MB).** A clip's
88905
90066
  * duration is whatever iOS pulled — there is no window we choose — so this
@@ -88912,12 +90073,18 @@ function clipStem(clipId) {
88912
90073
  * Both bounds exist for the same reason the prebuffer ring has both: at 4K
88913
90074
  * a fragment is 6.35 MB, a 25x spread, and a count-only bound is a
88914
90075
  * per-camera disk figure nobody can predict.
88915
- * - **{@link DEFAULT_CLIP_BOUNDS.maxAgeMs} (14 days)**, so a camera that
88916
- * stopped recording in March does not hold half a gigabyte forever.
88917
90076
  * - **{@link DEFAULT_CLIP_BOUNDS.maxStoreBytes} (4 GB)** across every camera,
88918
90077
  * applied after the per-device pass. Per-device bounds alone multiply by a
88919
90078
  * camera count the operator changes without thinking about this store.
88920
90079
  *
90080
+ * An operator able to set the first to zero could turn the tee into a silent
90081
+ * no-op, and one able to raise the last without limit could fill the disk; the
90082
+ * age changes neither. The consequence is stated rather than left to be
90083
+ * discovered: a retention LONGER than the rails allow does not reach — a busy
90084
+ * camera hits 200 clips or 512 MB first. Every prune logs which bound fired
90085
+ * (`reason: age | count | bytes`), so "my 30 days did nothing" has an answer in
90086
+ * the log instead of a theory.
90087
+ *
88921
90088
  * A file nobody prunes is a disk that fills, and the pruner is the only thing
88922
90089
  * standing between a permanent prebuffer and that.
88923
90090
  *
@@ -88930,8 +90097,12 @@ function clipStem(clipId) {
88930
90097
  * deleted — this repo has twice shipped a green test because the fake supplied
88931
90098
  * what production forgot, and a promise is not a file.
88932
90099
  */
88933
- /** The directory under the addon's data dir. Ours, and only ours. */
88934
- var CLIP_DIR_NAME = "hksv-clips";
90100
+ /** An `errno` code off an unknown throw, without a cast. */
90101
+ function errnoCode(err) {
90102
+ if (typeof err !== "object" || err === null || !("code" in err)) return null;
90103
+ const { code } = err;
90104
+ return typeof code === "string" ? code : null;
90105
+ }
88935
90106
  /** See the class docblock for what each number is, and where it comes from. */
88936
90107
  var DEFAULT_CLIP_BOUNDS = {
88937
90108
  maxClipBytes: 256 * 1024 * 1024,
@@ -88955,6 +90126,11 @@ var HksvClipStore = class {
88955
90126
  };
88956
90127
  this.now = input.now ?? Date.now;
88957
90128
  }
90129
+ /** This camera's retention. One authority, asked at every prune. */
90130
+ maxAgeMsFor(deviceId) {
90131
+ const resolved = this.input.maxAgeMsFor?.(deviceId);
90132
+ return resolved === void 0 || !Number.isFinite(resolved) || resolved <= 0 ? this.bounds.maxAgeMs : resolved;
90133
+ }
88958
90134
  /** The per-clip ceiling the tee enforces while writing. */
88959
90135
  get maxClipBytes() {
88960
90136
  return this.bounds.maxClipBytes;
@@ -88967,19 +90143,21 @@ var HksvClipStore = class {
88967
90143
  async open(input) {
88968
90144
  const clipId = clipIdFor(input.deviceId, input.startedAtMs, input.streamId);
88969
90145
  const names = clipFileNames(clipId);
88970
- const dir = this.deviceDir(input.deviceId);
88971
90146
  const tags = { deviceId: input.deviceId };
90147
+ const root = await this.resolveRootOrWarn(tags);
90148
+ if (root === null) return null;
90149
+ const dir = this.deviceDir(root, input.deviceId);
88972
90150
  try {
88973
90151
  await mkdir(dir, { recursive: true });
88974
90152
  const sink = await createFileSink(join(dir, names.clip));
88975
90153
  this.inFlight.set(clipId, {
88976
90154
  deviceId: input.deviceId,
88977
- stem: stemOf(names.clip)
90155
+ stem: stemOf$1(names.clip)
88978
90156
  });
88979
90157
  return {
88980
90158
  clipId,
88981
90159
  sink,
88982
- complete: (outcome) => this.complete(clipId, input, names, outcome)
90160
+ complete: (outcome) => this.complete(clipId, input, names, outcome, root)
88983
90161
  };
88984
90162
  } catch (err) {
88985
90163
  this.inFlight.delete(clipId);
@@ -88995,12 +90173,52 @@ var HksvClipStore = class {
88995
90173
  }
88996
90174
  /** Every sidecar for a camera, newest first. What a later provider lists. */
88997
90175
  async listRecords(deviceId) {
88998
- const dir = this.deviceDir(deviceId);
90176
+ const catalog = await this.listCatalog(deviceId);
90177
+ return catalog.kind === "catalog" ? catalog.records : [];
90178
+ }
90179
+ /**
90180
+ * ONE directory read, answering the three questions a provider has to tell
90181
+ * apart: what was written, what is still THERE, and — when neither — whether
90182
+ * the location is gone or the read failed.
90183
+ *
90184
+ * `listRecords` is this method's `records` and nothing else, so the published
90185
+ * D550 contract and the provider can never disagree about what the store
90186
+ * holds. The file name set is here rather than a `stat` per clip because the
90187
+ * `readdir` has already answered it: a sidecar whose fMP4 was removed is a
90188
+ * row that must still LIST, saying it cannot be played (D549's `playable`),
90189
+ * and N syscalls to re-learn what one call said is the shape D447 charges
90190
+ * for.
90191
+ *
90192
+ * A device directory that does not exist is an EMPTY catalog, not a refusal:
90193
+ * the location is fine and this camera has recorded nothing. A `readdir` that
90194
+ * failed for any other reason is `failed` — a measurement that failed is
90195
+ * never folded into "no clips" (D393).
90196
+ */
90197
+ async listCatalog(deviceId) {
90198
+ let root;
90199
+ try {
90200
+ root = await this.input.resolveRoot();
90201
+ } catch (err) {
90202
+ return {
90203
+ kind: "failed",
90204
+ error: err instanceof Error ? err.message : String(err)
90205
+ };
90206
+ }
90207
+ if (root === null) return { kind: "no-root" };
90208
+ const dir = this.deviceDir(root, deviceId);
88999
90209
  let names;
89000
90210
  try {
89001
90211
  names = await readdir(dir);
89002
- } catch {
89003
- return [];
90212
+ } catch (err) {
90213
+ if (errnoCode(err) === "ENOENT") return {
90214
+ kind: "catalog",
90215
+ records: [],
90216
+ files: /* @__PURE__ */ new Set()
90217
+ };
90218
+ return {
90219
+ kind: "failed",
90220
+ error: err instanceof Error ? err.message : String(err)
90221
+ };
89004
90222
  }
89005
90223
  const records = [];
89006
90224
  for (const name of names) {
@@ -89008,11 +90226,30 @@ var HksvClipStore = class {
89008
90226
  const record = await this.readRecord(join(dir, name), deviceId);
89009
90227
  if (record !== null) records.push(record);
89010
90228
  }
89011
- return records.sort((a, b) => b.startedAtMs - a.startedAtMs);
90229
+ return {
90230
+ kind: "catalog",
90231
+ records: records.toSorted((a, b) => b.startedAtMs - a.startedAtMs),
90232
+ files: new Set(names)
90233
+ };
90234
+ }
90235
+ /**
90236
+ * Where ONE artifact of a clip lives, or `null` when there is no root.
90237
+ *
90238
+ * The stem, never a path: the caller (a data-plane route) has a URL segment
90239
+ * and the store owns the layout. Nothing else may compose a path into the
90240
+ * clips location.
90241
+ */
90242
+ async artifactPath(deviceId, stem, kind) {
90243
+ const root = await this.rootOrNull();
90244
+ if (root === null) return null;
90245
+ const names = clipFileNamesForStem(stem);
90246
+ return join(this.deviceDir(root, deviceId), names[kind]);
89012
90247
  }
89013
90248
  /** iOS sent `ack`: HomeKit itself kept this clip. Recorded on the sidecar. */
89014
90249
  async acknowledge(deviceId, clipId) {
89015
- const path = join(this.deviceDir(deviceId), clipFileNames(clipId).record);
90250
+ const root = await this.rootOrNull();
90251
+ if (root === null) return;
90252
+ const path = join(this.deviceDir(root, deviceId), clipFileNames(clipId).record);
89016
90253
  const record = await this.readRecord(path, deviceId);
89017
90254
  if (record === null) return;
89018
90255
  await this.writeRecord(path, {
@@ -89020,12 +90257,41 @@ var HksvClipStore = class {
89020
90257
  acknowledgedByHomeKit: true
89021
90258
  });
89022
90259
  }
89023
- deviceDir(deviceId) {
89024
- return join(this.input.rootDir, String(deviceId));
90260
+ /**
90261
+ * The root, or `null` with ONE warn. Never throws: the caller's correct move
90262
+ * for every refusal is identical — no clip, HomeKit untouched.
90263
+ */
90264
+ async resolveRootOrWarn(tags) {
90265
+ try {
90266
+ const root = await this.input.resolveRoot();
90267
+ if (root !== null) return root;
90268
+ } catch (err) {
90269
+ this.log.warn("hksv clip store: resolving the clips location FAILED — nothing is teed", {
90270
+ tags,
90271
+ meta: { error: err instanceof Error ? err.message : String(err) }
90272
+ });
90273
+ return null;
90274
+ }
90275
+ this.log.warn("hksv clip store: no writable clips location — this camera keeps no HomeKit clips", {
90276
+ tags,
90277
+ meta: {}
90278
+ });
90279
+ return null;
90280
+ }
90281
+ /** The root for a READ. Quiet: a read with no location is simply empty. */
90282
+ async rootOrNull() {
90283
+ try {
90284
+ return await this.input.resolveRoot();
90285
+ } catch {
90286
+ return null;
90287
+ }
90288
+ }
90289
+ deviceDir(root, deviceId) {
90290
+ return join(root, String(deviceId));
89025
90291
  }
89026
- async complete(clipId, input, names, outcome) {
90292
+ async complete(clipId, input, names, outcome, root) {
89027
90293
  this.inFlight.delete(clipId);
89028
- const dir = this.deviceDir(input.deviceId);
90294
+ const dir = this.deviceDir(root, input.deviceId);
89029
90295
  const tags = { deviceId: input.deviceId };
89030
90296
  const clipPath = join(dir, names.clip);
89031
90297
  if (!outcome.sawInit || outcome.bytes === 0) {
@@ -89041,6 +90307,7 @@ var HksvClipStore = class {
89041
90307
  });
89042
90308
  return null;
89043
90309
  }
90310
+ const remux = await this.remuxAndVouch(clipPath, tags, clipId);
89044
90311
  const thumbnail = await this.mintAndVouch({
89045
90312
  deviceId: input.deviceId,
89046
90313
  clipPath,
@@ -89055,17 +90322,19 @@ var HksvClipStore = class {
89055
90322
  streamId: input.streamId,
89056
90323
  startedAtMs: outcome.startedAtMs,
89057
90324
  endedAtMs: outcome.endedAtMs,
89058
- durationMs: Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
90325
+ durationMs: remux.ok && remux.durationMs !== void 0 ? remux.durationMs : Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
89059
90326
  prebufferSpanMs: input.prebufferSpanMs,
89060
- bytes: outcome.bytes,
90327
+ bytes: remux.ok ? remux.bytes : outcome.bytes,
89061
90328
  fragments: outcome.fragments,
89062
90329
  file: names.clip,
89063
90330
  ...thumbnail.ok ? { thumbnailFile: names.thumbnail } : { thumbnailUnavailable: { reason: thumbnail.reason } },
90331
+ ...remux.ok ? { seekable: true } : { remuxUnavailable: { reason: remux.reason } },
89064
90332
  ...outcome.truncated === null ? {} : { truncated: outcome.truncated },
89065
90333
  acknowledgedByHomeKit: false,
89066
90334
  width: input.width,
89067
90335
  height: input.height,
89068
- fragmentMs: input.fragmentMs
90336
+ fragmentMs: input.fragmentMs,
90337
+ ...input.profile === void 0 ? {} : { profile: input.profile }
89069
90338
  };
89070
90339
  await this.writeRecord(join(dir, names.record), record);
89071
90340
  this.log.info("hksv clip tee: a HomeKit clip was KEPT", {
@@ -89082,13 +90351,53 @@ var HksvClipStore = class {
89082
90351
  thumbnailUnavailable: record.thumbnailUnavailable?.reason ?? null
89083
90352
  }
89084
90353
  });
89085
- await this.prune(input.deviceId);
90354
+ await this.prune(root, input.deviceId);
89086
90355
  return record;
89087
90356
  }
89088
90357
  /**
89089
90358
  * Mint, then CHECK. The minter's own verdict decides nothing on its own: a
89090
90359
  * JPEG is vouched by `stat`, never by a return value.
89091
90360
  */
90361
+ /**
90362
+ * Run the faststart pass, and never let it cost the clip.
90363
+ *
90364
+ * The remuxer already vouches for its own output and never throws by
90365
+ * contract — this wrapper exists for the one case a contract cannot cover: a
90366
+ * remuxer that throws anyway. A clip that does not seek is a clip; a clip
90367
+ * this pass ate is a HomeKit recording the operator lost, and that asymmetry
90368
+ * is the whole reason the failure is caught here and named on the record.
90369
+ */
90370
+ async remuxAndVouch(clipPath, tags, clipId) {
90371
+ const remux = this.input.remuxClip;
90372
+ if (remux === void 0) return {
90373
+ ok: false,
90374
+ reason: "not-attempted"
90375
+ };
90376
+ let result;
90377
+ try {
90378
+ result = await remux({ clipPath });
90379
+ } catch (err) {
90380
+ result = {
90381
+ ok: false,
90382
+ reason: "remux-failed"
90383
+ };
90384
+ this.log.warn("hksv clip tee: the faststart remuxer THREW — the clip is kept unseekable", {
90385
+ tags,
90386
+ meta: {
90387
+ clipId,
90388
+ error: err instanceof Error ? err.message : String(err)
90389
+ }
90390
+ });
90391
+ }
90392
+ if (!result.ok && result.reason !== "not-attempted") this.log.warn("hksv clip tee: the clip could not be made SEEKABLE — it plays, but nothing can scrub or Range it", {
90393
+ tags,
90394
+ meta: {
90395
+ clipId,
90396
+ reason: result.reason
90397
+ }
90398
+ });
90399
+ return result;
90400
+ }
89092
90401
  async mintAndVouch(mint) {
89093
90402
  const { deviceId, jpegPath } = mint;
89094
90403
  const minter = this.input.mintThumbnail;
@@ -89129,26 +90438,31 @@ var HksvClipStore = class {
89129
90438
  * sidecars, so a stem whose sidecar never landed — a crash mid-write — is
89130
90439
  * seen and removed instead of occupying the disk invisibly forever.
89131
90440
  */
89132
- async prune(deviceId) {
89133
- const cutoff = this.now() - this.bounds.maxAgeMs;
89134
- const perDevice = await this.scanDevice(deviceId);
89135
- await this.enforce(perDevice, cutoff, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
90441
+ async prune(root, deviceId) {
90442
+ const perDevice = await this.scanDevice(root, deviceId);
90443
+ await this.enforce(perDevice, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
89136
90444
  let deviceIds;
89137
90445
  try {
89138
- deviceIds = (await readdir(this.input.rootDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => Number.parseInt(e.name, 10)).filter((id) => Number.isFinite(id));
90446
+ deviceIds = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => Number.parseInt(e.name, 10)).filter((id) => Number.isFinite(id));
89139
90447
  } catch {
89140
90448
  return;
89141
90449
  }
89142
90450
  const all = [];
89143
- for (const id of deviceIds) all.push(...await this.scanDevice(id));
89144
- await this.enforce(all, cutoff, Number.POSITIVE_INFINITY, this.bounds.maxStoreBytes);
90451
+ for (const id of deviceIds) all.push(...await this.scanDevice(root, id));
90452
+ await this.enforce(all, Number.POSITIVE_INFINITY, this.bounds.maxStoreBytes);
89145
90453
  }
89146
- async enforce(clips, ageCutoff, maxCount, maxBytes) {
90454
+ /**
90455
+ * Enforce the bounds, oldest first. The AGE is asked per clip's CAMERA — two
90456
+ * cameras with different retentions share this pass, and folding them into
90457
+ * one cutoff would silently give every camera the shortest.
90458
+ */
90459
+ async enforce(clips, maxCount, maxBytes) {
90460
+ const now = this.now();
89147
90461
  const ordered = [...clips].sort((a, b) => b.startedAtMs - a.startedAtMs);
89148
90462
  let kept = 0;
89149
90463
  let bytes = 0;
89150
90464
  for (const clip of ordered) {
89151
- const tooOld = clip.startedAtMs < ageCutoff;
90465
+ const tooOld = clip.startedAtMs < now - this.maxAgeMsFor(clip.deviceId);
89152
90466
  const overCount = kept >= maxCount;
89153
90467
  const overBytes = bytes + clip.bytes > maxBytes;
89154
90468
  if (!tooOld && !overCount && !overBytes) {
@@ -89175,8 +90489,8 @@ var HksvClipStore = class {
89175
90489
  }
89176
90490
  });
89177
90491
  }
89178
- async scanDevice(deviceId) {
89179
- const dir = this.deviceDir(deviceId);
90492
+ async scanDevice(root, deviceId) {
90493
+ const dir = this.deviceDir(root, deviceId);
89180
90494
  let names;
89181
90495
  try {
89182
90496
  names = await readdir(dir);
@@ -89186,7 +90500,7 @@ var HksvClipStore = class {
89186
90500
  const clips = [];
89187
90501
  for (const name of names) {
89188
90502
  if (!name.endsWith(".mp4")) continue;
89189
- const stem = stemOf(name);
90503
+ const stem = stemOf$1(name);
89190
90504
  if (this.isInFlight(deviceId, stem)) continue;
89191
90505
  const bytes = await fileSize(join(dir, name));
89192
90506
  const record = await this.readRecord(join(dir, `${stem}.json`), deviceId);
@@ -89222,7 +90536,19 @@ var HksvClipStore = class {
89222
90536
  } catch {
89223
90537
  return null;
89224
90538
  }
89225
- const parsed = HksvClipRecordSchema.safeParse(JSON.parse(raw));
90539
+ let parsed;
90540
+ try {
90541
+ parsed = HksvClipRecordSchema.safeParse(JSON.parse(raw));
90542
+ } catch (err) {
90543
+ this.log.warn("hksv clip store: a sidecar is not readable JSON — the clip is not listed", {
90544
+ tags: { deviceId },
90545
+ meta: {
90546
+ path,
90547
+ error: err instanceof Error ? err.message : String(err)
90548
+ }
90549
+ });
90550
+ return null;
90551
+ }
89226
90552
  if (parsed.success) return parsed.data;
89227
90553
  this.log.warn("hksv clip store: a sidecar could not be read — the clip is not listed", {
89228
90554
  tags: { deviceId },
@@ -89233,11 +90559,22 @@ var HksvClipStore = class {
89233
90559
  });
89234
90560
  return null;
89235
90561
  }
90562
+ /**
90563
+ * ATOMICALLY: a temp file plus a rename, never a `writeFile` in place.
90564
+ *
90565
+ * The sidecar is the only thing that says a clip exists, and it is read
90566
+ * concurrently — by the pruner, and later by whatever lists these clips. A
90567
+ * plain write is visible half-finished, and a half-finished sidecar reads as
90568
+ * a corrupt clip rather than a clip being written. `rename` within one
90569
+ * directory is atomic, so a reader sees the old record or the new one.
90570
+ */
89236
90571
  async writeRecord(path, record) {
89237
- await writeFile(path, JSON.stringify(record, null, 2), "utf8");
90572
+ const staging = `${path}.tmp`;
90573
+ await writeFile(staging, JSON.stringify(record, null, 2), "utf8");
90574
+ await rename(staging, path);
89238
90575
  }
89239
90576
  };
89240
- function stemOf(fileName) {
90577
+ function stemOf$1(fileName) {
89241
90578
  return fileName.replace(/\.[^.]+$/, "");
89242
90579
  }
89243
90580
  async function fileSize(path) {
@@ -89266,6 +90603,8 @@ async function createFileSink(path) {
89266
90603
  }
89267
90604
  };
89268
90605
  }
90606
+ /** A decode that has not finished by here is not going to. */
90607
+ var CLIP_THUMBNAIL_TIMEOUT_MS = 1e4;
89269
90608
  function buildClipThumbnailArgs(input) {
89270
90609
  return [
89271
90610
  "-hide_banner",
@@ -89320,59 +90659,583 @@ function createClipThumbnailMinter(input) {
89320
90659
  return { ok: true };
89321
90660
  };
89322
90661
  }
89323
- /**
89324
- * The production runner: one bounded ffmpeg, killed at the deadline.
89325
- *
89326
- * Bounded and killed, not merely awaited — an ffmpeg that never exits on a
89327
- * clip it cannot parse would otherwise hold a process per clip on a hub that
89328
- * already runs a permanent prebuffer per recorded camera.
89329
- */
89330
- function createFfmpegClipThumbnailRunner(input) {
89331
- const timeoutMs = input.timeoutMs ?? 1e4;
89332
- return (args) => new Promise((resolve) => {
89333
- let settled = false;
89334
- const finish = (run) => {
89335
- if (settled) return;
89336
- settled = true;
89337
- clearTimeout(timer);
89338
- resolve(run);
90662
+ //#endregion
90663
+ //#region src/hksv/videoclips-plane.ts
90664
+ /**
90665
+ * The two data-plane routes behind the HomeKit clip source.
90666
+ *
90667
+ * Both are hosted by `ctx.dataPlane.serve({ access: 'authenticated' })`, so the
90668
+ * hub authenticates and reverse-proxies and this addon produces the bytes.
90669
+ * **Neither puts a token in its URL**: the hub's `/addon/<id>/<prefix>` proxy
90670
+ * reads the credential from `authorization` OR the session cookie, exactly as
90671
+ * the recorder's own playback plane does, so a clip URL may sit in history, in
90672
+ * a `Referer` or in a shared log and open nothing (D549 22).
90673
+ *
90674
+ * ## Why the bytes are served HERE
90675
+ *
90676
+ * The clips were written by this process, to a `local-path` storage location,
90677
+ * and this addon is `hub-only`. A byte path that went anywhere else would be a
90678
+ * cross-addon media move — D9/D18, and the repo's one existing such move is
90679
+ * capped at 50 MiB with a docblock naming the OOM it caused. `createReadStream
90680
+ * ().pipe(res)` gives backpressure natively, which is why the data plane hands
90681
+ * an addon the REAL `res` (D447: a raw HTTP stream reads no chunk the socket
90682
+ * has not taken).
90683
+ *
90684
+ * ## What the media route does NOT do
90685
+ *
90686
+ * It does not remux. A teed clip is `ftyp` + an EMPTY `moov` + `moof`/`mdat`
90687
+ * fragments, cut wherever iOS stopped pulling, so the container carries no
90688
+ * duration and no index — measured on a byte-identical file
90689
+ * (`-movflags +frag_keyframe+empty_moov+default_base_moof`, the tee's own
90690
+ * argv): `mvhd.duration = 0`, `mdhd.duration = 0`, no `sidx`, and no `mfra`
90691
+ * because nothing wrote a trailer. It decodes end to end, and the row's
90692
+ * `timeRange` carries the real duration from the sidecar, but a player cannot
90693
+ * map a time to a byte offset from this file. What it would cost to change is
90694
+ * measured and written down in D571 rather than guessed at here.
90695
+ *
90696
+ * ## The thumbnail route has no mint
90697
+ *
90698
+ * It only ever serves a JPEG the store already stat'd — a still is VOUCHED at
90699
+ * write time or it does not exist, and nothing re-mints a teed clip. So a miss
90700
+ * is a plain 404, not the 204-with-a-reason contract a live camera needs.
90701
+ */
90702
+ /** URL namespaces under `/addon/export-hap/`. */
90703
+ var HKSV_CLIP_MEDIA_PREFIX = "hksv-clip";
90704
+ var HKSV_CLIP_THUMB_PREFIX = "hksv-clip-thumb";
90705
+ /** A stem is what `clipStem` mints: nothing else may reach the filesystem. */
90706
+ var MEDIA_PATH = /^\/(\d{1,12})\/([A-Za-z0-9_-]{1,128})\.mp4$/;
90707
+ var THUMB_PATH = /^\/(\d{1,12})\/([A-Za-z0-9_-]{1,128})\.jpg$/;
90708
+ /** `bytes=a-b` / `bytes=a-` / `bytes=-n`. `'unsatisfiable'` is a 416 rather
90709
+ * than a silently empty 206. */
90710
+ function parseRange(header, size) {
90711
+ if (header === void 0) return null;
90712
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
90713
+ if (match === null) return null;
90714
+ const [, rawStart, rawEnd] = match;
90715
+ if (rawStart === "" && rawEnd === "") return "unsatisfiable";
90716
+ if (rawStart === "") {
90717
+ const length = Number(rawEnd);
90718
+ if (length <= 0) return "unsatisfiable";
90719
+ return {
90720
+ start: Math.max(0, size - length),
90721
+ end: size - 1
89339
90722
  };
89340
- const child = input.spawnFn(input.ffmpegBinaryPath, [...args], { stdio: [
89341
- "ignore",
89342
- "ignore",
89343
- "pipe"
89344
- ] });
89345
- let stderr = "";
89346
- child.stderr?.on("data", (chunk) => {
89347
- if (stderr.length < 2e3) stderr += chunk.toString("utf8");
89348
- });
89349
- const timer = setTimeout(() => {
89350
- child.kill("SIGKILL");
89351
- finish({
89352
- code: null,
89353
- timedOut: true,
89354
- spawnFailed: false,
89355
- stderr
90723
+ }
90724
+ const start = Number(rawStart);
90725
+ if (start >= size) return "unsatisfiable";
90726
+ const end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
90727
+ if (end < start) return "unsatisfiable";
90728
+ return {
90729
+ start,
90730
+ end
90731
+ };
90732
+ }
90733
+ function pathnameOf(req) {
90734
+ const raw = req.url ?? "/";
90735
+ const query = raw.indexOf("?");
90736
+ return query === -1 ? raw : raw.slice(0, query);
90737
+ }
90738
+ function address(req, pattern) {
90739
+ const match = pattern.exec(pathnameOf(req));
90740
+ if (match === null) return null;
90741
+ const deviceId = Number(match[1]);
90742
+ const stem = match[2];
90743
+ if (!Number.isInteger(deviceId) || stem === void 0) return null;
90744
+ return {
90745
+ deviceId,
90746
+ stem
90747
+ };
90748
+ }
90749
+ function createHksvClipPlanes(deps) {
90750
+ /**
90751
+ * The path of one artifact, or `null` with a reason ALREADY logged.
90752
+ *
90753
+ * A 404 is work this route dropped, and silence reads as "never happened" —
90754
+ * so every miss is one warn carrying `tags: { deviceId }`, which is the only
90755
+ * key a "why is 617 missing its clips and 615 not?" question can be answered
90756
+ * by.
90757
+ */
90758
+ async function locate(addressed, artifact) {
90759
+ const { deviceId, stem } = addressed;
90760
+ let path;
90761
+ try {
90762
+ path = await deps.artifactPath(deviceId, stem, artifact);
90763
+ } catch (err) {
90764
+ deps.logger.warn("videoclips: could not resolve a HomeKit clip artifact", {
90765
+ tags: { deviceId },
90766
+ meta: {
90767
+ stem,
90768
+ artifact,
90769
+ error: err instanceof Error ? err.message : String(err)
90770
+ }
89356
90771
  });
89357
- }, timeoutMs);
89358
- timer.unref?.();
89359
- child.on("error", (err) => {
89360
- finish({
89361
- code: null,
89362
- timedOut: false,
89363
- spawnFailed: true,
89364
- stderr: err.message
90772
+ return null;
90773
+ }
90774
+ if (path === null) {
90775
+ deps.logger.warn("videoclips: no writable HomeKit clips location — nothing to serve", {
90776
+ tags: { deviceId },
90777
+ meta: {
90778
+ stem,
90779
+ artifact
90780
+ }
89365
90781
  });
89366
- });
89367
- child.on("close", (code) => {
89368
- finish({
89369
- code,
89370
- timedOut: false,
89371
- spawnFailed: false,
89372
- stderr
90782
+ return null;
90783
+ }
90784
+ try {
90785
+ const stats = await stat(path);
90786
+ if (!stats.isFile()) throw new Error("not a file");
90787
+ return {
90788
+ path,
90789
+ size: stats.size
90790
+ };
90791
+ } catch (err) {
90792
+ deps.logger.warn("videoclips: a HomeKit clip artifact was asked for and is not there", {
90793
+ tags: { deviceId },
90794
+ meta: {
90795
+ stem,
90796
+ artifact,
90797
+ error: err instanceof Error ? err.message : String(err)
90798
+ }
89373
90799
  });
90800
+ return null;
90801
+ }
90802
+ }
90803
+ function readOnly(req, res) {
90804
+ if (req.method === "GET" || req.method === "HEAD") return true;
90805
+ res.writeHead(405, { allow: "GET, HEAD" });
90806
+ res.end();
90807
+ return false;
90808
+ }
90809
+ const mediaHandler = async (req, res) => {
90810
+ if (!readOnly(req, res)) return;
90811
+ const addressed = address(req, MEDIA_PATH);
90812
+ if (addressed === null) {
90813
+ res.writeHead(400);
90814
+ res.end();
90815
+ return;
90816
+ }
90817
+ const found = await locate(addressed, "clip");
90818
+ if (found === null) {
90819
+ res.writeHead(404);
90820
+ res.end();
90821
+ return;
90822
+ }
90823
+ const base = {
90824
+ "content-type": "video/mp4",
90825
+ "accept-ranges": "bytes",
90826
+ "cache-control": "private, max-age=0, must-revalidate"
90827
+ };
90828
+ const range = parseRange(req.headers.range, found.size);
90829
+ if (range === "unsatisfiable") {
90830
+ res.writeHead(416, {
90831
+ ...base,
90832
+ "content-range": `bytes */${String(found.size)}`
90833
+ });
90834
+ res.end();
90835
+ return;
90836
+ }
90837
+ if (range === null) {
90838
+ res.writeHead(200, {
90839
+ ...base,
90840
+ "content-length": String(found.size)
90841
+ });
90842
+ if (req.method === "HEAD") {
90843
+ res.end();
90844
+ return;
90845
+ }
90846
+ createReadStream(found.path).pipe(res);
90847
+ return;
90848
+ }
90849
+ res.writeHead(206, {
90850
+ ...base,
90851
+ "content-range": `bytes ${String(range.start)}-${String(range.end)}/${String(found.size)}`,
90852
+ "content-length": String(range.end - range.start + 1)
89374
90853
  });
89375
- });
90854
+ if (req.method === "HEAD") {
90855
+ res.end();
90856
+ return;
90857
+ }
90858
+ createReadStream(found.path, {
90859
+ start: range.start,
90860
+ end: range.end
90861
+ }).pipe(res);
90862
+ };
90863
+ const thumbHandler = async (req, res) => {
90864
+ if (!readOnly(req, res)) return;
90865
+ const addressed = address(req, THUMB_PATH);
90866
+ if (addressed === null) {
90867
+ res.writeHead(400);
90868
+ res.end();
90869
+ return;
90870
+ }
90871
+ const found = await locate(addressed, "thumbnail");
90872
+ if (found === null) {
90873
+ res.writeHead(404);
90874
+ res.end();
90875
+ return;
90876
+ }
90877
+ res.writeHead(200, {
90878
+ "content-type": "image/jpeg",
90879
+ "content-length": String(found.size),
90880
+ "cache-control": "private, max-age=31536000, immutable"
90881
+ });
90882
+ if (req.method === "HEAD") {
90883
+ res.end();
90884
+ return;
90885
+ }
90886
+ createReadStream(found.path).pipe(res);
90887
+ };
90888
+ return {
90889
+ mediaPrefix: HKSV_CLIP_MEDIA_PREFIX,
90890
+ thumbPrefix: HKSV_CLIP_THUMB_PREFIX,
90891
+ mediaHandler,
90892
+ thumbHandler
90893
+ };
90894
+ }
90895
+ //#endregion
90896
+ //#region src/hksv/videoclips-source.ts
90897
+ /**
90898
+ * The `videoclips` SOURCE over the clips this addon teed to HomeKit.
90899
+ *
90900
+ * ## Where it runs, and why there is no hand-off
90901
+ *
90902
+ * `export-hap` is `placement: 'hub-only'` and the teed clips live on a
90903
+ * `local-path` storage location this process writes with `node:fs`. D550
90904
+ * recorded the open question as "the store lives in the hub-only `export-hap`
90905
+ * runner, so listing and serving must REACH it from wherever the provider
90906
+ * mounts". The answer is that the provider mounts HERE: `videoclips` is a
90907
+ * `scope:'device'` + `mode:'collection'` wrapper, so every addon that declares
90908
+ * it becomes one of the camera's bindings (`collectionBindings`, gated only by
90909
+ * the cap's `deviceTypes`), and `resolveWrapperNodeId` routes a wrapper to the
90910
+ * hub — which is where this addon already is. Nothing about a `videoclips`
90911
+ * provider requires owning the device.
90912
+ *
90913
+ * That removes the hand-off rather than building one, and it is the only shape
90914
+ * that obeys both rules D550 named: addons never import each other (this one
90915
+ * imports nothing — it reads its own store), and frames never cross a process
90916
+ * boundary (the bytes never enter a cap call at all; `getClipPlayback` answers
90917
+ * a URL into this addon's own data plane and the file is streamed with `Range`
90918
+ * from the disk it was written to). The rejected alternative was to register
90919
+ * the provider in the recorder and have it fetch clips over `ctx.api` — a
90920
+ * cross-addon byte move by handle at best, and the repo's one existing such
90921
+ * move carries a 50 MiB cap and a docblock naming the OOM it caused.
90922
+ *
90923
+ * ## The source EXISTS because of HomeKit, and is not a switch
90924
+ *
90925
+ * A camera has this source because it is exported to HomeKit AND HomeKit
90926
+ * recording is on for it; turn either off and the row is gone (D550, D569 § 5).
90927
+ * There is deliberately no enable control of its own — a knob over somebody
90928
+ * else's decision is D62's defect. `keepClips` is NOT that gate either: it
90929
+ * governs the TEE, and the clips already kept remain the truth about this
90930
+ * camera, so the row stays and its `reason` says the copies are off.
90931
+ *
90932
+ * ## `ok` is measured, and an empty list is not always an answer
90933
+ *
90934
+ * D561: `ok` may never be a default. Here the measurement is a real read of the
90935
+ * clips directory, and every answer this source gives — the source row and the
90936
+ * clip list — comes from ONE such read. The states are the existing five, and
90937
+ * no sixth was needed:
90938
+ *
90939
+ * - `ok` — the directory answered. Including with nothing: a camera that has
90940
+ * recorded no HomeKit clip yet is a complete list of zero, and the `reason`
90941
+ * says which flavour of nothing it is.
90942
+ * - `no-storage` — there is no writable `homekitClips` location: none seeded,
90943
+ * or it is `readonly` / `drain` / `disabled` (D385, named by
90944
+ * `clip-location.ts`, which is the only module that may interpret the mode).
90945
+ * Exactly the Reolink meaning: the medium is not there.
90946
+ * - `unreachable` — the storage cap could not be reached, or the directory
90947
+ * read threw. A measurement that failed is never folded into "no clips"
90948
+ * (D393).
90949
+ * - `index-empty` — HomeKit recording is switched ON for this camera and the
90950
+ * store holds nothing, because `buildHksvRecording` WITHHELD the
90951
+ * advertisement (no H.264 slot ≤ 1080p, a GOP longer than any fragment
90952
+ * length, or unreadable profiles). The camera's own switch disagrees with
90953
+ * its own index, which is what this state means on the Reolink surface too,
90954
+ * and the refusal travels verbatim in `reason`.
90955
+ * - `sleeping` — never. There is no camera to wake: the read is a local
90956
+ * directory read.
90957
+ */
90958
+ /** What the picker calls this source. */
90959
+ var HKSV_CLIP_SOURCE_LABEL = "HomeKit clips";
90960
+ /** Why a listed row has no bytes behind it. Verbatim on `unplayableReason`. */
90961
+ var CLIP_FILE_MISSING = "clip-file-missing";
90962
+ function createHksvVideoclipsProvider(deps) {
90963
+ const now = deps.now ?? Date.now;
90964
+ /**
90965
+ * The one read, and the state it proves.
90966
+ *
90967
+ * `null` means this camera has no HomeKit clip source at all — it is not
90968
+ * exported, or HomeKit recording is off for it. Not an availability: a row
90969
+ * that should not exist is not a row saying it is unhappy.
90970
+ */
90971
+ async function measure(deviceId) {
90972
+ const facts = deps.describeCamera(deviceId);
90973
+ if (!facts.exported || !facts.recording) return null;
90974
+ const catalog = await deps.readCatalog(deviceId);
90975
+ return {
90976
+ availability: availabilityFor(facts, catalog),
90977
+ catalog
90978
+ };
90979
+ }
90980
+ function availabilityFor(facts, catalog) {
90981
+ if (catalog.kind === "failed") return {
90982
+ state: "unreachable",
90983
+ reason: `the HomeKit clips location could not be read: ${catalog.error}`
90984
+ };
90985
+ if (catalog.kind === "no-root") {
90986
+ const refusal = deps.locationRefusal();
90987
+ if (refusal === "unreachable") return {
90988
+ state: "unreachable",
90989
+ reason: "the storage capability could not be reached (unreachable)"
90990
+ };
90991
+ return {
90992
+ state: "no-storage",
90993
+ reason: refusal === "not-writable" ? "the HomeKit clips location is not writable (not-writable) — readonly, draining or disabled" : "no HomeKit clips location exists yet (no-location)"
90994
+ };
90995
+ }
90996
+ const asOf = now();
90997
+ if (catalog.records.length > 0) return {
90998
+ state: "ok",
90999
+ catalogAsOf: asOf,
91000
+ ...facts.keepingClips ? {} : { reason: "copies are switched off for this camera — nothing new is being kept" }
91001
+ };
91002
+ const withheld = facts.lastBuild?.withheld;
91003
+ if (withheld !== void 0 && withheld !== null) {
91004
+ const detail = facts.lastBuild?.detail;
91005
+ return {
91006
+ state: "index-empty",
91007
+ catalogAsOf: asOf,
91008
+ reason: `HomeKit recording is switched on for this camera but the advertisement was WITHHELD (${withheld})` + (detail === null || detail === void 0 ? "" : `: ${detail}`) + " — HomeKit has never recorded it, so there is nothing to keep"
91009
+ };
91010
+ }
91011
+ return {
91012
+ state: "ok",
91013
+ catalogAsOf: asOf,
91014
+ reason: facts.keepingClips ? "nothing has been recorded by HomeKit for this camera yet" : "copies are switched off for this camera — nothing new is being kept, and nothing was"
91015
+ };
91016
+ }
91017
+ function toClip(deviceId, record, files) {
91018
+ const names = clipFileNames(record.clipId);
91019
+ const hasBytes = files.has(record.file);
91020
+ const vouchedThumb = record.thumbnailFile !== void 0 && files.has(record.thumbnailFile) ? deps.thumbnailUrl(deviceId, stemOf(names.thumbnail)) : null;
91021
+ return {
91022
+ id: record.clipId,
91023
+ source: HKSV_CLIP_SOURCE,
91024
+ kind: "native",
91025
+ timeRange: {
91026
+ startMs: record.startedAtMs,
91027
+ endMs: record.endedAtMs
91028
+ },
91029
+ ...vouchedThumb === null ? { thumbnailUnavailable: { reason: thumbnailRefusal(record, files) } } : { thumbnail: vouchedThumb },
91030
+ ...hasBytes ? {} : {
91031
+ playable: false,
91032
+ unplayableReason: CLIP_FILE_MISSING
91033
+ }
91034
+ };
91035
+ }
91036
+ /**
91037
+ * The store's five reasons, narrowed onto the cap's four.
91038
+ *
91039
+ * The narrowing is LOSSY and it is forced: `ClipSchema.thumbnailUnavailable`
91040
+ * is a closed enum that `ui-library` maps exhaustively, so widening it is a
91041
+ * breaking change to a consumer, and this source's reasons postdate it. The
91042
+ * lossless copy is not lost — it is on the sidecar and on the listing line —
91043
+ * and the split preserves the only distinction a surface can act on:
91044
+ *
91045
+ * - `no-keyframe` — the CLIP had nothing decodable at the trigger instant
91046
+ * (`no-init-segment`, `decode-failed`, `timed-out`).
91047
+ * - `unsupported` — no still exists and nothing here will make one
91048
+ * (`ffmpeg-missing`, `not-attempted`, or a JPEG that is no longer on
91049
+ * disk). Nothing re-mints a teed clip, so every one of these is final.
91050
+ */
91051
+ function thumbnailRefusal(record, files) {
91052
+ if (record.thumbnailFile !== void 0 && !files.has(record.thumbnailFile)) return "unsupported";
91053
+ const reason = record.thumbnailUnavailable?.reason;
91054
+ if (reason === "no-init-segment" || reason === "decode-failed" || reason === "timed-out") return "no-keyframe";
91055
+ return "unsupported";
91056
+ }
91057
+ return {
91058
+ listSources: async ({ deviceId }) => {
91059
+ const measured = await measure(deviceId);
91060
+ if (measured === null) return [];
91061
+ deps.logger.info("videoclips: measured the HomeKit clip source", {
91062
+ tags: { deviceId },
91063
+ meta: {
91064
+ source: HKSV_CLIP_SOURCE,
91065
+ state: measured.availability.state,
91066
+ reason: measured.availability.reason ?? null,
91067
+ clips: measured.catalog.kind === "catalog" ? measured.catalog.records.length : null
91068
+ }
91069
+ });
91070
+ return [{
91071
+ source: HKSV_CLIP_SOURCE,
91072
+ addonId: deps.addonId,
91073
+ label: HKSV_CLIP_SOURCE_LABEL,
91074
+ availability: measured.availability
91075
+ }];
91076
+ },
91077
+ listClips: async ({ deviceId, since, until, limit }) => {
91078
+ const measured = await measure(deviceId);
91079
+ if (measured === null) return [];
91080
+ const tags = { deviceId };
91081
+ const catalog = measured.catalog;
91082
+ const records = catalog.kind === "catalog" ? catalog.records : [];
91083
+ const files = catalog.kind === "catalog" ? catalog.files : /* @__PURE__ */ new Set();
91084
+ const ordered = records.filter((r) => r.startedAtMs <= until && r.endedAtMs >= since).toSorted((a, b) => b.startedAtMs - a.startedAtMs);
91085
+ const capped = limit === void 0 ? ordered : ordered.slice(0, limit);
91086
+ const clips = capped.map((record) => toClip(deviceId, record, files));
91087
+ const unplayable = clips.filter((c) => c.playable === false).length;
91088
+ const vouched = clips.filter((c) => c.thumbnail !== void 0).length;
91089
+ deps.logger.info("videoclips: listed the HomeKit clips", {
91090
+ tags,
91091
+ meta: {
91092
+ source: HKSV_CLIP_SOURCE,
91093
+ clips: clips.length,
91094
+ truncated: limit !== void 0 && ordered.length > limit,
91095
+ unplayable,
91096
+ thumbsVouched: vouched,
91097
+ thumbsUnavailable: clips.length - vouched,
91098
+ thumbnailReason: capped.find((r) => r.thumbnailFile === void 0)?.thumbnailUnavailable?.reason,
91099
+ state: measured.availability.state
91100
+ }
91101
+ });
91102
+ if (clips.length === 0 && measured.availability.state !== "ok") {
91103
+ deps.logger.warn("videoclips: nothing to list, and the HomeKit clip source cannot answer", {
91104
+ tags,
91105
+ meta: {
91106
+ branch: "refused-empty",
91107
+ state: measured.availability.state,
91108
+ reason: measured.availability.reason ?? null
91109
+ }
91110
+ });
91111
+ throw new Error(`videoclips: device ${String(deviceId)} listed no HomeKit clips, and that is not an empty window — the source is "${measured.availability.state}"` + (measured.availability.reason === void 0 ? "." : `: ${measured.availability.reason}`));
91112
+ }
91113
+ return clips;
91114
+ },
91115
+ getClipPlayback: async ({ deviceId, clipId, profile }) => {
91116
+ const record = await findRecord(deviceId, clipId);
91117
+ if (profile !== void 0) deps.logger.debug("videoclips: a HomeKit clip has one rendition; the profile is moot", {
91118
+ tags: { deviceId },
91119
+ meta: {
91120
+ clipId,
91121
+ profile,
91122
+ width: record.width,
91123
+ height: record.height
91124
+ }
91125
+ });
91126
+ return {
91127
+ playbackUrl: deps.mediaUrl(deviceId, stemOf(clipFileNames(clipId).clip)),
91128
+ format: "mp4",
91129
+ ...record.profile === void 0 ? {} : { served: record.profile }
91130
+ };
91131
+ },
91132
+ /**
91133
+ * The clip's BYTES, inline and bounded — the seam another ADDON pulls
91134
+ * (D558), never the path a player takes.
91135
+ *
91136
+ * A browser or a viewer session plays `getClipPlayback`'s URL; an addon
91137
+ * cannot, because `AddonDataPlane` only lets an addon SERVE. So this is the
91138
+ * one place HomeKit clip bytes enter a cap envelope, and the bound is the
91139
+ * cap's own 50 MiB — held whole, base64, in this process AND the caller's,
91140
+ * on a hub that has already been OOM'd once (D9/D18). Above the bound it
91141
+ * REFUSES with the size: half a video is worse than an honest refusal.
91142
+ *
91143
+ * A 256 MB clip (the tee's per-clip rail) is therefore refusable here and
91144
+ * playable through the route, which is stated rather than discovered.
91145
+ */
91146
+ readClipBytes: async ({ deviceId, clipId, maxBytes }) => {
91147
+ const record = await findRecord(deviceId, clipId);
91148
+ const stem = stemOf(clipFileNames(clipId).clip);
91149
+ if (record.profile === void 0) {
91150
+ deps.logger.warn("videoclips: a HomeKit clip cannot say which profile it recorded from", {
91151
+ tags: { deviceId },
91152
+ meta: {
91153
+ clipId,
91154
+ branch: "no-profile"
91155
+ }
91156
+ });
91157
+ throw new Error(`videoclips: clip "${clipId}" does not record which profile HomeKit recorded from, so the twin it would be served as cannot be named`);
91158
+ }
91159
+ const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
91160
+ if (record.bytes > bound) throw new Error(`videoclips: clip "${clipId}" is ${String(record.bytes)} bytes, over the ${String(bound)}-byte bound for an inline read — fetch it from its playback URL`);
91161
+ const path = await deps.clipFilePath(deviceId, stem);
91162
+ if (path === null) throw new Error(`videoclips: the HomeKit clips location for device ${String(deviceId)} is not readable`);
91163
+ let bytes;
91164
+ try {
91165
+ bytes = await readFile(path);
91166
+ } catch (err) {
91167
+ deps.logger.warn("videoclips: the fMP4 of a listed HomeKit clip could not be read", {
91168
+ tags: { deviceId },
91169
+ meta: {
91170
+ clipId,
91171
+ branch: CLIP_FILE_MISSING,
91172
+ error: errorText(err)
91173
+ }
91174
+ });
91175
+ throw new Error(`videoclips: clip "${clipId}" has a sidecar but no readable bytes (${CLIP_FILE_MISSING})`, { cause: err });
91176
+ }
91177
+ deps.logger.info("videoclips: served a HomeKit clip inline", {
91178
+ tags: { deviceId },
91179
+ meta: {
91180
+ clipId,
91181
+ bytes: bytes.byteLength,
91182
+ served: record.profile
91183
+ }
91184
+ });
91185
+ return {
91186
+ base64: bytes.toString("base64"),
91187
+ contentType: "video/mp4",
91188
+ name: `${stem}.mp4`,
91189
+ bytes: bytes.byteLength,
91190
+ served: record.profile,
91191
+ ...record.durationMs > 0 ? { durationMs: record.durationMs } : {}
91192
+ };
91193
+ }
91194
+ };
91195
+ /**
91196
+ * The catalog row for one clip, or a THROW naming which of the three things
91197
+ * went wrong: not ours, not held, or held with no bytes behind it. Shared by
91198
+ * the two byte paths so they can never disagree about what a clip id means.
91199
+ */
91200
+ async function findRecord(deviceId, clipId) {
91201
+ if (!clipId.startsWith(`hksv:`)) throw new Error(`videoclips: clip id "${clipId}" was not minted by the HomeKit source`);
91202
+ if (!clipId.startsWith(`hksv:${String(deviceId)}:`)) throw new Error(`videoclips: clip id "${clipId}" belongs to another camera — ids are self-contained and name the device that teed them`);
91203
+ const measured = await measure(deviceId);
91204
+ if (measured === null) throw new Error(`videoclips: device ${String(deviceId)} has no HomeKit clip source — it is not exported to HomeKit, or HomeKit recording is off for it`);
91205
+ const catalog = measured.catalog;
91206
+ if (catalog.kind !== "catalog") throw new Error(`videoclips: the HomeKit clips for device ${String(deviceId)} cannot be read — the source is "${measured.availability.state}"`);
91207
+ const record = catalog.records.find((r) => r.clipId === clipId) ?? null;
91208
+ if (record === null) {
91209
+ deps.logger.warn("videoclips: asked for a HomeKit clip the catalog does not hold", {
91210
+ tags: { deviceId },
91211
+ meta: {
91212
+ clipId,
91213
+ branch: "no-catalog-row"
91214
+ }
91215
+ });
91216
+ throw new Error(`videoclips: no clip "${clipId}" is held for device ${String(deviceId)}`);
91217
+ }
91218
+ if (!catalog.files.has(record.file)) {
91219
+ deps.logger.warn("videoclips: the fMP4 for a listed HomeKit clip is gone", {
91220
+ tags: { deviceId },
91221
+ meta: {
91222
+ clipId,
91223
+ branch: CLIP_FILE_MISSING,
91224
+ file: record.file
91225
+ }
91226
+ });
91227
+ throw new Error(`videoclips: clip "${clipId}" has a sidecar but no bytes (${CLIP_FILE_MISSING})`);
91228
+ }
91229
+ return record;
91230
+ }
91231
+ }
91232
+ function errorText(err) {
91233
+ return err instanceof Error ? err.message : String(err);
91234
+ }
91235
+ /** `<stem>.mp4` → `<stem>`. The routes address a stem; the store owns the rest. */
91236
+ function stemOf(fileName) {
91237
+ const dot = fileName.lastIndexOf(".");
91238
+ return dot === -1 ? fileName : fileName.slice(0, dot);
89376
91239
  }
89377
91240
  //#endregion
89378
91241
  //#region src/mappers/builders/generic/characteristic-update.ts
@@ -104201,7 +106064,8 @@ var ClipTeeRunner = class {
104201
106064
  prebufferSpanMs: this.request.prebufferSpanMs,
104202
106065
  width: this.input.width,
104203
106066
  height: this.input.height,
104204
- fragmentMs: this.input.fragmentMs
106067
+ fragmentMs: this.input.fragmentMs,
106068
+ ...this.input.profile === void 0 ? {} : { profile: this.input.profile }
104205
106069
  });
104206
106070
  if (open === null) {
104207
106071
  this.request.subscription.release();
@@ -105169,9 +107033,26 @@ async function buildHksvRecording(input) {
105169
107033
  const { bctx } = input;
105170
107034
  const { ctx, numericDeviceId } = bctx;
105171
107035
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
107036
+ /**
107037
+ * Record the verdict for the clip source, next to the log line that already
107038
+ * states it. A camera HomeKit never recorded is not "a camera with no clips",
107039
+ * and `listSources` can only say which it is if the refusal was written down
107040
+ * (D571). Every `return null` below passes through here.
107041
+ */
107042
+ const note = (withheld, detail) => {
107043
+ const outcome = {
107044
+ deviceId: numericDeviceId,
107045
+ advertised: withheld === null,
107046
+ withheld,
107047
+ detail,
107048
+ atMs: Date.now()
107049
+ };
107050
+ bctx.options.noteHksvOutcome?.(outcome);
107051
+ };
105172
107052
  const entries = await readProfileEntries(bctx);
105173
107053
  if (entries === null) {
105174
107054
  log.warn("export-hap: HKSV withheld — could not read the camera profiles", {});
107055
+ note("profiles-unreadable", "cameraStreams.getProfileRtspEntries could not be read");
105175
107056
  return null;
105176
107057
  }
105177
107058
  const choice = pickRecordingSource(entries);
@@ -105180,6 +107061,7 @@ async function buildHksvRecording(input) {
105180
107061
  refusal: choice.refusal,
105181
107062
  reason: refusalReason(choice.refusal)
105182
107063
  } });
107064
+ note("no-recordable-stream", `${choice.refusal}: ${refusalReason(choice.refusal)}`);
105183
107065
  return null;
105184
107066
  }
105185
107067
  const source = choice.source;
@@ -105190,8 +107072,11 @@ async function buildHksvRecording(input) {
105190
107072
  gopMs,
105191
107073
  brokerId: source.brokerId
105192
107074
  } });
107075
+ note("key-frame-interval", `gopMs=${String(gopMs ?? "unknown")}`);
105193
107076
  return null;
105194
107077
  }
107078
+ const parsedProfile = CamProfileSchema.safeParse(source.profile);
107079
+ const recordedProfile = parsedProfile.success ? parsedProfile.data : null;
105195
107080
  const fps = resolveFps(input.fpsByProfile, source.profile);
105196
107081
  const options = buildRecordingOptions({
105197
107082
  width: source.width,
@@ -105199,14 +107084,16 @@ async function buildHksvRecording(input) {
105199
107084
  fps,
105200
107085
  fragmentLengthMs
105201
107086
  });
105202
- const clipStore = bctx.options.hksvClipStore;
107087
+ const keepClips = bctx.options.hapDeviceSettings.keepHomekitClips !== false;
107088
+ const clipStore = keepClips ? bctx.options.hksvClipStore : null;
105203
107089
  const openClipTee = clipStore === null ? void 0 : createClipTeeFactory({
105204
107090
  logger: log,
105205
107091
  deviceId: numericDeviceId,
105206
107092
  store: clipStore,
105207
107093
  width: source.width,
105208
107094
  height: source.height,
105209
- fragmentMs: fragmentLengthMs
107095
+ fragmentMs: fragmentLengthMs,
107096
+ ...recordedProfile === null ? {} : { profile: recordedProfile }
105210
107097
  });
105211
107098
  const delegate = new HksvRecordingDelegate({
105212
107099
  logger: log,
@@ -105235,8 +107122,10 @@ async function buildHksvRecording(input) {
105235
107122
  advertisedFps: advertisedResolution?.[2] ?? null,
105236
107123
  fragmentLengthMs,
105237
107124
  sourceGopMs: gopMs ?? "unknown",
105238
- clipsKept: openClipTee !== void 0
107125
+ clipsKept: openClipTee !== void 0,
107126
+ clipsSwitchedOn: keepClips
105239
107127
  } });
107128
+ note(null, null);
105240
107129
  return {
105241
107130
  options,
105242
107131
  delegate,
@@ -106519,6 +108408,65 @@ function resolveHksvRecording(settings) {
106519
108408
  * negotiation is built we say `streaming-enabled: false`, so declining is the
106520
108409
  * only thing a controller can do with it.
106521
108410
  */
108411
+ /**
108412
+ * Keep a copy of what we send HomeKit, for this camera.
108413
+ *
108414
+ * ABSENT MEANS ON, for the same reason as `resolveHksvRecording` and one
108415
+ * stronger: HKSV has no read-back, so every recording made while this said
108416
+ * "off by accident" is gone permanently. Only an explicit `false` turns it off.
108417
+ */
108418
+ function resolveKeepHomekitClips(settings) {
108419
+ return settings?.keepClips !== false;
108420
+ }
108421
+ /** This addon's id, as the manifest declares it and the registry knows it. */
108422
+ var EXPORT_HAP_ADDON_ID = "export-hap";
108423
+ /**
108424
+ * Whether a camera HAS a HomeKit clip source, read from the authorities that
108425
+ * already own each half.
108426
+ *
108427
+ * The source exists because the camera is EXPORTED to HomeKit and HomeKit
108428
+ * RECORDING is on for it, and it goes away when either goes off (D550, D569 §
108429
+ * 5). There is no third switch: a knob of this source's own would be a second
108430
+ * authority over somebody else's decision (D62), and this function is the only
108431
+ * place the two are read together.
108432
+ *
108433
+ * `keepClips` is deliberately NOT part of existence. It governs the TEE, and
108434
+ * the clips already kept are still the truth about this camera — so the row
108435
+ * stays and says the copies are off, which is D62's other half: an off switch
108436
+ * is REPORTED off, never made to look like a broken camera.
108437
+ */
108438
+ function describeHksvCamera(entry, lastBuild) {
108439
+ const isCamera = entry !== null && entry.mapperKind === "camera";
108440
+ return {
108441
+ exported: isCamera,
108442
+ recording: isCamera && resolveHksvRecording(entry.settings),
108443
+ keepingClips: isCamera && resolveKeepHomekitClips(entry.settings),
108444
+ lastBuild
108445
+ };
108446
+ }
108447
+ /** What ships, and what an unset global resolves to. */
108448
+ var DEFAULT_CLIP_RETENTION_DAYS = 14;
108449
+ /** The rails on the retention itself. Zero would silently disable the tee. */
108450
+ var MIN_CLIP_RETENTION_DAYS = 1;
108451
+ var MAX_CLIP_RETENTION_DAYS = 365;
108452
+ /**
108453
+ * This camera's retention in days: per-device override → global default →
108454
+ * {@link DEFAULT_CLIP_RETENTION_DAYS}.
108455
+ *
108456
+ * The cascade rule the repo already pays for (`stationary-settings.ts`):
108457
+ * **reset == unset**. A cleared override and a key that was never written must
108458
+ * resolve to the same number, or the Reset button is a lie. A value outside the
108459
+ * rails is not stored as a lie either — it falls back rather than turning the
108460
+ * tee into a no-op (0 days) or an unbounded archive.
108461
+ */
108462
+ function resolveClipRetentionDays(settings, globalDefaultDays) {
108463
+ const fleet = clampRetentionDays(globalDefaultDays) ?? 14;
108464
+ return clampRetentionDays(settings?.clipRetentionDays) ?? fleet;
108465
+ }
108466
+ function clampRetentionDays(value) {
108467
+ if (typeof value !== "number" || !Number.isFinite(value) || value < MIN_CLIP_RETENTION_DAYS) return null;
108468
+ return Math.min(Math.floor(value), MAX_CLIP_RETENTION_DAYS);
108469
+ }
106522
108470
  function resolveMultiTierService(settings) {
106523
108471
  return settings?.multiTierService === true;
106524
108472
  }
@@ -106546,6 +108494,7 @@ var DEFAULT_CONFIG = {
106546
108494
  fixedPin: "",
106547
108495
  interfaceName: "",
106548
108496
  ptzPulseMs: 400,
108497
+ clipRetentionDays: 14,
106549
108498
  identity: {
106550
108499
  username: "",
106551
108500
  pincode: "",
@@ -106618,6 +108567,18 @@ var ExportHapAddon = class extends BaseAddon {
106618
108567
  * failure to exist is one line at boot and not a surprise mid-recording.
106619
108568
  */
106620
108569
  hksvClipStore = null;
108570
+ /** Resolves the declared `homekitClips` location; the doorbell invalidates it. */
108571
+ hksvClipLocation = null;
108572
+ /**
108573
+ * What `buildHksvRecording` decided per camera, in this process.
108574
+ *
108575
+ * A MIRROR of the build, written by it and read only to be REPORTED: the
108576
+ * clip source's `index-empty` ("the switch says record and the store holds
108577
+ * nothing") is only honest if it can name which refusal fired (D571).
108578
+ */
108579
+ hksvBuilds = new HksvBuildOutcomes();
108580
+ /** The clip byte + still routes. `null` until `onInitialize` has served them. */
108581
+ hksvClipPlanes = null;
106621
108582
  /**
106622
108583
  * THE bridge. Lazily created and published the first time a non-camera
106623
108584
  * accessory needs it; never torn down while the addon runs, because an
@@ -106644,14 +108605,26 @@ var ExportHapAddon = class extends BaseAddon {
106644
108605
  this.lastError = errMsg(err);
106645
108606
  this.ctx.logger.error("export-hap: HAP storage init failed", { meta: { error: this.lastError } });
106646
108607
  }
108608
+ this.hksvClipLocation = new HksvClipLocation({
108609
+ logger: this.ctx.logger.child("hksv-clips"),
108610
+ ports: createClipLocationPorts(this.ctx.api)
108611
+ });
106647
108612
  this.hksvClipStore = new HksvClipStore({
106648
108613
  logger: this.ctx.logger.child("hksv-clips"),
106649
- rootDir: path.join(this.ctx.dataDir, CLIP_DIR_NAME),
106650
- mintThumbnail: createClipThumbnailMinter({ runner: createFfmpegClipThumbnailRunner({
108614
+ resolveRoot: () => this.hksvClipLocation?.root() ?? Promise.resolve(null),
108615
+ maxAgeMsFor: (deviceId) => resolveClipRetentionDays(this.findEntry(deviceId)?.settings, this.config.clipRetentionDays) * 24 * 60 * 60 * 1e3,
108616
+ mintThumbnail: createClipThumbnailMinter({ runner: createBoundedFfmpegRunner({
108617
+ ffmpegBinaryPath: "ffmpeg",
108618
+ spawnFn: spawn,
108619
+ timeoutMs: CLIP_THUMBNAIL_TIMEOUT_MS
108620
+ }) }),
108621
+ remuxClip: createClipRemuxer({ runner: createBoundedFfmpegRunner({
106651
108622
  ffmpegBinaryPath: "ffmpeg",
106652
- spawnFn: spawn
108623
+ spawnFn: spawn,
108624
+ timeoutMs: CLIP_REMUX_TIMEOUT_MS
106653
108625
  }) })
106654
108626
  });
108627
+ await this.serveHksvClipPlanes();
106655
108628
  const validKinds = new Set(SUPPORTED_MAPPER_KINDS);
106656
108629
  const cleaned = this.config.exposed.filter((entry) => {
106657
108630
  if (validKinds.has(entry.mapperKind)) return true;
@@ -106678,36 +108651,51 @@ var ExportHapAddon = class extends BaseAddon {
106678
108651
  });
106679
108652
  this.subscribeDeviceProvisionedForReconcile();
106680
108653
  this.subscribeDeviceReadyForReconcile();
108654
+ this.subscribeStorageLocationsForClips();
106681
108655
  this.disposeSharedRebuildTimers();
108656
+ const provider = {
108657
+ getStatus: async () => {
108658
+ const anyPaired = Array.from(this.exposed.values()).some((m) => accessoryPaired(m.accessory));
108659
+ const linkState = this.lastError ? "error" : anyPaired ? "linked" : "unlinked";
108660
+ const setup = this.buildSetupBlock();
108661
+ return {
108662
+ linkState,
108663
+ exposedDeviceCount: this.exposed.size,
108664
+ ...this.lastError ? { error: this.lastError } : {},
108665
+ ...setup ? { setup } : {}
108666
+ };
108667
+ },
108668
+ listSupportedDeviceKinds: async () => [...HAP_EXPORTABLE_DEVICE_TYPES],
108669
+ listExposedDevices: async () => Array.from(this.exposed.entries()).map(([deviceId, m]) => {
108670
+ const entry = this.config.exposed.find((e) => e.deviceId === deviceId);
108671
+ return {
108672
+ deviceId,
108673
+ exposedAs: m.accessory.displayName,
108674
+ ...entry?.capabilities ? { capabilities: [...entry.capabilities] } : {}
108675
+ };
108676
+ }),
108677
+ exposeDevice: async ({ deviceId, capabilities }) => this.exposeDevice(deviceId, capabilities),
108678
+ unexposeDevice: async ({ deviceId }) => this.unexposeDevice(deviceId),
108679
+ getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
108680
+ getDeviceLiveContribution: async () => null,
108681
+ applyDeviceSettingsPatch: (input) => this.applyDeviceSettingsPatch(input.deviceId, input.patch)
108682
+ };
108683
+ const clipsProvider = createHksvVideoclipsProvider({
108684
+ logger: this.ctx.logger.child("hksv-clips"),
108685
+ addonId: EXPORT_HAP_ADDON_ID,
108686
+ describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId)),
108687
+ readCatalog: async (deviceId) => await this.hksvClipStore?.listCatalog(deviceId) ?? { kind: "no-root" },
108688
+ locationRefusal: () => this.hksvClipLocation?.lastRefusal ?? null,
108689
+ mediaUrl: (deviceId, stem) => this.hksvClipUrl("media", deviceId, stem),
108690
+ thumbnailUrl: (deviceId, stem) => this.hksvClipUrl("thumb", deviceId, stem),
108691
+ clipFilePath: async (deviceId, stem) => await this.hksvClipStore?.artifactPath(deviceId, stem, "clip") ?? null
108692
+ });
106682
108693
  return [{
106683
108694
  capability: deviceExportCapability,
106684
- provider: {
106685
- getStatus: async () => {
106686
- const anyPaired = Array.from(this.exposed.values()).some((m) => accessoryPaired(m.accessory));
106687
- const linkState = this.lastError ? "error" : anyPaired ? "linked" : "unlinked";
106688
- const setup = this.buildSetupBlock();
106689
- return {
106690
- linkState,
106691
- exposedDeviceCount: this.exposed.size,
106692
- ...this.lastError ? { error: this.lastError } : {},
106693
- ...setup ? { setup } : {}
106694
- };
106695
- },
106696
- listSupportedDeviceKinds: async () => [...HAP_EXPORTABLE_DEVICE_TYPES],
106697
- listExposedDevices: async () => Array.from(this.exposed.entries()).map(([deviceId, m]) => {
106698
- const entry = this.config.exposed.find((e) => e.deviceId === deviceId);
106699
- return {
106700
- deviceId,
106701
- exposedAs: m.accessory.displayName,
106702
- ...entry?.capabilities ? { capabilities: [...entry.capabilities] } : {}
106703
- };
106704
- }),
106705
- exposeDevice: async ({ deviceId, capabilities }) => this.exposeDevice(deviceId, capabilities),
106706
- unexposeDevice: async ({ deviceId }) => this.unexposeDevice(deviceId),
106707
- getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
106708
- getDeviceLiveContribution: async () => null,
106709
- applyDeviceSettingsPatch: (input) => this.applyDeviceSettingsPatch(input.deviceId, input.patch)
106710
- }
108695
+ provider
108696
+ }, {
108697
+ capability: videoclipsCapability,
108698
+ provider: clipsProvider
106711
108699
  }];
106712
108700
  }
106713
108701
  async onConfigChanged() {
@@ -106832,6 +108820,7 @@ var ExportHapAddon = class extends BaseAddon {
106832
108820
  if (next.length !== this.config.exposed.length) await this.updateGlobalSettings({ exposed: next });
106833
108821
  if (options.clearPairing !== false) clearPairingFiles(accessoryUuidFor(mapperKind, numericId), this.ctx.logger);
106834
108822
  await this.forgetFingerprint(numericId);
108823
+ this.hksvBuilds.forget(numericId);
106835
108824
  log.info("export-hap: unexposed device");
106836
108825
  }
106837
108826
  async attachMapper(entry) {
@@ -106845,9 +108834,13 @@ var ExportHapAddon = class extends BaseAddon {
106845
108834
  ptzPulseMs: this.config.ptzPulseMs,
106846
108835
  decodeMemos: this.decodeMemos,
106847
108836
  hksvClipStore: this.hksvClipStore,
108837
+ noteHksvOutcome: (outcome) => {
108838
+ this.hksvBuilds.note(outcome);
108839
+ },
106848
108840
  hapDeviceSettings: {
106849
108841
  streamPreference: entrySettings.streamPreference ?? "auto",
106850
108842
  hksvRecording: resolveHksvRecording(entrySettings),
108843
+ keepHomekitClips: resolveKeepHomekitClips(entrySettings),
106851
108844
  multiTierService: resolveMultiTierService(entrySettings),
106852
108845
  allowTranscode: entrySettings.allowTranscode !== false
106853
108846
  }
@@ -106961,6 +108954,21 @@ var ExportHapAddon = class extends BaseAddon {
106961
108954
  * whether the reconcile actually rebuilds anything. Shared by the two
106962
108955
  * triggers below.
106963
108956
  */
108957
+ /**
108958
+ * The clips location moved. A DOORBELL, not a payload: the event names which
108959
+ * row changed and the re-read is the authority (`StorageLocationsChangedPayload`
108960
+ * says so itself). Dropping the mirror is all this does — the next clip
108961
+ * resolves it again, and a missed event self-heals on the revalidation
108962
+ * window, so nothing here gates on a fallible read (D49).
108963
+ */
108964
+ subscribeStorageLocationsForClips() {
108965
+ const unsubscribe = this.ctx.eventBus.subscribe({ category: EventCategory.StorageLocationsChanged }, () => {
108966
+ this.hksvClipLocation?.invalidate();
108967
+ });
108968
+ this.ctx.addDisposer(async () => {
108969
+ unsubscribe();
108970
+ });
108971
+ }
106964
108972
  subscribeDeviceLifecycleForReconcile(category) {
106965
108973
  const unsubscribe = this.ctx.eventBus.subscribe({ category }, (event) => {
106966
108974
  const data = event.data;
@@ -107159,6 +109167,14 @@ var ExportHapAddon = class extends BaseAddon {
107159
109167
  requiresRestart: true,
107160
109168
  placement: { tab: "advanced" }
107161
109169
  }),
109170
+ this.field({
109171
+ type: "number",
109172
+ key: "clipRetentionDays",
109173
+ label: "Keep HomeKit clips for (days)",
109174
+ description: "CamStack keeps its own copy of every clip it sends to HomeKit Secure Video — HomeKit itself never gives one back. This is the fleet default; each camera can override it, and each can switch the copies off entirely. Clips are also capped at 200 per camera, 512 MB per camera and 4 GB overall — whichever limit is reached first wins, and the log names which one.",
109175
+ default: DEFAULT_CONFIG.clipRetentionDays,
109176
+ placement: { tab: "advanced" }
109177
+ }),
107162
109178
  this.field({
107163
109179
  type: "number",
107164
109180
  key: "ptzPulseMs",
@@ -107182,6 +109198,8 @@ var ExportHapAddon = class extends BaseAddon {
107182
109198
  const multiTierKey = `hap:${deviceId}:multiTierService`;
107183
109199
  const useBridgeKey = `hap:${deviceId}:useBridge`;
107184
109200
  const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
109201
+ const keepClipsKey = `hap:${deviceId}:keepClips`;
109202
+ const clipRetentionKey = `hap:${deviceId}:clipRetentionDays`;
107185
109203
  const mapper = this.exposed.get(String(deviceId)) ?? null;
107186
109204
  const paired = mapper ? accessoryPaired(mapper.accessory) : false;
107187
109205
  const bridged = mapper !== null && this.bridgeHost?.holds(mapper.accessory) === true;
@@ -107252,6 +109270,31 @@ var ExportHapAddon = class extends BaseAddon {
107252
109270
  },
107253
109271
  immediate: true
107254
109272
  },
109273
+ {
109274
+ type: "boolean",
109275
+ key: keepClipsKey,
109276
+ label: "Keep CamStack copies of HomeKit clips",
109277
+ description: "Save a copy of every clip sent to HomeKit Secure Video, on the “HomeKit Clips” storage location. HomeKit never gives a clip back, so a recording made with this off cannot be recovered later. Off saves the disk and one thumbnail decode per clip; the HomeKit recording itself is unchanged either way.",
109278
+ style: "switch",
109279
+ value: resolveKeepHomekitClips(settings),
109280
+ showWhen: {
109281
+ field: hksvKey,
109282
+ equals: true
109283
+ },
109284
+ immediate: true
109285
+ },
109286
+ {
109287
+ type: "number",
109288
+ key: clipRetentionKey,
109289
+ label: "Keep this camera’s clips for (days)",
109290
+ description: `How long this camera’s kept clips survive before they are pruned, oldest first. Leave at ${this.config.clipRetentionDays} to follow the fleet default. The per-camera caps (200 clips, 512 MB) and the 4 GB overall cap still apply — whichever is reached first wins, and the prune log names it.`,
109291
+ value: resolveClipRetentionDays(settings, this.config.clipRetentionDays),
109292
+ showWhen: {
109293
+ field: keepClipsKey,
109294
+ equals: true
109295
+ },
109296
+ immediate: true
109297
+ },
107255
109298
  {
107256
109299
  type: "boolean",
107257
109300
  key: allowTranscodeKey,
@@ -107339,6 +109382,8 @@ var ExportHapAddon = class extends BaseAddon {
107339
109382
  const useBridgeKey = `hap:${deviceId}:useBridge`;
107340
109383
  const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
107341
109384
  const multiTierKey = `hap:${deviceId}:multiTierService`;
109385
+ const keepClipsKey = `hap:${deviceId}:keepClips`;
109386
+ const clipRetentionKey = `hap:${deviceId}:clipRetentionDays`;
107342
109387
  const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
107343
109388
  const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
107344
109389
  const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
@@ -107346,13 +109391,20 @@ var ExportHapAddon = class extends BaseAddon {
107346
109391
  const useBridge = useBridgeKey in patch ? Boolean(patch[useBridgeKey]) : current?.settings?.useBridge !== false;
107347
109392
  const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
107348
109393
  const multiTierService = multiTierKey in patch ? Boolean(patch[multiTierKey]) : resolveMultiTierService(current?.settings);
109394
+ const keepClips = keepClipsKey in patch ? Boolean(patch[keepClipsKey]) : resolveKeepHomekitClips(current?.settings);
109395
+ const clipRetentionDays = clipRetentionKey in patch ? resolveClipRetentionDays({
109396
+ streamPreference,
109397
+ clipRetentionDays: Number(patch[clipRetentionKey])
109398
+ }, this.config.clipRetentionDays) : resolveClipRetentionDays(current?.settings, this.config.clipRetentionDays);
107349
109399
  const nextSettings = {
107350
109400
  ...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
107351
109401
  streamPreference,
107352
109402
  hksvRecording,
107353
109403
  useBridge,
107354
109404
  allowTranscode,
107355
- multiTierService
109405
+ multiTierService,
109406
+ keepClips,
109407
+ clipRetentionDays
107356
109408
  };
107357
109409
  if (!enabledValue) {
107358
109410
  if (wasEnabled) await this.unexposeDevice(deviceIdStr);
@@ -107365,6 +109417,7 @@ var ExportHapAddon = class extends BaseAddon {
107365
109417
  }
107366
109418
  const currentPref = current?.settings?.streamPreference ?? "auto";
107367
109419
  const currentHksv = resolveHksvRecording(current?.settings);
109420
+ const currentKeepClips = resolveKeepHomekitClips(current?.settings);
107368
109421
  const mapperKind = current?.mapperKind ?? "generic";
107369
109422
  const wasBridged = shouldBridge({
107370
109423
  mapperKind,
@@ -107375,7 +109428,7 @@ var ExportHapAddon = class extends BaseAddon {
107375
109428
  useBridge
107376
109429
  });
107377
109430
  await this.updateEntrySettings(deviceIdStr, nextSettings);
107378
- if (currentPref !== streamPreference || currentHksv !== hksvRecording || wasBridged !== willBridge) {
109431
+ if (currentPref !== streamPreference || currentHksv !== hksvRecording || currentKeepClips !== keepClips || wasBridged !== willBridge) {
107379
109432
  log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
107380
109433
  streamPreference: {
107381
109434
  from: currentPref,
@@ -107385,6 +109438,10 @@ var ExportHapAddon = class extends BaseAddon {
107385
109438
  from: currentHksv,
107386
109439
  to: hksvRecording
107387
109440
  },
109441
+ keepClips: {
109442
+ from: currentKeepClips,
109443
+ to: keepClips
109444
+ },
107388
109445
  bridged: {
107389
109446
  from: wasBridged,
107390
109447
  to: willBridge
@@ -107399,6 +109456,57 @@ var ExportHapAddon = class extends BaseAddon {
107399
109456
  }
107400
109457
  return { success: true };
107401
109458
  }
109459
+ /**
109460
+ * Host the clip byte + still routes, and never let a host without the
109461
+ * facility take the addon down with it: a data plane is an enhancement to an
109462
+ * addon that already works, so a failure here degrades the clip surface and
109463
+ * nothing else. It is LOGGED, because a branch that drops work silently
109464
+ * reads as "never happened".
109465
+ *
109466
+ * `authenticated`, not `admin`, and NO TOKEN in either URL: the hub's
109467
+ * `/addon/<id>/<prefix>` proxy takes the session cookie, exactly as the
109468
+ * recorder's own playback plane does (D549 22).
109469
+ */
109470
+ async serveHksvClipPlanes() {
109471
+ const store = this.hksvClipStore;
109472
+ if (store === null) return;
109473
+ const planes = createHksvClipPlanes({
109474
+ logger: this.ctx.logger.child("hksv-clips"),
109475
+ artifactPath: (deviceId, stem, artifact) => store.artifactPath(deviceId, stem, artifact)
109476
+ });
109477
+ try {
109478
+ const media = await this.ctx.dataPlane?.serve({
109479
+ prefix: planes.mediaPrefix,
109480
+ access: "authenticated",
109481
+ handler: planes.mediaHandler
109482
+ });
109483
+ const thumb = await this.ctx.dataPlane?.serve({
109484
+ prefix: planes.thumbPrefix,
109485
+ access: "authenticated",
109486
+ handler: planes.thumbHandler
109487
+ });
109488
+ this.hksvClipPlanes = planes;
109489
+ this.ctx.logger.info("export-hap: HomeKit clip data planes served", { meta: {
109490
+ mediaPath: `/addon/${EXPORT_HAP_ADDON_ID}/${planes.mediaPrefix}`,
109491
+ thumbPath: `/addon/${EXPORT_HAP_ADDON_ID}/${planes.thumbPrefix}`,
109492
+ mediaServed: media !== void 0,
109493
+ thumbServed: thumb !== void 0
109494
+ } });
109495
+ } catch (err) {
109496
+ this.ctx.logger.warn("export-hap: HomeKit clip data planes failed to serve — clips will list but not play", { meta: { error: errMsg(err) } });
109497
+ }
109498
+ }
109499
+ /**
109500
+ * The client path of one clip artifact.
109501
+ *
109502
+ * Composed from the SERVED prefixes when the planes are up, so a URL is
109503
+ * never minted for a route that does not exist; the constants are the
109504
+ * fallback for the one window between registration and `serve` returning.
109505
+ */
109506
+ hksvClipUrl(kind, deviceId, stem) {
109507
+ const planes = this.hksvClipPlanes;
109508
+ return `/addon/${EXPORT_HAP_ADDON_ID}/${kind === "media" ? planes?.mediaPrefix ?? "hksv-clip" : planes?.thumbPrefix ?? "hksv-clip-thumb"}/${String(deviceId)}/${stem}.${kind === "media" ? "mp4" : "jpg"}`;
109509
+ }
107402
109510
  findEntry(deviceId) {
107403
109511
  const id = String(deviceId);
107404
109512
  return this.config.exposed.find((e) => e.deviceId === id) ?? null;
@@ -107415,4 +109523,4 @@ function errMsg(err) {
107415
109523
  return err instanceof Error ? err.message : String(err);
107416
109524
  }
107417
109525
  //#endregion
107418
- export { ExportHapAddon, ExportHapAddon as default, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveHksvRecording, resolveMultiTierService, deriveUsername as t };
109526
+ export { DEFAULT_CLIP_RETENTION_DAYS, EXPORT_HAP_ADDON_ID, ExportHapAddon, ExportHapAddon as default, describeHksvCamera, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveClipRetentionDays, resolveHksvRecording, resolveKeepHomekitClips, resolveMultiTierService, deriveUsername as t };