@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.
- package/dist/hap-export.addon.js +2279 -166
- package/dist/hap-export.addon.mjs +2277 -169
- package/package.json +14 -1
package/dist/hap-export.addon.js
CHANGED
|
@@ -5409,7 +5409,7 @@ var ZodIssueCode = {
|
|
|
5409
5409
|
var ZodFirstPartyTypeKind;
|
|
5410
5410
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5411
5411
|
//#endregion
|
|
5412
|
-
//#region ../types/dist/sleep-
|
|
5412
|
+
//#region ../types/dist/sleep-BVhJDJka.mjs
|
|
5413
5413
|
/**
|
|
5414
5414
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5415
5415
|
* window to float samples (D455).
|
|
@@ -7039,6 +7039,18 @@ function systemMethod(input, output, options) {
|
|
|
7039
7039
|
systemOnly: true
|
|
7040
7040
|
};
|
|
7041
7041
|
}
|
|
7042
|
+
/**
|
|
7043
|
+
* A method a SOURCE of a collection cap may legitimately not serve — OPTIONAL
|
|
7044
|
+
* on `InferProvider`. The `providerOptional: true` literal is what
|
|
7045
|
+
* `InferProvider` keys on; see {@link CapabilityMethodSchema.providerOptional}
|
|
7046
|
+
* for when this is the honest answer and when it is a soft stub.
|
|
7047
|
+
*/
|
|
7048
|
+
function optionalMethod(input, output, options) {
|
|
7049
|
+
return {
|
|
7050
|
+
...method(input, output, options),
|
|
7051
|
+
providerOptional: true
|
|
7052
|
+
};
|
|
7053
|
+
}
|
|
7042
7054
|
var StaticDirOutputSchema$1 = object({ staticDir: string() });
|
|
7043
7055
|
var VersionOutputSchema$1 = object({ version: string() });
|
|
7044
7056
|
method(_void(), StaticDirOutputSchema$1, { auth: "admin" }), method(_void(), VersionOutputSchema$1, { auth: "admin" });
|
|
@@ -8062,6 +8074,8 @@ var EVENT_OWNER_TYPES = [
|
|
|
8062
8074
|
* nothing failing until a caller asked.
|
|
8063
8075
|
*/
|
|
8064
8076
|
var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
|
|
8077
|
+
/** The same list as a Zod enum, for the cap input that carries it. */
|
|
8078
|
+
var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
|
|
8065
8079
|
new Set(EVENT_OWNER_TYPES);
|
|
8066
8080
|
var EncodeProfileSchema = object({
|
|
8067
8081
|
video: object({
|
|
@@ -9675,20 +9689,86 @@ var StorageCleanupJobSchema = object({
|
|
|
9675
9689
|
});
|
|
9676
9690
|
var StorageCleanupStatusInputSchema = object({ jobId: string().optional() });
|
|
9677
9691
|
/**
|
|
9678
|
-
* The
|
|
9679
|
-
*
|
|
9692
|
+
* The storage-location STATE MODEL (D385) — one typed state, one policy module.
|
|
9693
|
+
*
|
|
9694
|
+
* A location's state used to be split across two authorities: the typed
|
|
9695
|
+
* `enabled` field (THE write switch since D383) and an untyped `config.readOnly`
|
|
9696
|
+
* key. They did not mean the same thing — `enabled: false` was still evicted
|
|
9697
|
+
* under disk pressure while `config.readOnly` was deliberately excluded — and
|
|
9698
|
+
* neither name said which. Every consumer re-derived the difference, and the
|
|
9699
|
+
* three questions that actually matter were answered in six places.
|
|
9700
|
+
*
|
|
9701
|
+
* This module is the ONLY place in the repo allowed to interpret the state. It
|
|
9702
|
+
* answers three questions and nothing else:
|
|
9703
|
+
*
|
|
9704
|
+
* - may this location be WRITTEN to? {@link modeMayWrite}
|
|
9705
|
+
* - may this location be READ? {@link modeMayRead}
|
|
9706
|
+
* - what is its eviction policy? {@link evictionPolicyForMode}
|
|
9707
|
+
*
|
|
9708
|
+
* | mode | write | read | eviction |
|
|
9709
|
+
* | ---------- | ----- | ---- | ------------------------------ |
|
|
9710
|
+
* | `active` | yes | yes | `normal` (pressure + usage cap) |
|
|
9711
|
+
* | `readonly` | no | yes | `never` |
|
|
9712
|
+
* | `drain` | no | yes | `drain` (paced, until empty) |
|
|
9713
|
+
* | `disabled` | no | no | `never` |
|
|
9714
|
+
*
|
|
9715
|
+
* `scripts/check-storage-location-mode-single-owner.ts` fails the build when
|
|
9716
|
+
* anything outside this module reads `config['readOnly']` or compares `enabled`
|
|
9717
|
+
* directly. A rule nothing checks has already been broken somewhere.
|
|
9680
9718
|
*/
|
|
9681
|
-
var
|
|
9719
|
+
var STORAGE_LOCATION_MODES = [
|
|
9682
9720
|
"active",
|
|
9683
9721
|
"readonly",
|
|
9684
9722
|
"drain",
|
|
9685
9723
|
"disabled"
|
|
9686
|
-
]
|
|
9724
|
+
];
|
|
9725
|
+
/**
|
|
9726
|
+
* The one typed state of a storage location. Authoritative Zod schema — the TS
|
|
9727
|
+
* alias below is `z.infer<>` of it, never a second spelling.
|
|
9728
|
+
*/
|
|
9729
|
+
var StorageLocationModeSchema = _enum(STORAGE_LOCATION_MODES);
|
|
9687
9730
|
_enum([
|
|
9688
9731
|
"normal",
|
|
9689
9732
|
"never",
|
|
9690
9733
|
"drain"
|
|
9691
9734
|
]);
|
|
9735
|
+
/** Is this mode a write target? Only `active` is. */
|
|
9736
|
+
function modeMayWrite(mode) {
|
|
9737
|
+
return mode === "active";
|
|
9738
|
+
}
|
|
9739
|
+
/**
|
|
9740
|
+
* The mode a LEGACY row implies, or `null` when it implies nothing — the row is
|
|
9741
|
+
* already stamped, or it carried neither flag.
|
|
9742
|
+
*
|
|
9743
|
+
* Both legacy flags fold to `readonly`, which is the CONSERVATIVE direction: a
|
|
9744
|
+
* state change must never start deleting footage on its own, and it must never
|
|
9745
|
+
* make footage that was still being served disappear. `enabled: false` used to
|
|
9746
|
+
* leave the location evictable under pressure; folding it to `readonly` stops
|
|
9747
|
+
* that, which is a strictly safer answer than the one it replaces.
|
|
9748
|
+
*/
|
|
9749
|
+
function legacyModeOf(location) {
|
|
9750
|
+
if (location.mode !== void 0) return null;
|
|
9751
|
+
if (location.config["readOnly"] === true) return "readonly";
|
|
9752
|
+
if (location.enabled === false) return "readonly";
|
|
9753
|
+
return null;
|
|
9754
|
+
}
|
|
9755
|
+
/**
|
|
9756
|
+
* The state of a location, stamped or folded. THE one interpretation: a row
|
|
9757
|
+
* that predates D385 is never ambiguous, and a stamped `mode` always wins over
|
|
9758
|
+
* whatever the legacy pair still says.
|
|
9759
|
+
*/
|
|
9760
|
+
function resolveLocationMode(location) {
|
|
9761
|
+
return (isStorageLocationMode(location.mode) ? location.mode : void 0) ?? legacyModeOf(location) ?? "active";
|
|
9762
|
+
}
|
|
9763
|
+
/** Is this one of the four states? The stamped value crosses a wire, and a
|
|
9764
|
+
* value nobody defined must not be rendered as if it were a state. */
|
|
9765
|
+
function isStorageLocationMode(value) {
|
|
9766
|
+
return STORAGE_LOCATION_MODES.some((mode) => mode === value);
|
|
9767
|
+
}
|
|
9768
|
+
/** May this location be written to? */
|
|
9769
|
+
function mayWriteToLocation(location) {
|
|
9770
|
+
return modeMayWrite(resolveLocationMode(location));
|
|
9771
|
+
}
|
|
9692
9772
|
/**
|
|
9693
9773
|
* `StorageLocationType` — an addon-declared id that identifies the *kind* of
|
|
9694
9774
|
* storage a location serves. Defined here (not in `capabilities/storage.cap.ts`)
|
|
@@ -21885,7 +21965,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
21885
21965
|
ownerType: EventOwnerTypeSchema,
|
|
21886
21966
|
eventId: number().int(),
|
|
21887
21967
|
deviceId: number()
|
|
21888
|
-
}), array(MediaFileInfoSchema).readonly()), method(
|
|
21968
|
+
}), array(MediaFileInfoSchema).readonly()), method(object({
|
|
21969
|
+
deviceId: number(),
|
|
21970
|
+
ownerKind: MediaPresenceOwnerKindSchema,
|
|
21971
|
+
ownerIds: array(string()).max(5e3)
|
|
21972
|
+
}), array(string()).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
|
|
21889
21973
|
kind: "mutation",
|
|
21890
21974
|
auth: "admin"
|
|
21891
21975
|
}), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
|
|
@@ -24879,6 +24963,18 @@ method(VectorDeclareIndexInputSchema, _void(), {
|
|
|
24879
24963
|
kind: "mutation",
|
|
24880
24964
|
auth: "admin"
|
|
24881
24965
|
}), method(VectorStatsInputSchema, VectorStatsResultSchema, { auth: "admin" });
|
|
24966
|
+
_enum([
|
|
24967
|
+
"queue-full",
|
|
24968
|
+
"camera-backoff",
|
|
24969
|
+
"sleeping",
|
|
24970
|
+
"camera-refused",
|
|
24971
|
+
"no-keyframe",
|
|
24972
|
+
"no-catalog-row",
|
|
24973
|
+
"unsupported",
|
|
24974
|
+
"unknown-device",
|
|
24975
|
+
"uid-missing"
|
|
24976
|
+
]);
|
|
24977
|
+
_enum(["deferred", "final"]);
|
|
24882
24978
|
var ClipSchema = object({
|
|
24883
24979
|
/** Opaque, provider-namespaced id. The default provider encodes the time
|
|
24884
24980
|
* window so `getClipPlayback` is self-contained (no event re-query). */
|
|
@@ -24953,31 +25049,454 @@ var ClipSchema = object({
|
|
|
24953
25049
|
holes: array(object({
|
|
24954
25050
|
startMs: number(),
|
|
24955
25051
|
endMs: number()
|
|
24956
|
-
})).optional()
|
|
25052
|
+
})).optional(),
|
|
25053
|
+
/**
|
|
25054
|
+
* A URL that MAY produce this clip's still, asked only for rows actually on
|
|
25055
|
+
* screen. The opposite of {@link ClipSchema.thumbnail}: that one is VOUCHED
|
|
25056
|
+
* (a JPEG is already on disk), this one is an offer. The route answers 200
|
|
25057
|
+
* with the image, or 204 with `x-camstack-reason` when it could not mint one
|
|
25058
|
+
* — a surface latches that refusal to the instant rather than retrying.
|
|
25059
|
+
*
|
|
25060
|
+
* Never both: a clip with a vouched `thumbnail` needs no mint.
|
|
25061
|
+
*/
|
|
25062
|
+
thumbnailMint: string().optional(),
|
|
25063
|
+
/**
|
|
25064
|
+
* Why no still will be produced for this clip RIGHT NOW — set when the
|
|
25065
|
+
* provider already knows, so the surface draws the glyph and the reason
|
|
25066
|
+
* instead of firing a mint that cannot succeed.
|
|
25067
|
+
*
|
|
25068
|
+
* `sleeping` — a standalone battery camera; a read would be a wake (D549 4).
|
|
25069
|
+
* `camera-refused` — the camera answered the CoverPreview with a refusal.
|
|
25070
|
+
* `no-keyframe` — the window holds no decodable I-frame (an event that sits
|
|
25071
|
+
* inside no file is the measured case).
|
|
25072
|
+
* `unsupported` — this source cannot mint stills at all.
|
|
25073
|
+
*/
|
|
25074
|
+
thumbnailUnavailable: object({ reason: _enum([
|
|
25075
|
+
"sleeping",
|
|
25076
|
+
"camera-refused",
|
|
25077
|
+
"no-keyframe",
|
|
25078
|
+
"unsupported"
|
|
25079
|
+
]) }).optional(),
|
|
25080
|
+
/**
|
|
25081
|
+
* When the catalog this row came from was last CONFIRMED against the device.
|
|
25082
|
+
* Absent means "this row was read live". A persisted catalog served while a
|
|
25083
|
+
* camera sleeps carries the age it really has — a cached list is never drawn
|
|
25084
|
+
* as current (D549 13).
|
|
25085
|
+
*/
|
|
25086
|
+
catalogAsOf: number().optional(),
|
|
25087
|
+
/**
|
|
25088
|
+
* The camera's OWN type strings for this clip, all of them, unmapped
|
|
25089
|
+
* (`md`, `people`, `dog_cat`, `sched`, …). Kept beside {@link labels}
|
|
25090
|
+
* because a firmware inventing a type must not vanish: the mapping into our
|
|
25091
|
+
* filter vocabulary is lossy on purpose and this is the lossless copy
|
|
25092
|
+
* (D549 20).
|
|
25093
|
+
*/
|
|
25094
|
+
nativeTypes: array(string()).optional(),
|
|
25095
|
+
/**
|
|
25096
|
+
* What the subject DID — `crossline`, `intrude`, `loitering`. A behaviour
|
|
25097
|
+
* travels BESIDE a class, never instead of one, and the class filter ignores
|
|
25098
|
+
* it: "a person crossed a line" is still a person (D549 20).
|
|
25099
|
+
*/
|
|
25100
|
+
behaviours: array(string()).optional(),
|
|
25101
|
+
/**
|
|
25102
|
+
* The same recording as two files — the sub twin (what the row and its
|
|
25103
|
+
* thumbnail are) and its main twin, matched at listing time so a quality
|
|
25104
|
+
* change never re-searches the camera (D549 15). `getClipPlayback`'s
|
|
25105
|
+
* `profile` picks between them: `low | mid` → sub, `high` → main.
|
|
25106
|
+
*/
|
|
25107
|
+
streams: object({
|
|
25108
|
+
sub: object({
|
|
25109
|
+
id: string(),
|
|
25110
|
+
bytes: number().optional()
|
|
25111
|
+
}).optional(),
|
|
25112
|
+
main: object({
|
|
25113
|
+
id: string(),
|
|
25114
|
+
bytes: number().optional()
|
|
25115
|
+
}).optional()
|
|
25116
|
+
}).optional(),
|
|
25117
|
+
/** The camera says it holds a sub-stream copy of this recording. */
|
|
25118
|
+
supportSub: boolean().optional(),
|
|
25119
|
+
/**
|
|
25120
|
+
* Whether this row has bytes behind it. ABSENT means yes — every clip that
|
|
25121
|
+
* IS a file is playable, and only a source that lists EVENTS can produce a
|
|
25122
|
+
* row with nothing to play (the measured case: 1 of 29 hub events on 3628
|
|
25123
|
+
* fell inside no file). Such a row is shown, never dropped and never offered
|
|
25124
|
+
* as playable-then-failing (D549 19).
|
|
25125
|
+
*/
|
|
25126
|
+
playable: boolean().optional(),
|
|
25127
|
+
/** Why {@link playable} is false, verbatim (`no-file-for-window`). */
|
|
25128
|
+
unplayableReason: string().optional(),
|
|
25129
|
+
/**
|
|
25130
|
+
* This clip is STILL BEING WRITTEN, so {@link ClipSchema.timeRange}`.endMs`
|
|
25131
|
+
* is not its end.
|
|
25132
|
+
*
|
|
25133
|
+
* Absent — the common case — means the row is closed and its `endMs` is the
|
|
25134
|
+
* end of the recording. Present and `true` means the source told us the file
|
|
25135
|
+
* has no end yet, and the `endMs` we carry is whatever the camera's index
|
|
25136
|
+
* entry happened to hold: on a Reolink E1 Outdoor PoE (592, measured
|
|
25137
|
+
* 2026-09-20) the newest file `…_192927_000000_…_0.mp4` reported an `endTime`
|
|
25138
|
+
* of 19:29:56 and STILL reported it eight minutes later, while the file went
|
|
25139
|
+
* on growing. A surface that drew 29 s there was lying about a clip that
|
|
25140
|
+
* plays for minutes, on the one row the operator looks at first.
|
|
25141
|
+
*
|
|
25142
|
+
* `endMs` is deliberately still a number: it is the best bound anything has
|
|
25143
|
+
* for a mint window or a byte fetch, and every consumer already requires it.
|
|
25144
|
+
* This flag says what it is WORTH, and a duration is not drawn from it.
|
|
25145
|
+
*/
|
|
25146
|
+
inProgress: boolean().optional()
|
|
24957
25147
|
});
|
|
24958
25148
|
var ClipPlaybackSchema = object({
|
|
24959
|
-
/**
|
|
25149
|
+
/**
|
|
25150
|
+
* A media URL through the hub data-plane. {@link ClipPlaybackSchema.format}
|
|
25151
|
+
* says what kind — an HLS master playlist for a recording-derived clip, a
|
|
25152
|
+
* progressive MP4 for a native file the vendor addon muxed and serves with
|
|
25153
|
+
* `Range`. The URL is NOT a credential: both planes are registered
|
|
25154
|
+
* `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
|
|
25155
|
+
* the session cookie, so nothing is appended to it (D549 22).
|
|
25156
|
+
*/
|
|
24960
25157
|
playbackUrl: string(),
|
|
25158
|
+
/**
|
|
25159
|
+
* How to play {@link playbackUrl}. Absent means `hls` — the shape every
|
|
25160
|
+
* existing consumer already assumes. A player that branches on this is the
|
|
25161
|
+
* one change a native clip needs; a player that ignores it will hand an MP4
|
|
25162
|
+
* to hls.js and fail parsing it as a manifest.
|
|
25163
|
+
*/
|
|
25164
|
+
format: _enum(["hls", "mp4"]).optional(),
|
|
25165
|
+
/**
|
|
25166
|
+
* Which twin was actually served. A clip that holds only one stream answers
|
|
25167
|
+
* with the one it has, and the surface SAYS so — a missing main twin is
|
|
25168
|
+
* never served silently as if it were the asked-for quality (D549 15).
|
|
25169
|
+
*/
|
|
25170
|
+
served: CamProfileSchema.optional(),
|
|
24961
25171
|
/** Optional LAN/remote alternates for the same clip. */
|
|
24962
25172
|
playbackEndpoints: array(string()).optional(),
|
|
24963
25173
|
token: string().optional()
|
|
24964
25174
|
});
|
|
24965
|
-
|
|
24966
|
-
|
|
24967
|
-
|
|
24968
|
-
|
|
24969
|
-
|
|
24970
|
-
|
|
24971
|
-
|
|
24972
|
-
|
|
24973
|
-
|
|
24974
|
-
|
|
24975
|
-
|
|
24976
|
-
|
|
24977
|
-
|
|
24978
|
-
|
|
25175
|
+
/**
|
|
25176
|
+
* Why a source cannot answer right now — per SOURCE, never fleet-wide.
|
|
25177
|
+
*
|
|
25178
|
+
* `ok` is the only state whose clip list may be read as complete. The other
|
|
25179
|
+
* three exist because an empty list from a sleeping camera reads as "this
|
|
25180
|
+
* camera has no recordings", which is the defect this whole line of work is
|
|
25181
|
+
* about: a battery camera nobody woke (`sleeping`), a camera that could not be
|
|
25182
|
+
* reached at all (`unreachable`, also what a provider that THREW reports), a
|
|
25183
|
+
* camera whose SD card is not mounted (`no-storage`, measured on 640 —
|
|
25184
|
+
* `HddInfo mount 0`, honestly nothing to list rather than "no clips"), and a
|
|
25185
|
+
* camera whose OWN index disagrees with its OWN calendar (`index-empty`,
|
|
25186
|
+
* measured on 618: the calendar marks 3–20 September, days 6–20 list zero
|
|
25187
|
+
* files on both streams and both filters, with 177 GB free). That last one is
|
|
25188
|
+
* a defect ON THE CAMERA, and the only honest thing a provider can do is say
|
|
25189
|
+
* which days it asked for and got nothing — "no clips" would be a lie about a
|
|
25190
|
+
* card that is full of them.
|
|
25191
|
+
*/
|
|
25192
|
+
/**
|
|
25193
|
+
* The operator's authorisation to WAKE a sleeping camera for one clip read.
|
|
25194
|
+
*
|
|
25195
|
+
* ONE value, absent by default, and it is a `force`-shaped operator signal in
|
|
25196
|
+
* exactly the sense `snapshot-wake-gate.ts` uses the word: *"`snapshot.getSnapshot`'s
|
|
25197
|
+
* `force` flag, and nothing else… a background caller must never set it…
|
|
25198
|
+
* Stale but honest beats woken"*. A scheduler, a retry, a reconcile and a
|
|
25199
|
+
* prefetch never set it; a surface sets it only behind the same confirm the
|
|
25200
|
+
* "Wake and refresh" gesture uses, and it is refused below 15 % battery
|
|
25201
|
+
* exactly as that gesture is.
|
|
25202
|
+
*
|
|
25203
|
+
* The gate is decided BEFORE `getApi()`, because on UDP the login IS the wake
|
|
25204
|
+
* (D549) — a read that opened a session and then checked would have woken the
|
|
25205
|
+
* camera to find out it was not allowed to.
|
|
25206
|
+
*
|
|
25207
|
+
* **One authorised yes is one wake.** `next-natural` — take the clip the next
|
|
25208
|
+
* time the camera is awake for its own reasons — is deliberately not a member:
|
|
25209
|
+
* it was proposed, it is free on the battery, and the operator declined it on
|
|
25210
|
+
* 2026-09-20 (*"Quando un export viene richiesto si sveglia la camera."*,
|
|
25211
|
+
* D558 "Considered and not taken").
|
|
25212
|
+
*/
|
|
25213
|
+
var ClipWakeSchema = _enum(["authorised"]);
|
|
25214
|
+
/**
|
|
25215
|
+
* Hard ceiling on ONE {@link videoclipsCapability.methods.readClipBytes} — the
|
|
25216
|
+
* same 50 MiB `RECORDING_EXPORT_MAX_READ_BYTES` uses, and for the same second
|
|
25217
|
+
* reason: the envelope is unary, so a base64 payload is held whole (~1.33× its
|
|
25218
|
+
* size) in the provider AND in the caller, on a hub this repo has already
|
|
25219
|
+
* OOM'd once (D9/D18).
|
|
25220
|
+
*
|
|
25221
|
+
* Measured clips sit far below it — 92 KB–2.29 MB for a sub twin, 455 KB for a
|
|
25222
|
+
* 16 s main clip — so the bound bites rarely. "Rarely" is not "never": a long
|
|
25223
|
+
* 4K main twin can exceed it, and above the bound the provider REFUSES with
|
|
25224
|
+
* the size in the message, never truncates. Half a video is worse than an
|
|
25225
|
+
* honest refusal.
|
|
25226
|
+
*
|
|
25227
|
+
* The clean follow-on is a CHUNKED read so a `high` twin of a long clip stops
|
|
25228
|
+
* being refusable at all. That is a later slice, named here so the bound is
|
|
25229
|
+
* not mistaken for a design ceiling.
|
|
25230
|
+
*/
|
|
25231
|
+
var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
|
|
25232
|
+
/**
|
|
25233
|
+
* A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
|
|
25234
|
+
*
|
|
25235
|
+
* `bytes` is the DECODED length, so nobody infers it from the base64 length,
|
|
25236
|
+
* and `served` says which twin the caller actually got.
|
|
25237
|
+
*/
|
|
25238
|
+
var ClipBytesSchema = object({
|
|
25239
|
+
base64: string(),
|
|
25240
|
+
contentType: string(),
|
|
25241
|
+
/** Suggested filename, extension included. */
|
|
25242
|
+
name: string(),
|
|
25243
|
+
bytes: number().int().nonnegative(),
|
|
25244
|
+
/**
|
|
25245
|
+
* Which twin was actually served — the same contract
|
|
25246
|
+
* {@link ClipPlaybackSchema.served} carries, and REQUIRED here because the
|
|
25247
|
+
* export record persists it: a row read a week later must say the same thing
|
|
25248
|
+
* the panel said at the moment of the tap. A missing main twin is never
|
|
25249
|
+
* served silently as if it were the asked-for quality (D549 15).
|
|
25250
|
+
*/
|
|
25251
|
+
served: CamProfileSchema,
|
|
25252
|
+
/**
|
|
25253
|
+
* The DECODED duration of the delivered file, when the fetch measured one.
|
|
25254
|
+
*
|
|
25255
|
+
* A clip fetch verifies its own completion against the catalog row and
|
|
25256
|
+
* retries a materially short pass (D568); this is that measurement, carried
|
|
25257
|
+
* so a consumer can say the same thing rather than re-deriving it. Absent
|
|
25258
|
+
* when the producer did not measure — never zero, which would say the file
|
|
25259
|
+
* is empty.
|
|
25260
|
+
*/
|
|
25261
|
+
durationMs: number().positive().optional()
|
|
25262
|
+
});
|
|
25263
|
+
var ClipSourceAvailabilitySchema = object({
|
|
25264
|
+
state: _enum([
|
|
25265
|
+
"ok",
|
|
25266
|
+
"sleeping",
|
|
25267
|
+
"unreachable",
|
|
25268
|
+
"no-storage",
|
|
25269
|
+
"index-empty"
|
|
25270
|
+
]),
|
|
25271
|
+
/** Free text, shown verbatim. Names the camera's own refusal when there is one. */
|
|
25272
|
+
reason: string().optional(),
|
|
25273
|
+
/** When this source's catalog was last CONFIRMED. A cached list is never
|
|
25274
|
+
* drawn as current: the surface shows the age whenever it is older than the
|
|
25275
|
+
* refresh interval. */
|
|
25276
|
+
catalogAsOf: number().optional()
|
|
24979
25277
|
});
|
|
24980
25278
|
/**
|
|
25279
|
+
* One SOURCE of clips for a camera — a row of the picker, and the namespace
|
|
25280
|
+
* every clip id from it is prefixed with (`analytics`,
|
|
25281
|
+
* `native:reolink:onboard`, `hksv`, …).
|
|
25282
|
+
*
|
|
25283
|
+
* A provider lists the sources IT serves for that device, and answers for each
|
|
25284
|
+
* of them whether it can answer at all. A provider with nothing to offer on a
|
|
25285
|
+
* camera returns `[]` — it is not that camera's business.
|
|
25286
|
+
*/
|
|
25287
|
+
var ClipSourceSchema = object({
|
|
25288
|
+
/** The value this source stamps on {@link ClipSchema.source}, and the prefix
|
|
25289
|
+
* of every clip id it mints. `getClipPlayback` routes on it. */
|
|
25290
|
+
source: string(),
|
|
25291
|
+
/** Operator-facing name of the source ("CamStack recordings", "SD card"). */
|
|
25292
|
+
label: string(),
|
|
25293
|
+
/**
|
|
25294
|
+
* The addon that SERVES this row.
|
|
25295
|
+
*
|
|
25296
|
+
* A surface cannot otherwise resolve a source to who answers it, and the
|
|
25297
|
+
* alternative — a `native:reolink:* → provider-reolink` table inside the
|
|
25298
|
+
* widget — is a second authority on provider identity living in the one
|
|
25299
|
+
* package with no business knowing it, wrong the day a third source appears
|
|
25300
|
+
* (D557). One addon may serve SEVERAL sources, which is why the per-device
|
|
25301
|
+
* switch is keyed by SOURCE and not by this (D555) — `addon-provider-reolink`
|
|
25302
|
+
* served a hub child two of them until the two views were measured to be one
|
|
25303
|
+
* store read twice (D555) and then read ONE way (D565).
|
|
25304
|
+
*
|
|
25305
|
+
* Optional for version skew only. The collection dispatcher stamps it from
|
|
25306
|
+
* the registry, so a row that travelled through the fan-out carries the
|
|
25307
|
+
* authoritative id whatever the provider filled in.
|
|
25308
|
+
*/
|
|
25309
|
+
addonId: string().optional(),
|
|
25310
|
+
availability: ClipSourceAvailabilitySchema
|
|
25311
|
+
});
|
|
25312
|
+
var videoclipsCapability = {
|
|
25313
|
+
name: "videoclips",
|
|
25314
|
+
scope: "device",
|
|
25315
|
+
mode: "collection",
|
|
25316
|
+
kind: "wrapper",
|
|
25317
|
+
defaultActive: true,
|
|
25318
|
+
/** A clip is a window over a camera's footage — the cap is meaningless on a
|
|
25319
|
+
* sensor, a button or an event emitter, and the `defaultActive` auto-bind
|
|
25320
|
+
* reads this to decide which devices it may claim. */
|
|
25321
|
+
deviceTypes: [DeviceType.Camera],
|
|
25322
|
+
/**
|
|
25323
|
+
* The Clips section of a camera's device details is FRAMEWORK-DERIVED (D14):
|
|
25324
|
+
* the aggregator turns this declaration into the `type:'widget'` section and
|
|
25325
|
+
* `DeviceDetail.tsx` is never edited. `videoclips` is the first WRAPPER cap
|
|
25326
|
+
* to declare one — every previous `host/` widget cap is `deviceNative` — so
|
|
25327
|
+
* `device-config-widget-wrapped-binding.spec.ts` pins that a `kind:'wrapped'`
|
|
25328
|
+
* binding entry derives the same section a native one does.
|
|
25329
|
+
*
|
|
25330
|
+
* **It is a SECTION of the Recording tab, at the end of it — not a tab of
|
|
25331
|
+
* its own.** It shipped as a `clips` top-tab and the operator rejected the
|
|
25332
|
+
* placement: *"utilizzerei la stessa tab recordings, lì abbiamo già tutto il
|
|
25333
|
+
* necessario, una nuova sezione alla fine per le clips"*. The Recording tab
|
|
25334
|
+
* already holds the recorder's panel, the schedule bands and the unified
|
|
25335
|
+
* retention policy; footage the camera itself holds is the same question,
|
|
25336
|
+
* asked of a different store. `order: 100` puts it after all of them with
|
|
25337
|
+
* room left in front. The `clips` entry in `WELL_KNOWN_TABS` went with it —
|
|
25338
|
+
* a well-known id nobody declares is an invitation to mint the tab again.
|
|
25339
|
+
*
|
|
25340
|
+
* `topTab` stays: the browser owns a pane (a day's tiles, a source list and
|
|
25341
|
+
* a player) and the Config tab's inner bar has no room for one. The widget
|
|
25342
|
+
* itself is `host/clips-browser` in ui-library's `HOST_WIDGETS`, and because
|
|
25343
|
+
* the admin Recordings page renders every `location:'top-tab'` +
|
|
25344
|
+
* `tab:'recording'` section behind its camera picker
|
|
25345
|
+
* (`CameraRecordingSettingsSection`), this declaration lands the section on
|
|
25346
|
+
* BOTH surfaces with no second wiring.
|
|
25347
|
+
*/
|
|
25348
|
+
deviceConfig: { ui: {
|
|
25349
|
+
kind: "widget",
|
|
25350
|
+
widgetId: "host/clips-browser",
|
|
25351
|
+
tab: "recording",
|
|
25352
|
+
topTab: true,
|
|
25353
|
+
label: "Clips",
|
|
25354
|
+
order: 100
|
|
25355
|
+
} },
|
|
25356
|
+
methods: {
|
|
25357
|
+
listClips: method(object({
|
|
25358
|
+
deviceId: number(),
|
|
25359
|
+
since: number(),
|
|
25360
|
+
until: number(),
|
|
25361
|
+
limit: number().int().positive().optional(),
|
|
25362
|
+
/**
|
|
25363
|
+
* WHICH provider to ask — the `addonId` a {@link ClipSourceSchema} row
|
|
25364
|
+
* carries, never a source id and never a list. **Required** (D554 amended).
|
|
25365
|
+
*
|
|
25366
|
+
* A provider the device is not bound to is refused by name rather than
|
|
25367
|
+
* answered by another one (D552's `rejectUnresolvedAddonPin` rule).
|
|
25368
|
+
*
|
|
25369
|
+
* It was optional, documented as "absent means the device's BOUND
|
|
25370
|
+
* provider, which is CamStack on every camera". No code implemented
|
|
25371
|
+
* that. Measured on the live hub 2026-09-20 — device 592, bound to
|
|
25372
|
+
* `recorder` AND `provider-reolink` — a bare call with `limit: 3`
|
|
25373
|
+
* answered SIX rows, three from each source, merged newest-first:
|
|
25374
|
+
* `device-collection-dispatch.ts` simply left the fan-out un-narrowed,
|
|
25375
|
+
* so absence bought the union this method exists not to be, and
|
|
25376
|
+
* `limit` meant `limit × sources`.
|
|
25377
|
+
*
|
|
25378
|
+
* There is nothing to restore the default to. `getBindings` answers a
|
|
25379
|
+
* collection cap with a DERIVED, PLURAL set (D554 amended, step 0);
|
|
25380
|
+
* `setWrapperActive` — the singleton authority that could name one —
|
|
25381
|
+
* throws for a collection cap by design. Naming CamStack here instead
|
|
25382
|
+
* would privilege one addon by id inside a surface whose premise is
|
|
25383
|
+
* that sources are peers, and would be wrong on the first camera with
|
|
25384
|
+
* no recorder binding. So absence is REFUSED, in the schema, where the
|
|
25385
|
+
* generated types make it unomittable rather than merely discouraged.
|
|
25386
|
+
*
|
|
25387
|
+
* The default belongs to the SURFACE, which has the `listSources` rows
|
|
25388
|
+
* and can say which one it picked (D569 § 1–3; the viewer already
|
|
25389
|
+
* always sends this).
|
|
25390
|
+
*
|
|
25391
|
+
* It replaced `sources?: string[]`, a VIEW filter over source ids that
|
|
25392
|
+
* assumed the answer was a fan-out over everything a camera has. The
|
|
25393
|
+
* operator settled otherwise on 2026-09-20 — *"l'utilizzatore è uno
|
|
25394
|
+
* solo"* — so the list is asked of one provider at a time and there is
|
|
25395
|
+
* nothing to filter out of it.
|
|
25396
|
+
*/
|
|
25397
|
+
provider: string().min(1)
|
|
25398
|
+
}), array(ClipSchema).readonly(), {
|
|
25399
|
+
kind: "query",
|
|
25400
|
+
auth: "protected"
|
|
25401
|
+
}),
|
|
25402
|
+
/**
|
|
25403
|
+
* The sources this camera has, WITH the reason any of them cannot answer.
|
|
25404
|
+
*
|
|
25405
|
+
* Asked separately from `listClips` because an empty clip list is
|
|
25406
|
+
* ambiguous and this is the only place the ambiguity is resolved: every
|
|
25407
|
+
* bound provider contributes its own rows, and a provider that could not
|
|
25408
|
+
* be reached at all still produces one row saying so. A surface that draws
|
|
25409
|
+
* "no clips" without reading this is drawing a guess.
|
|
25410
|
+
*/
|
|
25411
|
+
listSources: method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
|
|
25412
|
+
kind: "query",
|
|
25413
|
+
auth: "protected"
|
|
25414
|
+
}),
|
|
25415
|
+
getClipPlayback: method(object({
|
|
25416
|
+
deviceId: number(),
|
|
25417
|
+
clipId: string(),
|
|
25418
|
+
/**
|
|
25419
|
+
* Which twin to serve, on the ONE quality scale the system already has
|
|
25420
|
+
* (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
|
|
25421
|
+
* twin; both ids are already on the row so this never re-searches the
|
|
25422
|
+
* camera. Absent means the provider's own default (the sub file, which
|
|
25423
|
+
* every source is measured to hold).
|
|
25424
|
+
*
|
|
25425
|
+
* `auto` is deliberately NOT accepted here: a stored file has no
|
|
25426
|
+
* broker session, so the adaptive tier cannot be resolved for it. The
|
|
25427
|
+
* viewer resolves `auto` to a profile the same way live does, before
|
|
25428
|
+
* it calls (D549 15).
|
|
25429
|
+
*/
|
|
25430
|
+
profile: CamProfileSchema.optional()
|
|
25431
|
+
}), ClipPlaybackSchema, {
|
|
25432
|
+
kind: "query",
|
|
25433
|
+
auth: "protected"
|
|
25434
|
+
}),
|
|
25435
|
+
/**
|
|
25436
|
+
* This clip's BYTES, base64, bounded — the by-handle read a clip EXPORT
|
|
25437
|
+
* pulls once (D558).
|
|
25438
|
+
*
|
|
25439
|
+
* `getClipPlayback` is the right answer for a player: it hands back a URL
|
|
25440
|
+
* on a plane the hub serves `access:'authenticated'`, which a browser and a
|
|
25441
|
+
* viewer session satisfy. It is the wrong answer for another ADDON. There
|
|
25442
|
+
* is no addon→addon byte transport in this framework — `AddonDataPlane`
|
|
25443
|
+
* only lets an addon SERVE, on `127.0.0.1` behind a per-listener secret
|
|
25444
|
+
* only the hub may present — so a recorder that wants a camera's clip
|
|
25445
|
+
* cannot fetch that URL. This method is the one seam that exists for it,
|
|
25446
|
+
* and it is deliberately the same shape (and the same bound) as
|
|
25447
|
+
* `recordingExport.readExportBytes`, which exists for the mirror-image
|
|
25448
|
+
* reason.
|
|
25449
|
+
*
|
|
25450
|
+
* Routing needs no `provider` pin: the id is source-prefixed and
|
|
25451
|
+
* self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
|
|
25452
|
+
* to the source that claims it — and an id nobody claims is REFUSED rather
|
|
25453
|
+
* than answered by another source.
|
|
25454
|
+
*
|
|
25455
|
+
* The producer reuses the fetch path it already has, completion rules
|
|
25456
|
+
* included: a clip is taken by cmd 5 and finished on a short idle window
|
|
25457
|
+
* whose result is PROVED against the catalog row's own span, retried once
|
|
25458
|
+
* when it comes up materially short, and served-and-named when it is still
|
|
25459
|
+
* short (D568). A second fetch with different completion rules is exactly
|
|
25460
|
+
* the second authority D558 refuses to create.
|
|
25461
|
+
*
|
|
25462
|
+
* Every refusal THROWS with its reason and none of them is silent — the
|
|
25463
|
+
* sleep gate (decided before `getApi()`, liftable only by
|
|
25464
|
+
* {@link ClipWakeSchema}), a catalog row nobody claims, a clip with no
|
|
25465
|
+
* bytes behind it, a mux that failed, and the size bound. The caller turns
|
|
25466
|
+
* that reason into an operator-facing one; a truncated file is never an
|
|
25467
|
+
* answer.
|
|
25468
|
+
*/
|
|
25469
|
+
readClipBytes: optionalMethod(object({
|
|
25470
|
+
deviceId: number(),
|
|
25471
|
+
clipId: string().min(1),
|
|
25472
|
+
/**
|
|
25473
|
+
* Which twin to fetch, on the mapping D549 15 already fixed:
|
|
25474
|
+
* `low | mid` → the sub file, `high` → the main twin. `auto` is not
|
|
25475
|
+
* accepted here for the same reason it is not accepted by
|
|
25476
|
+
* `getClipPlayback` — a stored file has no broker session, so the
|
|
25477
|
+
* adaptive tier cannot be resolved for it.
|
|
25478
|
+
*/
|
|
25479
|
+
profile: CamProfileSchema.optional(),
|
|
25480
|
+
/**
|
|
25481
|
+
* The CALLER's byte bound, so an over-size clip is refused before it is
|
|
25482
|
+
* read and encoded rather than after. Capped by
|
|
25483
|
+
* {@link VIDEOCLIPS_MAX_READ_BYTES} whatever is passed; absent means
|
|
25484
|
+
* that ceiling.
|
|
25485
|
+
*/
|
|
25486
|
+
maxBytes: number().int().positive().optional(),
|
|
25487
|
+
/**
|
|
25488
|
+
* The operator's authorisation to wake a sleeping camera for this
|
|
25489
|
+
* read. Absent — the default — means a sleeping standalone battery
|
|
25490
|
+
* camera is REFUSED by name, before any session is opened.
|
|
25491
|
+
*/
|
|
25492
|
+
wake: ClipWakeSchema.optional()
|
|
25493
|
+
}), ClipBytesSchema, {
|
|
25494
|
+
kind: "query",
|
|
25495
|
+
auth: "protected"
|
|
25496
|
+
})
|
|
25497
|
+
}
|
|
25498
|
+
};
|
|
25499
|
+
/**
|
|
24981
25500
|
* Optional client-side hints sent at session creation to help the provider
|
|
24982
25501
|
* pick the best native source. All fields optional — a viewer that knows
|
|
24983
25502
|
* nothing still gets a sane default. (Relocated from the retired `webrtc`
|
|
@@ -30944,13 +31463,101 @@ var ExportStateSchema = _enum([
|
|
|
30944
31463
|
"expired",
|
|
30945
31464
|
"deleted"
|
|
30946
31465
|
]);
|
|
30947
|
-
/**
|
|
31466
|
+
/**
|
|
31467
|
+
* WHAT an export is — the authority, as opposed to the four top-level fields
|
|
31468
|
+
* the Library sorts and labels on (D558 § 2.2).
|
|
31469
|
+
*
|
|
31470
|
+
* Read it through {@link exportSubjectOf}, never off the record directly: the
|
|
31471
|
+
* field is optional for the history rows written before it existed, and that
|
|
31472
|
+
* absence has exactly one interpreter.
|
|
31473
|
+
*/
|
|
31474
|
+
var ExportSubjectSchema = discriminatedUnion("kind", [object({
|
|
31475
|
+
kind: literal("footage"),
|
|
31476
|
+
deviceId: number(),
|
|
31477
|
+
profiles: array(string()).min(1),
|
|
31478
|
+
fromMs: number(),
|
|
31479
|
+
toMs: number()
|
|
31480
|
+
}), object({
|
|
31481
|
+
kind: literal("clip"),
|
|
31482
|
+
deviceId: number(),
|
|
31483
|
+
provider: string().min(1),
|
|
31484
|
+
source: string().min(1),
|
|
31485
|
+
sourceLabel: string().min(1),
|
|
31486
|
+
clipId: string().min(1),
|
|
31487
|
+
/**
|
|
31488
|
+
* WHERE IN THE CATALOG to confirm this clip — the day window the surface was
|
|
31489
|
+
* already listing when the operator picked the segment.
|
|
31490
|
+
*
|
|
31491
|
+
* It is **not** a time range control and it never becomes one: the record's
|
|
31492
|
+
* `fromMs`/`toMs` come from the catalog ROW and from nothing a caller
|
|
31493
|
+
* supplied (D558 § 5.4.3), and a window that does not contain the clip is a
|
|
31494
|
+
* `catalog-miss`, not a silently wider search. It exists because
|
|
31495
|
+
* `videoclips.listClips` takes `since`/`until` and has no by-handle twin:
|
|
31496
|
+
* the catalog check D558 asks for is literally that call, and a call needs a
|
|
31497
|
+
* window. The surface has one — `ClipsBrowser`'s `dayWindow`, one local
|
|
31498
|
+
* wall-clock day, which is also the only width measured to be cheap (one
|
|
31499
|
+
* day on 592 lists 171 clips in 712 ms, a busy day 546 in 2.0 s; twenty days
|
|
31500
|
+
* of one child's events took 3.6–5.4 s).
|
|
31501
|
+
*/
|
|
31502
|
+
catalogWindow: object({
|
|
31503
|
+
sinceMs: number(),
|
|
31504
|
+
untilMs: number()
|
|
31505
|
+
}),
|
|
31506
|
+
profile: _enum([
|
|
31507
|
+
"high",
|
|
31508
|
+
"mid",
|
|
31509
|
+
"low"
|
|
31510
|
+
]),
|
|
31511
|
+
/**
|
|
31512
|
+
* The operator's authorisation to wake a sleeping camera for this export.
|
|
31513
|
+
* ONE definition, in the cap that owns the clip read ({@link ClipWakeSchema}),
|
|
31514
|
+
* because the gate that honours it is the clip provider's sleep gate — a
|
|
31515
|
+
* second enum here would be a second contract. Absent by default; never
|
|
31516
|
+
* settable by a scheduler or a retry.
|
|
31517
|
+
*
|
|
31518
|
+
* **One authorised yes is ONE wake.** A failed clip export is retried only by
|
|
31519
|
+
* an operator act that asks again; a queued job that outlived its wake fails
|
|
31520
|
+
* with a reason rather than waking on its turn; and this subject carries
|
|
31521
|
+
* exactly one profile precisely so one tap is never two fetches (D558 § 5.2).
|
|
31522
|
+
*/
|
|
31523
|
+
wake: ClipWakeSchema.optional()
|
|
31524
|
+
})]);
|
|
31525
|
+
/**
|
|
31526
|
+
* One export job / history row.
|
|
31527
|
+
*
|
|
31528
|
+
* **`subject` is the AUTHORITY on what was exported. `deviceId`, `profile`,
|
|
31529
|
+
* `fromMs` and `toMs` are its PROJECTION** — kept top-level because the whole
|
|
31530
|
+
* Library sorts and labels on them (`library-items.ts` orders an export by
|
|
31531
|
+
* `fromMs`; `export-format.ts` draws `rangeLabel` from the pair), and a row
|
|
31532
|
+
* that did not fill them would sort under the epoch and render a blank range.
|
|
31533
|
+
* Write to the subject and read from the projection and the two will disagree;
|
|
31534
|
+
* the projection is derived at creation and never edited afterwards.
|
|
31535
|
+
*
|
|
31536
|
+
* And they mean DIFFERENT FACTS for the two kinds, which is the part a reader
|
|
31537
|
+
* who knows only the recording export will get wrong:
|
|
31538
|
+
*
|
|
31539
|
+
* | field | `kind:'footage'` | `kind:'clip'` |
|
|
31540
|
+
* | --- | --- | --- |
|
|
31541
|
+
* | `fromMs`/`toMs` | the stretch the operator ASKED for | the camera's own boundaries, always `clip.timeRange`, never anything a caller supplied |
|
|
31542
|
+
* | `profile` | the stream rendered | the twin actually SERVED (`subject.profile` is the one asked for) |
|
|
31543
|
+
*
|
|
31544
|
+
* Same type, different fact — the shape this repo keeps getting wrong (D385's
|
|
31545
|
+
* two authorities, D224's second copy).
|
|
31546
|
+
*/
|
|
30948
31547
|
var ExportRecordSchema = object({
|
|
30949
31548
|
id: string(),
|
|
30950
31549
|
deviceId: number(),
|
|
30951
31550
|
profile: string(),
|
|
30952
31551
|
fromMs: number(),
|
|
30953
31552
|
toMs: number(),
|
|
31553
|
+
/**
|
|
31554
|
+
* What this export IS. Optional ONLY for the rows written before D558: the
|
|
31555
|
+
* store parses every row through this schema on every read, so a required
|
|
31556
|
+
* field would make the export AUDIT — which is the whole reason rows survive
|
|
31557
|
+
* file deletion — unreadable in one release. Absence means `footage`, and
|
|
31558
|
+
* {@link exportSubjectOf} is the one place that says so.
|
|
31559
|
+
*/
|
|
31560
|
+
subject: ExportSubjectSchema.optional(),
|
|
30954
31561
|
options: ExportOptionsSchema,
|
|
30955
31562
|
state: ExportStateSchema,
|
|
30956
31563
|
/** 0–100 while rendering; null otherwise. */
|
|
@@ -30967,6 +31574,18 @@ var ExportRecordSchema = object({
|
|
|
30967
31574
|
/** Failure reason when state is 'failed'; null otherwise. */
|
|
30968
31575
|
error: string().nullable()
|
|
30969
31576
|
});
|
|
31577
|
+
_enum([
|
|
31578
|
+
"catalog-miss",
|
|
31579
|
+
"catalog-unreachable",
|
|
31580
|
+
"no-file-for-window",
|
|
31581
|
+
"clip-in-progress",
|
|
31582
|
+
"unsupported-option",
|
|
31583
|
+
"sleeping",
|
|
31584
|
+
"camera-refused",
|
|
31585
|
+
"fetch-failed",
|
|
31586
|
+
"too-large-to-transfer",
|
|
31587
|
+
"wake-expired"
|
|
31588
|
+
]);
|
|
30970
31589
|
/** Candidate download URLs (LAN first, then operator extra hosts). */
|
|
30971
31590
|
var ExportDownloadSchema = object({
|
|
30972
31591
|
url: string(),
|
|
@@ -30985,16 +31604,50 @@ var ExportBytesSchema = object({
|
|
|
30985
31604
|
name: string(),
|
|
30986
31605
|
bytes: number().int().nonnegative()
|
|
30987
31606
|
});
|
|
31607
|
+
/** Canonical `profiles[]`, falling back to the legacy singular `profile`. */
|
|
31608
|
+
function resolveExportProfiles(input) {
|
|
31609
|
+
if (input.profiles !== void 0 && input.profiles.length > 0) return [...input.profiles];
|
|
31610
|
+
if (typeof input.profile === "string" && input.profile.length > 0) return [input.profile];
|
|
31611
|
+
return [];
|
|
31612
|
+
}
|
|
30988
31613
|
method(object({
|
|
30989
31614
|
deviceId: number(),
|
|
30990
31615
|
/** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
|
|
30991
31616
|
profile: string().optional(),
|
|
30992
31617
|
profiles: array(string()).min(1).optional(),
|
|
30993
|
-
|
|
30994
|
-
|
|
31618
|
+
/** Footage only — a clip's boundaries are the camera's. */
|
|
31619
|
+
fromMs: number().optional(),
|
|
31620
|
+
toMs: number().optional(),
|
|
31621
|
+
/** What to export. Absent means the legacy flat footage request. */
|
|
31622
|
+
subject: ExportSubjectSchema.optional(),
|
|
30995
31623
|
options: ExportOptionsSchema
|
|
30996
31624
|
}).superRefine((v, ctx) => {
|
|
30997
|
-
if (
|
|
31625
|
+
if (v.subject?.kind === "clip") {
|
|
31626
|
+
if (v.subject.deviceId !== v.deviceId) ctx.addIssue({
|
|
31627
|
+
code: ZodIssueCode.custom,
|
|
31628
|
+
message: `subject.deviceId (${v.subject.deviceId}) must equal deviceId (${v.deviceId}) — the top-level field is what per-device scope enforcement reads`,
|
|
31629
|
+
path: ["subject", "deviceId"]
|
|
31630
|
+
});
|
|
31631
|
+
if (v.fromMs !== void 0 || v.toMs !== void 0) ctx.addIssue({
|
|
31632
|
+
code: ZodIssueCode.custom,
|
|
31633
|
+
message: "a clip export asks for no time range: the camera chose the boundaries and they are read from the catalog row",
|
|
31634
|
+
path: ["fromMs"]
|
|
31635
|
+
});
|
|
31636
|
+
return;
|
|
31637
|
+
}
|
|
31638
|
+
if (v.subject?.kind === "footage" && v.subject.deviceId !== v.deviceId) ctx.addIssue({
|
|
31639
|
+
code: ZodIssueCode.custom,
|
|
31640
|
+
message: `subject.deviceId (${v.subject.deviceId}) must equal deviceId (${v.deviceId})`,
|
|
31641
|
+
path: ["subject", "deviceId"]
|
|
31642
|
+
});
|
|
31643
|
+
const fromMs = v.subject?.kind === "footage" ? v.subject.fromMs : v.fromMs;
|
|
31644
|
+
const toMs = v.subject?.kind === "footage" ? v.subject.toMs : v.toMs;
|
|
31645
|
+
if (typeof fromMs !== "number" || typeof toMs !== "number") ctx.addIssue({
|
|
31646
|
+
code: ZodIssueCode.custom,
|
|
31647
|
+
message: "a footage export needs fromMs and toMs",
|
|
31648
|
+
path: ["fromMs"]
|
|
31649
|
+
});
|
|
31650
|
+
if ((v.subject?.kind === "footage" ? [...v.subject.profiles] : resolveExportProfiles(v)).length < 1) ctx.addIssue({
|
|
30998
31651
|
code: ZodIssueCode.custom,
|
|
30999
31652
|
message: "pass profiles[] (min 1) or legacy profile",
|
|
31000
31653
|
path: ["profiles"]
|
|
@@ -36415,6 +37068,12 @@ Object.freeze({
|
|
|
36415
37068
|
addonId: null,
|
|
36416
37069
|
access: "view"
|
|
36417
37070
|
},
|
|
37071
|
+
"pipelineAnalytics.ownersWithMedia": {
|
|
37072
|
+
capName: "pipeline-analytics",
|
|
37073
|
+
capScope: "device",
|
|
37074
|
+
addonId: null,
|
|
37075
|
+
access: "view"
|
|
37076
|
+
},
|
|
36418
37077
|
"pipelineAnalytics.pauseForStorageMigration": {
|
|
36419
37078
|
capName: "pipeline-analytics",
|
|
36420
37079
|
capScope: "device",
|
|
@@ -39007,6 +39666,18 @@ Object.freeze({
|
|
|
39007
39666
|
addonId: null,
|
|
39008
39667
|
access: "view"
|
|
39009
39668
|
},
|
|
39669
|
+
"videoclips.listSources": {
|
|
39670
|
+
capName: "videoclips",
|
|
39671
|
+
capScope: "device",
|
|
39672
|
+
addonId: null,
|
|
39673
|
+
access: "view"
|
|
39674
|
+
},
|
|
39675
|
+
"videoclips.readClipBytes": {
|
|
39676
|
+
capName: "videoclips",
|
|
39677
|
+
capScope: "device",
|
|
39678
|
+
addonId: null,
|
|
39679
|
+
access: "view"
|
|
39680
|
+
},
|
|
39010
39681
|
"viewerUi.getStaticDir": {
|
|
39011
39682
|
capName: "viewer-ui",
|
|
39012
39683
|
capScope: "system",
|
|
@@ -40309,6 +40980,11 @@ Object.freeze({
|
|
|
40309
40980
|
form: "single",
|
|
40310
40981
|
optional: false
|
|
40311
40982
|
}],
|
|
40983
|
+
"pipelineAnalytics.ownersWithMedia": [{
|
|
40984
|
+
name: "deviceId",
|
|
40985
|
+
form: "single",
|
|
40986
|
+
optional: false
|
|
40987
|
+
}],
|
|
40312
40988
|
"pipelineAnalytics.proposeRetrainAnnotations": [{
|
|
40313
40989
|
name: "deviceId",
|
|
40314
40990
|
form: "single",
|
|
@@ -41015,6 +41691,16 @@ Object.freeze({
|
|
|
41015
41691
|
form: "single",
|
|
41016
41692
|
optional: false
|
|
41017
41693
|
}],
|
|
41694
|
+
"videoclips.listSources": [{
|
|
41695
|
+
name: "deviceId",
|
|
41696
|
+
form: "single",
|
|
41697
|
+
optional: false
|
|
41698
|
+
}],
|
|
41699
|
+
"videoclips.readClipBytes": [{
|
|
41700
|
+
name: "deviceId",
|
|
41701
|
+
form: "single",
|
|
41702
|
+
optional: false
|
|
41703
|
+
}],
|
|
41018
41704
|
"waterHeater.setAway": [{
|
|
41019
41705
|
name: "deviceId",
|
|
41020
41706
|
form: "single",
|
|
@@ -88779,6 +89465,410 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
88779
89465
|
}
|
|
88780
89466
|
}
|
|
88781
89467
|
//#endregion
|
|
89468
|
+
//#region src/hksv/build-outcome.ts
|
|
89469
|
+
/**
|
|
89470
|
+
* Every camera's last build verdict, in this process.
|
|
89471
|
+
*
|
|
89472
|
+
* A `Map` behind a named type rather than a bare one, because the thing that
|
|
89473
|
+
* matters about it is what `get` returning `undefined` MEANS: not "recording is
|
|
89474
|
+
* fine", but "no accessory has been built for this camera since the addon
|
|
89475
|
+
* started".
|
|
89476
|
+
*/
|
|
89477
|
+
var HksvBuildOutcomes = class {
|
|
89478
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
89479
|
+
note(outcome) {
|
|
89480
|
+
this.byDevice.set(outcome.deviceId, outcome);
|
|
89481
|
+
}
|
|
89482
|
+
/** `null` when nothing has been established for this camera yet. */
|
|
89483
|
+
lastFor(deviceId) {
|
|
89484
|
+
return this.byDevice.get(deviceId) ?? null;
|
|
89485
|
+
}
|
|
89486
|
+
/** The camera left HomeKit: its verdict is not a fact about anything now. */
|
|
89487
|
+
forget(deviceId) {
|
|
89488
|
+
this.byDevice.delete(deviceId);
|
|
89489
|
+
}
|
|
89490
|
+
};
|
|
89491
|
+
//#endregion
|
|
89492
|
+
//#region src/hksv/clip-ffmpeg-run.ts
|
|
89493
|
+
function createBoundedFfmpegRunner(input) {
|
|
89494
|
+
return (args) => new Promise((resolve) => {
|
|
89495
|
+
let settled = false;
|
|
89496
|
+
const finish = (run) => {
|
|
89497
|
+
if (settled) return;
|
|
89498
|
+
settled = true;
|
|
89499
|
+
clearTimeout(timer);
|
|
89500
|
+
resolve(run);
|
|
89501
|
+
};
|
|
89502
|
+
const child = input.spawnFn(input.ffmpegBinaryPath, [...args], { stdio: [
|
|
89503
|
+
"ignore",
|
|
89504
|
+
"ignore",
|
|
89505
|
+
"pipe"
|
|
89506
|
+
] });
|
|
89507
|
+
let stderr = "";
|
|
89508
|
+
child.stderr?.on("data", (chunk) => {
|
|
89509
|
+
if (stderr.length < 2e3) stderr += chunk.toString("utf8");
|
|
89510
|
+
});
|
|
89511
|
+
const timer = setTimeout(() => {
|
|
89512
|
+
child.kill("SIGKILL");
|
|
89513
|
+
finish({
|
|
89514
|
+
code: null,
|
|
89515
|
+
timedOut: true,
|
|
89516
|
+
spawnFailed: false,
|
|
89517
|
+
stderr
|
|
89518
|
+
});
|
|
89519
|
+
}, input.timeoutMs);
|
|
89520
|
+
timer.unref?.();
|
|
89521
|
+
child.on("error", (err) => {
|
|
89522
|
+
finish({
|
|
89523
|
+
code: null,
|
|
89524
|
+
timedOut: false,
|
|
89525
|
+
spawnFailed: true,
|
|
89526
|
+
stderr: err.message
|
|
89527
|
+
});
|
|
89528
|
+
});
|
|
89529
|
+
child.on("close", (code) => {
|
|
89530
|
+
finish({
|
|
89531
|
+
code,
|
|
89532
|
+
timedOut: false,
|
|
89533
|
+
spawnFailed: false,
|
|
89534
|
+
stderr
|
|
89535
|
+
});
|
|
89536
|
+
});
|
|
89537
|
+
});
|
|
89538
|
+
}
|
|
89539
|
+
//#endregion
|
|
89540
|
+
//#region src/hksv/clip-location.ts
|
|
89541
|
+
/** The declared id. Deployment-wide; see the docblock for why it is new. */
|
|
89542
|
+
var HOMEKIT_CLIPS_LOCATION_TYPE = "homekitClips";
|
|
89543
|
+
/** The class subtree under the resolved root. Never written at the root (D327). */
|
|
89544
|
+
var HOMEKIT_CLIPS_SUBTREE = "homekit-clips";
|
|
89545
|
+
/**
|
|
89546
|
+
* The cap calls, HERE and not in the addon.
|
|
89547
|
+
*
|
|
89548
|
+
* Deliberately co-located with the `mayWriteToLocation` filter below: a file
|
|
89549
|
+
* that enumerates storage locations must visibly decide whether it is choosing
|
|
89550
|
+
* a WRITE target (`scripts/check-storage-write-target-enabled.ts` enforces
|
|
89551
|
+
* exactly that, and it fired when the closure lived in the addon). Splitting
|
|
89552
|
+
* the enumeration from the decision is how the backup fan-out grew a second
|
|
89553
|
+
* enabled flag.
|
|
89554
|
+
*/
|
|
89555
|
+
function createClipLocationPorts(api) {
|
|
89556
|
+
return {
|
|
89557
|
+
listLocations: () => api.storage.listLocations.query({}),
|
|
89558
|
+
resolvePath: (locationId) => api.storage.resolve.query({
|
|
89559
|
+
location: locationId,
|
|
89560
|
+
relativePath: ""
|
|
89561
|
+
})
|
|
89562
|
+
};
|
|
89563
|
+
}
|
|
89564
|
+
var HksvClipLocation = class {
|
|
89565
|
+
input;
|
|
89566
|
+
log;
|
|
89567
|
+
now;
|
|
89568
|
+
revalidateMs;
|
|
89569
|
+
cachedRoot = null;
|
|
89570
|
+
resolvedAt = Number.NEGATIVE_INFINITY;
|
|
89571
|
+
refusal = null;
|
|
89572
|
+
/** What the last warn said, so a per-clip refusal is not a per-clip log line. */
|
|
89573
|
+
announced = null;
|
|
89574
|
+
inFlight = null;
|
|
89575
|
+
constructor(input) {
|
|
89576
|
+
this.input = input;
|
|
89577
|
+
this.log = input.logger;
|
|
89578
|
+
this.now = input.now ?? Date.now;
|
|
89579
|
+
this.revalidateMs = input.revalidateMs ?? 6e4;
|
|
89580
|
+
}
|
|
89581
|
+
/** Why the last resolution produced no root. `null` once one succeeded. */
|
|
89582
|
+
get lastRefusal() {
|
|
89583
|
+
return this.refusal;
|
|
89584
|
+
}
|
|
89585
|
+
/** The doorbell rang (`StorageLocationsChanged`): drop the mirror. */
|
|
89586
|
+
invalidate() {
|
|
89587
|
+
this.resolvedAt = Number.NEGATIVE_INFINITY;
|
|
89588
|
+
}
|
|
89589
|
+
/**
|
|
89590
|
+
* The directory clips are written under, or `null` when there is none right
|
|
89591
|
+
* now. Never throws: the caller's correct move for every refusal is the same
|
|
89592
|
+
* — do not tee this clip, and leave HomeKit alone.
|
|
89593
|
+
*/
|
|
89594
|
+
async root() {
|
|
89595
|
+
if (this.now() - this.resolvedAt < this.revalidateMs) return this.cachedRoot;
|
|
89596
|
+
const inflight = this.inFlight;
|
|
89597
|
+
if (inflight !== null) return inflight;
|
|
89598
|
+
const attempt = this.resolve();
|
|
89599
|
+
this.inFlight = attempt;
|
|
89600
|
+
try {
|
|
89601
|
+
return await attempt;
|
|
89602
|
+
} finally {
|
|
89603
|
+
this.inFlight = null;
|
|
89604
|
+
}
|
|
89605
|
+
}
|
|
89606
|
+
async resolve() {
|
|
89607
|
+
try {
|
|
89608
|
+
const rows = (await this.input.ports.listLocations()).filter((l) => l.type === HOMEKIT_CLIPS_LOCATION_TYPE);
|
|
89609
|
+
if (rows.length === 0) return this.refuse("no-location", {});
|
|
89610
|
+
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) });
|
|
89611
|
+
const writable = rows.find((l) => mayWriteToLocation(l));
|
|
89612
|
+
if (writable === void 0) {
|
|
89613
|
+
const first = rows[0];
|
|
89614
|
+
return this.refuse("not-writable", {
|
|
89615
|
+
locationId: first?.id ?? null,
|
|
89616
|
+
mode: first === void 0 ? null : resolveLocationMode(first)
|
|
89617
|
+
});
|
|
89618
|
+
}
|
|
89619
|
+
const root = `${(await this.input.ports.resolvePath(writable.id)).replace(/\/+$/, "")}/${HOMEKIT_CLIPS_SUBTREE}`;
|
|
89620
|
+
this.cachedRoot = root;
|
|
89621
|
+
this.resolvedAt = this.now();
|
|
89622
|
+
this.refusal = null;
|
|
89623
|
+
if (this.announced !== null) {
|
|
89624
|
+
this.announced = null;
|
|
89625
|
+
this.log.info("hksv clip store: the clips location is writable again", { meta: {
|
|
89626
|
+
locationId: writable.id,
|
|
89627
|
+
root
|
|
89628
|
+
} });
|
|
89629
|
+
}
|
|
89630
|
+
return root;
|
|
89631
|
+
} catch (err) {
|
|
89632
|
+
return this.refuse("unreachable", { error: err instanceof Error ? err.message : String(err) });
|
|
89633
|
+
}
|
|
89634
|
+
}
|
|
89635
|
+
refuse(reason, meta) {
|
|
89636
|
+
this.cachedRoot = null;
|
|
89637
|
+
this.resolvedAt = this.now();
|
|
89638
|
+
this.refusal = reason;
|
|
89639
|
+
this.warnOnce("hksv clip store: no writable HomeKit clips location — clips are NOT being kept (HomeKit recording is unaffected)", {
|
|
89640
|
+
reason,
|
|
89641
|
+
...meta
|
|
89642
|
+
});
|
|
89643
|
+
return null;
|
|
89644
|
+
}
|
|
89645
|
+
/**
|
|
89646
|
+
* One line per distinct situation. A refusal that reprinted on every clip
|
|
89647
|
+
* would drown the line that says it CHANGED, and the operator reads the log
|
|
89648
|
+
* to find out which of the two is happening.
|
|
89649
|
+
*/
|
|
89650
|
+
warnOnce(message, meta) {
|
|
89651
|
+
const key = `${message}|${JSON.stringify(meta)}`;
|
|
89652
|
+
if (this.announced === key) return;
|
|
89653
|
+
this.announced = key;
|
|
89654
|
+
this.log.warn(message, { meta });
|
|
89655
|
+
}
|
|
89656
|
+
};
|
|
89657
|
+
//#endregion
|
|
89658
|
+
//#region src/hksv/clip-remux.ts
|
|
89659
|
+
/**
|
|
89660
|
+
* The faststart pass: the teed clip is made SEEKABLE, once, by the process that
|
|
89661
|
+
* minted it.
|
|
89662
|
+
*
|
|
89663
|
+
* ## Why the producer owes this
|
|
89664
|
+
*
|
|
89665
|
+
* The tee cuts like a pipe. ffmpeg writes `frag_keyframe+empty_moov` and never
|
|
89666
|
+
* returns to write a trailer, so on a teed clip `mvhd.duration` and
|
|
89667
|
+
* `mdhd.duration` are **0**, the `stbl` sample tables are EMPTY and there is no
|
|
89668
|
+
* `mfra`. Measured, on this hub's own files. The result decodes end to end — it
|
|
89669
|
+
* plays — but nothing can map a time to a byte in it, and a time-to-byte map is
|
|
89670
|
+
* the whole of what a scrub, a `Range` request and a stated duration are.
|
|
89671
|
+
*
|
|
89672
|
+
* The alternative was a remux in the CONSUMER, at play time. That puts an
|
|
89673
|
+
* ffmpeg per viewer in the broker's runner and buffers the whole file to write
|
|
89674
|
+
* a trailer, every time anybody drags — the cost D570 § 5(b) rejected, moved
|
|
89675
|
+
* one process along. Here it is one pass per clip, at the moment the clip is
|
|
89676
|
+
* born, measured at 40 ms and +0.7 % bytes. A clip is written once and read
|
|
89677
|
+
* many times; this is the side of that asymmetry the work belongs on.
|
|
89678
|
+
*
|
|
89679
|
+
* ## The constraint that outranks the feature
|
|
89680
|
+
*
|
|
89681
|
+
* Exactly as for the tee itself: **a clip that does not seek is a clip; a clip
|
|
89682
|
+
* the remux ate is a HomeKit recording the operator lost.** So the pass writes
|
|
89683
|
+
* a SIBLING and renames over the original only once it has vouched for it, it
|
|
89684
|
+
* never throws, and every way of failing has its own name on the record —
|
|
89685
|
+
* a missing binary, a deadline and a file ffmpeg blessed but did not fix are
|
|
89686
|
+
* three different operator actions.
|
|
89687
|
+
*
|
|
89688
|
+
* ## The vouch is a measurement, not an exit code
|
|
89689
|
+
*
|
|
89690
|
+
* `-movflags +faststart` exiting 0 is not evidence. {@link moovPrecedesMdat}
|
|
89691
|
+
* reads the top-level box order off the produced file and requires `moov`
|
|
89692
|
+
* ahead of the media — and requires the file to be PROGRESSIVE, because the
|
|
89693
|
+
* fragmented shape the tee already wrote is *also* moov-first and is exactly
|
|
89694
|
+
* what this pass exists to replace.
|
|
89695
|
+
*/
|
|
89696
|
+
/** A remux that has not finished by here is not going to. */
|
|
89697
|
+
var CLIP_REMUX_TIMEOUT_MS = 3e4;
|
|
89698
|
+
/** The sibling the pass writes before it has earned the clip's own name. */
|
|
89699
|
+
var CLIP_REMUX_SUFFIX = ".faststart";
|
|
89700
|
+
/**
|
|
89701
|
+
* How much of the head is read back to judge the box order. The `moov` of a
|
|
89702
|
+
* short clip is a few hundred KB at most, and this only has to reach far enough
|
|
89703
|
+
* to meet the first `mdat` or `moof`.
|
|
89704
|
+
*/
|
|
89705
|
+
var HEAD_BYTES = 1024 * 1024;
|
|
89706
|
+
function buildClipRemuxArgs(input) {
|
|
89707
|
+
return [
|
|
89708
|
+
"-hide_banner",
|
|
89709
|
+
"-loglevel",
|
|
89710
|
+
"error",
|
|
89711
|
+
"-nostdin",
|
|
89712
|
+
"-y",
|
|
89713
|
+
"-i",
|
|
89714
|
+
input.clipPath,
|
|
89715
|
+
"-c",
|
|
89716
|
+
"copy",
|
|
89717
|
+
"-movflags",
|
|
89718
|
+
"+faststart",
|
|
89719
|
+
"-f",
|
|
89720
|
+
"mp4",
|
|
89721
|
+
input.outPath
|
|
89722
|
+
];
|
|
89723
|
+
}
|
|
89724
|
+
function createClipRemuxer(input) {
|
|
89725
|
+
return async ({ clipPath }) => {
|
|
89726
|
+
const outPath = `${clipPath}${CLIP_REMUX_SUFFIX}`;
|
|
89727
|
+
try {
|
|
89728
|
+
const run = await input.runner(buildClipRemuxArgs({
|
|
89729
|
+
clipPath,
|
|
89730
|
+
outPath
|
|
89731
|
+
}));
|
|
89732
|
+
if (run.spawnFailed) return await discard(outPath, "ffmpeg-missing");
|
|
89733
|
+
if (run.timedOut) return await discard(outPath, "timed-out");
|
|
89734
|
+
if (run.code !== 0) return await discard(outPath, "remux-failed");
|
|
89735
|
+
const size = await sizeOf(outPath);
|
|
89736
|
+
if (size === null || size === 0) return await discard(outPath, "remux-failed");
|
|
89737
|
+
const head = await readHead(outPath);
|
|
89738
|
+
if (!moovPrecedesMdat(head)) return await discard(outPath, "not-seekable");
|
|
89739
|
+
const durationMs = readMovieDurationMs(head);
|
|
89740
|
+
await (0, node_fs_promises.rename)(outPath, clipPath);
|
|
89741
|
+
return {
|
|
89742
|
+
ok: true,
|
|
89743
|
+
bytes: size,
|
|
89744
|
+
...durationMs === null ? {} : { durationMs }
|
|
89745
|
+
};
|
|
89746
|
+
} catch {
|
|
89747
|
+
return await discard(outPath, "remux-failed");
|
|
89748
|
+
}
|
|
89749
|
+
};
|
|
89750
|
+
}
|
|
89751
|
+
async function discard(outPath, reason) {
|
|
89752
|
+
await (0, node_fs_promises.rm)(outPath, { force: true }).catch(() => void 0);
|
|
89753
|
+
return {
|
|
89754
|
+
ok: false,
|
|
89755
|
+
reason
|
|
89756
|
+
};
|
|
89757
|
+
}
|
|
89758
|
+
async function sizeOf(path) {
|
|
89759
|
+
try {
|
|
89760
|
+
return (await (0, node_fs_promises.stat)(path)).size;
|
|
89761
|
+
} catch {
|
|
89762
|
+
return null;
|
|
89763
|
+
}
|
|
89764
|
+
}
|
|
89765
|
+
async function readHead(path) {
|
|
89766
|
+
const buf = await (0, node_fs_promises.readFile)(path);
|
|
89767
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, Math.min(buf.byteLength, HEAD_BYTES));
|
|
89768
|
+
}
|
|
89769
|
+
/**
|
|
89770
|
+
* Does this file carry a progressive index ahead of its media?
|
|
89771
|
+
*
|
|
89772
|
+
* Walks the TOP-LEVEL boxes only. Three verdicts collapse into `false`, and
|
|
89773
|
+
* each of them is a real file this store has held:
|
|
89774
|
+
*
|
|
89775
|
+
* - `moov` after `mdat` — a plain mux, seekable only once the whole file is
|
|
89776
|
+
* in hand, which over a `Range` reader means never.
|
|
89777
|
+
* - `moof` before any `mdat` — the FRAGMENTED shape the tee writes. Its
|
|
89778
|
+
* leading `moov` is the empty one `empty_moov` produced, so judging on
|
|
89779
|
+
* position alone would bless exactly the file this pass replaces.
|
|
89780
|
+
* - anything unreadable — a truncated header, or a box claiming a size that
|
|
89781
|
+
* does not advance the cursor. A corrupt file is never guessed at, and a
|
|
89782
|
+
* non-advancing size is how a scanner spins for ever.
|
|
89783
|
+
*/
|
|
89784
|
+
function moovPrecedesMdat(bytes) {
|
|
89785
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
89786
|
+
let at = 0;
|
|
89787
|
+
let sawMoov = false;
|
|
89788
|
+
while (at + 8 <= bytes.byteLength) {
|
|
89789
|
+
const declared = view.getUint32(at);
|
|
89790
|
+
const type = String.fromCharCode(bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]);
|
|
89791
|
+
let size = declared;
|
|
89792
|
+
let header = 8;
|
|
89793
|
+
if (declared === 1) {
|
|
89794
|
+
if (at + 16 > bytes.byteLength) return false;
|
|
89795
|
+
const large = view.getBigUint64(at + 8);
|
|
89796
|
+
if (large > BigInt(Number.MAX_SAFE_INTEGER)) return false;
|
|
89797
|
+
size = Number(large);
|
|
89798
|
+
header = 16;
|
|
89799
|
+
}
|
|
89800
|
+
if (size < header) return false;
|
|
89801
|
+
if (type === "moov") sawMoov = true;
|
|
89802
|
+
if (type === "mdat") return sawMoov;
|
|
89803
|
+
if (type === "moof") return false;
|
|
89804
|
+
at += size;
|
|
89805
|
+
}
|
|
89806
|
+
return false;
|
|
89807
|
+
}
|
|
89808
|
+
/**
|
|
89809
|
+
* The movie duration, in ms, from the `mvhd` inside `moov`.
|
|
89810
|
+
*
|
|
89811
|
+
* `null`, never `0`, for every way of not knowing — no `moov`, no `mvhd`, a
|
|
89812
|
+
* truncated header, a zero timescale, or the **zero duration `empty_moov`
|
|
89813
|
+
* writes**, which is the case that matters: believing it would replace a
|
|
89814
|
+
* duration that is merely wrong with one that confidently says the clip is
|
|
89815
|
+
* empty (D393).
|
|
89816
|
+
*
|
|
89817
|
+
* Worth having because the alternative number is wrong in a specific,
|
|
89818
|
+
* measurable way: `endedAtMs - startedAtMs` is how long the TEE ran, and the
|
|
89819
|
+
* first fragments it wrote were the prebuffer — footage older than the tee
|
|
89820
|
+
* itself. On 615 that is 12.0 s claimed against 19.99 s held.
|
|
89821
|
+
*/
|
|
89822
|
+
function readMovieDurationMs(bytes) {
|
|
89823
|
+
const moov = findTopLevelBox(bytes, "moov");
|
|
89824
|
+
if (moov === null) return null;
|
|
89825
|
+
const mvhd = findTopLevelBox(moov, "mvhd");
|
|
89826
|
+
if (mvhd === null || mvhd.byteLength < 4) return null;
|
|
89827
|
+
const view = new DataView(mvhd.buffer, mvhd.byteOffset, mvhd.byteLength);
|
|
89828
|
+
const version = mvhd[0];
|
|
89829
|
+
const timescaleAt = version === 1 ? 20 : 12;
|
|
89830
|
+
const durationAt = timescaleAt + 4;
|
|
89831
|
+
if (durationAt + (version === 1 ? 8 : 4) > mvhd.byteLength) return null;
|
|
89832
|
+
const timescale = view.getUint32(timescaleAt);
|
|
89833
|
+
if (timescale === 0) return null;
|
|
89834
|
+
const duration = version === 1 ? Number(view.getBigUint64(durationAt)) : view.getUint32(durationAt);
|
|
89835
|
+
if (duration <= 0 || !Number.isFinite(duration)) return null;
|
|
89836
|
+
return Math.round(duration / timescale * 1e3);
|
|
89837
|
+
}
|
|
89838
|
+
/**
|
|
89839
|
+
* The PAYLOAD — header stripped — of the first top-level box of `type`, or
|
|
89840
|
+
* `null`. Stripped, because the only reason to hold a box here is to walk its
|
|
89841
|
+
* children, and leaving the header on makes the first child look like the
|
|
89842
|
+
* parent.
|
|
89843
|
+
*
|
|
89844
|
+
* Deliberately the same walk as {@link moovPrecedesMdat} rather than a shared
|
|
89845
|
+
* generator: that one answers a question about ORDER and stops at the media,
|
|
89846
|
+
* this one descends. Both refuse a box that does not advance the cursor, which
|
|
89847
|
+
* is the property neither can do without.
|
|
89848
|
+
*/
|
|
89849
|
+
function findTopLevelBox(bytes, type) {
|
|
89850
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
89851
|
+
let at = 0;
|
|
89852
|
+
while (at + 8 <= bytes.byteLength) {
|
|
89853
|
+
const declared = view.getUint32(at);
|
|
89854
|
+
const found = String.fromCharCode(bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]);
|
|
89855
|
+
let size = declared;
|
|
89856
|
+
let header = 8;
|
|
89857
|
+
if (declared === 1) {
|
|
89858
|
+
if (at + 16 > bytes.byteLength) return null;
|
|
89859
|
+
const large = view.getBigUint64(at + 8);
|
|
89860
|
+
if (large > BigInt(Number.MAX_SAFE_INTEGER)) return null;
|
|
89861
|
+
size = Number(large);
|
|
89862
|
+
header = 16;
|
|
89863
|
+
}
|
|
89864
|
+
if (size < header) return null;
|
|
89865
|
+
const end = Math.min(at + size, bytes.byteLength);
|
|
89866
|
+
if (found === type) return at + header >= end ? null : bytes.subarray(at + header, end);
|
|
89867
|
+
at += size;
|
|
89868
|
+
}
|
|
89869
|
+
return null;
|
|
89870
|
+
}
|
|
89871
|
+
//#endregion
|
|
88782
89872
|
//#region src/hksv/clip-record.ts
|
|
88783
89873
|
/**
|
|
88784
89874
|
* What ONE teed HomeKit clip is, on disk.
|
|
@@ -88800,10 +89890,14 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
88800
89890
|
* `deriveFragmentLengthMs` refuses a GOP longer than 8 s. A camera the tee
|
|
88801
89891
|
* never writes for is not a gap in this store, it is a camera HKSV never
|
|
88802
89892
|
* recorded; {@link HksvClipRecord} exists only for the ones it did.
|
|
88803
|
-
* - **The duration is whatever iOS PULLED.**
|
|
88804
|
-
*
|
|
88805
|
-
*
|
|
88806
|
-
*
|
|
89893
|
+
* - **The duration is whatever iOS PULLED.** Not a window CamStack chose, and
|
|
89894
|
+
* a short clip is not a truncated one — {@link HksvClipRecord.truncated} is
|
|
89895
|
+
* the only thing that says truncated. Since D578 it is READ FROM THE FILE
|
|
89896
|
+
* (`mvhd`, after the faststart pass), because the tee's own wall clock is
|
|
89897
|
+
* wrong in a specific direction: the clock starts when the tee does, and the
|
|
89898
|
+
* first fragments it writes are the PREBUFFER — footage older than the tee
|
|
89899
|
+
* itself. Measured on 615: 12.0 s claimed against 19.99 s held. The wall
|
|
89900
|
+
* clock remains the fallback for a file that could not be measured.
|
|
88807
89901
|
*
|
|
88808
89902
|
* ## `thumbnailFile` is VOUCHED
|
|
88809
89903
|
*
|
|
@@ -88822,6 +89916,18 @@ var HksvThumbnailUnavailableReasonSchema = _enum([
|
|
|
88822
89916
|
"no-init-segment",
|
|
88823
89917
|
"not-attempted"
|
|
88824
89918
|
]);
|
|
89919
|
+
/**
|
|
89920
|
+
* Why a teed clip could not be made seekable. Named, never blank — a missing
|
|
89921
|
+
* binary, a deadline and a file ffmpeg blessed but did not fix are three
|
|
89922
|
+
* different operator actions, and "it does not seek" is none of them.
|
|
89923
|
+
*/
|
|
89924
|
+
var HksvRemuxUnavailableReasonSchema = _enum([
|
|
89925
|
+
"remux-failed",
|
|
89926
|
+
"timed-out",
|
|
89927
|
+
"ffmpeg-missing",
|
|
89928
|
+
"not-seekable",
|
|
89929
|
+
"not-attempted"
|
|
89930
|
+
]);
|
|
88825
89931
|
/** Why a clip stopped short of the stream that fed it. */
|
|
88826
89932
|
var HksvClipTruncationSchema = _enum([
|
|
88827
89933
|
"size-cap",
|
|
@@ -88840,21 +89946,59 @@ var HksvClipRecordSchema = object({
|
|
|
88840
89946
|
streamId: number().int(),
|
|
88841
89947
|
startedAtMs: number().int(),
|
|
88842
89948
|
endedAtMs: number().int(),
|
|
88843
|
-
/**
|
|
89949
|
+
/**
|
|
89950
|
+
* What iOS pulled, never a window we chose — measured from the file's own
|
|
89951
|
+
* `mvhd` where the faststart pass could read it, and `endedAtMs -
|
|
89952
|
+
* startedAtMs` otherwise. See the docblock: the two differ by the prebuffer.
|
|
89953
|
+
*/
|
|
88844
89954
|
durationMs: number().int().nonnegative(),
|
|
88845
89955
|
/** How much of the clip's head came from the prebuffer ring, by ARRIVAL age. */
|
|
88846
89956
|
prebufferSpanMs: number().int().nonnegative(),
|
|
89957
|
+
/**
|
|
89958
|
+
* The size of {@link HksvClipRecord.file} AS IT NOW STANDS on disk — so on a
|
|
89959
|
+
* clip the faststart pass rewrote, the post-remux size, re-stat'd and never
|
|
89960
|
+
* carried over from the tee's own count. The pass costs about +0.7 %, and a
|
|
89961
|
+
* record stating the pre-remux figure would be wrong by exactly that for
|
|
89962
|
+
* ever, on the one field a `Range` reader and the disk accounting both use.
|
|
89963
|
+
*/
|
|
88847
89964
|
bytes: number().int().nonnegative(),
|
|
88848
89965
|
fragments: number().int().nonnegative(),
|
|
88849
89966
|
/** Basenames, relative to the clip's own device directory. */
|
|
88850
89967
|
file: string().min(1),
|
|
88851
89968
|
thumbnailFile: string().min(1).optional(),
|
|
88852
89969
|
thumbnailUnavailable: object({ reason: HksvThumbnailUnavailableReasonSchema }).optional(),
|
|
89970
|
+
/**
|
|
89971
|
+
* The faststart pass ran and its output was VOUCHED: `moov` ahead of the
|
|
89972
|
+
* media, progressive, read back off the produced file. Present only when
|
|
89973
|
+
* that is true of {@link HksvClipRecord.file}.
|
|
89974
|
+
*
|
|
89975
|
+
* Three states, not two, and the third is the reason this is optional rather
|
|
89976
|
+
* than a boolean: `true` means it seeks, {@link
|
|
89977
|
+
* HksvClipRecord.remuxUnavailable} means it does not and says why, and
|
|
89978
|
+
* NEITHER means unknown — a sidecar written before this field existed cannot
|
|
89979
|
+
* be given one after the fact, and a reader must not read that silence as
|
|
89980
|
+
* "does not seek" (D393).
|
|
89981
|
+
*/
|
|
89982
|
+
seekable: literal(true).optional(),
|
|
89983
|
+
remuxUnavailable: object({ reason: HksvRemuxUnavailableReasonSchema }).optional(),
|
|
88853
89984
|
truncated: HksvClipTruncationSchema.optional(),
|
|
88854
89985
|
/** iOS sent `ack` for this stream: HomeKit itself kept the clip. */
|
|
88855
89986
|
acknowledgedByHomeKit: boolean(),
|
|
88856
89987
|
width: number().int().positive(),
|
|
88857
89988
|
height: number().int().positive(),
|
|
89989
|
+
/**
|
|
89990
|
+
* WHICH broker slot HomeKit recorded from, on the one quality scale the
|
|
89991
|
+
* system has (`high | mid | low`).
|
|
89992
|
+
*
|
|
89993
|
+
* Written by the tee from `pickRecordingSource`'s choice, because it is the
|
|
89994
|
+
* only thing that knows: the pixels do not say which profile produced them,
|
|
89995
|
+
* and a consumer that needs `served` (`getClipPlayback`, `readClipBytes`)
|
|
89996
|
+
* must never derive it from the resolution. Optional because a sidecar
|
|
89997
|
+
* written before this field existed cannot be given one after the fact — a
|
|
89998
|
+
* reader REFUSES to name a twin it was never told (D393), rather than
|
|
89999
|
+
* guessing one.
|
|
90000
|
+
*/
|
|
90001
|
+
profile: CamProfileSchema.optional(),
|
|
88858
90002
|
/** The advertised fragment length the source was cutting at. */
|
|
88859
90003
|
fragmentMs: number().int().positive()
|
|
88860
90004
|
});
|
|
@@ -88863,7 +90007,17 @@ var HksvClipRecordSchema = object({
|
|
|
88863
90007
|
* else, so a pruner deleting a clip never has to guess which JPEG was its.
|
|
88864
90008
|
*/
|
|
88865
90009
|
function clipFileNames(clipId) {
|
|
88866
|
-
|
|
90010
|
+
return clipFileNamesForStem(clipStem(clipId));
|
|
90011
|
+
}
|
|
90012
|
+
/**
|
|
90013
|
+
* The same three names, from a stem a URL already carries.
|
|
90014
|
+
*
|
|
90015
|
+
* A data-plane route is handed the stem, not the id — `:` is legal on ext4 and
|
|
90016
|
+
* hostile in a path everywhere else, which is why {@link clipStem} exists — and
|
|
90017
|
+
* it must not re-derive the layout. One place mints these names, for both
|
|
90018
|
+
* callers.
|
|
90019
|
+
*/
|
|
90020
|
+
function clipFileNamesForStem(stem) {
|
|
88867
90021
|
return {
|
|
88868
90022
|
clip: `${stem}.mp4`,
|
|
88869
90023
|
thumbnail: `${stem}.jpg`,
|
|
@@ -88884,32 +90038,39 @@ function clipStem(clipId) {
|
|
|
88884
90038
|
*
|
|
88885
90039
|
* ## The owner
|
|
88886
90040
|
*
|
|
88887
|
-
* The **export-hap addon**,
|
|
88888
|
-
*
|
|
90041
|
+
* The **export-hap addon**, on its own DECLARED storage location
|
|
90042
|
+
* (`homekitClips`, see `clip-location.ts`) — the operator picks the disk. Not
|
|
90043
|
+
* the `eventMedia` class and not `recordings`:
|
|
88889
90044
|
*
|
|
88890
|
-
* - `export-hap` is hub-only and runs in its own runner. The recorder's
|
|
88891
|
-
* location machinery (D385/D386/D389) is another addon's, in another
|
|
88892
|
-
* process, and reaching it would be a cross-runner write on the frame path
|
|
88893
|
-
* of a live HomeKit session — exactly what D9/D18 forbid.
|
|
88894
90045
|
* - A teed clip is a DERIVED artifact of a HomeKit session, with no track, no
|
|
88895
90046
|
* detection and no event behind it. Filing it under `eventMedia` would put
|
|
88896
90047
|
* it inside the post-analysis retention sweep, where its lifetime would be
|
|
88897
90048
|
* decided by a policy written for a different question.
|
|
88898
|
-
* -
|
|
88899
|
-
*
|
|
88900
|
-
*
|
|
90049
|
+
* - `recordings` rows belong to the recorder's placement planner, its evictor
|
|
90050
|
+
* and its drain ratchet, and these files are not segments its index knows.
|
|
90051
|
+
* - The precedent for a class of its own is D549 decision 13: Reolink's clip
|
|
90052
|
+
* thumbnails sit outside `eventMedia` and outside the sweep for the same
|
|
90053
|
+
* reason.
|
|
88901
90054
|
*
|
|
88902
|
-
* Layout: `<
|
|
88903
|
-
* clip, three extensions, so a pruner deleting a clip never has to
|
|
88904
|
-
* JPEG was its
|
|
90055
|
+
* Layout: `<location root>/homekit-clips/<deviceId>/<stem>.{mp4,jpg,json}`. One
|
|
90056
|
+
* stem per clip, three extensions, so a pruner deleting a clip never has to
|
|
90057
|
+
* guess which JPEG was its; the `homekit-clips/` subtree is mandatory because
|
|
90058
|
+
* the location's seeded default SHARES the recordings root (D327).
|
|
88905
90059
|
*
|
|
88906
|
-
*
|
|
90060
|
+
* The first cut wrote to `<dataDir>/hksv-clips/`. It was defensible and gave
|
|
90061
|
+
* the operator no say, which was the first thing he asked for after seeing it.
|
|
90062
|
+
*
|
|
90063
|
+
* ## The retention: one preference, three rails
|
|
88907
90064
|
*
|
|
88908
90065
|
* HomeKit's own retention is unreadable — HAP has no read-back, so CamStack can
|
|
88909
90066
|
* never ask "does iOS still have this one?" and reconcile. The store therefore
|
|
88910
90067
|
* cannot mirror HomeKit's lifetime and does not pretend to: it keeps a bounded
|
|
88911
|
-
* recent window and says, per clip, exactly when it started.
|
|
88912
|
-
*
|
|
90068
|
+
* recent window and says, per clip, exactly when it started.
|
|
90069
|
+
*
|
|
90070
|
+
* **The AGE is the operator's, per camera**
|
|
90071
|
+
* ({@link HksvClipStoreInput.maxAgeMsFor}, cascaded global-default plus
|
|
90072
|
+
* per-device override by the addon), defaulting to 14 days. The other three are
|
|
90073
|
+
* RAILS, not preferences, and stay fixed:
|
|
88913
90074
|
*
|
|
88914
90075
|
* - **{@link DEFAULT_CLIP_BOUNDS.maxClipBytes} per clip (256 MB).** A clip's
|
|
88915
90076
|
* duration is whatever iOS pulled — there is no window we choose — so this
|
|
@@ -88922,12 +90083,18 @@ function clipStem(clipId) {
|
|
|
88922
90083
|
* Both bounds exist for the same reason the prebuffer ring has both: at 4K
|
|
88923
90084
|
* a fragment is 6.35 MB, a 25x spread, and a count-only bound is a
|
|
88924
90085
|
* per-camera disk figure nobody can predict.
|
|
88925
|
-
* - **{@link DEFAULT_CLIP_BOUNDS.maxAgeMs} (14 days)**, so a camera that
|
|
88926
|
-
* stopped recording in March does not hold half a gigabyte forever.
|
|
88927
90086
|
* - **{@link DEFAULT_CLIP_BOUNDS.maxStoreBytes} (4 GB)** across every camera,
|
|
88928
90087
|
* applied after the per-device pass. Per-device bounds alone multiply by a
|
|
88929
90088
|
* camera count the operator changes without thinking about this store.
|
|
88930
90089
|
*
|
|
90090
|
+
* An operator able to set the first to zero could turn the tee into a silent
|
|
90091
|
+
* no-op, and one able to raise the last without limit could fill the disk; the
|
|
90092
|
+
* age changes neither. The consequence is stated rather than left to be
|
|
90093
|
+
* discovered: a retention LONGER than the rails allow does not reach — a busy
|
|
90094
|
+
* camera hits 200 clips or 512 MB first. Every prune logs which bound fired
|
|
90095
|
+
* (`reason: age | count | bytes`), so "my 30 days did nothing" has an answer in
|
|
90096
|
+
* the log instead of a theory.
|
|
90097
|
+
*
|
|
88931
90098
|
* A file nobody prunes is a disk that fills, and the pruner is the only thing
|
|
88932
90099
|
* standing between a permanent prebuffer and that.
|
|
88933
90100
|
*
|
|
@@ -88940,8 +90107,12 @@ function clipStem(clipId) {
|
|
|
88940
90107
|
* deleted — this repo has twice shipped a green test because the fake supplied
|
|
88941
90108
|
* what production forgot, and a promise is not a file.
|
|
88942
90109
|
*/
|
|
88943
|
-
/**
|
|
88944
|
-
|
|
90110
|
+
/** An `errno` code off an unknown throw, without a cast. */
|
|
90111
|
+
function errnoCode(err) {
|
|
90112
|
+
if (typeof err !== "object" || err === null || !("code" in err)) return null;
|
|
90113
|
+
const { code } = err;
|
|
90114
|
+
return typeof code === "string" ? code : null;
|
|
90115
|
+
}
|
|
88945
90116
|
/** See the class docblock for what each number is, and where it comes from. */
|
|
88946
90117
|
var DEFAULT_CLIP_BOUNDS = {
|
|
88947
90118
|
maxClipBytes: 256 * 1024 * 1024,
|
|
@@ -88965,6 +90136,11 @@ var HksvClipStore = class {
|
|
|
88965
90136
|
};
|
|
88966
90137
|
this.now = input.now ?? Date.now;
|
|
88967
90138
|
}
|
|
90139
|
+
/** This camera's retention. One authority, asked at every prune. */
|
|
90140
|
+
maxAgeMsFor(deviceId) {
|
|
90141
|
+
const resolved = this.input.maxAgeMsFor?.(deviceId);
|
|
90142
|
+
return resolved === void 0 || !Number.isFinite(resolved) || resolved <= 0 ? this.bounds.maxAgeMs : resolved;
|
|
90143
|
+
}
|
|
88968
90144
|
/** The per-clip ceiling the tee enforces while writing. */
|
|
88969
90145
|
get maxClipBytes() {
|
|
88970
90146
|
return this.bounds.maxClipBytes;
|
|
@@ -88977,19 +90153,21 @@ var HksvClipStore = class {
|
|
|
88977
90153
|
async open(input) {
|
|
88978
90154
|
const clipId = clipIdFor(input.deviceId, input.startedAtMs, input.streamId);
|
|
88979
90155
|
const names = clipFileNames(clipId);
|
|
88980
|
-
const dir = this.deviceDir(input.deviceId);
|
|
88981
90156
|
const tags = { deviceId: input.deviceId };
|
|
90157
|
+
const root = await this.resolveRootOrWarn(tags);
|
|
90158
|
+
if (root === null) return null;
|
|
90159
|
+
const dir = this.deviceDir(root, input.deviceId);
|
|
88982
90160
|
try {
|
|
88983
90161
|
await (0, node_fs_promises.mkdir)(dir, { recursive: true });
|
|
88984
90162
|
const sink = await createFileSink((0, node_path.join)(dir, names.clip));
|
|
88985
90163
|
this.inFlight.set(clipId, {
|
|
88986
90164
|
deviceId: input.deviceId,
|
|
88987
|
-
stem: stemOf(names.clip)
|
|
90165
|
+
stem: stemOf$1(names.clip)
|
|
88988
90166
|
});
|
|
88989
90167
|
return {
|
|
88990
90168
|
clipId,
|
|
88991
90169
|
sink,
|
|
88992
|
-
complete: (outcome) => this.complete(clipId, input, names, outcome)
|
|
90170
|
+
complete: (outcome) => this.complete(clipId, input, names, outcome, root)
|
|
88993
90171
|
};
|
|
88994
90172
|
} catch (err) {
|
|
88995
90173
|
this.inFlight.delete(clipId);
|
|
@@ -89005,12 +90183,52 @@ var HksvClipStore = class {
|
|
|
89005
90183
|
}
|
|
89006
90184
|
/** Every sidecar for a camera, newest first. What a later provider lists. */
|
|
89007
90185
|
async listRecords(deviceId) {
|
|
89008
|
-
const
|
|
90186
|
+
const catalog = await this.listCatalog(deviceId);
|
|
90187
|
+
return catalog.kind === "catalog" ? catalog.records : [];
|
|
90188
|
+
}
|
|
90189
|
+
/**
|
|
90190
|
+
* ONE directory read, answering the three questions a provider has to tell
|
|
90191
|
+
* apart: what was written, what is still THERE, and — when neither — whether
|
|
90192
|
+
* the location is gone or the read failed.
|
|
90193
|
+
*
|
|
90194
|
+
* `listRecords` is this method's `records` and nothing else, so the published
|
|
90195
|
+
* D550 contract and the provider can never disagree about what the store
|
|
90196
|
+
* holds. The file name set is here rather than a `stat` per clip because the
|
|
90197
|
+
* `readdir` has already answered it: a sidecar whose fMP4 was removed is a
|
|
90198
|
+
* row that must still LIST, saying it cannot be played (D549's `playable`),
|
|
90199
|
+
* and N syscalls to re-learn what one call said is the shape D447 charges
|
|
90200
|
+
* for.
|
|
90201
|
+
*
|
|
90202
|
+
* A device directory that does not exist is an EMPTY catalog, not a refusal:
|
|
90203
|
+
* the location is fine and this camera has recorded nothing. A `readdir` that
|
|
90204
|
+
* failed for any other reason is `failed` — a measurement that failed is
|
|
90205
|
+
* never folded into "no clips" (D393).
|
|
90206
|
+
*/
|
|
90207
|
+
async listCatalog(deviceId) {
|
|
90208
|
+
let root;
|
|
90209
|
+
try {
|
|
90210
|
+
root = await this.input.resolveRoot();
|
|
90211
|
+
} catch (err) {
|
|
90212
|
+
return {
|
|
90213
|
+
kind: "failed",
|
|
90214
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90215
|
+
};
|
|
90216
|
+
}
|
|
90217
|
+
if (root === null) return { kind: "no-root" };
|
|
90218
|
+
const dir = this.deviceDir(root, deviceId);
|
|
89009
90219
|
let names;
|
|
89010
90220
|
try {
|
|
89011
90221
|
names = await (0, node_fs_promises.readdir)(dir);
|
|
89012
|
-
} catch {
|
|
89013
|
-
return
|
|
90222
|
+
} catch (err) {
|
|
90223
|
+
if (errnoCode(err) === "ENOENT") return {
|
|
90224
|
+
kind: "catalog",
|
|
90225
|
+
records: [],
|
|
90226
|
+
files: /* @__PURE__ */ new Set()
|
|
90227
|
+
};
|
|
90228
|
+
return {
|
|
90229
|
+
kind: "failed",
|
|
90230
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90231
|
+
};
|
|
89014
90232
|
}
|
|
89015
90233
|
const records = [];
|
|
89016
90234
|
for (const name of names) {
|
|
@@ -89018,11 +90236,30 @@ var HksvClipStore = class {
|
|
|
89018
90236
|
const record = await this.readRecord((0, node_path.join)(dir, name), deviceId);
|
|
89019
90237
|
if (record !== null) records.push(record);
|
|
89020
90238
|
}
|
|
89021
|
-
return
|
|
90239
|
+
return {
|
|
90240
|
+
kind: "catalog",
|
|
90241
|
+
records: records.toSorted((a, b) => b.startedAtMs - a.startedAtMs),
|
|
90242
|
+
files: new Set(names)
|
|
90243
|
+
};
|
|
90244
|
+
}
|
|
90245
|
+
/**
|
|
90246
|
+
* Where ONE artifact of a clip lives, or `null` when there is no root.
|
|
90247
|
+
*
|
|
90248
|
+
* The stem, never a path: the caller (a data-plane route) has a URL segment
|
|
90249
|
+
* and the store owns the layout. Nothing else may compose a path into the
|
|
90250
|
+
* clips location.
|
|
90251
|
+
*/
|
|
90252
|
+
async artifactPath(deviceId, stem, kind) {
|
|
90253
|
+
const root = await this.rootOrNull();
|
|
90254
|
+
if (root === null) return null;
|
|
90255
|
+
const names = clipFileNamesForStem(stem);
|
|
90256
|
+
return (0, node_path.join)(this.deviceDir(root, deviceId), names[kind]);
|
|
89022
90257
|
}
|
|
89023
90258
|
/** iOS sent `ack`: HomeKit itself kept this clip. Recorded on the sidecar. */
|
|
89024
90259
|
async acknowledge(deviceId, clipId) {
|
|
89025
|
-
const
|
|
90260
|
+
const root = await this.rootOrNull();
|
|
90261
|
+
if (root === null) return;
|
|
90262
|
+
const path = (0, node_path.join)(this.deviceDir(root, deviceId), clipFileNames(clipId).record);
|
|
89026
90263
|
const record = await this.readRecord(path, deviceId);
|
|
89027
90264
|
if (record === null) return;
|
|
89028
90265
|
await this.writeRecord(path, {
|
|
@@ -89030,12 +90267,41 @@ var HksvClipStore = class {
|
|
|
89030
90267
|
acknowledgedByHomeKit: true
|
|
89031
90268
|
});
|
|
89032
90269
|
}
|
|
89033
|
-
|
|
89034
|
-
|
|
90270
|
+
/**
|
|
90271
|
+
* The root, or `null` with ONE warn. Never throws: the caller's correct move
|
|
90272
|
+
* for every refusal is identical — no clip, HomeKit untouched.
|
|
90273
|
+
*/
|
|
90274
|
+
async resolveRootOrWarn(tags) {
|
|
90275
|
+
try {
|
|
90276
|
+
const root = await this.input.resolveRoot();
|
|
90277
|
+
if (root !== null) return root;
|
|
90278
|
+
} catch (err) {
|
|
90279
|
+
this.log.warn("hksv clip store: resolving the clips location FAILED — nothing is teed", {
|
|
90280
|
+
tags,
|
|
90281
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
90282
|
+
});
|
|
90283
|
+
return null;
|
|
90284
|
+
}
|
|
90285
|
+
this.log.warn("hksv clip store: no writable clips location — this camera keeps no HomeKit clips", {
|
|
90286
|
+
tags,
|
|
90287
|
+
meta: {}
|
|
90288
|
+
});
|
|
90289
|
+
return null;
|
|
90290
|
+
}
|
|
90291
|
+
/** The root for a READ. Quiet: a read with no location is simply empty. */
|
|
90292
|
+
async rootOrNull() {
|
|
90293
|
+
try {
|
|
90294
|
+
return await this.input.resolveRoot();
|
|
90295
|
+
} catch {
|
|
90296
|
+
return null;
|
|
90297
|
+
}
|
|
90298
|
+
}
|
|
90299
|
+
deviceDir(root, deviceId) {
|
|
90300
|
+
return (0, node_path.join)(root, String(deviceId));
|
|
89035
90301
|
}
|
|
89036
|
-
async complete(clipId, input, names, outcome) {
|
|
90302
|
+
async complete(clipId, input, names, outcome, root) {
|
|
89037
90303
|
this.inFlight.delete(clipId);
|
|
89038
|
-
const dir = this.deviceDir(input.deviceId);
|
|
90304
|
+
const dir = this.deviceDir(root, input.deviceId);
|
|
89039
90305
|
const tags = { deviceId: input.deviceId };
|
|
89040
90306
|
const clipPath = (0, node_path.join)(dir, names.clip);
|
|
89041
90307
|
if (!outcome.sawInit || outcome.bytes === 0) {
|
|
@@ -89051,6 +90317,7 @@ var HksvClipStore = class {
|
|
|
89051
90317
|
});
|
|
89052
90318
|
return null;
|
|
89053
90319
|
}
|
|
90320
|
+
const remux = await this.remuxAndVouch(clipPath, tags, clipId);
|
|
89054
90321
|
const thumbnail = await this.mintAndVouch({
|
|
89055
90322
|
deviceId: input.deviceId,
|
|
89056
90323
|
clipPath,
|
|
@@ -89065,17 +90332,19 @@ var HksvClipStore = class {
|
|
|
89065
90332
|
streamId: input.streamId,
|
|
89066
90333
|
startedAtMs: outcome.startedAtMs,
|
|
89067
90334
|
endedAtMs: outcome.endedAtMs,
|
|
89068
|
-
durationMs: Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
|
|
90335
|
+
durationMs: remux.ok && remux.durationMs !== void 0 ? remux.durationMs : Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
|
|
89069
90336
|
prebufferSpanMs: input.prebufferSpanMs,
|
|
89070
|
-
bytes: outcome.bytes,
|
|
90337
|
+
bytes: remux.ok ? remux.bytes : outcome.bytes,
|
|
89071
90338
|
fragments: outcome.fragments,
|
|
89072
90339
|
file: names.clip,
|
|
89073
90340
|
...thumbnail.ok ? { thumbnailFile: names.thumbnail } : { thumbnailUnavailable: { reason: thumbnail.reason } },
|
|
90341
|
+
...remux.ok ? { seekable: true } : { remuxUnavailable: { reason: remux.reason } },
|
|
89074
90342
|
...outcome.truncated === null ? {} : { truncated: outcome.truncated },
|
|
89075
90343
|
acknowledgedByHomeKit: false,
|
|
89076
90344
|
width: input.width,
|
|
89077
90345
|
height: input.height,
|
|
89078
|
-
fragmentMs: input.fragmentMs
|
|
90346
|
+
fragmentMs: input.fragmentMs,
|
|
90347
|
+
...input.profile === void 0 ? {} : { profile: input.profile }
|
|
89079
90348
|
};
|
|
89080
90349
|
await this.writeRecord((0, node_path.join)(dir, names.record), record);
|
|
89081
90350
|
this.log.info("hksv clip tee: a HomeKit clip was KEPT", {
|
|
@@ -89092,13 +90361,53 @@ var HksvClipStore = class {
|
|
|
89092
90361
|
thumbnailUnavailable: record.thumbnailUnavailable?.reason ?? null
|
|
89093
90362
|
}
|
|
89094
90363
|
});
|
|
89095
|
-
await this.prune(input.deviceId);
|
|
90364
|
+
await this.prune(root, input.deviceId);
|
|
89096
90365
|
return record;
|
|
89097
90366
|
}
|
|
89098
90367
|
/**
|
|
89099
90368
|
* Mint, then CHECK. The minter's own verdict decides nothing on its own: a
|
|
89100
90369
|
* JPEG is vouched by `stat`, never by a return value.
|
|
89101
90370
|
*/
|
|
90371
|
+
/**
|
|
90372
|
+
* Run the faststart pass, and never let it cost the clip.
|
|
90373
|
+
*
|
|
90374
|
+
* The remuxer already vouches for its own output and never throws by
|
|
90375
|
+
* contract — this wrapper exists for the one case a contract cannot cover: a
|
|
90376
|
+
* remuxer that throws anyway. A clip that does not seek is a clip; a clip
|
|
90377
|
+
* this pass ate is a HomeKit recording the operator lost, and that asymmetry
|
|
90378
|
+
* is the whole reason the failure is caught here and named on the record.
|
|
90379
|
+
*/
|
|
90380
|
+
async remuxAndVouch(clipPath, tags, clipId) {
|
|
90381
|
+
const remux = this.input.remuxClip;
|
|
90382
|
+
if (remux === void 0) return {
|
|
90383
|
+
ok: false,
|
|
90384
|
+
reason: "not-attempted"
|
|
90385
|
+
};
|
|
90386
|
+
let result;
|
|
90387
|
+
try {
|
|
90388
|
+
result = await remux({ clipPath });
|
|
90389
|
+
} catch (err) {
|
|
90390
|
+
result = {
|
|
90391
|
+
ok: false,
|
|
90392
|
+
reason: "remux-failed"
|
|
90393
|
+
};
|
|
90394
|
+
this.log.warn("hksv clip tee: the faststart remuxer THREW — the clip is kept unseekable", {
|
|
90395
|
+
tags,
|
|
90396
|
+
meta: {
|
|
90397
|
+
clipId,
|
|
90398
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90399
|
+
}
|
|
90400
|
+
});
|
|
90401
|
+
}
|
|
90402
|
+
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", {
|
|
90403
|
+
tags,
|
|
90404
|
+
meta: {
|
|
90405
|
+
clipId,
|
|
90406
|
+
reason: result.reason
|
|
90407
|
+
}
|
|
90408
|
+
});
|
|
90409
|
+
return result;
|
|
90410
|
+
}
|
|
89102
90411
|
async mintAndVouch(mint) {
|
|
89103
90412
|
const { deviceId, jpegPath } = mint;
|
|
89104
90413
|
const minter = this.input.mintThumbnail;
|
|
@@ -89139,26 +90448,31 @@ var HksvClipStore = class {
|
|
|
89139
90448
|
* sidecars, so a stem whose sidecar never landed — a crash mid-write — is
|
|
89140
90449
|
* seen and removed instead of occupying the disk invisibly forever.
|
|
89141
90450
|
*/
|
|
89142
|
-
async prune(deviceId) {
|
|
89143
|
-
const
|
|
89144
|
-
|
|
89145
|
-
await this.enforce(perDevice, cutoff, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
|
|
90451
|
+
async prune(root, deviceId) {
|
|
90452
|
+
const perDevice = await this.scanDevice(root, deviceId);
|
|
90453
|
+
await this.enforce(perDevice, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
|
|
89146
90454
|
let deviceIds;
|
|
89147
90455
|
try {
|
|
89148
|
-
deviceIds = (await (0, node_fs_promises.readdir)(
|
|
90456
|
+
deviceIds = (await (0, node_fs_promises.readdir)(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => Number.parseInt(e.name, 10)).filter((id) => Number.isFinite(id));
|
|
89149
90457
|
} catch {
|
|
89150
90458
|
return;
|
|
89151
90459
|
}
|
|
89152
90460
|
const all = [];
|
|
89153
|
-
for (const id of deviceIds) all.push(...await this.scanDevice(id));
|
|
89154
|
-
await this.enforce(all,
|
|
90461
|
+
for (const id of deviceIds) all.push(...await this.scanDevice(root, id));
|
|
90462
|
+
await this.enforce(all, Number.POSITIVE_INFINITY, this.bounds.maxStoreBytes);
|
|
89155
90463
|
}
|
|
89156
|
-
|
|
90464
|
+
/**
|
|
90465
|
+
* Enforce the bounds, oldest first. The AGE is asked per clip's CAMERA — two
|
|
90466
|
+
* cameras with different retentions share this pass, and folding them into
|
|
90467
|
+
* one cutoff would silently give every camera the shortest.
|
|
90468
|
+
*/
|
|
90469
|
+
async enforce(clips, maxCount, maxBytes) {
|
|
90470
|
+
const now = this.now();
|
|
89157
90471
|
const ordered = [...clips].sort((a, b) => b.startedAtMs - a.startedAtMs);
|
|
89158
90472
|
let kept = 0;
|
|
89159
90473
|
let bytes = 0;
|
|
89160
90474
|
for (const clip of ordered) {
|
|
89161
|
-
const tooOld = clip.startedAtMs <
|
|
90475
|
+
const tooOld = clip.startedAtMs < now - this.maxAgeMsFor(clip.deviceId);
|
|
89162
90476
|
const overCount = kept >= maxCount;
|
|
89163
90477
|
const overBytes = bytes + clip.bytes > maxBytes;
|
|
89164
90478
|
if (!tooOld && !overCount && !overBytes) {
|
|
@@ -89185,8 +90499,8 @@ var HksvClipStore = class {
|
|
|
89185
90499
|
}
|
|
89186
90500
|
});
|
|
89187
90501
|
}
|
|
89188
|
-
async scanDevice(deviceId) {
|
|
89189
|
-
const dir = this.deviceDir(deviceId);
|
|
90502
|
+
async scanDevice(root, deviceId) {
|
|
90503
|
+
const dir = this.deviceDir(root, deviceId);
|
|
89190
90504
|
let names;
|
|
89191
90505
|
try {
|
|
89192
90506
|
names = await (0, node_fs_promises.readdir)(dir);
|
|
@@ -89196,7 +90510,7 @@ var HksvClipStore = class {
|
|
|
89196
90510
|
const clips = [];
|
|
89197
90511
|
for (const name of names) {
|
|
89198
90512
|
if (!name.endsWith(".mp4")) continue;
|
|
89199
|
-
const stem = stemOf(name);
|
|
90513
|
+
const stem = stemOf$1(name);
|
|
89200
90514
|
if (this.isInFlight(deviceId, stem)) continue;
|
|
89201
90515
|
const bytes = await fileSize((0, node_path.join)(dir, name));
|
|
89202
90516
|
const record = await this.readRecord((0, node_path.join)(dir, `${stem}.json`), deviceId);
|
|
@@ -89232,7 +90546,19 @@ var HksvClipStore = class {
|
|
|
89232
90546
|
} catch {
|
|
89233
90547
|
return null;
|
|
89234
90548
|
}
|
|
89235
|
-
|
|
90549
|
+
let parsed;
|
|
90550
|
+
try {
|
|
90551
|
+
parsed = HksvClipRecordSchema.safeParse(JSON.parse(raw));
|
|
90552
|
+
} catch (err) {
|
|
90553
|
+
this.log.warn("hksv clip store: a sidecar is not readable JSON — the clip is not listed", {
|
|
90554
|
+
tags: { deviceId },
|
|
90555
|
+
meta: {
|
|
90556
|
+
path,
|
|
90557
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90558
|
+
}
|
|
90559
|
+
});
|
|
90560
|
+
return null;
|
|
90561
|
+
}
|
|
89236
90562
|
if (parsed.success) return parsed.data;
|
|
89237
90563
|
this.log.warn("hksv clip store: a sidecar could not be read — the clip is not listed", {
|
|
89238
90564
|
tags: { deviceId },
|
|
@@ -89243,11 +90569,22 @@ var HksvClipStore = class {
|
|
|
89243
90569
|
});
|
|
89244
90570
|
return null;
|
|
89245
90571
|
}
|
|
90572
|
+
/**
|
|
90573
|
+
* ATOMICALLY: a temp file plus a rename, never a `writeFile` in place.
|
|
90574
|
+
*
|
|
90575
|
+
* The sidecar is the only thing that says a clip exists, and it is read
|
|
90576
|
+
* concurrently — by the pruner, and later by whatever lists these clips. A
|
|
90577
|
+
* plain write is visible half-finished, and a half-finished sidecar reads as
|
|
90578
|
+
* a corrupt clip rather than a clip being written. `rename` within one
|
|
90579
|
+
* directory is atomic, so a reader sees the old record or the new one.
|
|
90580
|
+
*/
|
|
89246
90581
|
async writeRecord(path, record) {
|
|
89247
|
-
|
|
90582
|
+
const staging = `${path}.tmp`;
|
|
90583
|
+
await (0, node_fs_promises.writeFile)(staging, JSON.stringify(record, null, 2), "utf8");
|
|
90584
|
+
await (0, node_fs_promises.rename)(staging, path);
|
|
89248
90585
|
}
|
|
89249
90586
|
};
|
|
89250
|
-
function stemOf(fileName) {
|
|
90587
|
+
function stemOf$1(fileName) {
|
|
89251
90588
|
return fileName.replace(/\.[^.]+$/, "");
|
|
89252
90589
|
}
|
|
89253
90590
|
async function fileSize(path) {
|
|
@@ -89276,6 +90613,8 @@ async function createFileSink(path) {
|
|
|
89276
90613
|
}
|
|
89277
90614
|
};
|
|
89278
90615
|
}
|
|
90616
|
+
/** A decode that has not finished by here is not going to. */
|
|
90617
|
+
var CLIP_THUMBNAIL_TIMEOUT_MS = 1e4;
|
|
89279
90618
|
function buildClipThumbnailArgs(input) {
|
|
89280
90619
|
return [
|
|
89281
90620
|
"-hide_banner",
|
|
@@ -89330,59 +90669,583 @@ function createClipThumbnailMinter(input) {
|
|
|
89330
90669
|
return { ok: true };
|
|
89331
90670
|
};
|
|
89332
90671
|
}
|
|
89333
|
-
|
|
89334
|
-
|
|
89335
|
-
|
|
89336
|
-
*
|
|
89337
|
-
*
|
|
89338
|
-
*
|
|
89339
|
-
|
|
89340
|
-
|
|
89341
|
-
|
|
89342
|
-
|
|
89343
|
-
|
|
89344
|
-
|
|
89345
|
-
|
|
89346
|
-
|
|
89347
|
-
|
|
89348
|
-
|
|
90672
|
+
//#endregion
|
|
90673
|
+
//#region src/hksv/videoclips-plane.ts
|
|
90674
|
+
/**
|
|
90675
|
+
* The two data-plane routes behind the HomeKit clip source.
|
|
90676
|
+
*
|
|
90677
|
+
* Both are hosted by `ctx.dataPlane.serve({ access: 'authenticated' })`, so the
|
|
90678
|
+
* hub authenticates and reverse-proxies and this addon produces the bytes.
|
|
90679
|
+
* **Neither puts a token in its URL**: the hub's `/addon/<id>/<prefix>` proxy
|
|
90680
|
+
* reads the credential from `authorization` OR the session cookie, exactly as
|
|
90681
|
+
* the recorder's own playback plane does, so a clip URL may sit in history, in
|
|
90682
|
+
* a `Referer` or in a shared log and open nothing (D549 22).
|
|
90683
|
+
*
|
|
90684
|
+
* ## Why the bytes are served HERE
|
|
90685
|
+
*
|
|
90686
|
+
* The clips were written by this process, to a `local-path` storage location,
|
|
90687
|
+
* and this addon is `hub-only`. A byte path that went anywhere else would be a
|
|
90688
|
+
* cross-addon media move — D9/D18, and the repo's one existing such move is
|
|
90689
|
+
* capped at 50 MiB with a docblock naming the OOM it caused. `createReadStream
|
|
90690
|
+
* ().pipe(res)` gives backpressure natively, which is why the data plane hands
|
|
90691
|
+
* an addon the REAL `res` (D447: a raw HTTP stream reads no chunk the socket
|
|
90692
|
+
* has not taken).
|
|
90693
|
+
*
|
|
90694
|
+
* ## What the media route does NOT do
|
|
90695
|
+
*
|
|
90696
|
+
* It does not remux. A teed clip is `ftyp` + an EMPTY `moov` + `moof`/`mdat`
|
|
90697
|
+
* fragments, cut wherever iOS stopped pulling, so the container carries no
|
|
90698
|
+
* duration and no index — measured on a byte-identical file
|
|
90699
|
+
* (`-movflags +frag_keyframe+empty_moov+default_base_moof`, the tee's own
|
|
90700
|
+
* argv): `mvhd.duration = 0`, `mdhd.duration = 0`, no `sidx`, and no `mfra`
|
|
90701
|
+
* because nothing wrote a trailer. It decodes end to end, and the row's
|
|
90702
|
+
* `timeRange` carries the real duration from the sidecar, but a player cannot
|
|
90703
|
+
* map a time to a byte offset from this file. What it would cost to change is
|
|
90704
|
+
* measured and written down in D571 rather than guessed at here.
|
|
90705
|
+
*
|
|
90706
|
+
* ## The thumbnail route has no mint
|
|
90707
|
+
*
|
|
90708
|
+
* It only ever serves a JPEG the store already stat'd — a still is VOUCHED at
|
|
90709
|
+
* write time or it does not exist, and nothing re-mints a teed clip. So a miss
|
|
90710
|
+
* is a plain 404, not the 204-with-a-reason contract a live camera needs.
|
|
90711
|
+
*/
|
|
90712
|
+
/** URL namespaces under `/addon/export-hap/`. */
|
|
90713
|
+
var HKSV_CLIP_MEDIA_PREFIX = "hksv-clip";
|
|
90714
|
+
var HKSV_CLIP_THUMB_PREFIX = "hksv-clip-thumb";
|
|
90715
|
+
/** A stem is what `clipStem` mints: nothing else may reach the filesystem. */
|
|
90716
|
+
var MEDIA_PATH = /^\/(\d{1,12})\/([A-Za-z0-9_-]{1,128})\.mp4$/;
|
|
90717
|
+
var THUMB_PATH = /^\/(\d{1,12})\/([A-Za-z0-9_-]{1,128})\.jpg$/;
|
|
90718
|
+
/** `bytes=a-b` / `bytes=a-` / `bytes=-n`. `'unsatisfiable'` is a 416 rather
|
|
90719
|
+
* than a silently empty 206. */
|
|
90720
|
+
function parseRange(header, size) {
|
|
90721
|
+
if (header === void 0) return null;
|
|
90722
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
90723
|
+
if (match === null) return null;
|
|
90724
|
+
const [, rawStart, rawEnd] = match;
|
|
90725
|
+
if (rawStart === "" && rawEnd === "") return "unsatisfiable";
|
|
90726
|
+
if (rawStart === "") {
|
|
90727
|
+
const length = Number(rawEnd);
|
|
90728
|
+
if (length <= 0) return "unsatisfiable";
|
|
90729
|
+
return {
|
|
90730
|
+
start: Math.max(0, size - length),
|
|
90731
|
+
end: size - 1
|
|
89349
90732
|
};
|
|
89350
|
-
|
|
89351
|
-
|
|
89352
|
-
|
|
89353
|
-
|
|
89354
|
-
|
|
89355
|
-
|
|
89356
|
-
|
|
89357
|
-
|
|
89358
|
-
|
|
89359
|
-
|
|
89360
|
-
|
|
89361
|
-
|
|
89362
|
-
|
|
89363
|
-
|
|
89364
|
-
|
|
89365
|
-
|
|
90733
|
+
}
|
|
90734
|
+
const start = Number(rawStart);
|
|
90735
|
+
if (start >= size) return "unsatisfiable";
|
|
90736
|
+
const end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
|
|
90737
|
+
if (end < start) return "unsatisfiable";
|
|
90738
|
+
return {
|
|
90739
|
+
start,
|
|
90740
|
+
end
|
|
90741
|
+
};
|
|
90742
|
+
}
|
|
90743
|
+
function pathnameOf(req) {
|
|
90744
|
+
const raw = req.url ?? "/";
|
|
90745
|
+
const query = raw.indexOf("?");
|
|
90746
|
+
return query === -1 ? raw : raw.slice(0, query);
|
|
90747
|
+
}
|
|
90748
|
+
function address(req, pattern) {
|
|
90749
|
+
const match = pattern.exec(pathnameOf(req));
|
|
90750
|
+
if (match === null) return null;
|
|
90751
|
+
const deviceId = Number(match[1]);
|
|
90752
|
+
const stem = match[2];
|
|
90753
|
+
if (!Number.isInteger(deviceId) || stem === void 0) return null;
|
|
90754
|
+
return {
|
|
90755
|
+
deviceId,
|
|
90756
|
+
stem
|
|
90757
|
+
};
|
|
90758
|
+
}
|
|
90759
|
+
function createHksvClipPlanes(deps) {
|
|
90760
|
+
/**
|
|
90761
|
+
* The path of one artifact, or `null` with a reason ALREADY logged.
|
|
90762
|
+
*
|
|
90763
|
+
* A 404 is work this route dropped, and silence reads as "never happened" —
|
|
90764
|
+
* so every miss is one warn carrying `tags: { deviceId }`, which is the only
|
|
90765
|
+
* key a "why is 617 missing its clips and 615 not?" question can be answered
|
|
90766
|
+
* by.
|
|
90767
|
+
*/
|
|
90768
|
+
async function locate(addressed, artifact) {
|
|
90769
|
+
const { deviceId, stem } = addressed;
|
|
90770
|
+
let path;
|
|
90771
|
+
try {
|
|
90772
|
+
path = await deps.artifactPath(deviceId, stem, artifact);
|
|
90773
|
+
} catch (err) {
|
|
90774
|
+
deps.logger.warn("videoclips: could not resolve a HomeKit clip artifact", {
|
|
90775
|
+
tags: { deviceId },
|
|
90776
|
+
meta: {
|
|
90777
|
+
stem,
|
|
90778
|
+
artifact,
|
|
90779
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90780
|
+
}
|
|
89366
90781
|
});
|
|
89367
|
-
|
|
89368
|
-
|
|
89369
|
-
|
|
89370
|
-
|
|
89371
|
-
|
|
89372
|
-
|
|
89373
|
-
|
|
89374
|
-
|
|
90782
|
+
return null;
|
|
90783
|
+
}
|
|
90784
|
+
if (path === null) {
|
|
90785
|
+
deps.logger.warn("videoclips: no writable HomeKit clips location — nothing to serve", {
|
|
90786
|
+
tags: { deviceId },
|
|
90787
|
+
meta: {
|
|
90788
|
+
stem,
|
|
90789
|
+
artifact
|
|
90790
|
+
}
|
|
89375
90791
|
});
|
|
89376
|
-
|
|
89377
|
-
|
|
89378
|
-
|
|
89379
|
-
|
|
89380
|
-
|
|
89381
|
-
|
|
89382
|
-
|
|
90792
|
+
return null;
|
|
90793
|
+
}
|
|
90794
|
+
try {
|
|
90795
|
+
const stats = await (0, node_fs_promises.stat)(path);
|
|
90796
|
+
if (!stats.isFile()) throw new Error("not a file");
|
|
90797
|
+
return {
|
|
90798
|
+
path,
|
|
90799
|
+
size: stats.size
|
|
90800
|
+
};
|
|
90801
|
+
} catch (err) {
|
|
90802
|
+
deps.logger.warn("videoclips: a HomeKit clip artifact was asked for and is not there", {
|
|
90803
|
+
tags: { deviceId },
|
|
90804
|
+
meta: {
|
|
90805
|
+
stem,
|
|
90806
|
+
artifact,
|
|
90807
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90808
|
+
}
|
|
89383
90809
|
});
|
|
90810
|
+
return null;
|
|
90811
|
+
}
|
|
90812
|
+
}
|
|
90813
|
+
function readOnly(req, res) {
|
|
90814
|
+
if (req.method === "GET" || req.method === "HEAD") return true;
|
|
90815
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
90816
|
+
res.end();
|
|
90817
|
+
return false;
|
|
90818
|
+
}
|
|
90819
|
+
const mediaHandler = async (req, res) => {
|
|
90820
|
+
if (!readOnly(req, res)) return;
|
|
90821
|
+
const addressed = address(req, MEDIA_PATH);
|
|
90822
|
+
if (addressed === null) {
|
|
90823
|
+
res.writeHead(400);
|
|
90824
|
+
res.end();
|
|
90825
|
+
return;
|
|
90826
|
+
}
|
|
90827
|
+
const found = await locate(addressed, "clip");
|
|
90828
|
+
if (found === null) {
|
|
90829
|
+
res.writeHead(404);
|
|
90830
|
+
res.end();
|
|
90831
|
+
return;
|
|
90832
|
+
}
|
|
90833
|
+
const base = {
|
|
90834
|
+
"content-type": "video/mp4",
|
|
90835
|
+
"accept-ranges": "bytes",
|
|
90836
|
+
"cache-control": "private, max-age=0, must-revalidate"
|
|
90837
|
+
};
|
|
90838
|
+
const range = parseRange(req.headers.range, found.size);
|
|
90839
|
+
if (range === "unsatisfiable") {
|
|
90840
|
+
res.writeHead(416, {
|
|
90841
|
+
...base,
|
|
90842
|
+
"content-range": `bytes */${String(found.size)}`
|
|
90843
|
+
});
|
|
90844
|
+
res.end();
|
|
90845
|
+
return;
|
|
90846
|
+
}
|
|
90847
|
+
if (range === null) {
|
|
90848
|
+
res.writeHead(200, {
|
|
90849
|
+
...base,
|
|
90850
|
+
"content-length": String(found.size)
|
|
90851
|
+
});
|
|
90852
|
+
if (req.method === "HEAD") {
|
|
90853
|
+
res.end();
|
|
90854
|
+
return;
|
|
90855
|
+
}
|
|
90856
|
+
(0, node_fs.createReadStream)(found.path).pipe(res);
|
|
90857
|
+
return;
|
|
90858
|
+
}
|
|
90859
|
+
res.writeHead(206, {
|
|
90860
|
+
...base,
|
|
90861
|
+
"content-range": `bytes ${String(range.start)}-${String(range.end)}/${String(found.size)}`,
|
|
90862
|
+
"content-length": String(range.end - range.start + 1)
|
|
89384
90863
|
});
|
|
89385
|
-
|
|
90864
|
+
if (req.method === "HEAD") {
|
|
90865
|
+
res.end();
|
|
90866
|
+
return;
|
|
90867
|
+
}
|
|
90868
|
+
(0, node_fs.createReadStream)(found.path, {
|
|
90869
|
+
start: range.start,
|
|
90870
|
+
end: range.end
|
|
90871
|
+
}).pipe(res);
|
|
90872
|
+
};
|
|
90873
|
+
const thumbHandler = async (req, res) => {
|
|
90874
|
+
if (!readOnly(req, res)) return;
|
|
90875
|
+
const addressed = address(req, THUMB_PATH);
|
|
90876
|
+
if (addressed === null) {
|
|
90877
|
+
res.writeHead(400);
|
|
90878
|
+
res.end();
|
|
90879
|
+
return;
|
|
90880
|
+
}
|
|
90881
|
+
const found = await locate(addressed, "thumbnail");
|
|
90882
|
+
if (found === null) {
|
|
90883
|
+
res.writeHead(404);
|
|
90884
|
+
res.end();
|
|
90885
|
+
return;
|
|
90886
|
+
}
|
|
90887
|
+
res.writeHead(200, {
|
|
90888
|
+
"content-type": "image/jpeg",
|
|
90889
|
+
"content-length": String(found.size),
|
|
90890
|
+
"cache-control": "private, max-age=31536000, immutable"
|
|
90891
|
+
});
|
|
90892
|
+
if (req.method === "HEAD") {
|
|
90893
|
+
res.end();
|
|
90894
|
+
return;
|
|
90895
|
+
}
|
|
90896
|
+
(0, node_fs.createReadStream)(found.path).pipe(res);
|
|
90897
|
+
};
|
|
90898
|
+
return {
|
|
90899
|
+
mediaPrefix: HKSV_CLIP_MEDIA_PREFIX,
|
|
90900
|
+
thumbPrefix: HKSV_CLIP_THUMB_PREFIX,
|
|
90901
|
+
mediaHandler,
|
|
90902
|
+
thumbHandler
|
|
90903
|
+
};
|
|
90904
|
+
}
|
|
90905
|
+
//#endregion
|
|
90906
|
+
//#region src/hksv/videoclips-source.ts
|
|
90907
|
+
/**
|
|
90908
|
+
* The `videoclips` SOURCE over the clips this addon teed to HomeKit.
|
|
90909
|
+
*
|
|
90910
|
+
* ## Where it runs, and why there is no hand-off
|
|
90911
|
+
*
|
|
90912
|
+
* `export-hap` is `placement: 'hub-only'` and the teed clips live on a
|
|
90913
|
+
* `local-path` storage location this process writes with `node:fs`. D550
|
|
90914
|
+
* recorded the open question as "the store lives in the hub-only `export-hap`
|
|
90915
|
+
* runner, so listing and serving must REACH it from wherever the provider
|
|
90916
|
+
* mounts". The answer is that the provider mounts HERE: `videoclips` is a
|
|
90917
|
+
* `scope:'device'` + `mode:'collection'` wrapper, so every addon that declares
|
|
90918
|
+
* it becomes one of the camera's bindings (`collectionBindings`, gated only by
|
|
90919
|
+
* the cap's `deviceTypes`), and `resolveWrapperNodeId` routes a wrapper to the
|
|
90920
|
+
* hub — which is where this addon already is. Nothing about a `videoclips`
|
|
90921
|
+
* provider requires owning the device.
|
|
90922
|
+
*
|
|
90923
|
+
* That removes the hand-off rather than building one, and it is the only shape
|
|
90924
|
+
* that obeys both rules D550 named: addons never import each other (this one
|
|
90925
|
+
* imports nothing — it reads its own store), and frames never cross a process
|
|
90926
|
+
* boundary (the bytes never enter a cap call at all; `getClipPlayback` answers
|
|
90927
|
+
* a URL into this addon's own data plane and the file is streamed with `Range`
|
|
90928
|
+
* from the disk it was written to). The rejected alternative was to register
|
|
90929
|
+
* the provider in the recorder and have it fetch clips over `ctx.api` — a
|
|
90930
|
+
* cross-addon byte move by handle at best, and the repo's one existing such
|
|
90931
|
+
* move carries a 50 MiB cap and a docblock naming the OOM it caused.
|
|
90932
|
+
*
|
|
90933
|
+
* ## The source EXISTS because of HomeKit, and is not a switch
|
|
90934
|
+
*
|
|
90935
|
+
* A camera has this source because it is exported to HomeKit AND HomeKit
|
|
90936
|
+
* recording is on for it; turn either off and the row is gone (D550, D569 § 5).
|
|
90937
|
+
* There is deliberately no enable control of its own — a knob over somebody
|
|
90938
|
+
* else's decision is D62's defect. `keepClips` is NOT that gate either: it
|
|
90939
|
+
* governs the TEE, and the clips already kept remain the truth about this
|
|
90940
|
+
* camera, so the row stays and its `reason` says the copies are off.
|
|
90941
|
+
*
|
|
90942
|
+
* ## `ok` is measured, and an empty list is not always an answer
|
|
90943
|
+
*
|
|
90944
|
+
* D561: `ok` may never be a default. Here the measurement is a real read of the
|
|
90945
|
+
* clips directory, and every answer this source gives — the source row and the
|
|
90946
|
+
* clip list — comes from ONE such read. The states are the existing five, and
|
|
90947
|
+
* no sixth was needed:
|
|
90948
|
+
*
|
|
90949
|
+
* - `ok` — the directory answered. Including with nothing: a camera that has
|
|
90950
|
+
* recorded no HomeKit clip yet is a complete list of zero, and the `reason`
|
|
90951
|
+
* says which flavour of nothing it is.
|
|
90952
|
+
* - `no-storage` — there is no writable `homekitClips` location: none seeded,
|
|
90953
|
+
* or it is `readonly` / `drain` / `disabled` (D385, named by
|
|
90954
|
+
* `clip-location.ts`, which is the only module that may interpret the mode).
|
|
90955
|
+
* Exactly the Reolink meaning: the medium is not there.
|
|
90956
|
+
* - `unreachable` — the storage cap could not be reached, or the directory
|
|
90957
|
+
* read threw. A measurement that failed is never folded into "no clips"
|
|
90958
|
+
* (D393).
|
|
90959
|
+
* - `index-empty` — HomeKit recording is switched ON for this camera and the
|
|
90960
|
+
* store holds nothing, because `buildHksvRecording` WITHHELD the
|
|
90961
|
+
* advertisement (no H.264 slot ≤ 1080p, a GOP longer than any fragment
|
|
90962
|
+
* length, or unreadable profiles). The camera's own switch disagrees with
|
|
90963
|
+
* its own index, which is what this state means on the Reolink surface too,
|
|
90964
|
+
* and the refusal travels verbatim in `reason`.
|
|
90965
|
+
* - `sleeping` — never. There is no camera to wake: the read is a local
|
|
90966
|
+
* directory read.
|
|
90967
|
+
*/
|
|
90968
|
+
/** What the picker calls this source. */
|
|
90969
|
+
var HKSV_CLIP_SOURCE_LABEL = "HomeKit clips";
|
|
90970
|
+
/** Why a listed row has no bytes behind it. Verbatim on `unplayableReason`. */
|
|
90971
|
+
var CLIP_FILE_MISSING = "clip-file-missing";
|
|
90972
|
+
function createHksvVideoclipsProvider(deps) {
|
|
90973
|
+
const now = deps.now ?? Date.now;
|
|
90974
|
+
/**
|
|
90975
|
+
* The one read, and the state it proves.
|
|
90976
|
+
*
|
|
90977
|
+
* `null` means this camera has no HomeKit clip source at all — it is not
|
|
90978
|
+
* exported, or HomeKit recording is off for it. Not an availability: a row
|
|
90979
|
+
* that should not exist is not a row saying it is unhappy.
|
|
90980
|
+
*/
|
|
90981
|
+
async function measure(deviceId) {
|
|
90982
|
+
const facts = deps.describeCamera(deviceId);
|
|
90983
|
+
if (!facts.exported || !facts.recording) return null;
|
|
90984
|
+
const catalog = await deps.readCatalog(deviceId);
|
|
90985
|
+
return {
|
|
90986
|
+
availability: availabilityFor(facts, catalog),
|
|
90987
|
+
catalog
|
|
90988
|
+
};
|
|
90989
|
+
}
|
|
90990
|
+
function availabilityFor(facts, catalog) {
|
|
90991
|
+
if (catalog.kind === "failed") return {
|
|
90992
|
+
state: "unreachable",
|
|
90993
|
+
reason: `the HomeKit clips location could not be read: ${catalog.error}`
|
|
90994
|
+
};
|
|
90995
|
+
if (catalog.kind === "no-root") {
|
|
90996
|
+
const refusal = deps.locationRefusal();
|
|
90997
|
+
if (refusal === "unreachable") return {
|
|
90998
|
+
state: "unreachable",
|
|
90999
|
+
reason: "the storage capability could not be reached (unreachable)"
|
|
91000
|
+
};
|
|
91001
|
+
return {
|
|
91002
|
+
state: "no-storage",
|
|
91003
|
+
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)"
|
|
91004
|
+
};
|
|
91005
|
+
}
|
|
91006
|
+
const asOf = now();
|
|
91007
|
+
if (catalog.records.length > 0) return {
|
|
91008
|
+
state: "ok",
|
|
91009
|
+
catalogAsOf: asOf,
|
|
91010
|
+
...facts.keepingClips ? {} : { reason: "copies are switched off for this camera — nothing new is being kept" }
|
|
91011
|
+
};
|
|
91012
|
+
const withheld = facts.lastBuild?.withheld;
|
|
91013
|
+
if (withheld !== void 0 && withheld !== null) {
|
|
91014
|
+
const detail = facts.lastBuild?.detail;
|
|
91015
|
+
return {
|
|
91016
|
+
state: "index-empty",
|
|
91017
|
+
catalogAsOf: asOf,
|
|
91018
|
+
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"
|
|
91019
|
+
};
|
|
91020
|
+
}
|
|
91021
|
+
return {
|
|
91022
|
+
state: "ok",
|
|
91023
|
+
catalogAsOf: asOf,
|
|
91024
|
+
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"
|
|
91025
|
+
};
|
|
91026
|
+
}
|
|
91027
|
+
function toClip(deviceId, record, files) {
|
|
91028
|
+
const names = clipFileNames(record.clipId);
|
|
91029
|
+
const hasBytes = files.has(record.file);
|
|
91030
|
+
const vouchedThumb = record.thumbnailFile !== void 0 && files.has(record.thumbnailFile) ? deps.thumbnailUrl(deviceId, stemOf(names.thumbnail)) : null;
|
|
91031
|
+
return {
|
|
91032
|
+
id: record.clipId,
|
|
91033
|
+
source: HKSV_CLIP_SOURCE,
|
|
91034
|
+
kind: "native",
|
|
91035
|
+
timeRange: {
|
|
91036
|
+
startMs: record.startedAtMs,
|
|
91037
|
+
endMs: record.endedAtMs
|
|
91038
|
+
},
|
|
91039
|
+
...vouchedThumb === null ? { thumbnailUnavailable: { reason: thumbnailRefusal(record, files) } } : { thumbnail: vouchedThumb },
|
|
91040
|
+
...hasBytes ? {} : {
|
|
91041
|
+
playable: false,
|
|
91042
|
+
unplayableReason: CLIP_FILE_MISSING
|
|
91043
|
+
}
|
|
91044
|
+
};
|
|
91045
|
+
}
|
|
91046
|
+
/**
|
|
91047
|
+
* The store's five reasons, narrowed onto the cap's four.
|
|
91048
|
+
*
|
|
91049
|
+
* The narrowing is LOSSY and it is forced: `ClipSchema.thumbnailUnavailable`
|
|
91050
|
+
* is a closed enum that `ui-library` maps exhaustively, so widening it is a
|
|
91051
|
+
* breaking change to a consumer, and this source's reasons postdate it. The
|
|
91052
|
+
* lossless copy is not lost — it is on the sidecar and on the listing line —
|
|
91053
|
+
* and the split preserves the only distinction a surface can act on:
|
|
91054
|
+
*
|
|
91055
|
+
* - `no-keyframe` — the CLIP had nothing decodable at the trigger instant
|
|
91056
|
+
* (`no-init-segment`, `decode-failed`, `timed-out`).
|
|
91057
|
+
* - `unsupported` — no still exists and nothing here will make one
|
|
91058
|
+
* (`ffmpeg-missing`, `not-attempted`, or a JPEG that is no longer on
|
|
91059
|
+
* disk). Nothing re-mints a teed clip, so every one of these is final.
|
|
91060
|
+
*/
|
|
91061
|
+
function thumbnailRefusal(record, files) {
|
|
91062
|
+
if (record.thumbnailFile !== void 0 && !files.has(record.thumbnailFile)) return "unsupported";
|
|
91063
|
+
const reason = record.thumbnailUnavailable?.reason;
|
|
91064
|
+
if (reason === "no-init-segment" || reason === "decode-failed" || reason === "timed-out") return "no-keyframe";
|
|
91065
|
+
return "unsupported";
|
|
91066
|
+
}
|
|
91067
|
+
return {
|
|
91068
|
+
listSources: async ({ deviceId }) => {
|
|
91069
|
+
const measured = await measure(deviceId);
|
|
91070
|
+
if (measured === null) return [];
|
|
91071
|
+
deps.logger.info("videoclips: measured the HomeKit clip source", {
|
|
91072
|
+
tags: { deviceId },
|
|
91073
|
+
meta: {
|
|
91074
|
+
source: HKSV_CLIP_SOURCE,
|
|
91075
|
+
state: measured.availability.state,
|
|
91076
|
+
reason: measured.availability.reason ?? null,
|
|
91077
|
+
clips: measured.catalog.kind === "catalog" ? measured.catalog.records.length : null
|
|
91078
|
+
}
|
|
91079
|
+
});
|
|
91080
|
+
return [{
|
|
91081
|
+
source: HKSV_CLIP_SOURCE,
|
|
91082
|
+
addonId: deps.addonId,
|
|
91083
|
+
label: HKSV_CLIP_SOURCE_LABEL,
|
|
91084
|
+
availability: measured.availability
|
|
91085
|
+
}];
|
|
91086
|
+
},
|
|
91087
|
+
listClips: async ({ deviceId, since, until, limit }) => {
|
|
91088
|
+
const measured = await measure(deviceId);
|
|
91089
|
+
if (measured === null) return [];
|
|
91090
|
+
const tags = { deviceId };
|
|
91091
|
+
const catalog = measured.catalog;
|
|
91092
|
+
const records = catalog.kind === "catalog" ? catalog.records : [];
|
|
91093
|
+
const files = catalog.kind === "catalog" ? catalog.files : /* @__PURE__ */ new Set();
|
|
91094
|
+
const ordered = records.filter((r) => r.startedAtMs <= until && r.endedAtMs >= since).toSorted((a, b) => b.startedAtMs - a.startedAtMs);
|
|
91095
|
+
const capped = limit === void 0 ? ordered : ordered.slice(0, limit);
|
|
91096
|
+
const clips = capped.map((record) => toClip(deviceId, record, files));
|
|
91097
|
+
const unplayable = clips.filter((c) => c.playable === false).length;
|
|
91098
|
+
const vouched = clips.filter((c) => c.thumbnail !== void 0).length;
|
|
91099
|
+
deps.logger.info("videoclips: listed the HomeKit clips", {
|
|
91100
|
+
tags,
|
|
91101
|
+
meta: {
|
|
91102
|
+
source: HKSV_CLIP_SOURCE,
|
|
91103
|
+
clips: clips.length,
|
|
91104
|
+
truncated: limit !== void 0 && ordered.length > limit,
|
|
91105
|
+
unplayable,
|
|
91106
|
+
thumbsVouched: vouched,
|
|
91107
|
+
thumbsUnavailable: clips.length - vouched,
|
|
91108
|
+
thumbnailReason: capped.find((r) => r.thumbnailFile === void 0)?.thumbnailUnavailable?.reason,
|
|
91109
|
+
state: measured.availability.state
|
|
91110
|
+
}
|
|
91111
|
+
});
|
|
91112
|
+
if (clips.length === 0 && measured.availability.state !== "ok") {
|
|
91113
|
+
deps.logger.warn("videoclips: nothing to list, and the HomeKit clip source cannot answer", {
|
|
91114
|
+
tags,
|
|
91115
|
+
meta: {
|
|
91116
|
+
branch: "refused-empty",
|
|
91117
|
+
state: measured.availability.state,
|
|
91118
|
+
reason: measured.availability.reason ?? null
|
|
91119
|
+
}
|
|
91120
|
+
});
|
|
91121
|
+
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}`));
|
|
91122
|
+
}
|
|
91123
|
+
return clips;
|
|
91124
|
+
},
|
|
91125
|
+
getClipPlayback: async ({ deviceId, clipId, profile }) => {
|
|
91126
|
+
const record = await findRecord(deviceId, clipId);
|
|
91127
|
+
if (profile !== void 0) deps.logger.debug("videoclips: a HomeKit clip has one rendition; the profile is moot", {
|
|
91128
|
+
tags: { deviceId },
|
|
91129
|
+
meta: {
|
|
91130
|
+
clipId,
|
|
91131
|
+
profile,
|
|
91132
|
+
width: record.width,
|
|
91133
|
+
height: record.height
|
|
91134
|
+
}
|
|
91135
|
+
});
|
|
91136
|
+
return {
|
|
91137
|
+
playbackUrl: deps.mediaUrl(deviceId, stemOf(clipFileNames(clipId).clip)),
|
|
91138
|
+
format: "mp4",
|
|
91139
|
+
...record.profile === void 0 ? {} : { served: record.profile }
|
|
91140
|
+
};
|
|
91141
|
+
},
|
|
91142
|
+
/**
|
|
91143
|
+
* The clip's BYTES, inline and bounded — the seam another ADDON pulls
|
|
91144
|
+
* (D558), never the path a player takes.
|
|
91145
|
+
*
|
|
91146
|
+
* A browser or a viewer session plays `getClipPlayback`'s URL; an addon
|
|
91147
|
+
* cannot, because `AddonDataPlane` only lets an addon SERVE. So this is the
|
|
91148
|
+
* one place HomeKit clip bytes enter a cap envelope, and the bound is the
|
|
91149
|
+
* cap's own 50 MiB — held whole, base64, in this process AND the caller's,
|
|
91150
|
+
* on a hub that has already been OOM'd once (D9/D18). Above the bound it
|
|
91151
|
+
* REFUSES with the size: half a video is worse than an honest refusal.
|
|
91152
|
+
*
|
|
91153
|
+
* A 256 MB clip (the tee's per-clip rail) is therefore refusable here and
|
|
91154
|
+
* playable through the route, which is stated rather than discovered.
|
|
91155
|
+
*/
|
|
91156
|
+
readClipBytes: async ({ deviceId, clipId, maxBytes }) => {
|
|
91157
|
+
const record = await findRecord(deviceId, clipId);
|
|
91158
|
+
const stem = stemOf(clipFileNames(clipId).clip);
|
|
91159
|
+
if (record.profile === void 0) {
|
|
91160
|
+
deps.logger.warn("videoclips: a HomeKit clip cannot say which profile it recorded from", {
|
|
91161
|
+
tags: { deviceId },
|
|
91162
|
+
meta: {
|
|
91163
|
+
clipId,
|
|
91164
|
+
branch: "no-profile"
|
|
91165
|
+
}
|
|
91166
|
+
});
|
|
91167
|
+
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`);
|
|
91168
|
+
}
|
|
91169
|
+
const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
|
|
91170
|
+
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`);
|
|
91171
|
+
const path = await deps.clipFilePath(deviceId, stem);
|
|
91172
|
+
if (path === null) throw new Error(`videoclips: the HomeKit clips location for device ${String(deviceId)} is not readable`);
|
|
91173
|
+
let bytes;
|
|
91174
|
+
try {
|
|
91175
|
+
bytes = await (0, node_fs_promises.readFile)(path);
|
|
91176
|
+
} catch (err) {
|
|
91177
|
+
deps.logger.warn("videoclips: the fMP4 of a listed HomeKit clip could not be read", {
|
|
91178
|
+
tags: { deviceId },
|
|
91179
|
+
meta: {
|
|
91180
|
+
clipId,
|
|
91181
|
+
branch: CLIP_FILE_MISSING,
|
|
91182
|
+
error: errorText(err)
|
|
91183
|
+
}
|
|
91184
|
+
});
|
|
91185
|
+
throw new Error(`videoclips: clip "${clipId}" has a sidecar but no readable bytes (${CLIP_FILE_MISSING})`, { cause: err });
|
|
91186
|
+
}
|
|
91187
|
+
deps.logger.info("videoclips: served a HomeKit clip inline", {
|
|
91188
|
+
tags: { deviceId },
|
|
91189
|
+
meta: {
|
|
91190
|
+
clipId,
|
|
91191
|
+
bytes: bytes.byteLength,
|
|
91192
|
+
served: record.profile
|
|
91193
|
+
}
|
|
91194
|
+
});
|
|
91195
|
+
return {
|
|
91196
|
+
base64: bytes.toString("base64"),
|
|
91197
|
+
contentType: "video/mp4",
|
|
91198
|
+
name: `${stem}.mp4`,
|
|
91199
|
+
bytes: bytes.byteLength,
|
|
91200
|
+
served: record.profile,
|
|
91201
|
+
...record.durationMs > 0 ? { durationMs: record.durationMs } : {}
|
|
91202
|
+
};
|
|
91203
|
+
}
|
|
91204
|
+
};
|
|
91205
|
+
/**
|
|
91206
|
+
* The catalog row for one clip, or a THROW naming which of the three things
|
|
91207
|
+
* went wrong: not ours, not held, or held with no bytes behind it. Shared by
|
|
91208
|
+
* the two byte paths so they can never disagree about what a clip id means.
|
|
91209
|
+
*/
|
|
91210
|
+
async function findRecord(deviceId, clipId) {
|
|
91211
|
+
if (!clipId.startsWith(`hksv:`)) throw new Error(`videoclips: clip id "${clipId}" was not minted by the HomeKit source`);
|
|
91212
|
+
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`);
|
|
91213
|
+
const measured = await measure(deviceId);
|
|
91214
|
+
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`);
|
|
91215
|
+
const catalog = measured.catalog;
|
|
91216
|
+
if (catalog.kind !== "catalog") throw new Error(`videoclips: the HomeKit clips for device ${String(deviceId)} cannot be read — the source is "${measured.availability.state}"`);
|
|
91217
|
+
const record = catalog.records.find((r) => r.clipId === clipId) ?? null;
|
|
91218
|
+
if (record === null) {
|
|
91219
|
+
deps.logger.warn("videoclips: asked for a HomeKit clip the catalog does not hold", {
|
|
91220
|
+
tags: { deviceId },
|
|
91221
|
+
meta: {
|
|
91222
|
+
clipId,
|
|
91223
|
+
branch: "no-catalog-row"
|
|
91224
|
+
}
|
|
91225
|
+
});
|
|
91226
|
+
throw new Error(`videoclips: no clip "${clipId}" is held for device ${String(deviceId)}`);
|
|
91227
|
+
}
|
|
91228
|
+
if (!catalog.files.has(record.file)) {
|
|
91229
|
+
deps.logger.warn("videoclips: the fMP4 for a listed HomeKit clip is gone", {
|
|
91230
|
+
tags: { deviceId },
|
|
91231
|
+
meta: {
|
|
91232
|
+
clipId,
|
|
91233
|
+
branch: CLIP_FILE_MISSING,
|
|
91234
|
+
file: record.file
|
|
91235
|
+
}
|
|
91236
|
+
});
|
|
91237
|
+
throw new Error(`videoclips: clip "${clipId}" has a sidecar but no bytes (${CLIP_FILE_MISSING})`);
|
|
91238
|
+
}
|
|
91239
|
+
return record;
|
|
91240
|
+
}
|
|
91241
|
+
}
|
|
91242
|
+
function errorText(err) {
|
|
91243
|
+
return err instanceof Error ? err.message : String(err);
|
|
91244
|
+
}
|
|
91245
|
+
/** `<stem>.mp4` → `<stem>`. The routes address a stem; the store owns the rest. */
|
|
91246
|
+
function stemOf(fileName) {
|
|
91247
|
+
const dot = fileName.lastIndexOf(".");
|
|
91248
|
+
return dot === -1 ? fileName : fileName.slice(0, dot);
|
|
89386
91249
|
}
|
|
89387
91250
|
//#endregion
|
|
89388
91251
|
//#region src/mappers/builders/generic/characteristic-update.ts
|
|
@@ -104211,7 +106074,8 @@ var ClipTeeRunner = class {
|
|
|
104211
106074
|
prebufferSpanMs: this.request.prebufferSpanMs,
|
|
104212
106075
|
width: this.input.width,
|
|
104213
106076
|
height: this.input.height,
|
|
104214
|
-
fragmentMs: this.input.fragmentMs
|
|
106077
|
+
fragmentMs: this.input.fragmentMs,
|
|
106078
|
+
...this.input.profile === void 0 ? {} : { profile: this.input.profile }
|
|
104215
106079
|
});
|
|
104216
106080
|
if (open === null) {
|
|
104217
106081
|
this.request.subscription.release();
|
|
@@ -105179,9 +107043,26 @@ async function buildHksvRecording(input) {
|
|
|
105179
107043
|
const { bctx } = input;
|
|
105180
107044
|
const { ctx, numericDeviceId } = bctx;
|
|
105181
107045
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
107046
|
+
/**
|
|
107047
|
+
* Record the verdict for the clip source, next to the log line that already
|
|
107048
|
+
* states it. A camera HomeKit never recorded is not "a camera with no clips",
|
|
107049
|
+
* and `listSources` can only say which it is if the refusal was written down
|
|
107050
|
+
* (D571). Every `return null` below passes through here.
|
|
107051
|
+
*/
|
|
107052
|
+
const note = (withheld, detail) => {
|
|
107053
|
+
const outcome = {
|
|
107054
|
+
deviceId: numericDeviceId,
|
|
107055
|
+
advertised: withheld === null,
|
|
107056
|
+
withheld,
|
|
107057
|
+
detail,
|
|
107058
|
+
atMs: Date.now()
|
|
107059
|
+
};
|
|
107060
|
+
bctx.options.noteHksvOutcome?.(outcome);
|
|
107061
|
+
};
|
|
105182
107062
|
const entries = await readProfileEntries(bctx);
|
|
105183
107063
|
if (entries === null) {
|
|
105184
107064
|
log.warn("export-hap: HKSV withheld — could not read the camera profiles", {});
|
|
107065
|
+
note("profiles-unreadable", "cameraStreams.getProfileRtspEntries could not be read");
|
|
105185
107066
|
return null;
|
|
105186
107067
|
}
|
|
105187
107068
|
const choice = pickRecordingSource(entries);
|
|
@@ -105190,6 +107071,7 @@ async function buildHksvRecording(input) {
|
|
|
105190
107071
|
refusal: choice.refusal,
|
|
105191
107072
|
reason: refusalReason(choice.refusal)
|
|
105192
107073
|
} });
|
|
107074
|
+
note("no-recordable-stream", `${choice.refusal}: ${refusalReason(choice.refusal)}`);
|
|
105193
107075
|
return null;
|
|
105194
107076
|
}
|
|
105195
107077
|
const source = choice.source;
|
|
@@ -105200,8 +107082,11 @@ async function buildHksvRecording(input) {
|
|
|
105200
107082
|
gopMs,
|
|
105201
107083
|
brokerId: source.brokerId
|
|
105202
107084
|
} });
|
|
107085
|
+
note("key-frame-interval", `gopMs=${String(gopMs ?? "unknown")}`);
|
|
105203
107086
|
return null;
|
|
105204
107087
|
}
|
|
107088
|
+
const parsedProfile = CamProfileSchema.safeParse(source.profile);
|
|
107089
|
+
const recordedProfile = parsedProfile.success ? parsedProfile.data : null;
|
|
105205
107090
|
const fps = resolveFps(input.fpsByProfile, source.profile);
|
|
105206
107091
|
const options = buildRecordingOptions({
|
|
105207
107092
|
width: source.width,
|
|
@@ -105209,14 +107094,16 @@ async function buildHksvRecording(input) {
|
|
|
105209
107094
|
fps,
|
|
105210
107095
|
fragmentLengthMs
|
|
105211
107096
|
});
|
|
105212
|
-
const
|
|
107097
|
+
const keepClips = bctx.options.hapDeviceSettings.keepHomekitClips !== false;
|
|
107098
|
+
const clipStore = keepClips ? bctx.options.hksvClipStore : null;
|
|
105213
107099
|
const openClipTee = clipStore === null ? void 0 : createClipTeeFactory({
|
|
105214
107100
|
logger: log,
|
|
105215
107101
|
deviceId: numericDeviceId,
|
|
105216
107102
|
store: clipStore,
|
|
105217
107103
|
width: source.width,
|
|
105218
107104
|
height: source.height,
|
|
105219
|
-
fragmentMs: fragmentLengthMs
|
|
107105
|
+
fragmentMs: fragmentLengthMs,
|
|
107106
|
+
...recordedProfile === null ? {} : { profile: recordedProfile }
|
|
105220
107107
|
});
|
|
105221
107108
|
const delegate = new HksvRecordingDelegate({
|
|
105222
107109
|
logger: log,
|
|
@@ -105245,8 +107132,10 @@ async function buildHksvRecording(input) {
|
|
|
105245
107132
|
advertisedFps: advertisedResolution?.[2] ?? null,
|
|
105246
107133
|
fragmentLengthMs,
|
|
105247
107134
|
sourceGopMs: gopMs ?? "unknown",
|
|
105248
|
-
clipsKept: openClipTee !== void 0
|
|
107135
|
+
clipsKept: openClipTee !== void 0,
|
|
107136
|
+
clipsSwitchedOn: keepClips
|
|
105249
107137
|
} });
|
|
107138
|
+
note(null, null);
|
|
105250
107139
|
return {
|
|
105251
107140
|
options,
|
|
105252
107141
|
delegate,
|
|
@@ -106529,6 +108418,65 @@ function resolveHksvRecording(settings) {
|
|
|
106529
108418
|
* negotiation is built we say `streaming-enabled: false`, so declining is the
|
|
106530
108419
|
* only thing a controller can do with it.
|
|
106531
108420
|
*/
|
|
108421
|
+
/**
|
|
108422
|
+
* Keep a copy of what we send HomeKit, for this camera.
|
|
108423
|
+
*
|
|
108424
|
+
* ABSENT MEANS ON, for the same reason as `resolveHksvRecording` and one
|
|
108425
|
+
* stronger: HKSV has no read-back, so every recording made while this said
|
|
108426
|
+
* "off by accident" is gone permanently. Only an explicit `false` turns it off.
|
|
108427
|
+
*/
|
|
108428
|
+
function resolveKeepHomekitClips(settings) {
|
|
108429
|
+
return settings?.keepClips !== false;
|
|
108430
|
+
}
|
|
108431
|
+
/** This addon's id, as the manifest declares it and the registry knows it. */
|
|
108432
|
+
var EXPORT_HAP_ADDON_ID = "export-hap";
|
|
108433
|
+
/**
|
|
108434
|
+
* Whether a camera HAS a HomeKit clip source, read from the authorities that
|
|
108435
|
+
* already own each half.
|
|
108436
|
+
*
|
|
108437
|
+
* The source exists because the camera is EXPORTED to HomeKit and HomeKit
|
|
108438
|
+
* RECORDING is on for it, and it goes away when either goes off (D550, D569 §
|
|
108439
|
+
* 5). There is no third switch: a knob of this source's own would be a second
|
|
108440
|
+
* authority over somebody else's decision (D62), and this function is the only
|
|
108441
|
+
* place the two are read together.
|
|
108442
|
+
*
|
|
108443
|
+
* `keepClips` is deliberately NOT part of existence. It governs the TEE, and
|
|
108444
|
+
* the clips already kept are still the truth about this camera — so the row
|
|
108445
|
+
* stays and says the copies are off, which is D62's other half: an off switch
|
|
108446
|
+
* is REPORTED off, never made to look like a broken camera.
|
|
108447
|
+
*/
|
|
108448
|
+
function describeHksvCamera(entry, lastBuild) {
|
|
108449
|
+
const isCamera = entry !== null && entry.mapperKind === "camera";
|
|
108450
|
+
return {
|
|
108451
|
+
exported: isCamera,
|
|
108452
|
+
recording: isCamera && resolveHksvRecording(entry.settings),
|
|
108453
|
+
keepingClips: isCamera && resolveKeepHomekitClips(entry.settings),
|
|
108454
|
+
lastBuild
|
|
108455
|
+
};
|
|
108456
|
+
}
|
|
108457
|
+
/** What ships, and what an unset global resolves to. */
|
|
108458
|
+
var DEFAULT_CLIP_RETENTION_DAYS = 14;
|
|
108459
|
+
/** The rails on the retention itself. Zero would silently disable the tee. */
|
|
108460
|
+
var MIN_CLIP_RETENTION_DAYS = 1;
|
|
108461
|
+
var MAX_CLIP_RETENTION_DAYS = 365;
|
|
108462
|
+
/**
|
|
108463
|
+
* This camera's retention in days: per-device override → global default →
|
|
108464
|
+
* {@link DEFAULT_CLIP_RETENTION_DAYS}.
|
|
108465
|
+
*
|
|
108466
|
+
* The cascade rule the repo already pays for (`stationary-settings.ts`):
|
|
108467
|
+
* **reset == unset**. A cleared override and a key that was never written must
|
|
108468
|
+
* resolve to the same number, or the Reset button is a lie. A value outside the
|
|
108469
|
+
* rails is not stored as a lie either — it falls back rather than turning the
|
|
108470
|
+
* tee into a no-op (0 days) or an unbounded archive.
|
|
108471
|
+
*/
|
|
108472
|
+
function resolveClipRetentionDays(settings, globalDefaultDays) {
|
|
108473
|
+
const fleet = clampRetentionDays(globalDefaultDays) ?? 14;
|
|
108474
|
+
return clampRetentionDays(settings?.clipRetentionDays) ?? fleet;
|
|
108475
|
+
}
|
|
108476
|
+
function clampRetentionDays(value) {
|
|
108477
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < MIN_CLIP_RETENTION_DAYS) return null;
|
|
108478
|
+
return Math.min(Math.floor(value), MAX_CLIP_RETENTION_DAYS);
|
|
108479
|
+
}
|
|
106532
108480
|
function resolveMultiTierService(settings) {
|
|
106533
108481
|
return settings?.multiTierService === true;
|
|
106534
108482
|
}
|
|
@@ -106556,6 +108504,7 @@ var DEFAULT_CONFIG = {
|
|
|
106556
108504
|
fixedPin: "",
|
|
106557
108505
|
interfaceName: "",
|
|
106558
108506
|
ptzPulseMs: 400,
|
|
108507
|
+
clipRetentionDays: 14,
|
|
106559
108508
|
identity: {
|
|
106560
108509
|
username: "",
|
|
106561
108510
|
pincode: "",
|
|
@@ -106628,6 +108577,18 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
106628
108577
|
* failure to exist is one line at boot and not a surprise mid-recording.
|
|
106629
108578
|
*/
|
|
106630
108579
|
hksvClipStore = null;
|
|
108580
|
+
/** Resolves the declared `homekitClips` location; the doorbell invalidates it. */
|
|
108581
|
+
hksvClipLocation = null;
|
|
108582
|
+
/**
|
|
108583
|
+
* What `buildHksvRecording` decided per camera, in this process.
|
|
108584
|
+
*
|
|
108585
|
+
* A MIRROR of the build, written by it and read only to be REPORTED: the
|
|
108586
|
+
* clip source's `index-empty` ("the switch says record and the store holds
|
|
108587
|
+
* nothing") is only honest if it can name which refusal fired (D571).
|
|
108588
|
+
*/
|
|
108589
|
+
hksvBuilds = new HksvBuildOutcomes();
|
|
108590
|
+
/** The clip byte + still routes. `null` until `onInitialize` has served them. */
|
|
108591
|
+
hksvClipPlanes = null;
|
|
106631
108592
|
/**
|
|
106632
108593
|
* THE bridge. Lazily created and published the first time a non-camera
|
|
106633
108594
|
* accessory needs it; never torn down while the addon runs, because an
|
|
@@ -106654,14 +108615,26 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
106654
108615
|
this.lastError = errMsg(err);
|
|
106655
108616
|
this.ctx.logger.error("export-hap: HAP storage init failed", { meta: { error: this.lastError } });
|
|
106656
108617
|
}
|
|
108618
|
+
this.hksvClipLocation = new HksvClipLocation({
|
|
108619
|
+
logger: this.ctx.logger.child("hksv-clips"),
|
|
108620
|
+
ports: createClipLocationPorts(this.ctx.api)
|
|
108621
|
+
});
|
|
106657
108622
|
this.hksvClipStore = new HksvClipStore({
|
|
106658
108623
|
logger: this.ctx.logger.child("hksv-clips"),
|
|
106659
|
-
|
|
106660
|
-
|
|
108624
|
+
resolveRoot: () => this.hksvClipLocation?.root() ?? Promise.resolve(null),
|
|
108625
|
+
maxAgeMsFor: (deviceId) => resolveClipRetentionDays(this.findEntry(deviceId)?.settings, this.config.clipRetentionDays) * 24 * 60 * 60 * 1e3,
|
|
108626
|
+
mintThumbnail: createClipThumbnailMinter({ runner: createBoundedFfmpegRunner({
|
|
108627
|
+
ffmpegBinaryPath: "ffmpeg",
|
|
108628
|
+
spawnFn: node_child_process.spawn,
|
|
108629
|
+
timeoutMs: CLIP_THUMBNAIL_TIMEOUT_MS
|
|
108630
|
+
}) }),
|
|
108631
|
+
remuxClip: createClipRemuxer({ runner: createBoundedFfmpegRunner({
|
|
106661
108632
|
ffmpegBinaryPath: "ffmpeg",
|
|
106662
|
-
spawnFn: node_child_process.spawn
|
|
108633
|
+
spawnFn: node_child_process.spawn,
|
|
108634
|
+
timeoutMs: CLIP_REMUX_TIMEOUT_MS
|
|
106663
108635
|
}) })
|
|
106664
108636
|
});
|
|
108637
|
+
await this.serveHksvClipPlanes();
|
|
106665
108638
|
const validKinds = new Set(SUPPORTED_MAPPER_KINDS);
|
|
106666
108639
|
const cleaned = this.config.exposed.filter((entry) => {
|
|
106667
108640
|
if (validKinds.has(entry.mapperKind)) return true;
|
|
@@ -106688,36 +108661,51 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
106688
108661
|
});
|
|
106689
108662
|
this.subscribeDeviceProvisionedForReconcile();
|
|
106690
108663
|
this.subscribeDeviceReadyForReconcile();
|
|
108664
|
+
this.subscribeStorageLocationsForClips();
|
|
106691
108665
|
this.disposeSharedRebuildTimers();
|
|
108666
|
+
const provider = {
|
|
108667
|
+
getStatus: async () => {
|
|
108668
|
+
const anyPaired = Array.from(this.exposed.values()).some((m) => accessoryPaired(m.accessory));
|
|
108669
|
+
const linkState = this.lastError ? "error" : anyPaired ? "linked" : "unlinked";
|
|
108670
|
+
const setup = this.buildSetupBlock();
|
|
108671
|
+
return {
|
|
108672
|
+
linkState,
|
|
108673
|
+
exposedDeviceCount: this.exposed.size,
|
|
108674
|
+
...this.lastError ? { error: this.lastError } : {},
|
|
108675
|
+
...setup ? { setup } : {}
|
|
108676
|
+
};
|
|
108677
|
+
},
|
|
108678
|
+
listSupportedDeviceKinds: async () => [...HAP_EXPORTABLE_DEVICE_TYPES],
|
|
108679
|
+
listExposedDevices: async () => Array.from(this.exposed.entries()).map(([deviceId, m]) => {
|
|
108680
|
+
const entry = this.config.exposed.find((e) => e.deviceId === deviceId);
|
|
108681
|
+
return {
|
|
108682
|
+
deviceId,
|
|
108683
|
+
exposedAs: m.accessory.displayName,
|
|
108684
|
+
...entry?.capabilities ? { capabilities: [...entry.capabilities] } : {}
|
|
108685
|
+
};
|
|
108686
|
+
}),
|
|
108687
|
+
exposeDevice: async ({ deviceId, capabilities }) => this.exposeDevice(deviceId, capabilities),
|
|
108688
|
+
unexposeDevice: async ({ deviceId }) => this.unexposeDevice(deviceId),
|
|
108689
|
+
getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
|
|
108690
|
+
getDeviceLiveContribution: async () => null,
|
|
108691
|
+
applyDeviceSettingsPatch: (input) => this.applyDeviceSettingsPatch(input.deviceId, input.patch)
|
|
108692
|
+
};
|
|
108693
|
+
const clipsProvider = createHksvVideoclipsProvider({
|
|
108694
|
+
logger: this.ctx.logger.child("hksv-clips"),
|
|
108695
|
+
addonId: EXPORT_HAP_ADDON_ID,
|
|
108696
|
+
describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId)),
|
|
108697
|
+
readCatalog: async (deviceId) => await this.hksvClipStore?.listCatalog(deviceId) ?? { kind: "no-root" },
|
|
108698
|
+
locationRefusal: () => this.hksvClipLocation?.lastRefusal ?? null,
|
|
108699
|
+
mediaUrl: (deviceId, stem) => this.hksvClipUrl("media", deviceId, stem),
|
|
108700
|
+
thumbnailUrl: (deviceId, stem) => this.hksvClipUrl("thumb", deviceId, stem),
|
|
108701
|
+
clipFilePath: async (deviceId, stem) => await this.hksvClipStore?.artifactPath(deviceId, stem, "clip") ?? null
|
|
108702
|
+
});
|
|
106692
108703
|
return [{
|
|
106693
108704
|
capability: deviceExportCapability,
|
|
106694
|
-
provider
|
|
106695
|
-
|
|
106696
|
-
|
|
106697
|
-
|
|
106698
|
-
const setup = this.buildSetupBlock();
|
|
106699
|
-
return {
|
|
106700
|
-
linkState,
|
|
106701
|
-
exposedDeviceCount: this.exposed.size,
|
|
106702
|
-
...this.lastError ? { error: this.lastError } : {},
|
|
106703
|
-
...setup ? { setup } : {}
|
|
106704
|
-
};
|
|
106705
|
-
},
|
|
106706
|
-
listSupportedDeviceKinds: async () => [...HAP_EXPORTABLE_DEVICE_TYPES],
|
|
106707
|
-
listExposedDevices: async () => Array.from(this.exposed.entries()).map(([deviceId, m]) => {
|
|
106708
|
-
const entry = this.config.exposed.find((e) => e.deviceId === deviceId);
|
|
106709
|
-
return {
|
|
106710
|
-
deviceId,
|
|
106711
|
-
exposedAs: m.accessory.displayName,
|
|
106712
|
-
...entry?.capabilities ? { capabilities: [...entry.capabilities] } : {}
|
|
106713
|
-
};
|
|
106714
|
-
}),
|
|
106715
|
-
exposeDevice: async ({ deviceId, capabilities }) => this.exposeDevice(deviceId, capabilities),
|
|
106716
|
-
unexposeDevice: async ({ deviceId }) => this.unexposeDevice(deviceId),
|
|
106717
|
-
getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
|
|
106718
|
-
getDeviceLiveContribution: async () => null,
|
|
106719
|
-
applyDeviceSettingsPatch: (input) => this.applyDeviceSettingsPatch(input.deviceId, input.patch)
|
|
106720
|
-
}
|
|
108705
|
+
provider
|
|
108706
|
+
}, {
|
|
108707
|
+
capability: videoclipsCapability,
|
|
108708
|
+
provider: clipsProvider
|
|
106721
108709
|
}];
|
|
106722
108710
|
}
|
|
106723
108711
|
async onConfigChanged() {
|
|
@@ -106842,6 +108830,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
106842
108830
|
if (next.length !== this.config.exposed.length) await this.updateGlobalSettings({ exposed: next });
|
|
106843
108831
|
if (options.clearPairing !== false) clearPairingFiles(accessoryUuidFor(mapperKind, numericId), this.ctx.logger);
|
|
106844
108832
|
await this.forgetFingerprint(numericId);
|
|
108833
|
+
this.hksvBuilds.forget(numericId);
|
|
106845
108834
|
log.info("export-hap: unexposed device");
|
|
106846
108835
|
}
|
|
106847
108836
|
async attachMapper(entry) {
|
|
@@ -106855,9 +108844,13 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
106855
108844
|
ptzPulseMs: this.config.ptzPulseMs,
|
|
106856
108845
|
decodeMemos: this.decodeMemos,
|
|
106857
108846
|
hksvClipStore: this.hksvClipStore,
|
|
108847
|
+
noteHksvOutcome: (outcome) => {
|
|
108848
|
+
this.hksvBuilds.note(outcome);
|
|
108849
|
+
},
|
|
106858
108850
|
hapDeviceSettings: {
|
|
106859
108851
|
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
106860
108852
|
hksvRecording: resolveHksvRecording(entrySettings),
|
|
108853
|
+
keepHomekitClips: resolveKeepHomekitClips(entrySettings),
|
|
106861
108854
|
multiTierService: resolveMultiTierService(entrySettings),
|
|
106862
108855
|
allowTranscode: entrySettings.allowTranscode !== false
|
|
106863
108856
|
}
|
|
@@ -106971,6 +108964,21 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
106971
108964
|
* whether the reconcile actually rebuilds anything. Shared by the two
|
|
106972
108965
|
* triggers below.
|
|
106973
108966
|
*/
|
|
108967
|
+
/**
|
|
108968
|
+
* The clips location moved. A DOORBELL, not a payload: the event names which
|
|
108969
|
+
* row changed and the re-read is the authority (`StorageLocationsChangedPayload`
|
|
108970
|
+
* says so itself). Dropping the mirror is all this does — the next clip
|
|
108971
|
+
* resolves it again, and a missed event self-heals on the revalidation
|
|
108972
|
+
* window, so nothing here gates on a fallible read (D49).
|
|
108973
|
+
*/
|
|
108974
|
+
subscribeStorageLocationsForClips() {
|
|
108975
|
+
const unsubscribe = this.ctx.eventBus.subscribe({ category: EventCategory.StorageLocationsChanged }, () => {
|
|
108976
|
+
this.hksvClipLocation?.invalidate();
|
|
108977
|
+
});
|
|
108978
|
+
this.ctx.addDisposer(async () => {
|
|
108979
|
+
unsubscribe();
|
|
108980
|
+
});
|
|
108981
|
+
}
|
|
106974
108982
|
subscribeDeviceLifecycleForReconcile(category) {
|
|
106975
108983
|
const unsubscribe = this.ctx.eventBus.subscribe({ category }, (event) => {
|
|
106976
108984
|
const data = event.data;
|
|
@@ -107169,6 +109177,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107169
109177
|
requiresRestart: true,
|
|
107170
109178
|
placement: { tab: "advanced" }
|
|
107171
109179
|
}),
|
|
109180
|
+
this.field({
|
|
109181
|
+
type: "number",
|
|
109182
|
+
key: "clipRetentionDays",
|
|
109183
|
+
label: "Keep HomeKit clips for (days)",
|
|
109184
|
+
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.",
|
|
109185
|
+
default: DEFAULT_CONFIG.clipRetentionDays,
|
|
109186
|
+
placement: { tab: "advanced" }
|
|
109187
|
+
}),
|
|
107172
109188
|
this.field({
|
|
107173
109189
|
type: "number",
|
|
107174
109190
|
key: "ptzPulseMs",
|
|
@@ -107192,6 +109208,8 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107192
109208
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
107193
109209
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
107194
109210
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
109211
|
+
const keepClipsKey = `hap:${deviceId}:keepClips`;
|
|
109212
|
+
const clipRetentionKey = `hap:${deviceId}:clipRetentionDays`;
|
|
107195
109213
|
const mapper = this.exposed.get(String(deviceId)) ?? null;
|
|
107196
109214
|
const paired = mapper ? accessoryPaired(mapper.accessory) : false;
|
|
107197
109215
|
const bridged = mapper !== null && this.bridgeHost?.holds(mapper.accessory) === true;
|
|
@@ -107262,6 +109280,31 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107262
109280
|
},
|
|
107263
109281
|
immediate: true
|
|
107264
109282
|
},
|
|
109283
|
+
{
|
|
109284
|
+
type: "boolean",
|
|
109285
|
+
key: keepClipsKey,
|
|
109286
|
+
label: "Keep CamStack copies of HomeKit clips",
|
|
109287
|
+
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.",
|
|
109288
|
+
style: "switch",
|
|
109289
|
+
value: resolveKeepHomekitClips(settings),
|
|
109290
|
+
showWhen: {
|
|
109291
|
+
field: hksvKey,
|
|
109292
|
+
equals: true
|
|
109293
|
+
},
|
|
109294
|
+
immediate: true
|
|
109295
|
+
},
|
|
109296
|
+
{
|
|
109297
|
+
type: "number",
|
|
109298
|
+
key: clipRetentionKey,
|
|
109299
|
+
label: "Keep this camera’s clips for (days)",
|
|
109300
|
+
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.`,
|
|
109301
|
+
value: resolveClipRetentionDays(settings, this.config.clipRetentionDays),
|
|
109302
|
+
showWhen: {
|
|
109303
|
+
field: keepClipsKey,
|
|
109304
|
+
equals: true
|
|
109305
|
+
},
|
|
109306
|
+
immediate: true
|
|
109307
|
+
},
|
|
107265
109308
|
{
|
|
107266
109309
|
type: "boolean",
|
|
107267
109310
|
key: allowTranscodeKey,
|
|
@@ -107349,6 +109392,8 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107349
109392
|
const useBridgeKey = `hap:${deviceId}:useBridge`;
|
|
107350
109393
|
const allowTranscodeKey = `hap:${deviceId}:allowTranscode`;
|
|
107351
109394
|
const multiTierKey = `hap:${deviceId}:multiTierService`;
|
|
109395
|
+
const keepClipsKey = `hap:${deviceId}:keepClips`;
|
|
109396
|
+
const clipRetentionKey = `hap:${deviceId}:clipRetentionDays`;
|
|
107352
109397
|
const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
|
|
107353
109398
|
const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
|
|
107354
109399
|
const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
|
|
@@ -107356,13 +109401,20 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107356
109401
|
const useBridge = useBridgeKey in patch ? Boolean(patch[useBridgeKey]) : current?.settings?.useBridge !== false;
|
|
107357
109402
|
const allowTranscode = allowTranscodeKey in patch ? Boolean(patch[allowTranscodeKey]) : current?.settings?.allowTranscode !== false;
|
|
107358
109403
|
const multiTierService = multiTierKey in patch ? Boolean(patch[multiTierKey]) : resolveMultiTierService(current?.settings);
|
|
109404
|
+
const keepClips = keepClipsKey in patch ? Boolean(patch[keepClipsKey]) : resolveKeepHomekitClips(current?.settings);
|
|
109405
|
+
const clipRetentionDays = clipRetentionKey in patch ? resolveClipRetentionDays({
|
|
109406
|
+
streamPreference,
|
|
109407
|
+
clipRetentionDays: Number(patch[clipRetentionKey])
|
|
109408
|
+
}, this.config.clipRetentionDays) : resolveClipRetentionDays(current?.settings, this.config.clipRetentionDays);
|
|
107359
109409
|
const nextSettings = {
|
|
107360
109410
|
...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
|
|
107361
109411
|
streamPreference,
|
|
107362
109412
|
hksvRecording,
|
|
107363
109413
|
useBridge,
|
|
107364
109414
|
allowTranscode,
|
|
107365
|
-
multiTierService
|
|
109415
|
+
multiTierService,
|
|
109416
|
+
keepClips,
|
|
109417
|
+
clipRetentionDays
|
|
107366
109418
|
};
|
|
107367
109419
|
if (!enabledValue) {
|
|
107368
109420
|
if (wasEnabled) await this.unexposeDevice(deviceIdStr);
|
|
@@ -107375,6 +109427,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107375
109427
|
}
|
|
107376
109428
|
const currentPref = current?.settings?.streamPreference ?? "auto";
|
|
107377
109429
|
const currentHksv = resolveHksvRecording(current?.settings);
|
|
109430
|
+
const currentKeepClips = resolveKeepHomekitClips(current?.settings);
|
|
107378
109431
|
const mapperKind = current?.mapperKind ?? "generic";
|
|
107379
109432
|
const wasBridged = shouldBridge({
|
|
107380
109433
|
mapperKind,
|
|
@@ -107385,7 +109438,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107385
109438
|
useBridge
|
|
107386
109439
|
});
|
|
107387
109440
|
await this.updateEntrySettings(deviceIdStr, nextSettings);
|
|
107388
|
-
if (currentPref !== streamPreference || currentHksv !== hksvRecording || wasBridged !== willBridge) {
|
|
109441
|
+
if (currentPref !== streamPreference || currentHksv !== hksvRecording || currentKeepClips !== keepClips || wasBridged !== willBridge) {
|
|
107389
109442
|
log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
|
|
107390
109443
|
streamPreference: {
|
|
107391
109444
|
from: currentPref,
|
|
@@ -107395,6 +109448,10 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107395
109448
|
from: currentHksv,
|
|
107396
109449
|
to: hksvRecording
|
|
107397
109450
|
},
|
|
109451
|
+
keepClips: {
|
|
109452
|
+
from: currentKeepClips,
|
|
109453
|
+
to: keepClips
|
|
109454
|
+
},
|
|
107398
109455
|
bridged: {
|
|
107399
109456
|
from: wasBridged,
|
|
107400
109457
|
to: willBridge
|
|
@@ -107409,6 +109466,57 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107409
109466
|
}
|
|
107410
109467
|
return { success: true };
|
|
107411
109468
|
}
|
|
109469
|
+
/**
|
|
109470
|
+
* Host the clip byte + still routes, and never let a host without the
|
|
109471
|
+
* facility take the addon down with it: a data plane is an enhancement to an
|
|
109472
|
+
* addon that already works, so a failure here degrades the clip surface and
|
|
109473
|
+
* nothing else. It is LOGGED, because a branch that drops work silently
|
|
109474
|
+
* reads as "never happened".
|
|
109475
|
+
*
|
|
109476
|
+
* `authenticated`, not `admin`, and NO TOKEN in either URL: the hub's
|
|
109477
|
+
* `/addon/<id>/<prefix>` proxy takes the session cookie, exactly as the
|
|
109478
|
+
* recorder's own playback plane does (D549 22).
|
|
109479
|
+
*/
|
|
109480
|
+
async serveHksvClipPlanes() {
|
|
109481
|
+
const store = this.hksvClipStore;
|
|
109482
|
+
if (store === null) return;
|
|
109483
|
+
const planes = createHksvClipPlanes({
|
|
109484
|
+
logger: this.ctx.logger.child("hksv-clips"),
|
|
109485
|
+
artifactPath: (deviceId, stem, artifact) => store.artifactPath(deviceId, stem, artifact)
|
|
109486
|
+
});
|
|
109487
|
+
try {
|
|
109488
|
+
const media = await this.ctx.dataPlane?.serve({
|
|
109489
|
+
prefix: planes.mediaPrefix,
|
|
109490
|
+
access: "authenticated",
|
|
109491
|
+
handler: planes.mediaHandler
|
|
109492
|
+
});
|
|
109493
|
+
const thumb = await this.ctx.dataPlane?.serve({
|
|
109494
|
+
prefix: planes.thumbPrefix,
|
|
109495
|
+
access: "authenticated",
|
|
109496
|
+
handler: planes.thumbHandler
|
|
109497
|
+
});
|
|
109498
|
+
this.hksvClipPlanes = planes;
|
|
109499
|
+
this.ctx.logger.info("export-hap: HomeKit clip data planes served", { meta: {
|
|
109500
|
+
mediaPath: `/addon/${EXPORT_HAP_ADDON_ID}/${planes.mediaPrefix}`,
|
|
109501
|
+
thumbPath: `/addon/${EXPORT_HAP_ADDON_ID}/${planes.thumbPrefix}`,
|
|
109502
|
+
mediaServed: media !== void 0,
|
|
109503
|
+
thumbServed: thumb !== void 0
|
|
109504
|
+
} });
|
|
109505
|
+
} catch (err) {
|
|
109506
|
+
this.ctx.logger.warn("export-hap: HomeKit clip data planes failed to serve — clips will list but not play", { meta: { error: errMsg(err) } });
|
|
109507
|
+
}
|
|
109508
|
+
}
|
|
109509
|
+
/**
|
|
109510
|
+
* The client path of one clip artifact.
|
|
109511
|
+
*
|
|
109512
|
+
* Composed from the SERVED prefixes when the planes are up, so a URL is
|
|
109513
|
+
* never minted for a route that does not exist; the constants are the
|
|
109514
|
+
* fallback for the one window between registration and `serve` returning.
|
|
109515
|
+
*/
|
|
109516
|
+
hksvClipUrl(kind, deviceId, stem) {
|
|
109517
|
+
const planes = this.hksvClipPlanes;
|
|
109518
|
+
return `/addon/${EXPORT_HAP_ADDON_ID}/${kind === "media" ? planes?.mediaPrefix ?? "hksv-clip" : planes?.thumbPrefix ?? "hksv-clip-thumb"}/${String(deviceId)}/${stem}.${kind === "media" ? "mp4" : "jpg"}`;
|
|
109519
|
+
}
|
|
107412
109520
|
findEntry(deviceId) {
|
|
107413
109521
|
const id = String(deviceId);
|
|
107414
109522
|
return this.config.exposed.find((e) => e.deviceId === id) ?? null;
|
|
@@ -107425,12 +109533,17 @@ function errMsg(err) {
|
|
|
107425
109533
|
return err instanceof Error ? err.message : String(err);
|
|
107426
109534
|
}
|
|
107427
109535
|
//#endregion
|
|
109536
|
+
exports.DEFAULT_CLIP_RETENTION_DAYS = DEFAULT_CLIP_RETENTION_DAYS;
|
|
109537
|
+
exports.EXPORT_HAP_ADDON_ID = EXPORT_HAP_ADDON_ID;
|
|
107428
109538
|
exports.ExportHapAddon = ExportHapAddon;
|
|
107429
109539
|
exports.default = ExportHapAddon;
|
|
107430
109540
|
exports.__toCommonJS = __toCommonJS;
|
|
107431
109541
|
exports.deriveUsername = deriveUsername;
|
|
109542
|
+
exports.describeHksvCamera = describeHksvCamera;
|
|
107432
109543
|
exports.initHapStorage = initHapStorage;
|
|
107433
109544
|
exports.publishStandalone = publishStandalone;
|
|
109545
|
+
exports.resolveClipRetentionDays = resolveClipRetentionDays;
|
|
107434
109546
|
exports.resolveHksvRecording = resolveHksvRecording;
|
|
109547
|
+
exports.resolveKeepHomekitClips = resolveKeepHomekitClips;
|
|
107435
109548
|
exports.resolveMultiTierService = resolveMultiTierService;
|
|
107436
109549
|
exports.unpublishAccessory = unpublishAccessory;
|