@camstack/addon-export-hap 1.2.129 → 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 +1688 -197
- package/dist/hap-export.addon.mjs +1688 -199
- package/package.json +4 -1
|
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { createWriteStream, readFileSync } from "node:fs";
|
|
6
|
+
import { createReadStream, createWriteStream, readFileSync } from "node:fs";
|
|
7
7
|
import * as fs from "node:fs/promises";
|
|
8
8
|
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
9
9
|
import { once } from "node:events";
|
|
@@ -5399,7 +5399,7 @@ var ZodIssueCode = {
|
|
|
5399
5399
|
var ZodFirstPartyTypeKind;
|
|
5400
5400
|
ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
|
|
5401
5401
|
//#endregion
|
|
5402
|
-
//#region ../types/dist/sleep-
|
|
5402
|
+
//#region ../types/dist/sleep-BVhJDJka.mjs
|
|
5403
5403
|
/**
|
|
5404
5404
|
* The audio chunk plane's byte format, and the ONE expansion from a coded
|
|
5405
5405
|
* window to float samples (D455).
|
|
@@ -5599,12 +5599,6 @@ Object.fromEntries([
|
|
|
5599
5599
|
icon: "circle-dot",
|
|
5600
5600
|
order: 40
|
|
5601
5601
|
},
|
|
5602
|
-
{
|
|
5603
|
-
id: "clips",
|
|
5604
|
-
label: "Clips",
|
|
5605
|
-
icon: "clapperboard",
|
|
5606
|
-
order: 41
|
|
5607
|
-
},
|
|
5608
5602
|
{
|
|
5609
5603
|
id: "engine",
|
|
5610
5604
|
label: "Inference Engine",
|
|
@@ -7035,6 +7029,18 @@ function systemMethod(input, output, options) {
|
|
|
7035
7029
|
systemOnly: true
|
|
7036
7030
|
};
|
|
7037
7031
|
}
|
|
7032
|
+
/**
|
|
7033
|
+
* A method a SOURCE of a collection cap may legitimately not serve — OPTIONAL
|
|
7034
|
+
* on `InferProvider`. The `providerOptional: true` literal is what
|
|
7035
|
+
* `InferProvider` keys on; see {@link CapabilityMethodSchema.providerOptional}
|
|
7036
|
+
* for when this is the honest answer and when it is a soft stub.
|
|
7037
|
+
*/
|
|
7038
|
+
function optionalMethod(input, output, options) {
|
|
7039
|
+
return {
|
|
7040
|
+
...method(input, output, options),
|
|
7041
|
+
providerOptional: true
|
|
7042
|
+
};
|
|
7043
|
+
}
|
|
7038
7044
|
var StaticDirOutputSchema$1 = object({ staticDir: string() });
|
|
7039
7045
|
var VersionOutputSchema$1 = object({ version: string() });
|
|
7040
7046
|
method(_void(), StaticDirOutputSchema$1, { auth: "admin" }), method(_void(), VersionOutputSchema$1, { auth: "admin" });
|
|
@@ -8058,6 +8064,8 @@ var EVENT_OWNER_TYPES = [
|
|
|
8058
8064
|
* nothing failing until a caller asked.
|
|
8059
8065
|
*/
|
|
8060
8066
|
var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
|
|
8067
|
+
/** The same list as a Zod enum, for the cap input that carries it. */
|
|
8068
|
+
var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
|
|
8061
8069
|
new Set(EVENT_OWNER_TYPES);
|
|
8062
8070
|
var EncodeProfileSchema = object({
|
|
8063
8071
|
video: object({
|
|
@@ -15037,8 +15045,6 @@ method(object({
|
|
|
15037
15045
|
deviceId: number(),
|
|
15038
15046
|
capName: string(),
|
|
15039
15047
|
wrapperAddonId: string(),
|
|
15040
|
-
/** The `ClipSource.source` id to toggle. Absent = all of the addon's. */
|
|
15041
|
-
sourceId: string().optional(),
|
|
15042
15048
|
active: boolean()
|
|
15043
15049
|
}), _void(), {
|
|
15044
15050
|
kind: "mutation",
|
|
@@ -21949,7 +21955,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
21949
21955
|
ownerType: EventOwnerTypeSchema,
|
|
21950
21956
|
eventId: number().int(),
|
|
21951
21957
|
deviceId: number()
|
|
21952
|
-
}), array(MediaFileInfoSchema).readonly()), method(
|
|
21958
|
+
}), array(MediaFileInfoSchema).readonly()), method(object({
|
|
21959
|
+
deviceId: number(),
|
|
21960
|
+
ownerKind: MediaPresenceOwnerKindSchema,
|
|
21961
|
+
ownerIds: array(string()).max(5e3)
|
|
21962
|
+
}), array(string()).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
|
|
21953
21963
|
kind: "mutation",
|
|
21954
21964
|
auth: "admin"
|
|
21955
21965
|
}), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
|
|
@@ -24943,6 +24953,18 @@ method(VectorDeclareIndexInputSchema, _void(), {
|
|
|
24943
24953
|
kind: "mutation",
|
|
24944
24954
|
auth: "admin"
|
|
24945
24955
|
}), method(VectorStatsInputSchema, VectorStatsResultSchema, { auth: "admin" });
|
|
24956
|
+
_enum([
|
|
24957
|
+
"queue-full",
|
|
24958
|
+
"camera-backoff",
|
|
24959
|
+
"sleeping",
|
|
24960
|
+
"camera-refused",
|
|
24961
|
+
"no-keyframe",
|
|
24962
|
+
"no-catalog-row",
|
|
24963
|
+
"unsupported",
|
|
24964
|
+
"unknown-device",
|
|
24965
|
+
"uid-missing"
|
|
24966
|
+
]);
|
|
24967
|
+
_enum(["deferred", "final"]);
|
|
24946
24968
|
var ClipSchema = object({
|
|
24947
24969
|
/** Opaque, provider-namespaced id. The default provider encodes the time
|
|
24948
24970
|
* window so `getClipPlayback` is self-contained (no event re-query). */
|
|
@@ -25093,7 +25115,25 @@ var ClipSchema = object({
|
|
|
25093
25115
|
*/
|
|
25094
25116
|
playable: boolean().optional(),
|
|
25095
25117
|
/** Why {@link playable} is false, verbatim (`no-file-for-window`). */
|
|
25096
|
-
unplayableReason: string().optional()
|
|
25118
|
+
unplayableReason: string().optional(),
|
|
25119
|
+
/**
|
|
25120
|
+
* This clip is STILL BEING WRITTEN, so {@link ClipSchema.timeRange}`.endMs`
|
|
25121
|
+
* is not its end.
|
|
25122
|
+
*
|
|
25123
|
+
* Absent — the common case — means the row is closed and its `endMs` is the
|
|
25124
|
+
* end of the recording. Present and `true` means the source told us the file
|
|
25125
|
+
* has no end yet, and the `endMs` we carry is whatever the camera's index
|
|
25126
|
+
* entry happened to hold: on a Reolink E1 Outdoor PoE (592, measured
|
|
25127
|
+
* 2026-09-20) the newest file `…_192927_000000_…_0.mp4` reported an `endTime`
|
|
25128
|
+
* of 19:29:56 and STILL reported it eight minutes later, while the file went
|
|
25129
|
+
* on growing. A surface that drew 29 s there was lying about a clip that
|
|
25130
|
+
* plays for minutes, on the one row the operator looks at first.
|
|
25131
|
+
*
|
|
25132
|
+
* `endMs` is deliberately still a number: it is the best bound anything has
|
|
25133
|
+
* for a mint window or a byte fetch, and every consumer already requires it.
|
|
25134
|
+
* This flag says what it is WORTH, and a duration is not drawn from it.
|
|
25135
|
+
*/
|
|
25136
|
+
inProgress: boolean().optional()
|
|
25097
25137
|
});
|
|
25098
25138
|
var ClipPlaybackSchema = object({
|
|
25099
25139
|
/**
|
|
@@ -25139,6 +25179,77 @@ var ClipPlaybackSchema = object({
|
|
|
25139
25179
|
* which days it asked for and got nothing — "no clips" would be a lie about a
|
|
25140
25180
|
* card that is full of them.
|
|
25141
25181
|
*/
|
|
25182
|
+
/**
|
|
25183
|
+
* The operator's authorisation to WAKE a sleeping camera for one clip read.
|
|
25184
|
+
*
|
|
25185
|
+
* ONE value, absent by default, and it is a `force`-shaped operator signal in
|
|
25186
|
+
* exactly the sense `snapshot-wake-gate.ts` uses the word: *"`snapshot.getSnapshot`'s
|
|
25187
|
+
* `force` flag, and nothing else… a background caller must never set it…
|
|
25188
|
+
* Stale but honest beats woken"*. A scheduler, a retry, a reconcile and a
|
|
25189
|
+
* prefetch never set it; a surface sets it only behind the same confirm the
|
|
25190
|
+
* "Wake and refresh" gesture uses, and it is refused below 15 % battery
|
|
25191
|
+
* exactly as that gesture is.
|
|
25192
|
+
*
|
|
25193
|
+
* The gate is decided BEFORE `getApi()`, because on UDP the login IS the wake
|
|
25194
|
+
* (D549) — a read that opened a session and then checked would have woken the
|
|
25195
|
+
* camera to find out it was not allowed to.
|
|
25196
|
+
*
|
|
25197
|
+
* **One authorised yes is one wake.** `next-natural` — take the clip the next
|
|
25198
|
+
* time the camera is awake for its own reasons — is deliberately not a member:
|
|
25199
|
+
* it was proposed, it is free on the battery, and the operator declined it on
|
|
25200
|
+
* 2026-09-20 (*"Quando un export viene richiesto si sveglia la camera."*,
|
|
25201
|
+
* D558 "Considered and not taken").
|
|
25202
|
+
*/
|
|
25203
|
+
var ClipWakeSchema = _enum(["authorised"]);
|
|
25204
|
+
/**
|
|
25205
|
+
* Hard ceiling on ONE {@link videoclipsCapability.methods.readClipBytes} — the
|
|
25206
|
+
* same 50 MiB `RECORDING_EXPORT_MAX_READ_BYTES` uses, and for the same second
|
|
25207
|
+
* reason: the envelope is unary, so a base64 payload is held whole (~1.33× its
|
|
25208
|
+
* size) in the provider AND in the caller, on a hub this repo has already
|
|
25209
|
+
* OOM'd once (D9/D18).
|
|
25210
|
+
*
|
|
25211
|
+
* Measured clips sit far below it — 92 KB–2.29 MB for a sub twin, 455 KB for a
|
|
25212
|
+
* 16 s main clip — so the bound bites rarely. "Rarely" is not "never": a long
|
|
25213
|
+
* 4K main twin can exceed it, and above the bound the provider REFUSES with
|
|
25214
|
+
* the size in the message, never truncates. Half a video is worse than an
|
|
25215
|
+
* honest refusal.
|
|
25216
|
+
*
|
|
25217
|
+
* The clean follow-on is a CHUNKED read so a `high` twin of a long clip stops
|
|
25218
|
+
* being refusable at all. That is a later slice, named here so the bound is
|
|
25219
|
+
* not mistaken for a design ceiling.
|
|
25220
|
+
*/
|
|
25221
|
+
var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
|
|
25222
|
+
/**
|
|
25223
|
+
* A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
|
|
25224
|
+
*
|
|
25225
|
+
* `bytes` is the DECODED length, so nobody infers it from the base64 length,
|
|
25226
|
+
* and `served` says which twin the caller actually got.
|
|
25227
|
+
*/
|
|
25228
|
+
var ClipBytesSchema = object({
|
|
25229
|
+
base64: string(),
|
|
25230
|
+
contentType: string(),
|
|
25231
|
+
/** Suggested filename, extension included. */
|
|
25232
|
+
name: string(),
|
|
25233
|
+
bytes: number().int().nonnegative(),
|
|
25234
|
+
/**
|
|
25235
|
+
* Which twin was actually served — the same contract
|
|
25236
|
+
* {@link ClipPlaybackSchema.served} carries, and REQUIRED here because the
|
|
25237
|
+
* export record persists it: a row read a week later must say the same thing
|
|
25238
|
+
* the panel said at the moment of the tap. A missing main twin is never
|
|
25239
|
+
* served silently as if it were the asked-for quality (D549 15).
|
|
25240
|
+
*/
|
|
25241
|
+
served: CamProfileSchema,
|
|
25242
|
+
/**
|
|
25243
|
+
* The DECODED duration of the delivered file, when the fetch measured one.
|
|
25244
|
+
*
|
|
25245
|
+
* A clip fetch verifies its own completion against the catalog row and
|
|
25246
|
+
* retries a materially short pass (D568); this is that measurement, carried
|
|
25247
|
+
* so a consumer can say the same thing rather than re-deriving it. Absent
|
|
25248
|
+
* when the producer did not measure — never zero, which would say the file
|
|
25249
|
+
* is empty.
|
|
25250
|
+
*/
|
|
25251
|
+
durationMs: number().positive().optional()
|
|
25252
|
+
});
|
|
25142
25253
|
var ClipSourceAvailabilitySchema = object({
|
|
25143
25254
|
state: _enum([
|
|
25144
25255
|
"ok",
|
|
@@ -25176,99 +25287,205 @@ var ClipSourceSchema = object({
|
|
|
25176
25287
|
* alternative — a `native:reolink:* → provider-reolink` table inside the
|
|
25177
25288
|
* widget — is a second authority on provider identity living in the one
|
|
25178
25289
|
* package with no business knowing it, wrong the day a third source appears
|
|
25179
|
-
* (D557). One addon may serve SEVERAL sources
|
|
25180
|
-
*
|
|
25181
|
-
*
|
|
25182
|
-
*
|
|
25290
|
+
* (D557). One addon may serve SEVERAL sources, which is why the per-device
|
|
25291
|
+
* switch is keyed by SOURCE and not by this (D555) — `addon-provider-reolink`
|
|
25292
|
+
* served a hub child two of them until the two views were measured to be one
|
|
25293
|
+
* store read twice (D555) and then read ONE way (D565).
|
|
25183
25294
|
*
|
|
25184
25295
|
* Optional for version skew only. The collection dispatcher stamps it from
|
|
25185
25296
|
* the registry, so a row that travelled through the fan-out carries the
|
|
25186
25297
|
* authoritative id whatever the provider filled in.
|
|
25187
25298
|
*/
|
|
25188
25299
|
addonId: string().optional(),
|
|
25189
|
-
|
|
25190
|
-
* Which API this source resolved to FOR THIS CAMERA, when it has a choice.
|
|
25191
|
-
*
|
|
25192
|
-
* A source may cover one store through more than one surface — the Reolink
|
|
25193
|
-
* provider reads a hub child through the parent's event log and a standalone
|
|
25194
|
-
* through its own file list, because that is what each camera answers. The
|
|
25195
|
-
* CHOICE is the provider's, made from what the camera is, and is never a row
|
|
25196
|
-
* the operator has to understand; but it is REPORTED, because a source that
|
|
25197
|
-
* silently reads a different API on two cameras and then behaves differently
|
|
25198
|
-
* is the thing nobody can debug later. Absent when the source has only one
|
|
25199
|
-
* way to read its store.
|
|
25200
|
-
*/
|
|
25201
|
-
via: string().optional(),
|
|
25202
|
-
availability: ClipSourceAvailabilitySchema,
|
|
25203
|
-
/**
|
|
25204
|
-
* The operator switched this source OFF for this camera.
|
|
25205
|
-
*
|
|
25206
|
-
* Deliberately NOT a member of {@link ClipSourceAvailabilitySchema}'s
|
|
25207
|
-
* vocabulary. That enum models what the source CAN do — a sleeping camera, an
|
|
25208
|
-
* unmounted card, an index that disagrees with its own calendar — and a
|
|
25209
|
-
* switched-off source could answer perfectly well; the operator decided it
|
|
25210
|
-
* should not. Folding the choice in is how `disabled` and `broken` stop being
|
|
25211
|
-
* distinguishable, which is the D62 rule this repo has already paid for twice:
|
|
25212
|
-
* an off switch is REPORTED off (`CameraStatus.switchedOff`, the same word),
|
|
25213
|
-
* and disabled must never look like broken. It is also what lets every
|
|
25214
|
-
* exhaustive consumer of the availability enum keep compiling.
|
|
25215
|
-
*
|
|
25216
|
-
* A switched-off source contributes NO clips (`listClips` never calls it) and
|
|
25217
|
-
* its row carries no `catalogAsOf`: nothing confirms a catalog it is not
|
|
25218
|
-
* allowed to serve, and a frozen age that can only grow draws a stalling
|
|
25219
|
-
* source rather than an off switch.
|
|
25220
|
-
*
|
|
25221
|
-
* The row survives BECAUSE it is the control the operator switches the source
|
|
25222
|
-
* back on from — D554 decision 4's rule ("a source that cannot answer
|
|
25223
|
-
* produces a ROW, not an absence") applied to the one case D556 carved out of
|
|
25224
|
-
* it, and D557's own kept property ("a source is never hidden, only its
|
|
25225
|
-
* rows"). Absent means on.
|
|
25226
|
-
*
|
|
25227
|
-
* A provider never sets this — like {@link ClipSourceSchema.addonId} it is
|
|
25228
|
-
* stamped by the collection dispatcher, which holds the registry's projection
|
|
25229
|
-
* of the persisted authority (D556) and is the only place that knows it.
|
|
25230
|
-
*/
|
|
25231
|
-
switchedOff: boolean().optional()
|
|
25232
|
-
});
|
|
25233
|
-
DeviceType.Camera, method(object({
|
|
25234
|
-
deviceId: number(),
|
|
25235
|
-
since: number(),
|
|
25236
|
-
until: number(),
|
|
25237
|
-
limit: number().int().positive().optional(),
|
|
25238
|
-
/**
|
|
25239
|
-
* View filter over {@link ClipSourceSchema.source} values — the
|
|
25240
|
-
* picker's selection, forwarded so a provider need not list what
|
|
25241
|
-
* nobody is looking at. ABSENT means every source this camera has,
|
|
25242
|
-
* which is the honest default for a surface whose whole point is that
|
|
25243
|
-
* nothing is hidden (D554 3). A provider with one source ignores it.
|
|
25244
|
-
*/
|
|
25245
|
-
sources: array(string()).optional()
|
|
25246
|
-
}), array(ClipSchema).readonly(), {
|
|
25247
|
-
kind: "query",
|
|
25248
|
-
auth: "protected"
|
|
25249
|
-
}), method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
|
|
25250
|
-
kind: "query",
|
|
25251
|
-
auth: "protected"
|
|
25252
|
-
}), method(object({
|
|
25253
|
-
deviceId: number(),
|
|
25254
|
-
clipId: string(),
|
|
25255
|
-
/**
|
|
25256
|
-
* Which twin to serve, on the ONE quality scale the system already has
|
|
25257
|
-
* (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
|
|
25258
|
-
* twin; both ids are already on the row so this never re-searches the
|
|
25259
|
-
* camera. Absent means the provider's own default (the sub file, which
|
|
25260
|
-
* every source is measured to hold).
|
|
25261
|
-
*
|
|
25262
|
-
* `auto` is deliberately NOT accepted here: a stored file has no
|
|
25263
|
-
* broker session, so the adaptive tier cannot be resolved for it. The
|
|
25264
|
-
* viewer resolves `auto` to a profile the same way live does, before
|
|
25265
|
-
* it calls (D549 15).
|
|
25266
|
-
*/
|
|
25267
|
-
profile: CamProfileSchema.optional()
|
|
25268
|
-
}), ClipPlaybackSchema, {
|
|
25269
|
-
kind: "query",
|
|
25270
|
-
auth: "protected"
|
|
25300
|
+
availability: ClipSourceAvailabilitySchema
|
|
25271
25301
|
});
|
|
25302
|
+
var videoclipsCapability = {
|
|
25303
|
+
name: "videoclips",
|
|
25304
|
+
scope: "device",
|
|
25305
|
+
mode: "collection",
|
|
25306
|
+
kind: "wrapper",
|
|
25307
|
+
defaultActive: true,
|
|
25308
|
+
/** A clip is a window over a camera's footage — the cap is meaningless on a
|
|
25309
|
+
* sensor, a button or an event emitter, and the `defaultActive` auto-bind
|
|
25310
|
+
* reads this to decide which devices it may claim. */
|
|
25311
|
+
deviceTypes: [DeviceType.Camera],
|
|
25312
|
+
/**
|
|
25313
|
+
* The Clips section of a camera's device details is FRAMEWORK-DERIVED (D14):
|
|
25314
|
+
* the aggregator turns this declaration into the `type:'widget'` section and
|
|
25315
|
+
* `DeviceDetail.tsx` is never edited. `videoclips` is the first WRAPPER cap
|
|
25316
|
+
* to declare one — every previous `host/` widget cap is `deviceNative` — so
|
|
25317
|
+
* `device-config-widget-wrapped-binding.spec.ts` pins that a `kind:'wrapped'`
|
|
25318
|
+
* binding entry derives the same section a native one does.
|
|
25319
|
+
*
|
|
25320
|
+
* **It is a SECTION of the Recording tab, at the end of it — not a tab of
|
|
25321
|
+
* its own.** It shipped as a `clips` top-tab and the operator rejected the
|
|
25322
|
+
* placement: *"utilizzerei la stessa tab recordings, lì abbiamo già tutto il
|
|
25323
|
+
* necessario, una nuova sezione alla fine per le clips"*. The Recording tab
|
|
25324
|
+
* already holds the recorder's panel, the schedule bands and the unified
|
|
25325
|
+
* retention policy; footage the camera itself holds is the same question,
|
|
25326
|
+
* asked of a different store. `order: 100` puts it after all of them with
|
|
25327
|
+
* room left in front. The `clips` entry in `WELL_KNOWN_TABS` went with it —
|
|
25328
|
+
* a well-known id nobody declares is an invitation to mint the tab again.
|
|
25329
|
+
*
|
|
25330
|
+
* `topTab` stays: the browser owns a pane (a day's tiles, a source list and
|
|
25331
|
+
* a player) and the Config tab's inner bar has no room for one. The widget
|
|
25332
|
+
* itself is `host/clips-browser` in ui-library's `HOST_WIDGETS`, and because
|
|
25333
|
+
* the admin Recordings page renders every `location:'top-tab'` +
|
|
25334
|
+
* `tab:'recording'` section behind its camera picker
|
|
25335
|
+
* (`CameraRecordingSettingsSection`), this declaration lands the section on
|
|
25336
|
+
* BOTH surfaces with no second wiring.
|
|
25337
|
+
*/
|
|
25338
|
+
deviceConfig: { ui: {
|
|
25339
|
+
kind: "widget",
|
|
25340
|
+
widgetId: "host/clips-browser",
|
|
25341
|
+
tab: "recording",
|
|
25342
|
+
topTab: true,
|
|
25343
|
+
label: "Clips",
|
|
25344
|
+
order: 100
|
|
25345
|
+
} },
|
|
25346
|
+
methods: {
|
|
25347
|
+
listClips: method(object({
|
|
25348
|
+
deviceId: number(),
|
|
25349
|
+
since: number(),
|
|
25350
|
+
until: number(),
|
|
25351
|
+
limit: number().int().positive().optional(),
|
|
25352
|
+
/**
|
|
25353
|
+
* WHICH provider to ask — the `addonId` a {@link ClipSourceSchema} row
|
|
25354
|
+
* carries, never a source id and never a list. **Required** (D554 amended).
|
|
25355
|
+
*
|
|
25356
|
+
* A provider the device is not bound to is refused by name rather than
|
|
25357
|
+
* answered by another one (D552's `rejectUnresolvedAddonPin` rule).
|
|
25358
|
+
*
|
|
25359
|
+
* It was optional, documented as "absent means the device's BOUND
|
|
25360
|
+
* provider, which is CamStack on every camera". No code implemented
|
|
25361
|
+
* that. Measured on the live hub 2026-09-20 — device 592, bound to
|
|
25362
|
+
* `recorder` AND `provider-reolink` — a bare call with `limit: 3`
|
|
25363
|
+
* answered SIX rows, three from each source, merged newest-first:
|
|
25364
|
+
* `device-collection-dispatch.ts` simply left the fan-out un-narrowed,
|
|
25365
|
+
* so absence bought the union this method exists not to be, and
|
|
25366
|
+
* `limit` meant `limit × sources`.
|
|
25367
|
+
*
|
|
25368
|
+
* There is nothing to restore the default to. `getBindings` answers a
|
|
25369
|
+
* collection cap with a DERIVED, PLURAL set (D554 amended, step 0);
|
|
25370
|
+
* `setWrapperActive` — the singleton authority that could name one —
|
|
25371
|
+
* throws for a collection cap by design. Naming CamStack here instead
|
|
25372
|
+
* would privilege one addon by id inside a surface whose premise is
|
|
25373
|
+
* that sources are peers, and would be wrong on the first camera with
|
|
25374
|
+
* no recorder binding. So absence is REFUSED, in the schema, where the
|
|
25375
|
+
* generated types make it unomittable rather than merely discouraged.
|
|
25376
|
+
*
|
|
25377
|
+
* The default belongs to the SURFACE, which has the `listSources` rows
|
|
25378
|
+
* and can say which one it picked (D569 § 1–3; the viewer already
|
|
25379
|
+
* always sends this).
|
|
25380
|
+
*
|
|
25381
|
+
* It replaced `sources?: string[]`, a VIEW filter over source ids that
|
|
25382
|
+
* assumed the answer was a fan-out over everything a camera has. The
|
|
25383
|
+
* operator settled otherwise on 2026-09-20 — *"l'utilizzatore è uno
|
|
25384
|
+
* solo"* — so the list is asked of one provider at a time and there is
|
|
25385
|
+
* nothing to filter out of it.
|
|
25386
|
+
*/
|
|
25387
|
+
provider: string().min(1)
|
|
25388
|
+
}), array(ClipSchema).readonly(), {
|
|
25389
|
+
kind: "query",
|
|
25390
|
+
auth: "protected"
|
|
25391
|
+
}),
|
|
25392
|
+
/**
|
|
25393
|
+
* The sources this camera has, WITH the reason any of them cannot answer.
|
|
25394
|
+
*
|
|
25395
|
+
* Asked separately from `listClips` because an empty clip list is
|
|
25396
|
+
* ambiguous and this is the only place the ambiguity is resolved: every
|
|
25397
|
+
* bound provider contributes its own rows, and a provider that could not
|
|
25398
|
+
* be reached at all still produces one row saying so. A surface that draws
|
|
25399
|
+
* "no clips" without reading this is drawing a guess.
|
|
25400
|
+
*/
|
|
25401
|
+
listSources: method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
|
|
25402
|
+
kind: "query",
|
|
25403
|
+
auth: "protected"
|
|
25404
|
+
}),
|
|
25405
|
+
getClipPlayback: method(object({
|
|
25406
|
+
deviceId: number(),
|
|
25407
|
+
clipId: string(),
|
|
25408
|
+
/**
|
|
25409
|
+
* Which twin to serve, on the ONE quality scale the system already has
|
|
25410
|
+
* (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
|
|
25411
|
+
* twin; both ids are already on the row so this never re-searches the
|
|
25412
|
+
* camera. Absent means the provider's own default (the sub file, which
|
|
25413
|
+
* every source is measured to hold).
|
|
25414
|
+
*
|
|
25415
|
+
* `auto` is deliberately NOT accepted here: a stored file has no
|
|
25416
|
+
* broker session, so the adaptive tier cannot be resolved for it. The
|
|
25417
|
+
* viewer resolves `auto` to a profile the same way live does, before
|
|
25418
|
+
* it calls (D549 15).
|
|
25419
|
+
*/
|
|
25420
|
+
profile: CamProfileSchema.optional()
|
|
25421
|
+
}), ClipPlaybackSchema, {
|
|
25422
|
+
kind: "query",
|
|
25423
|
+
auth: "protected"
|
|
25424
|
+
}),
|
|
25425
|
+
/**
|
|
25426
|
+
* This clip's BYTES, base64, bounded — the by-handle read a clip EXPORT
|
|
25427
|
+
* pulls once (D558).
|
|
25428
|
+
*
|
|
25429
|
+
* `getClipPlayback` is the right answer for a player: it hands back a URL
|
|
25430
|
+
* on a plane the hub serves `access:'authenticated'`, which a browser and a
|
|
25431
|
+
* viewer session satisfy. It is the wrong answer for another ADDON. There
|
|
25432
|
+
* is no addon→addon byte transport in this framework — `AddonDataPlane`
|
|
25433
|
+
* only lets an addon SERVE, on `127.0.0.1` behind a per-listener secret
|
|
25434
|
+
* only the hub may present — so a recorder that wants a camera's clip
|
|
25435
|
+
* cannot fetch that URL. This method is the one seam that exists for it,
|
|
25436
|
+
* and it is deliberately the same shape (and the same bound) as
|
|
25437
|
+
* `recordingExport.readExportBytes`, which exists for the mirror-image
|
|
25438
|
+
* reason.
|
|
25439
|
+
*
|
|
25440
|
+
* Routing needs no `provider` pin: the id is source-prefixed and
|
|
25441
|
+
* self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
|
|
25442
|
+
* to the source that claims it — and an id nobody claims is REFUSED rather
|
|
25443
|
+
* than answered by another source.
|
|
25444
|
+
*
|
|
25445
|
+
* The producer reuses the fetch path it already has, completion rules
|
|
25446
|
+
* included: a clip is taken by cmd 5 and finished on a short idle window
|
|
25447
|
+
* whose result is PROVED against the catalog row's own span, retried once
|
|
25448
|
+
* when it comes up materially short, and served-and-named when it is still
|
|
25449
|
+
* short (D568). A second fetch with different completion rules is exactly
|
|
25450
|
+
* the second authority D558 refuses to create.
|
|
25451
|
+
*
|
|
25452
|
+
* Every refusal THROWS with its reason and none of them is silent — the
|
|
25453
|
+
* sleep gate (decided before `getApi()`, liftable only by
|
|
25454
|
+
* {@link ClipWakeSchema}), a catalog row nobody claims, a clip with no
|
|
25455
|
+
* bytes behind it, a mux that failed, and the size bound. The caller turns
|
|
25456
|
+
* that reason into an operator-facing one; a truncated file is never an
|
|
25457
|
+
* answer.
|
|
25458
|
+
*/
|
|
25459
|
+
readClipBytes: optionalMethod(object({
|
|
25460
|
+
deviceId: number(),
|
|
25461
|
+
clipId: string().min(1),
|
|
25462
|
+
/**
|
|
25463
|
+
* Which twin to fetch, on the mapping D549 15 already fixed:
|
|
25464
|
+
* `low | mid` → the sub file, `high` → the main twin. `auto` is not
|
|
25465
|
+
* accepted here for the same reason it is not accepted by
|
|
25466
|
+
* `getClipPlayback` — a stored file has no broker session, so the
|
|
25467
|
+
* adaptive tier cannot be resolved for it.
|
|
25468
|
+
*/
|
|
25469
|
+
profile: CamProfileSchema.optional(),
|
|
25470
|
+
/**
|
|
25471
|
+
* The CALLER's byte bound, so an over-size clip is refused before it is
|
|
25472
|
+
* read and encoded rather than after. Capped by
|
|
25473
|
+
* {@link VIDEOCLIPS_MAX_READ_BYTES} whatever is passed; absent means
|
|
25474
|
+
* that ceiling.
|
|
25475
|
+
*/
|
|
25476
|
+
maxBytes: number().int().positive().optional(),
|
|
25477
|
+
/**
|
|
25478
|
+
* The operator's authorisation to wake a sleeping camera for this
|
|
25479
|
+
* read. Absent — the default — means a sleeping standalone battery
|
|
25480
|
+
* camera is REFUSED by name, before any session is opened.
|
|
25481
|
+
*/
|
|
25482
|
+
wake: ClipWakeSchema.optional()
|
|
25483
|
+
}), ClipBytesSchema, {
|
|
25484
|
+
kind: "query",
|
|
25485
|
+
auth: "protected"
|
|
25486
|
+
})
|
|
25487
|
+
}
|
|
25488
|
+
};
|
|
25272
25489
|
/**
|
|
25273
25490
|
* Optional client-side hints sent at session creation to help the provider
|
|
25274
25491
|
* pick the best native source. All fields optional — a viewer that knows
|
|
@@ -31236,13 +31453,101 @@ var ExportStateSchema = _enum([
|
|
|
31236
31453
|
"expired",
|
|
31237
31454
|
"deleted"
|
|
31238
31455
|
]);
|
|
31239
|
-
/**
|
|
31456
|
+
/**
|
|
31457
|
+
* WHAT an export is — the authority, as opposed to the four top-level fields
|
|
31458
|
+
* the Library sorts and labels on (D558 § 2.2).
|
|
31459
|
+
*
|
|
31460
|
+
* Read it through {@link exportSubjectOf}, never off the record directly: the
|
|
31461
|
+
* field is optional for the history rows written before it existed, and that
|
|
31462
|
+
* absence has exactly one interpreter.
|
|
31463
|
+
*/
|
|
31464
|
+
var ExportSubjectSchema = discriminatedUnion("kind", [object({
|
|
31465
|
+
kind: literal("footage"),
|
|
31466
|
+
deviceId: number(),
|
|
31467
|
+
profiles: array(string()).min(1),
|
|
31468
|
+
fromMs: number(),
|
|
31469
|
+
toMs: number()
|
|
31470
|
+
}), object({
|
|
31471
|
+
kind: literal("clip"),
|
|
31472
|
+
deviceId: number(),
|
|
31473
|
+
provider: string().min(1),
|
|
31474
|
+
source: string().min(1),
|
|
31475
|
+
sourceLabel: string().min(1),
|
|
31476
|
+
clipId: string().min(1),
|
|
31477
|
+
/**
|
|
31478
|
+
* WHERE IN THE CATALOG to confirm this clip — the day window the surface was
|
|
31479
|
+
* already listing when the operator picked the segment.
|
|
31480
|
+
*
|
|
31481
|
+
* It is **not** a time range control and it never becomes one: the record's
|
|
31482
|
+
* `fromMs`/`toMs` come from the catalog ROW and from nothing a caller
|
|
31483
|
+
* supplied (D558 § 5.4.3), and a window that does not contain the clip is a
|
|
31484
|
+
* `catalog-miss`, not a silently wider search. It exists because
|
|
31485
|
+
* `videoclips.listClips` takes `since`/`until` and has no by-handle twin:
|
|
31486
|
+
* the catalog check D558 asks for is literally that call, and a call needs a
|
|
31487
|
+
* window. The surface has one — `ClipsBrowser`'s `dayWindow`, one local
|
|
31488
|
+
* wall-clock day, which is also the only width measured to be cheap (one
|
|
31489
|
+
* day on 592 lists 171 clips in 712 ms, a busy day 546 in 2.0 s; twenty days
|
|
31490
|
+
* of one child's events took 3.6–5.4 s).
|
|
31491
|
+
*/
|
|
31492
|
+
catalogWindow: object({
|
|
31493
|
+
sinceMs: number(),
|
|
31494
|
+
untilMs: number()
|
|
31495
|
+
}),
|
|
31496
|
+
profile: _enum([
|
|
31497
|
+
"high",
|
|
31498
|
+
"mid",
|
|
31499
|
+
"low"
|
|
31500
|
+
]),
|
|
31501
|
+
/**
|
|
31502
|
+
* The operator's authorisation to wake a sleeping camera for this export.
|
|
31503
|
+
* ONE definition, in the cap that owns the clip read ({@link ClipWakeSchema}),
|
|
31504
|
+
* because the gate that honours it is the clip provider's sleep gate — a
|
|
31505
|
+
* second enum here would be a second contract. Absent by default; never
|
|
31506
|
+
* settable by a scheduler or a retry.
|
|
31507
|
+
*
|
|
31508
|
+
* **One authorised yes is ONE wake.** A failed clip export is retried only by
|
|
31509
|
+
* an operator act that asks again; a queued job that outlived its wake fails
|
|
31510
|
+
* with a reason rather than waking on its turn; and this subject carries
|
|
31511
|
+
* exactly one profile precisely so one tap is never two fetches (D558 § 5.2).
|
|
31512
|
+
*/
|
|
31513
|
+
wake: ClipWakeSchema.optional()
|
|
31514
|
+
})]);
|
|
31515
|
+
/**
|
|
31516
|
+
* One export job / history row.
|
|
31517
|
+
*
|
|
31518
|
+
* **`subject` is the AUTHORITY on what was exported. `deviceId`, `profile`,
|
|
31519
|
+
* `fromMs` and `toMs` are its PROJECTION** — kept top-level because the whole
|
|
31520
|
+
* Library sorts and labels on them (`library-items.ts` orders an export by
|
|
31521
|
+
* `fromMs`; `export-format.ts` draws `rangeLabel` from the pair), and a row
|
|
31522
|
+
* that did not fill them would sort under the epoch and render a blank range.
|
|
31523
|
+
* Write to the subject and read from the projection and the two will disagree;
|
|
31524
|
+
* the projection is derived at creation and never edited afterwards.
|
|
31525
|
+
*
|
|
31526
|
+
* And they mean DIFFERENT FACTS for the two kinds, which is the part a reader
|
|
31527
|
+
* who knows only the recording export will get wrong:
|
|
31528
|
+
*
|
|
31529
|
+
* | field | `kind:'footage'` | `kind:'clip'` |
|
|
31530
|
+
* | --- | --- | --- |
|
|
31531
|
+
* | `fromMs`/`toMs` | the stretch the operator ASKED for | the camera's own boundaries, always `clip.timeRange`, never anything a caller supplied |
|
|
31532
|
+
* | `profile` | the stream rendered | the twin actually SERVED (`subject.profile` is the one asked for) |
|
|
31533
|
+
*
|
|
31534
|
+
* Same type, different fact — the shape this repo keeps getting wrong (D385's
|
|
31535
|
+
* two authorities, D224's second copy).
|
|
31536
|
+
*/
|
|
31240
31537
|
var ExportRecordSchema = object({
|
|
31241
31538
|
id: string(),
|
|
31242
31539
|
deviceId: number(),
|
|
31243
31540
|
profile: string(),
|
|
31244
31541
|
fromMs: number(),
|
|
31245
31542
|
toMs: number(),
|
|
31543
|
+
/**
|
|
31544
|
+
* What this export IS. Optional ONLY for the rows written before D558: the
|
|
31545
|
+
* store parses every row through this schema on every read, so a required
|
|
31546
|
+
* field would make the export AUDIT — which is the whole reason rows survive
|
|
31547
|
+
* file deletion — unreadable in one release. Absence means `footage`, and
|
|
31548
|
+
* {@link exportSubjectOf} is the one place that says so.
|
|
31549
|
+
*/
|
|
31550
|
+
subject: ExportSubjectSchema.optional(),
|
|
31246
31551
|
options: ExportOptionsSchema,
|
|
31247
31552
|
state: ExportStateSchema,
|
|
31248
31553
|
/** 0–100 while rendering; null otherwise. */
|
|
@@ -31259,6 +31564,18 @@ var ExportRecordSchema = object({
|
|
|
31259
31564
|
/** Failure reason when state is 'failed'; null otherwise. */
|
|
31260
31565
|
error: string().nullable()
|
|
31261
31566
|
});
|
|
31567
|
+
_enum([
|
|
31568
|
+
"catalog-miss",
|
|
31569
|
+
"catalog-unreachable",
|
|
31570
|
+
"no-file-for-window",
|
|
31571
|
+
"clip-in-progress",
|
|
31572
|
+
"unsupported-option",
|
|
31573
|
+
"sleeping",
|
|
31574
|
+
"camera-refused",
|
|
31575
|
+
"fetch-failed",
|
|
31576
|
+
"too-large-to-transfer",
|
|
31577
|
+
"wake-expired"
|
|
31578
|
+
]);
|
|
31262
31579
|
/** Candidate download URLs (LAN first, then operator extra hosts). */
|
|
31263
31580
|
var ExportDownloadSchema = object({
|
|
31264
31581
|
url: string(),
|
|
@@ -31277,16 +31594,50 @@ var ExportBytesSchema = object({
|
|
|
31277
31594
|
name: string(),
|
|
31278
31595
|
bytes: number().int().nonnegative()
|
|
31279
31596
|
});
|
|
31597
|
+
/** Canonical `profiles[]`, falling back to the legacy singular `profile`. */
|
|
31598
|
+
function resolveExportProfiles(input) {
|
|
31599
|
+
if (input.profiles !== void 0 && input.profiles.length > 0) return [...input.profiles];
|
|
31600
|
+
if (typeof input.profile === "string" && input.profile.length > 0) return [input.profile];
|
|
31601
|
+
return [];
|
|
31602
|
+
}
|
|
31280
31603
|
method(object({
|
|
31281
31604
|
deviceId: number(),
|
|
31282
31605
|
/** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
|
|
31283
31606
|
profile: string().optional(),
|
|
31284
31607
|
profiles: array(string()).min(1).optional(),
|
|
31285
|
-
|
|
31286
|
-
|
|
31608
|
+
/** Footage only — a clip's boundaries are the camera's. */
|
|
31609
|
+
fromMs: number().optional(),
|
|
31610
|
+
toMs: number().optional(),
|
|
31611
|
+
/** What to export. Absent means the legacy flat footage request. */
|
|
31612
|
+
subject: ExportSubjectSchema.optional(),
|
|
31287
31613
|
options: ExportOptionsSchema
|
|
31288
31614
|
}).superRefine((v, ctx) => {
|
|
31289
|
-
if (
|
|
31615
|
+
if (v.subject?.kind === "clip") {
|
|
31616
|
+
if (v.subject.deviceId !== v.deviceId) ctx.addIssue({
|
|
31617
|
+
code: ZodIssueCode.custom,
|
|
31618
|
+
message: `subject.deviceId (${v.subject.deviceId}) must equal deviceId (${v.deviceId}) — the top-level field is what per-device scope enforcement reads`,
|
|
31619
|
+
path: ["subject", "deviceId"]
|
|
31620
|
+
});
|
|
31621
|
+
if (v.fromMs !== void 0 || v.toMs !== void 0) ctx.addIssue({
|
|
31622
|
+
code: ZodIssueCode.custom,
|
|
31623
|
+
message: "a clip export asks for no time range: the camera chose the boundaries and they are read from the catalog row",
|
|
31624
|
+
path: ["fromMs"]
|
|
31625
|
+
});
|
|
31626
|
+
return;
|
|
31627
|
+
}
|
|
31628
|
+
if (v.subject?.kind === "footage" && v.subject.deviceId !== v.deviceId) ctx.addIssue({
|
|
31629
|
+
code: ZodIssueCode.custom,
|
|
31630
|
+
message: `subject.deviceId (${v.subject.deviceId}) must equal deviceId (${v.deviceId})`,
|
|
31631
|
+
path: ["subject", "deviceId"]
|
|
31632
|
+
});
|
|
31633
|
+
const fromMs = v.subject?.kind === "footage" ? v.subject.fromMs : v.fromMs;
|
|
31634
|
+
const toMs = v.subject?.kind === "footage" ? v.subject.toMs : v.toMs;
|
|
31635
|
+
if (typeof fromMs !== "number" || typeof toMs !== "number") ctx.addIssue({
|
|
31636
|
+
code: ZodIssueCode.custom,
|
|
31637
|
+
message: "a footage export needs fromMs and toMs",
|
|
31638
|
+
path: ["fromMs"]
|
|
31639
|
+
});
|
|
31640
|
+
if ((v.subject?.kind === "footage" ? [...v.subject.profiles] : resolveExportProfiles(v)).length < 1) ctx.addIssue({
|
|
31290
31641
|
code: ZodIssueCode.custom,
|
|
31291
31642
|
message: "pass profiles[] (min 1) or legacy profile",
|
|
31292
31643
|
path: ["profiles"]
|
|
@@ -36707,6 +37058,12 @@ Object.freeze({
|
|
|
36707
37058
|
addonId: null,
|
|
36708
37059
|
access: "view"
|
|
36709
37060
|
},
|
|
37061
|
+
"pipelineAnalytics.ownersWithMedia": {
|
|
37062
|
+
capName: "pipeline-analytics",
|
|
37063
|
+
capScope: "device",
|
|
37064
|
+
addonId: null,
|
|
37065
|
+
access: "view"
|
|
37066
|
+
},
|
|
36710
37067
|
"pipelineAnalytics.pauseForStorageMigration": {
|
|
36711
37068
|
capName: "pipeline-analytics",
|
|
36712
37069
|
capScope: "device",
|
|
@@ -39305,6 +39662,12 @@ Object.freeze({
|
|
|
39305
39662
|
addonId: null,
|
|
39306
39663
|
access: "view"
|
|
39307
39664
|
},
|
|
39665
|
+
"videoclips.readClipBytes": {
|
|
39666
|
+
capName: "videoclips",
|
|
39667
|
+
capScope: "device",
|
|
39668
|
+
addonId: null,
|
|
39669
|
+
access: "view"
|
|
39670
|
+
},
|
|
39308
39671
|
"viewerUi.getStaticDir": {
|
|
39309
39672
|
capName: "viewer-ui",
|
|
39310
39673
|
capScope: "system",
|
|
@@ -40607,6 +40970,11 @@ Object.freeze({
|
|
|
40607
40970
|
form: "single",
|
|
40608
40971
|
optional: false
|
|
40609
40972
|
}],
|
|
40973
|
+
"pipelineAnalytics.ownersWithMedia": [{
|
|
40974
|
+
name: "deviceId",
|
|
40975
|
+
form: "single",
|
|
40976
|
+
optional: false
|
|
40977
|
+
}],
|
|
40610
40978
|
"pipelineAnalytics.proposeRetrainAnnotations": [{
|
|
40611
40979
|
name: "deviceId",
|
|
40612
40980
|
form: "single",
|
|
@@ -41318,6 +41686,11 @@ Object.freeze({
|
|
|
41318
41686
|
form: "single",
|
|
41319
41687
|
optional: false
|
|
41320
41688
|
}],
|
|
41689
|
+
"videoclips.readClipBytes": [{
|
|
41690
|
+
name: "deviceId",
|
|
41691
|
+
form: "single",
|
|
41692
|
+
optional: false
|
|
41693
|
+
}],
|
|
41321
41694
|
"waterHeater.setAway": [{
|
|
41322
41695
|
name: "deviceId",
|
|
41323
41696
|
form: "single",
|
|
@@ -89082,6 +89455,78 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
89082
89455
|
}
|
|
89083
89456
|
}
|
|
89084
89457
|
//#endregion
|
|
89458
|
+
//#region src/hksv/build-outcome.ts
|
|
89459
|
+
/**
|
|
89460
|
+
* Every camera's last build verdict, in this process.
|
|
89461
|
+
*
|
|
89462
|
+
* A `Map` behind a named type rather than a bare one, because the thing that
|
|
89463
|
+
* matters about it is what `get` returning `undefined` MEANS: not "recording is
|
|
89464
|
+
* fine", but "no accessory has been built for this camera since the addon
|
|
89465
|
+
* started".
|
|
89466
|
+
*/
|
|
89467
|
+
var HksvBuildOutcomes = class {
|
|
89468
|
+
byDevice = /* @__PURE__ */ new Map();
|
|
89469
|
+
note(outcome) {
|
|
89470
|
+
this.byDevice.set(outcome.deviceId, outcome);
|
|
89471
|
+
}
|
|
89472
|
+
/** `null` when nothing has been established for this camera yet. */
|
|
89473
|
+
lastFor(deviceId) {
|
|
89474
|
+
return this.byDevice.get(deviceId) ?? null;
|
|
89475
|
+
}
|
|
89476
|
+
/** The camera left HomeKit: its verdict is not a fact about anything now. */
|
|
89477
|
+
forget(deviceId) {
|
|
89478
|
+
this.byDevice.delete(deviceId);
|
|
89479
|
+
}
|
|
89480
|
+
};
|
|
89481
|
+
//#endregion
|
|
89482
|
+
//#region src/hksv/clip-ffmpeg-run.ts
|
|
89483
|
+
function createBoundedFfmpegRunner(input) {
|
|
89484
|
+
return (args) => new Promise((resolve) => {
|
|
89485
|
+
let settled = false;
|
|
89486
|
+
const finish = (run) => {
|
|
89487
|
+
if (settled) return;
|
|
89488
|
+
settled = true;
|
|
89489
|
+
clearTimeout(timer);
|
|
89490
|
+
resolve(run);
|
|
89491
|
+
};
|
|
89492
|
+
const child = input.spawnFn(input.ffmpegBinaryPath, [...args], { stdio: [
|
|
89493
|
+
"ignore",
|
|
89494
|
+
"ignore",
|
|
89495
|
+
"pipe"
|
|
89496
|
+
] });
|
|
89497
|
+
let stderr = "";
|
|
89498
|
+
child.stderr?.on("data", (chunk) => {
|
|
89499
|
+
if (stderr.length < 2e3) stderr += chunk.toString("utf8");
|
|
89500
|
+
});
|
|
89501
|
+
const timer = setTimeout(() => {
|
|
89502
|
+
child.kill("SIGKILL");
|
|
89503
|
+
finish({
|
|
89504
|
+
code: null,
|
|
89505
|
+
timedOut: true,
|
|
89506
|
+
spawnFailed: false,
|
|
89507
|
+
stderr
|
|
89508
|
+
});
|
|
89509
|
+
}, input.timeoutMs);
|
|
89510
|
+
timer.unref?.();
|
|
89511
|
+
child.on("error", (err) => {
|
|
89512
|
+
finish({
|
|
89513
|
+
code: null,
|
|
89514
|
+
timedOut: false,
|
|
89515
|
+
spawnFailed: true,
|
|
89516
|
+
stderr: err.message
|
|
89517
|
+
});
|
|
89518
|
+
});
|
|
89519
|
+
child.on("close", (code) => {
|
|
89520
|
+
finish({
|
|
89521
|
+
code,
|
|
89522
|
+
timedOut: false,
|
|
89523
|
+
spawnFailed: false,
|
|
89524
|
+
stderr
|
|
89525
|
+
});
|
|
89526
|
+
});
|
|
89527
|
+
});
|
|
89528
|
+
}
|
|
89529
|
+
//#endregion
|
|
89085
89530
|
//#region src/hksv/clip-location.ts
|
|
89086
89531
|
/** The declared id. Deployment-wide; see the docblock for why it is new. */
|
|
89087
89532
|
var HOMEKIT_CLIPS_LOCATION_TYPE = "homekitClips";
|
|
@@ -89200,6 +89645,220 @@ var HksvClipLocation = class {
|
|
|
89200
89645
|
}
|
|
89201
89646
|
};
|
|
89202
89647
|
//#endregion
|
|
89648
|
+
//#region src/hksv/clip-remux.ts
|
|
89649
|
+
/**
|
|
89650
|
+
* The faststart pass: the teed clip is made SEEKABLE, once, by the process that
|
|
89651
|
+
* minted it.
|
|
89652
|
+
*
|
|
89653
|
+
* ## Why the producer owes this
|
|
89654
|
+
*
|
|
89655
|
+
* The tee cuts like a pipe. ffmpeg writes `frag_keyframe+empty_moov` and never
|
|
89656
|
+
* returns to write a trailer, so on a teed clip `mvhd.duration` and
|
|
89657
|
+
* `mdhd.duration` are **0**, the `stbl` sample tables are EMPTY and there is no
|
|
89658
|
+
* `mfra`. Measured, on this hub's own files. The result decodes end to end — it
|
|
89659
|
+
* plays — but nothing can map a time to a byte in it, and a time-to-byte map is
|
|
89660
|
+
* the whole of what a scrub, a `Range` request and a stated duration are.
|
|
89661
|
+
*
|
|
89662
|
+
* The alternative was a remux in the CONSUMER, at play time. That puts an
|
|
89663
|
+
* ffmpeg per viewer in the broker's runner and buffers the whole file to write
|
|
89664
|
+
* a trailer, every time anybody drags — the cost D570 § 5(b) rejected, moved
|
|
89665
|
+
* one process along. Here it is one pass per clip, at the moment the clip is
|
|
89666
|
+
* born, measured at 40 ms and +0.7 % bytes. A clip is written once and read
|
|
89667
|
+
* many times; this is the side of that asymmetry the work belongs on.
|
|
89668
|
+
*
|
|
89669
|
+
* ## The constraint that outranks the feature
|
|
89670
|
+
*
|
|
89671
|
+
* Exactly as for the tee itself: **a clip that does not seek is a clip; a clip
|
|
89672
|
+
* the remux ate is a HomeKit recording the operator lost.** So the pass writes
|
|
89673
|
+
* a SIBLING and renames over the original only once it has vouched for it, it
|
|
89674
|
+
* never throws, and every way of failing has its own name on the record —
|
|
89675
|
+
* a missing binary, a deadline and a file ffmpeg blessed but did not fix are
|
|
89676
|
+
* three different operator actions.
|
|
89677
|
+
*
|
|
89678
|
+
* ## The vouch is a measurement, not an exit code
|
|
89679
|
+
*
|
|
89680
|
+
* `-movflags +faststart` exiting 0 is not evidence. {@link moovPrecedesMdat}
|
|
89681
|
+
* reads the top-level box order off the produced file and requires `moov`
|
|
89682
|
+
* ahead of the media — and requires the file to be PROGRESSIVE, because the
|
|
89683
|
+
* fragmented shape the tee already wrote is *also* moov-first and is exactly
|
|
89684
|
+
* what this pass exists to replace.
|
|
89685
|
+
*/
|
|
89686
|
+
/** A remux that has not finished by here is not going to. */
|
|
89687
|
+
var CLIP_REMUX_TIMEOUT_MS = 3e4;
|
|
89688
|
+
/** The sibling the pass writes before it has earned the clip's own name. */
|
|
89689
|
+
var CLIP_REMUX_SUFFIX = ".faststart";
|
|
89690
|
+
/**
|
|
89691
|
+
* How much of the head is read back to judge the box order. The `moov` of a
|
|
89692
|
+
* short clip is a few hundred KB at most, and this only has to reach far enough
|
|
89693
|
+
* to meet the first `mdat` or `moof`.
|
|
89694
|
+
*/
|
|
89695
|
+
var HEAD_BYTES = 1024 * 1024;
|
|
89696
|
+
function buildClipRemuxArgs(input) {
|
|
89697
|
+
return [
|
|
89698
|
+
"-hide_banner",
|
|
89699
|
+
"-loglevel",
|
|
89700
|
+
"error",
|
|
89701
|
+
"-nostdin",
|
|
89702
|
+
"-y",
|
|
89703
|
+
"-i",
|
|
89704
|
+
input.clipPath,
|
|
89705
|
+
"-c",
|
|
89706
|
+
"copy",
|
|
89707
|
+
"-movflags",
|
|
89708
|
+
"+faststart",
|
|
89709
|
+
"-f",
|
|
89710
|
+
"mp4",
|
|
89711
|
+
input.outPath
|
|
89712
|
+
];
|
|
89713
|
+
}
|
|
89714
|
+
function createClipRemuxer(input) {
|
|
89715
|
+
return async ({ clipPath }) => {
|
|
89716
|
+
const outPath = `${clipPath}${CLIP_REMUX_SUFFIX}`;
|
|
89717
|
+
try {
|
|
89718
|
+
const run = await input.runner(buildClipRemuxArgs({
|
|
89719
|
+
clipPath,
|
|
89720
|
+
outPath
|
|
89721
|
+
}));
|
|
89722
|
+
if (run.spawnFailed) return await discard(outPath, "ffmpeg-missing");
|
|
89723
|
+
if (run.timedOut) return await discard(outPath, "timed-out");
|
|
89724
|
+
if (run.code !== 0) return await discard(outPath, "remux-failed");
|
|
89725
|
+
const size = await sizeOf(outPath);
|
|
89726
|
+
if (size === null || size === 0) return await discard(outPath, "remux-failed");
|
|
89727
|
+
const head = await readHead(outPath);
|
|
89728
|
+
if (!moovPrecedesMdat(head)) return await discard(outPath, "not-seekable");
|
|
89729
|
+
const durationMs = readMovieDurationMs(head);
|
|
89730
|
+
await rename(outPath, clipPath);
|
|
89731
|
+
return {
|
|
89732
|
+
ok: true,
|
|
89733
|
+
bytes: size,
|
|
89734
|
+
...durationMs === null ? {} : { durationMs }
|
|
89735
|
+
};
|
|
89736
|
+
} catch {
|
|
89737
|
+
return await discard(outPath, "remux-failed");
|
|
89738
|
+
}
|
|
89739
|
+
};
|
|
89740
|
+
}
|
|
89741
|
+
async function discard(outPath, reason) {
|
|
89742
|
+
await rm(outPath, { force: true }).catch(() => void 0);
|
|
89743
|
+
return {
|
|
89744
|
+
ok: false,
|
|
89745
|
+
reason
|
|
89746
|
+
};
|
|
89747
|
+
}
|
|
89748
|
+
async function sizeOf(path) {
|
|
89749
|
+
try {
|
|
89750
|
+
return (await stat(path)).size;
|
|
89751
|
+
} catch {
|
|
89752
|
+
return null;
|
|
89753
|
+
}
|
|
89754
|
+
}
|
|
89755
|
+
async function readHead(path) {
|
|
89756
|
+
const buf = await readFile(path);
|
|
89757
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, Math.min(buf.byteLength, HEAD_BYTES));
|
|
89758
|
+
}
|
|
89759
|
+
/**
|
|
89760
|
+
* Does this file carry a progressive index ahead of its media?
|
|
89761
|
+
*
|
|
89762
|
+
* Walks the TOP-LEVEL boxes only. Three verdicts collapse into `false`, and
|
|
89763
|
+
* each of them is a real file this store has held:
|
|
89764
|
+
*
|
|
89765
|
+
* - `moov` after `mdat` — a plain mux, seekable only once the whole file is
|
|
89766
|
+
* in hand, which over a `Range` reader means never.
|
|
89767
|
+
* - `moof` before any `mdat` — the FRAGMENTED shape the tee writes. Its
|
|
89768
|
+
* leading `moov` is the empty one `empty_moov` produced, so judging on
|
|
89769
|
+
* position alone would bless exactly the file this pass replaces.
|
|
89770
|
+
* - anything unreadable — a truncated header, or a box claiming a size that
|
|
89771
|
+
* does not advance the cursor. A corrupt file is never guessed at, and a
|
|
89772
|
+
* non-advancing size is how a scanner spins for ever.
|
|
89773
|
+
*/
|
|
89774
|
+
function moovPrecedesMdat(bytes) {
|
|
89775
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
89776
|
+
let at = 0;
|
|
89777
|
+
let sawMoov = false;
|
|
89778
|
+
while (at + 8 <= bytes.byteLength) {
|
|
89779
|
+
const declared = view.getUint32(at);
|
|
89780
|
+
const type = String.fromCharCode(bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]);
|
|
89781
|
+
let size = declared;
|
|
89782
|
+
let header = 8;
|
|
89783
|
+
if (declared === 1) {
|
|
89784
|
+
if (at + 16 > bytes.byteLength) return false;
|
|
89785
|
+
const large = view.getBigUint64(at + 8);
|
|
89786
|
+
if (large > BigInt(Number.MAX_SAFE_INTEGER)) return false;
|
|
89787
|
+
size = Number(large);
|
|
89788
|
+
header = 16;
|
|
89789
|
+
}
|
|
89790
|
+
if (size < header) return false;
|
|
89791
|
+
if (type === "moov") sawMoov = true;
|
|
89792
|
+
if (type === "mdat") return sawMoov;
|
|
89793
|
+
if (type === "moof") return false;
|
|
89794
|
+
at += size;
|
|
89795
|
+
}
|
|
89796
|
+
return false;
|
|
89797
|
+
}
|
|
89798
|
+
/**
|
|
89799
|
+
* The movie duration, in ms, from the `mvhd` inside `moov`.
|
|
89800
|
+
*
|
|
89801
|
+
* `null`, never `0`, for every way of not knowing — no `moov`, no `mvhd`, a
|
|
89802
|
+
* truncated header, a zero timescale, or the **zero duration `empty_moov`
|
|
89803
|
+
* writes**, which is the case that matters: believing it would replace a
|
|
89804
|
+
* duration that is merely wrong with one that confidently says the clip is
|
|
89805
|
+
* empty (D393).
|
|
89806
|
+
*
|
|
89807
|
+
* Worth having because the alternative number is wrong in a specific,
|
|
89808
|
+
* measurable way: `endedAtMs - startedAtMs` is how long the TEE ran, and the
|
|
89809
|
+
* first fragments it wrote were the prebuffer — footage older than the tee
|
|
89810
|
+
* itself. On 615 that is 12.0 s claimed against 19.99 s held.
|
|
89811
|
+
*/
|
|
89812
|
+
function readMovieDurationMs(bytes) {
|
|
89813
|
+
const moov = findTopLevelBox(bytes, "moov");
|
|
89814
|
+
if (moov === null) return null;
|
|
89815
|
+
const mvhd = findTopLevelBox(moov, "mvhd");
|
|
89816
|
+
if (mvhd === null || mvhd.byteLength < 4) return null;
|
|
89817
|
+
const view = new DataView(mvhd.buffer, mvhd.byteOffset, mvhd.byteLength);
|
|
89818
|
+
const version = mvhd[0];
|
|
89819
|
+
const timescaleAt = version === 1 ? 20 : 12;
|
|
89820
|
+
const durationAt = timescaleAt + 4;
|
|
89821
|
+
if (durationAt + (version === 1 ? 8 : 4) > mvhd.byteLength) return null;
|
|
89822
|
+
const timescale = view.getUint32(timescaleAt);
|
|
89823
|
+
if (timescale === 0) return null;
|
|
89824
|
+
const duration = version === 1 ? Number(view.getBigUint64(durationAt)) : view.getUint32(durationAt);
|
|
89825
|
+
if (duration <= 0 || !Number.isFinite(duration)) return null;
|
|
89826
|
+
return Math.round(duration / timescale * 1e3);
|
|
89827
|
+
}
|
|
89828
|
+
/**
|
|
89829
|
+
* The PAYLOAD — header stripped — of the first top-level box of `type`, or
|
|
89830
|
+
* `null`. Stripped, because the only reason to hold a box here is to walk its
|
|
89831
|
+
* children, and leaving the header on makes the first child look like the
|
|
89832
|
+
* parent.
|
|
89833
|
+
*
|
|
89834
|
+
* Deliberately the same walk as {@link moovPrecedesMdat} rather than a shared
|
|
89835
|
+
* generator: that one answers a question about ORDER and stops at the media,
|
|
89836
|
+
* this one descends. Both refuse a box that does not advance the cursor, which
|
|
89837
|
+
* is the property neither can do without.
|
|
89838
|
+
*/
|
|
89839
|
+
function findTopLevelBox(bytes, type) {
|
|
89840
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
89841
|
+
let at = 0;
|
|
89842
|
+
while (at + 8 <= bytes.byteLength) {
|
|
89843
|
+
const declared = view.getUint32(at);
|
|
89844
|
+
const found = String.fromCharCode(bytes[at + 4], bytes[at + 5], bytes[at + 6], bytes[at + 7]);
|
|
89845
|
+
let size = declared;
|
|
89846
|
+
let header = 8;
|
|
89847
|
+
if (declared === 1) {
|
|
89848
|
+
if (at + 16 > bytes.byteLength) return null;
|
|
89849
|
+
const large = view.getBigUint64(at + 8);
|
|
89850
|
+
if (large > BigInt(Number.MAX_SAFE_INTEGER)) return null;
|
|
89851
|
+
size = Number(large);
|
|
89852
|
+
header = 16;
|
|
89853
|
+
}
|
|
89854
|
+
if (size < header) return null;
|
|
89855
|
+
const end = Math.min(at + size, bytes.byteLength);
|
|
89856
|
+
if (found === type) return at + header >= end ? null : bytes.subarray(at + header, end);
|
|
89857
|
+
at += size;
|
|
89858
|
+
}
|
|
89859
|
+
return null;
|
|
89860
|
+
}
|
|
89861
|
+
//#endregion
|
|
89203
89862
|
//#region src/hksv/clip-record.ts
|
|
89204
89863
|
/**
|
|
89205
89864
|
* What ONE teed HomeKit clip is, on disk.
|
|
@@ -89221,10 +89880,14 @@ var HksvClipLocation = class {
|
|
|
89221
89880
|
* `deriveFragmentLengthMs` refuses a GOP longer than 8 s. A camera the tee
|
|
89222
89881
|
* never writes for is not a gap in this store, it is a camera HKSV never
|
|
89223
89882
|
* recorded; {@link HksvClipRecord} exists only for the ones it did.
|
|
89224
|
-
* - **The duration is whatever iOS PULLED.**
|
|
89225
|
-
*
|
|
89226
|
-
*
|
|
89227
|
-
*
|
|
89883
|
+
* - **The duration is whatever iOS PULLED.** Not a window CamStack chose, and
|
|
89884
|
+
* a short clip is not a truncated one — {@link HksvClipRecord.truncated} is
|
|
89885
|
+
* the only thing that says truncated. Since D578 it is READ FROM THE FILE
|
|
89886
|
+
* (`mvhd`, after the faststart pass), because the tee's own wall clock is
|
|
89887
|
+
* wrong in a specific direction: the clock starts when the tee does, and the
|
|
89888
|
+
* first fragments it writes are the PREBUFFER — footage older than the tee
|
|
89889
|
+
* itself. Measured on 615: 12.0 s claimed against 19.99 s held. The wall
|
|
89890
|
+
* clock remains the fallback for a file that could not be measured.
|
|
89228
89891
|
*
|
|
89229
89892
|
* ## `thumbnailFile` is VOUCHED
|
|
89230
89893
|
*
|
|
@@ -89243,6 +89906,18 @@ var HksvThumbnailUnavailableReasonSchema = _enum([
|
|
|
89243
89906
|
"no-init-segment",
|
|
89244
89907
|
"not-attempted"
|
|
89245
89908
|
]);
|
|
89909
|
+
/**
|
|
89910
|
+
* Why a teed clip could not be made seekable. Named, never blank — a missing
|
|
89911
|
+
* binary, a deadline and a file ffmpeg blessed but did not fix are three
|
|
89912
|
+
* different operator actions, and "it does not seek" is none of them.
|
|
89913
|
+
*/
|
|
89914
|
+
var HksvRemuxUnavailableReasonSchema = _enum([
|
|
89915
|
+
"remux-failed",
|
|
89916
|
+
"timed-out",
|
|
89917
|
+
"ffmpeg-missing",
|
|
89918
|
+
"not-seekable",
|
|
89919
|
+
"not-attempted"
|
|
89920
|
+
]);
|
|
89246
89921
|
/** Why a clip stopped short of the stream that fed it. */
|
|
89247
89922
|
var HksvClipTruncationSchema = _enum([
|
|
89248
89923
|
"size-cap",
|
|
@@ -89261,21 +89936,59 @@ var HksvClipRecordSchema = object({
|
|
|
89261
89936
|
streamId: number().int(),
|
|
89262
89937
|
startedAtMs: number().int(),
|
|
89263
89938
|
endedAtMs: number().int(),
|
|
89264
|
-
/**
|
|
89939
|
+
/**
|
|
89940
|
+
* What iOS pulled, never a window we chose — measured from the file's own
|
|
89941
|
+
* `mvhd` where the faststart pass could read it, and `endedAtMs -
|
|
89942
|
+
* startedAtMs` otherwise. See the docblock: the two differ by the prebuffer.
|
|
89943
|
+
*/
|
|
89265
89944
|
durationMs: number().int().nonnegative(),
|
|
89266
89945
|
/** How much of the clip's head came from the prebuffer ring, by ARRIVAL age. */
|
|
89267
89946
|
prebufferSpanMs: number().int().nonnegative(),
|
|
89947
|
+
/**
|
|
89948
|
+
* The size of {@link HksvClipRecord.file} AS IT NOW STANDS on disk — so on a
|
|
89949
|
+
* clip the faststart pass rewrote, the post-remux size, re-stat'd and never
|
|
89950
|
+
* carried over from the tee's own count. The pass costs about +0.7 %, and a
|
|
89951
|
+
* record stating the pre-remux figure would be wrong by exactly that for
|
|
89952
|
+
* ever, on the one field a `Range` reader and the disk accounting both use.
|
|
89953
|
+
*/
|
|
89268
89954
|
bytes: number().int().nonnegative(),
|
|
89269
89955
|
fragments: number().int().nonnegative(),
|
|
89270
89956
|
/** Basenames, relative to the clip's own device directory. */
|
|
89271
89957
|
file: string().min(1),
|
|
89272
89958
|
thumbnailFile: string().min(1).optional(),
|
|
89273
89959
|
thumbnailUnavailable: object({ reason: HksvThumbnailUnavailableReasonSchema }).optional(),
|
|
89960
|
+
/**
|
|
89961
|
+
* The faststart pass ran and its output was VOUCHED: `moov` ahead of the
|
|
89962
|
+
* media, progressive, read back off the produced file. Present only when
|
|
89963
|
+
* that is true of {@link HksvClipRecord.file}.
|
|
89964
|
+
*
|
|
89965
|
+
* Three states, not two, and the third is the reason this is optional rather
|
|
89966
|
+
* than a boolean: `true` means it seeks, {@link
|
|
89967
|
+
* HksvClipRecord.remuxUnavailable} means it does not and says why, and
|
|
89968
|
+
* NEITHER means unknown — a sidecar written before this field existed cannot
|
|
89969
|
+
* be given one after the fact, and a reader must not read that silence as
|
|
89970
|
+
* "does not seek" (D393).
|
|
89971
|
+
*/
|
|
89972
|
+
seekable: literal(true).optional(),
|
|
89973
|
+
remuxUnavailable: object({ reason: HksvRemuxUnavailableReasonSchema }).optional(),
|
|
89274
89974
|
truncated: HksvClipTruncationSchema.optional(),
|
|
89275
89975
|
/** iOS sent `ack` for this stream: HomeKit itself kept the clip. */
|
|
89276
89976
|
acknowledgedByHomeKit: boolean(),
|
|
89277
89977
|
width: number().int().positive(),
|
|
89278
89978
|
height: number().int().positive(),
|
|
89979
|
+
/**
|
|
89980
|
+
* WHICH broker slot HomeKit recorded from, on the one quality scale the
|
|
89981
|
+
* system has (`high | mid | low`).
|
|
89982
|
+
*
|
|
89983
|
+
* Written by the tee from `pickRecordingSource`'s choice, because it is the
|
|
89984
|
+
* only thing that knows: the pixels do not say which profile produced them,
|
|
89985
|
+
* and a consumer that needs `served` (`getClipPlayback`, `readClipBytes`)
|
|
89986
|
+
* must never derive it from the resolution. Optional because a sidecar
|
|
89987
|
+
* written before this field existed cannot be given one after the fact — a
|
|
89988
|
+
* reader REFUSES to name a twin it was never told (D393), rather than
|
|
89989
|
+
* guessing one.
|
|
89990
|
+
*/
|
|
89991
|
+
profile: CamProfileSchema.optional(),
|
|
89279
89992
|
/** The advertised fragment length the source was cutting at. */
|
|
89280
89993
|
fragmentMs: number().int().positive()
|
|
89281
89994
|
});
|
|
@@ -89284,7 +89997,17 @@ var HksvClipRecordSchema = object({
|
|
|
89284
89997
|
* else, so a pruner deleting a clip never has to guess which JPEG was its.
|
|
89285
89998
|
*/
|
|
89286
89999
|
function clipFileNames(clipId) {
|
|
89287
|
-
|
|
90000
|
+
return clipFileNamesForStem(clipStem(clipId));
|
|
90001
|
+
}
|
|
90002
|
+
/**
|
|
90003
|
+
* The same three names, from a stem a URL already carries.
|
|
90004
|
+
*
|
|
90005
|
+
* A data-plane route is handed the stem, not the id — `:` is legal on ext4 and
|
|
90006
|
+
* hostile in a path everywhere else, which is why {@link clipStem} exists — and
|
|
90007
|
+
* it must not re-derive the layout. One place mints these names, for both
|
|
90008
|
+
* callers.
|
|
90009
|
+
*/
|
|
90010
|
+
function clipFileNamesForStem(stem) {
|
|
89288
90011
|
return {
|
|
89289
90012
|
clip: `${stem}.mp4`,
|
|
89290
90013
|
thumbnail: `${stem}.jpg`,
|
|
@@ -89374,6 +90097,12 @@ function clipStem(clipId) {
|
|
|
89374
90097
|
* deleted — this repo has twice shipped a green test because the fake supplied
|
|
89375
90098
|
* what production forgot, and a promise is not a file.
|
|
89376
90099
|
*/
|
|
90100
|
+
/** An `errno` code off an unknown throw, without a cast. */
|
|
90101
|
+
function errnoCode(err) {
|
|
90102
|
+
if (typeof err !== "object" || err === null || !("code" in err)) return null;
|
|
90103
|
+
const { code } = err;
|
|
90104
|
+
return typeof code === "string" ? code : null;
|
|
90105
|
+
}
|
|
89377
90106
|
/** See the class docblock for what each number is, and where it comes from. */
|
|
89378
90107
|
var DEFAULT_CLIP_BOUNDS = {
|
|
89379
90108
|
maxClipBytes: 256 * 1024 * 1024,
|
|
@@ -89423,7 +90152,7 @@ var HksvClipStore = class {
|
|
|
89423
90152
|
const sink = await createFileSink(join(dir, names.clip));
|
|
89424
90153
|
this.inFlight.set(clipId, {
|
|
89425
90154
|
deviceId: input.deviceId,
|
|
89426
|
-
stem: stemOf(names.clip)
|
|
90155
|
+
stem: stemOf$1(names.clip)
|
|
89427
90156
|
});
|
|
89428
90157
|
return {
|
|
89429
90158
|
clipId,
|
|
@@ -89444,14 +90173,52 @@ var HksvClipStore = class {
|
|
|
89444
90173
|
}
|
|
89445
90174
|
/** Every sidecar for a camera, newest first. What a later provider lists. */
|
|
89446
90175
|
async listRecords(deviceId) {
|
|
89447
|
-
const
|
|
89448
|
-
|
|
90176
|
+
const catalog = await this.listCatalog(deviceId);
|
|
90177
|
+
return catalog.kind === "catalog" ? catalog.records : [];
|
|
90178
|
+
}
|
|
90179
|
+
/**
|
|
90180
|
+
* ONE directory read, answering the three questions a provider has to tell
|
|
90181
|
+
* apart: what was written, what is still THERE, and — when neither — whether
|
|
90182
|
+
* the location is gone or the read failed.
|
|
90183
|
+
*
|
|
90184
|
+
* `listRecords` is this method's `records` and nothing else, so the published
|
|
90185
|
+
* D550 contract and the provider can never disagree about what the store
|
|
90186
|
+
* holds. The file name set is here rather than a `stat` per clip because the
|
|
90187
|
+
* `readdir` has already answered it: a sidecar whose fMP4 was removed is a
|
|
90188
|
+
* row that must still LIST, saying it cannot be played (D549's `playable`),
|
|
90189
|
+
* and N syscalls to re-learn what one call said is the shape D447 charges
|
|
90190
|
+
* for.
|
|
90191
|
+
*
|
|
90192
|
+
* A device directory that does not exist is an EMPTY catalog, not a refusal:
|
|
90193
|
+
* the location is fine and this camera has recorded nothing. A `readdir` that
|
|
90194
|
+
* failed for any other reason is `failed` — a measurement that failed is
|
|
90195
|
+
* never folded into "no clips" (D393).
|
|
90196
|
+
*/
|
|
90197
|
+
async listCatalog(deviceId) {
|
|
90198
|
+
let root;
|
|
90199
|
+
try {
|
|
90200
|
+
root = await this.input.resolveRoot();
|
|
90201
|
+
} catch (err) {
|
|
90202
|
+
return {
|
|
90203
|
+
kind: "failed",
|
|
90204
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90205
|
+
};
|
|
90206
|
+
}
|
|
90207
|
+
if (root === null) return { kind: "no-root" };
|
|
89449
90208
|
const dir = this.deviceDir(root, deviceId);
|
|
89450
90209
|
let names;
|
|
89451
90210
|
try {
|
|
89452
90211
|
names = await readdir(dir);
|
|
89453
|
-
} catch {
|
|
89454
|
-
return
|
|
90212
|
+
} catch (err) {
|
|
90213
|
+
if (errnoCode(err) === "ENOENT") return {
|
|
90214
|
+
kind: "catalog",
|
|
90215
|
+
records: [],
|
|
90216
|
+
files: /* @__PURE__ */ new Set()
|
|
90217
|
+
};
|
|
90218
|
+
return {
|
|
90219
|
+
kind: "failed",
|
|
90220
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90221
|
+
};
|
|
89455
90222
|
}
|
|
89456
90223
|
const records = [];
|
|
89457
90224
|
for (const name of names) {
|
|
@@ -89459,7 +90226,24 @@ var HksvClipStore = class {
|
|
|
89459
90226
|
const record = await this.readRecord(join(dir, name), deviceId);
|
|
89460
90227
|
if (record !== null) records.push(record);
|
|
89461
90228
|
}
|
|
89462
|
-
return
|
|
90229
|
+
return {
|
|
90230
|
+
kind: "catalog",
|
|
90231
|
+
records: records.toSorted((a, b) => b.startedAtMs - a.startedAtMs),
|
|
90232
|
+
files: new Set(names)
|
|
90233
|
+
};
|
|
90234
|
+
}
|
|
90235
|
+
/**
|
|
90236
|
+
* Where ONE artifact of a clip lives, or `null` when there is no root.
|
|
90237
|
+
*
|
|
90238
|
+
* The stem, never a path: the caller (a data-plane route) has a URL segment
|
|
90239
|
+
* and the store owns the layout. Nothing else may compose a path into the
|
|
90240
|
+
* clips location.
|
|
90241
|
+
*/
|
|
90242
|
+
async artifactPath(deviceId, stem, kind) {
|
|
90243
|
+
const root = await this.rootOrNull();
|
|
90244
|
+
if (root === null) return null;
|
|
90245
|
+
const names = clipFileNamesForStem(stem);
|
|
90246
|
+
return join(this.deviceDir(root, deviceId), names[kind]);
|
|
89463
90247
|
}
|
|
89464
90248
|
/** iOS sent `ack`: HomeKit itself kept this clip. Recorded on the sidecar. */
|
|
89465
90249
|
async acknowledge(deviceId, clipId) {
|
|
@@ -89523,6 +90307,7 @@ var HksvClipStore = class {
|
|
|
89523
90307
|
});
|
|
89524
90308
|
return null;
|
|
89525
90309
|
}
|
|
90310
|
+
const remux = await this.remuxAndVouch(clipPath, tags, clipId);
|
|
89526
90311
|
const thumbnail = await this.mintAndVouch({
|
|
89527
90312
|
deviceId: input.deviceId,
|
|
89528
90313
|
clipPath,
|
|
@@ -89537,17 +90322,19 @@ var HksvClipStore = class {
|
|
|
89537
90322
|
streamId: input.streamId,
|
|
89538
90323
|
startedAtMs: outcome.startedAtMs,
|
|
89539
90324
|
endedAtMs: outcome.endedAtMs,
|
|
89540
|
-
durationMs: Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
|
|
90325
|
+
durationMs: remux.ok && remux.durationMs !== void 0 ? remux.durationMs : Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
|
|
89541
90326
|
prebufferSpanMs: input.prebufferSpanMs,
|
|
89542
|
-
bytes: outcome.bytes,
|
|
90327
|
+
bytes: remux.ok ? remux.bytes : outcome.bytes,
|
|
89543
90328
|
fragments: outcome.fragments,
|
|
89544
90329
|
file: names.clip,
|
|
89545
90330
|
...thumbnail.ok ? { thumbnailFile: names.thumbnail } : { thumbnailUnavailable: { reason: thumbnail.reason } },
|
|
90331
|
+
...remux.ok ? { seekable: true } : { remuxUnavailable: { reason: remux.reason } },
|
|
89546
90332
|
...outcome.truncated === null ? {} : { truncated: outcome.truncated },
|
|
89547
90333
|
acknowledgedByHomeKit: false,
|
|
89548
90334
|
width: input.width,
|
|
89549
90335
|
height: input.height,
|
|
89550
|
-
fragmentMs: input.fragmentMs
|
|
90336
|
+
fragmentMs: input.fragmentMs,
|
|
90337
|
+
...input.profile === void 0 ? {} : { profile: input.profile }
|
|
89551
90338
|
};
|
|
89552
90339
|
await this.writeRecord(join(dir, names.record), record);
|
|
89553
90340
|
this.log.info("hksv clip tee: a HomeKit clip was KEPT", {
|
|
@@ -89571,6 +90358,46 @@ var HksvClipStore = class {
|
|
|
89571
90358
|
* Mint, then CHECK. The minter's own verdict decides nothing on its own: a
|
|
89572
90359
|
* JPEG is vouched by `stat`, never by a return value.
|
|
89573
90360
|
*/
|
|
90361
|
+
/**
|
|
90362
|
+
* Run the faststart pass, and never let it cost the clip.
|
|
90363
|
+
*
|
|
90364
|
+
* The remuxer already vouches for its own output and never throws by
|
|
90365
|
+
* contract — this wrapper exists for the one case a contract cannot cover: a
|
|
90366
|
+
* remuxer that throws anyway. A clip that does not seek is a clip; a clip
|
|
90367
|
+
* this pass ate is a HomeKit recording the operator lost, and that asymmetry
|
|
90368
|
+
* is the whole reason the failure is caught here and named on the record.
|
|
90369
|
+
*/
|
|
90370
|
+
async remuxAndVouch(clipPath, tags, clipId) {
|
|
90371
|
+
const remux = this.input.remuxClip;
|
|
90372
|
+
if (remux === void 0) return {
|
|
90373
|
+
ok: false,
|
|
90374
|
+
reason: "not-attempted"
|
|
90375
|
+
};
|
|
90376
|
+
let result;
|
|
90377
|
+
try {
|
|
90378
|
+
result = await remux({ clipPath });
|
|
90379
|
+
} catch (err) {
|
|
90380
|
+
result = {
|
|
90381
|
+
ok: false,
|
|
90382
|
+
reason: "remux-failed"
|
|
90383
|
+
};
|
|
90384
|
+
this.log.warn("hksv clip tee: the faststart remuxer THREW — the clip is kept unseekable", {
|
|
90385
|
+
tags,
|
|
90386
|
+
meta: {
|
|
90387
|
+
clipId,
|
|
90388
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90389
|
+
}
|
|
90390
|
+
});
|
|
90391
|
+
}
|
|
90392
|
+
if (!result.ok && result.reason !== "not-attempted") this.log.warn("hksv clip tee: the clip could not be made SEEKABLE — it plays, but nothing can scrub or Range it", {
|
|
90393
|
+
tags,
|
|
90394
|
+
meta: {
|
|
90395
|
+
clipId,
|
|
90396
|
+
reason: result.reason
|
|
90397
|
+
}
|
|
90398
|
+
});
|
|
90399
|
+
return result;
|
|
90400
|
+
}
|
|
89574
90401
|
async mintAndVouch(mint) {
|
|
89575
90402
|
const { deviceId, jpegPath } = mint;
|
|
89576
90403
|
const minter = this.input.mintThumbnail;
|
|
@@ -89673,7 +90500,7 @@ var HksvClipStore = class {
|
|
|
89673
90500
|
const clips = [];
|
|
89674
90501
|
for (const name of names) {
|
|
89675
90502
|
if (!name.endsWith(".mp4")) continue;
|
|
89676
|
-
const stem = stemOf(name);
|
|
90503
|
+
const stem = stemOf$1(name);
|
|
89677
90504
|
if (this.isInFlight(deviceId, stem)) continue;
|
|
89678
90505
|
const bytes = await fileSize(join(dir, name));
|
|
89679
90506
|
const record = await this.readRecord(join(dir, `${stem}.json`), deviceId);
|
|
@@ -89747,7 +90574,7 @@ var HksvClipStore = class {
|
|
|
89747
90574
|
await rename(staging, path);
|
|
89748
90575
|
}
|
|
89749
90576
|
};
|
|
89750
|
-
function stemOf(fileName) {
|
|
90577
|
+
function stemOf$1(fileName) {
|
|
89751
90578
|
return fileName.replace(/\.[^.]+$/, "");
|
|
89752
90579
|
}
|
|
89753
90580
|
async function fileSize(path) {
|
|
@@ -89776,6 +90603,8 @@ async function createFileSink(path) {
|
|
|
89776
90603
|
}
|
|
89777
90604
|
};
|
|
89778
90605
|
}
|
|
90606
|
+
/** A decode that has not finished by here is not going to. */
|
|
90607
|
+
var CLIP_THUMBNAIL_TIMEOUT_MS = 1e4;
|
|
89779
90608
|
function buildClipThumbnailArgs(input) {
|
|
89780
90609
|
return [
|
|
89781
90610
|
"-hide_banner",
|
|
@@ -89830,59 +90659,583 @@ function createClipThumbnailMinter(input) {
|
|
|
89830
90659
|
return { ok: true };
|
|
89831
90660
|
};
|
|
89832
90661
|
}
|
|
89833
|
-
|
|
89834
|
-
|
|
89835
|
-
|
|
89836
|
-
*
|
|
89837
|
-
*
|
|
89838
|
-
*
|
|
89839
|
-
|
|
89840
|
-
|
|
89841
|
-
|
|
89842
|
-
|
|
89843
|
-
|
|
89844
|
-
|
|
89845
|
-
|
|
89846
|
-
|
|
89847
|
-
|
|
89848
|
-
|
|
90662
|
+
//#endregion
|
|
90663
|
+
//#region src/hksv/videoclips-plane.ts
|
|
90664
|
+
/**
|
|
90665
|
+
* The two data-plane routes behind the HomeKit clip source.
|
|
90666
|
+
*
|
|
90667
|
+
* Both are hosted by `ctx.dataPlane.serve({ access: 'authenticated' })`, so the
|
|
90668
|
+
* hub authenticates and reverse-proxies and this addon produces the bytes.
|
|
90669
|
+
* **Neither puts a token in its URL**: the hub's `/addon/<id>/<prefix>` proxy
|
|
90670
|
+
* reads the credential from `authorization` OR the session cookie, exactly as
|
|
90671
|
+
* the recorder's own playback plane does, so a clip URL may sit in history, in
|
|
90672
|
+
* a `Referer` or in a shared log and open nothing (D549 22).
|
|
90673
|
+
*
|
|
90674
|
+
* ## Why the bytes are served HERE
|
|
90675
|
+
*
|
|
90676
|
+
* The clips were written by this process, to a `local-path` storage location,
|
|
90677
|
+
* and this addon is `hub-only`. A byte path that went anywhere else would be a
|
|
90678
|
+
* cross-addon media move — D9/D18, and the repo's one existing such move is
|
|
90679
|
+
* capped at 50 MiB with a docblock naming the OOM it caused. `createReadStream
|
|
90680
|
+
* ().pipe(res)` gives backpressure natively, which is why the data plane hands
|
|
90681
|
+
* an addon the REAL `res` (D447: a raw HTTP stream reads no chunk the socket
|
|
90682
|
+
* has not taken).
|
|
90683
|
+
*
|
|
90684
|
+
* ## What the media route does NOT do
|
|
90685
|
+
*
|
|
90686
|
+
* It does not remux. A teed clip is `ftyp` + an EMPTY `moov` + `moof`/`mdat`
|
|
90687
|
+
* fragments, cut wherever iOS stopped pulling, so the container carries no
|
|
90688
|
+
* duration and no index — measured on a byte-identical file
|
|
90689
|
+
* (`-movflags +frag_keyframe+empty_moov+default_base_moof`, the tee's own
|
|
90690
|
+
* argv): `mvhd.duration = 0`, `mdhd.duration = 0`, no `sidx`, and no `mfra`
|
|
90691
|
+
* because nothing wrote a trailer. It decodes end to end, and the row's
|
|
90692
|
+
* `timeRange` carries the real duration from the sidecar, but a player cannot
|
|
90693
|
+
* map a time to a byte offset from this file. What it would cost to change is
|
|
90694
|
+
* measured and written down in D571 rather than guessed at here.
|
|
90695
|
+
*
|
|
90696
|
+
* ## The thumbnail route has no mint
|
|
90697
|
+
*
|
|
90698
|
+
* It only ever serves a JPEG the store already stat'd — a still is VOUCHED at
|
|
90699
|
+
* write time or it does not exist, and nothing re-mints a teed clip. So a miss
|
|
90700
|
+
* is a plain 404, not the 204-with-a-reason contract a live camera needs.
|
|
90701
|
+
*/
|
|
90702
|
+
/** URL namespaces under `/addon/export-hap/`. */
|
|
90703
|
+
var HKSV_CLIP_MEDIA_PREFIX = "hksv-clip";
|
|
90704
|
+
var HKSV_CLIP_THUMB_PREFIX = "hksv-clip-thumb";
|
|
90705
|
+
/** A stem is what `clipStem` mints: nothing else may reach the filesystem. */
|
|
90706
|
+
var MEDIA_PATH = /^\/(\d{1,12})\/([A-Za-z0-9_-]{1,128})\.mp4$/;
|
|
90707
|
+
var THUMB_PATH = /^\/(\d{1,12})\/([A-Za-z0-9_-]{1,128})\.jpg$/;
|
|
90708
|
+
/** `bytes=a-b` / `bytes=a-` / `bytes=-n`. `'unsatisfiable'` is a 416 rather
|
|
90709
|
+
* than a silently empty 206. */
|
|
90710
|
+
function parseRange(header, size) {
|
|
90711
|
+
if (header === void 0) return null;
|
|
90712
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
90713
|
+
if (match === null) return null;
|
|
90714
|
+
const [, rawStart, rawEnd] = match;
|
|
90715
|
+
if (rawStart === "" && rawEnd === "") return "unsatisfiable";
|
|
90716
|
+
if (rawStart === "") {
|
|
90717
|
+
const length = Number(rawEnd);
|
|
90718
|
+
if (length <= 0) return "unsatisfiable";
|
|
90719
|
+
return {
|
|
90720
|
+
start: Math.max(0, size - length),
|
|
90721
|
+
end: size - 1
|
|
89849
90722
|
};
|
|
89850
|
-
|
|
89851
|
-
|
|
89852
|
-
|
|
89853
|
-
|
|
89854
|
-
|
|
89855
|
-
|
|
89856
|
-
|
|
89857
|
-
|
|
89858
|
-
|
|
89859
|
-
|
|
89860
|
-
|
|
89861
|
-
|
|
89862
|
-
|
|
89863
|
-
|
|
89864
|
-
|
|
89865
|
-
|
|
90723
|
+
}
|
|
90724
|
+
const start = Number(rawStart);
|
|
90725
|
+
if (start >= size) return "unsatisfiable";
|
|
90726
|
+
const end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
|
|
90727
|
+
if (end < start) return "unsatisfiable";
|
|
90728
|
+
return {
|
|
90729
|
+
start,
|
|
90730
|
+
end
|
|
90731
|
+
};
|
|
90732
|
+
}
|
|
90733
|
+
function pathnameOf(req) {
|
|
90734
|
+
const raw = req.url ?? "/";
|
|
90735
|
+
const query = raw.indexOf("?");
|
|
90736
|
+
return query === -1 ? raw : raw.slice(0, query);
|
|
90737
|
+
}
|
|
90738
|
+
function address(req, pattern) {
|
|
90739
|
+
const match = pattern.exec(pathnameOf(req));
|
|
90740
|
+
if (match === null) return null;
|
|
90741
|
+
const deviceId = Number(match[1]);
|
|
90742
|
+
const stem = match[2];
|
|
90743
|
+
if (!Number.isInteger(deviceId) || stem === void 0) return null;
|
|
90744
|
+
return {
|
|
90745
|
+
deviceId,
|
|
90746
|
+
stem
|
|
90747
|
+
};
|
|
90748
|
+
}
|
|
90749
|
+
function createHksvClipPlanes(deps) {
|
|
90750
|
+
/**
|
|
90751
|
+
* The path of one artifact, or `null` with a reason ALREADY logged.
|
|
90752
|
+
*
|
|
90753
|
+
* A 404 is work this route dropped, and silence reads as "never happened" —
|
|
90754
|
+
* so every miss is one warn carrying `tags: { deviceId }`, which is the only
|
|
90755
|
+
* key a "why is 617 missing its clips and 615 not?" question can be answered
|
|
90756
|
+
* by.
|
|
90757
|
+
*/
|
|
90758
|
+
async function locate(addressed, artifact) {
|
|
90759
|
+
const { deviceId, stem } = addressed;
|
|
90760
|
+
let path;
|
|
90761
|
+
try {
|
|
90762
|
+
path = await deps.artifactPath(deviceId, stem, artifact);
|
|
90763
|
+
} catch (err) {
|
|
90764
|
+
deps.logger.warn("videoclips: could not resolve a HomeKit clip artifact", {
|
|
90765
|
+
tags: { deviceId },
|
|
90766
|
+
meta: {
|
|
90767
|
+
stem,
|
|
90768
|
+
artifact,
|
|
90769
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90770
|
+
}
|
|
89866
90771
|
});
|
|
89867
|
-
|
|
89868
|
-
|
|
89869
|
-
|
|
89870
|
-
|
|
89871
|
-
|
|
89872
|
-
|
|
89873
|
-
|
|
89874
|
-
|
|
90772
|
+
return null;
|
|
90773
|
+
}
|
|
90774
|
+
if (path === null) {
|
|
90775
|
+
deps.logger.warn("videoclips: no writable HomeKit clips location — nothing to serve", {
|
|
90776
|
+
tags: { deviceId },
|
|
90777
|
+
meta: {
|
|
90778
|
+
stem,
|
|
90779
|
+
artifact
|
|
90780
|
+
}
|
|
89875
90781
|
});
|
|
89876
|
-
|
|
89877
|
-
|
|
89878
|
-
|
|
89879
|
-
|
|
89880
|
-
|
|
89881
|
-
|
|
89882
|
-
|
|
90782
|
+
return null;
|
|
90783
|
+
}
|
|
90784
|
+
try {
|
|
90785
|
+
const stats = await stat(path);
|
|
90786
|
+
if (!stats.isFile()) throw new Error("not a file");
|
|
90787
|
+
return {
|
|
90788
|
+
path,
|
|
90789
|
+
size: stats.size
|
|
90790
|
+
};
|
|
90791
|
+
} catch (err) {
|
|
90792
|
+
deps.logger.warn("videoclips: a HomeKit clip artifact was asked for and is not there", {
|
|
90793
|
+
tags: { deviceId },
|
|
90794
|
+
meta: {
|
|
90795
|
+
stem,
|
|
90796
|
+
artifact,
|
|
90797
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90798
|
+
}
|
|
90799
|
+
});
|
|
90800
|
+
return null;
|
|
90801
|
+
}
|
|
90802
|
+
}
|
|
90803
|
+
function readOnly(req, res) {
|
|
90804
|
+
if (req.method === "GET" || req.method === "HEAD") return true;
|
|
90805
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
90806
|
+
res.end();
|
|
90807
|
+
return false;
|
|
90808
|
+
}
|
|
90809
|
+
const mediaHandler = async (req, res) => {
|
|
90810
|
+
if (!readOnly(req, res)) return;
|
|
90811
|
+
const addressed = address(req, MEDIA_PATH);
|
|
90812
|
+
if (addressed === null) {
|
|
90813
|
+
res.writeHead(400);
|
|
90814
|
+
res.end();
|
|
90815
|
+
return;
|
|
90816
|
+
}
|
|
90817
|
+
const found = await locate(addressed, "clip");
|
|
90818
|
+
if (found === null) {
|
|
90819
|
+
res.writeHead(404);
|
|
90820
|
+
res.end();
|
|
90821
|
+
return;
|
|
90822
|
+
}
|
|
90823
|
+
const base = {
|
|
90824
|
+
"content-type": "video/mp4",
|
|
90825
|
+
"accept-ranges": "bytes",
|
|
90826
|
+
"cache-control": "private, max-age=0, must-revalidate"
|
|
90827
|
+
};
|
|
90828
|
+
const range = parseRange(req.headers.range, found.size);
|
|
90829
|
+
if (range === "unsatisfiable") {
|
|
90830
|
+
res.writeHead(416, {
|
|
90831
|
+
...base,
|
|
90832
|
+
"content-range": `bytes */${String(found.size)}`
|
|
90833
|
+
});
|
|
90834
|
+
res.end();
|
|
90835
|
+
return;
|
|
90836
|
+
}
|
|
90837
|
+
if (range === null) {
|
|
90838
|
+
res.writeHead(200, {
|
|
90839
|
+
...base,
|
|
90840
|
+
"content-length": String(found.size)
|
|
89883
90841
|
});
|
|
90842
|
+
if (req.method === "HEAD") {
|
|
90843
|
+
res.end();
|
|
90844
|
+
return;
|
|
90845
|
+
}
|
|
90846
|
+
createReadStream(found.path).pipe(res);
|
|
90847
|
+
return;
|
|
90848
|
+
}
|
|
90849
|
+
res.writeHead(206, {
|
|
90850
|
+
...base,
|
|
90851
|
+
"content-range": `bytes ${String(range.start)}-${String(range.end)}/${String(found.size)}`,
|
|
90852
|
+
"content-length": String(range.end - range.start + 1)
|
|
89884
90853
|
});
|
|
89885
|
-
|
|
90854
|
+
if (req.method === "HEAD") {
|
|
90855
|
+
res.end();
|
|
90856
|
+
return;
|
|
90857
|
+
}
|
|
90858
|
+
createReadStream(found.path, {
|
|
90859
|
+
start: range.start,
|
|
90860
|
+
end: range.end
|
|
90861
|
+
}).pipe(res);
|
|
90862
|
+
};
|
|
90863
|
+
const thumbHandler = async (req, res) => {
|
|
90864
|
+
if (!readOnly(req, res)) return;
|
|
90865
|
+
const addressed = address(req, THUMB_PATH);
|
|
90866
|
+
if (addressed === null) {
|
|
90867
|
+
res.writeHead(400);
|
|
90868
|
+
res.end();
|
|
90869
|
+
return;
|
|
90870
|
+
}
|
|
90871
|
+
const found = await locate(addressed, "thumbnail");
|
|
90872
|
+
if (found === null) {
|
|
90873
|
+
res.writeHead(404);
|
|
90874
|
+
res.end();
|
|
90875
|
+
return;
|
|
90876
|
+
}
|
|
90877
|
+
res.writeHead(200, {
|
|
90878
|
+
"content-type": "image/jpeg",
|
|
90879
|
+
"content-length": String(found.size),
|
|
90880
|
+
"cache-control": "private, max-age=31536000, immutable"
|
|
90881
|
+
});
|
|
90882
|
+
if (req.method === "HEAD") {
|
|
90883
|
+
res.end();
|
|
90884
|
+
return;
|
|
90885
|
+
}
|
|
90886
|
+
createReadStream(found.path).pipe(res);
|
|
90887
|
+
};
|
|
90888
|
+
return {
|
|
90889
|
+
mediaPrefix: HKSV_CLIP_MEDIA_PREFIX,
|
|
90890
|
+
thumbPrefix: HKSV_CLIP_THUMB_PREFIX,
|
|
90891
|
+
mediaHandler,
|
|
90892
|
+
thumbHandler
|
|
90893
|
+
};
|
|
90894
|
+
}
|
|
90895
|
+
//#endregion
|
|
90896
|
+
//#region src/hksv/videoclips-source.ts
|
|
90897
|
+
/**
|
|
90898
|
+
* The `videoclips` SOURCE over the clips this addon teed to HomeKit.
|
|
90899
|
+
*
|
|
90900
|
+
* ## Where it runs, and why there is no hand-off
|
|
90901
|
+
*
|
|
90902
|
+
* `export-hap` is `placement: 'hub-only'` and the teed clips live on a
|
|
90903
|
+
* `local-path` storage location this process writes with `node:fs`. D550
|
|
90904
|
+
* recorded the open question as "the store lives in the hub-only `export-hap`
|
|
90905
|
+
* runner, so listing and serving must REACH it from wherever the provider
|
|
90906
|
+
* mounts". The answer is that the provider mounts HERE: `videoclips` is a
|
|
90907
|
+
* `scope:'device'` + `mode:'collection'` wrapper, so every addon that declares
|
|
90908
|
+
* it becomes one of the camera's bindings (`collectionBindings`, gated only by
|
|
90909
|
+
* the cap's `deviceTypes`), and `resolveWrapperNodeId` routes a wrapper to the
|
|
90910
|
+
* hub — which is where this addon already is. Nothing about a `videoclips`
|
|
90911
|
+
* provider requires owning the device.
|
|
90912
|
+
*
|
|
90913
|
+
* That removes the hand-off rather than building one, and it is the only shape
|
|
90914
|
+
* that obeys both rules D550 named: addons never import each other (this one
|
|
90915
|
+
* imports nothing — it reads its own store), and frames never cross a process
|
|
90916
|
+
* boundary (the bytes never enter a cap call at all; `getClipPlayback` answers
|
|
90917
|
+
* a URL into this addon's own data plane and the file is streamed with `Range`
|
|
90918
|
+
* from the disk it was written to). The rejected alternative was to register
|
|
90919
|
+
* the provider in the recorder and have it fetch clips over `ctx.api` — a
|
|
90920
|
+
* cross-addon byte move by handle at best, and the repo's one existing such
|
|
90921
|
+
* move carries a 50 MiB cap and a docblock naming the OOM it caused.
|
|
90922
|
+
*
|
|
90923
|
+
* ## The source EXISTS because of HomeKit, and is not a switch
|
|
90924
|
+
*
|
|
90925
|
+
* A camera has this source because it is exported to HomeKit AND HomeKit
|
|
90926
|
+
* recording is on for it; turn either off and the row is gone (D550, D569 § 5).
|
|
90927
|
+
* There is deliberately no enable control of its own — a knob over somebody
|
|
90928
|
+
* else's decision is D62's defect. `keepClips` is NOT that gate either: it
|
|
90929
|
+
* governs the TEE, and the clips already kept remain the truth about this
|
|
90930
|
+
* camera, so the row stays and its `reason` says the copies are off.
|
|
90931
|
+
*
|
|
90932
|
+
* ## `ok` is measured, and an empty list is not always an answer
|
|
90933
|
+
*
|
|
90934
|
+
* D561: `ok` may never be a default. Here the measurement is a real read of the
|
|
90935
|
+
* clips directory, and every answer this source gives — the source row and the
|
|
90936
|
+
* clip list — comes from ONE such read. The states are the existing five, and
|
|
90937
|
+
* no sixth was needed:
|
|
90938
|
+
*
|
|
90939
|
+
* - `ok` — the directory answered. Including with nothing: a camera that has
|
|
90940
|
+
* recorded no HomeKit clip yet is a complete list of zero, and the `reason`
|
|
90941
|
+
* says which flavour of nothing it is.
|
|
90942
|
+
* - `no-storage` — there is no writable `homekitClips` location: none seeded,
|
|
90943
|
+
* or it is `readonly` / `drain` / `disabled` (D385, named by
|
|
90944
|
+
* `clip-location.ts`, which is the only module that may interpret the mode).
|
|
90945
|
+
* Exactly the Reolink meaning: the medium is not there.
|
|
90946
|
+
* - `unreachable` — the storage cap could not be reached, or the directory
|
|
90947
|
+
* read threw. A measurement that failed is never folded into "no clips"
|
|
90948
|
+
* (D393).
|
|
90949
|
+
* - `index-empty` — HomeKit recording is switched ON for this camera and the
|
|
90950
|
+
* store holds nothing, because `buildHksvRecording` WITHHELD the
|
|
90951
|
+
* advertisement (no H.264 slot ≤ 1080p, a GOP longer than any fragment
|
|
90952
|
+
* length, or unreadable profiles). The camera's own switch disagrees with
|
|
90953
|
+
* its own index, which is what this state means on the Reolink surface too,
|
|
90954
|
+
* and the refusal travels verbatim in `reason`.
|
|
90955
|
+
* - `sleeping` — never. There is no camera to wake: the read is a local
|
|
90956
|
+
* directory read.
|
|
90957
|
+
*/
|
|
90958
|
+
/** What the picker calls this source. */
|
|
90959
|
+
var HKSV_CLIP_SOURCE_LABEL = "HomeKit clips";
|
|
90960
|
+
/** Why a listed row has no bytes behind it. Verbatim on `unplayableReason`. */
|
|
90961
|
+
var CLIP_FILE_MISSING = "clip-file-missing";
|
|
90962
|
+
function createHksvVideoclipsProvider(deps) {
|
|
90963
|
+
const now = deps.now ?? Date.now;
|
|
90964
|
+
/**
|
|
90965
|
+
* The one read, and the state it proves.
|
|
90966
|
+
*
|
|
90967
|
+
* `null` means this camera has no HomeKit clip source at all — it is not
|
|
90968
|
+
* exported, or HomeKit recording is off for it. Not an availability: a row
|
|
90969
|
+
* that should not exist is not a row saying it is unhappy.
|
|
90970
|
+
*/
|
|
90971
|
+
async function measure(deviceId) {
|
|
90972
|
+
const facts = deps.describeCamera(deviceId);
|
|
90973
|
+
if (!facts.exported || !facts.recording) return null;
|
|
90974
|
+
const catalog = await deps.readCatalog(deviceId);
|
|
90975
|
+
return {
|
|
90976
|
+
availability: availabilityFor(facts, catalog),
|
|
90977
|
+
catalog
|
|
90978
|
+
};
|
|
90979
|
+
}
|
|
90980
|
+
function availabilityFor(facts, catalog) {
|
|
90981
|
+
if (catalog.kind === "failed") return {
|
|
90982
|
+
state: "unreachable",
|
|
90983
|
+
reason: `the HomeKit clips location could not be read: ${catalog.error}`
|
|
90984
|
+
};
|
|
90985
|
+
if (catalog.kind === "no-root") {
|
|
90986
|
+
const refusal = deps.locationRefusal();
|
|
90987
|
+
if (refusal === "unreachable") return {
|
|
90988
|
+
state: "unreachable",
|
|
90989
|
+
reason: "the storage capability could not be reached (unreachable)"
|
|
90990
|
+
};
|
|
90991
|
+
return {
|
|
90992
|
+
state: "no-storage",
|
|
90993
|
+
reason: refusal === "not-writable" ? "the HomeKit clips location is not writable (not-writable) — readonly, draining or disabled" : "no HomeKit clips location exists yet (no-location)"
|
|
90994
|
+
};
|
|
90995
|
+
}
|
|
90996
|
+
const asOf = now();
|
|
90997
|
+
if (catalog.records.length > 0) return {
|
|
90998
|
+
state: "ok",
|
|
90999
|
+
catalogAsOf: asOf,
|
|
91000
|
+
...facts.keepingClips ? {} : { reason: "copies are switched off for this camera — nothing new is being kept" }
|
|
91001
|
+
};
|
|
91002
|
+
const withheld = facts.lastBuild?.withheld;
|
|
91003
|
+
if (withheld !== void 0 && withheld !== null) {
|
|
91004
|
+
const detail = facts.lastBuild?.detail;
|
|
91005
|
+
return {
|
|
91006
|
+
state: "index-empty",
|
|
91007
|
+
catalogAsOf: asOf,
|
|
91008
|
+
reason: `HomeKit recording is switched on for this camera but the advertisement was WITHHELD (${withheld})` + (detail === null || detail === void 0 ? "" : `: ${detail}`) + " — HomeKit has never recorded it, so there is nothing to keep"
|
|
91009
|
+
};
|
|
91010
|
+
}
|
|
91011
|
+
return {
|
|
91012
|
+
state: "ok",
|
|
91013
|
+
catalogAsOf: asOf,
|
|
91014
|
+
reason: facts.keepingClips ? "nothing has been recorded by HomeKit for this camera yet" : "copies are switched off for this camera — nothing new is being kept, and nothing was"
|
|
91015
|
+
};
|
|
91016
|
+
}
|
|
91017
|
+
function toClip(deviceId, record, files) {
|
|
91018
|
+
const names = clipFileNames(record.clipId);
|
|
91019
|
+
const hasBytes = files.has(record.file);
|
|
91020
|
+
const vouchedThumb = record.thumbnailFile !== void 0 && files.has(record.thumbnailFile) ? deps.thumbnailUrl(deviceId, stemOf(names.thumbnail)) : null;
|
|
91021
|
+
return {
|
|
91022
|
+
id: record.clipId,
|
|
91023
|
+
source: HKSV_CLIP_SOURCE,
|
|
91024
|
+
kind: "native",
|
|
91025
|
+
timeRange: {
|
|
91026
|
+
startMs: record.startedAtMs,
|
|
91027
|
+
endMs: record.endedAtMs
|
|
91028
|
+
},
|
|
91029
|
+
...vouchedThumb === null ? { thumbnailUnavailable: { reason: thumbnailRefusal(record, files) } } : { thumbnail: vouchedThumb },
|
|
91030
|
+
...hasBytes ? {} : {
|
|
91031
|
+
playable: false,
|
|
91032
|
+
unplayableReason: CLIP_FILE_MISSING
|
|
91033
|
+
}
|
|
91034
|
+
};
|
|
91035
|
+
}
|
|
91036
|
+
/**
|
|
91037
|
+
* The store's five reasons, narrowed onto the cap's four.
|
|
91038
|
+
*
|
|
91039
|
+
* The narrowing is LOSSY and it is forced: `ClipSchema.thumbnailUnavailable`
|
|
91040
|
+
* is a closed enum that `ui-library` maps exhaustively, so widening it is a
|
|
91041
|
+
* breaking change to a consumer, and this source's reasons postdate it. The
|
|
91042
|
+
* lossless copy is not lost — it is on the sidecar and on the listing line —
|
|
91043
|
+
* and the split preserves the only distinction a surface can act on:
|
|
91044
|
+
*
|
|
91045
|
+
* - `no-keyframe` — the CLIP had nothing decodable at the trigger instant
|
|
91046
|
+
* (`no-init-segment`, `decode-failed`, `timed-out`).
|
|
91047
|
+
* - `unsupported` — no still exists and nothing here will make one
|
|
91048
|
+
* (`ffmpeg-missing`, `not-attempted`, or a JPEG that is no longer on
|
|
91049
|
+
* disk). Nothing re-mints a teed clip, so every one of these is final.
|
|
91050
|
+
*/
|
|
91051
|
+
function thumbnailRefusal(record, files) {
|
|
91052
|
+
if (record.thumbnailFile !== void 0 && !files.has(record.thumbnailFile)) return "unsupported";
|
|
91053
|
+
const reason = record.thumbnailUnavailable?.reason;
|
|
91054
|
+
if (reason === "no-init-segment" || reason === "decode-failed" || reason === "timed-out") return "no-keyframe";
|
|
91055
|
+
return "unsupported";
|
|
91056
|
+
}
|
|
91057
|
+
return {
|
|
91058
|
+
listSources: async ({ deviceId }) => {
|
|
91059
|
+
const measured = await measure(deviceId);
|
|
91060
|
+
if (measured === null) return [];
|
|
91061
|
+
deps.logger.info("videoclips: measured the HomeKit clip source", {
|
|
91062
|
+
tags: { deviceId },
|
|
91063
|
+
meta: {
|
|
91064
|
+
source: HKSV_CLIP_SOURCE,
|
|
91065
|
+
state: measured.availability.state,
|
|
91066
|
+
reason: measured.availability.reason ?? null,
|
|
91067
|
+
clips: measured.catalog.kind === "catalog" ? measured.catalog.records.length : null
|
|
91068
|
+
}
|
|
91069
|
+
});
|
|
91070
|
+
return [{
|
|
91071
|
+
source: HKSV_CLIP_SOURCE,
|
|
91072
|
+
addonId: deps.addonId,
|
|
91073
|
+
label: HKSV_CLIP_SOURCE_LABEL,
|
|
91074
|
+
availability: measured.availability
|
|
91075
|
+
}];
|
|
91076
|
+
},
|
|
91077
|
+
listClips: async ({ deviceId, since, until, limit }) => {
|
|
91078
|
+
const measured = await measure(deviceId);
|
|
91079
|
+
if (measured === null) return [];
|
|
91080
|
+
const tags = { deviceId };
|
|
91081
|
+
const catalog = measured.catalog;
|
|
91082
|
+
const records = catalog.kind === "catalog" ? catalog.records : [];
|
|
91083
|
+
const files = catalog.kind === "catalog" ? catalog.files : /* @__PURE__ */ new Set();
|
|
91084
|
+
const ordered = records.filter((r) => r.startedAtMs <= until && r.endedAtMs >= since).toSorted((a, b) => b.startedAtMs - a.startedAtMs);
|
|
91085
|
+
const capped = limit === void 0 ? ordered : ordered.slice(0, limit);
|
|
91086
|
+
const clips = capped.map((record) => toClip(deviceId, record, files));
|
|
91087
|
+
const unplayable = clips.filter((c) => c.playable === false).length;
|
|
91088
|
+
const vouched = clips.filter((c) => c.thumbnail !== void 0).length;
|
|
91089
|
+
deps.logger.info("videoclips: listed the HomeKit clips", {
|
|
91090
|
+
tags,
|
|
91091
|
+
meta: {
|
|
91092
|
+
source: HKSV_CLIP_SOURCE,
|
|
91093
|
+
clips: clips.length,
|
|
91094
|
+
truncated: limit !== void 0 && ordered.length > limit,
|
|
91095
|
+
unplayable,
|
|
91096
|
+
thumbsVouched: vouched,
|
|
91097
|
+
thumbsUnavailable: clips.length - vouched,
|
|
91098
|
+
thumbnailReason: capped.find((r) => r.thumbnailFile === void 0)?.thumbnailUnavailable?.reason,
|
|
91099
|
+
state: measured.availability.state
|
|
91100
|
+
}
|
|
91101
|
+
});
|
|
91102
|
+
if (clips.length === 0 && measured.availability.state !== "ok") {
|
|
91103
|
+
deps.logger.warn("videoclips: nothing to list, and the HomeKit clip source cannot answer", {
|
|
91104
|
+
tags,
|
|
91105
|
+
meta: {
|
|
91106
|
+
branch: "refused-empty",
|
|
91107
|
+
state: measured.availability.state,
|
|
91108
|
+
reason: measured.availability.reason ?? null
|
|
91109
|
+
}
|
|
91110
|
+
});
|
|
91111
|
+
throw new Error(`videoclips: device ${String(deviceId)} listed no HomeKit clips, and that is not an empty window — the source is "${measured.availability.state}"` + (measured.availability.reason === void 0 ? "." : `: ${measured.availability.reason}`));
|
|
91112
|
+
}
|
|
91113
|
+
return clips;
|
|
91114
|
+
},
|
|
91115
|
+
getClipPlayback: async ({ deviceId, clipId, profile }) => {
|
|
91116
|
+
const record = await findRecord(deviceId, clipId);
|
|
91117
|
+
if (profile !== void 0) deps.logger.debug("videoclips: a HomeKit clip has one rendition; the profile is moot", {
|
|
91118
|
+
tags: { deviceId },
|
|
91119
|
+
meta: {
|
|
91120
|
+
clipId,
|
|
91121
|
+
profile,
|
|
91122
|
+
width: record.width,
|
|
91123
|
+
height: record.height
|
|
91124
|
+
}
|
|
91125
|
+
});
|
|
91126
|
+
return {
|
|
91127
|
+
playbackUrl: deps.mediaUrl(deviceId, stemOf(clipFileNames(clipId).clip)),
|
|
91128
|
+
format: "mp4",
|
|
91129
|
+
...record.profile === void 0 ? {} : { served: record.profile }
|
|
91130
|
+
};
|
|
91131
|
+
},
|
|
91132
|
+
/**
|
|
91133
|
+
* The clip's BYTES, inline and bounded — the seam another ADDON pulls
|
|
91134
|
+
* (D558), never the path a player takes.
|
|
91135
|
+
*
|
|
91136
|
+
* A browser or a viewer session plays `getClipPlayback`'s URL; an addon
|
|
91137
|
+
* cannot, because `AddonDataPlane` only lets an addon SERVE. So this is the
|
|
91138
|
+
* one place HomeKit clip bytes enter a cap envelope, and the bound is the
|
|
91139
|
+
* cap's own 50 MiB — held whole, base64, in this process AND the caller's,
|
|
91140
|
+
* on a hub that has already been OOM'd once (D9/D18). Above the bound it
|
|
91141
|
+
* REFUSES with the size: half a video is worse than an honest refusal.
|
|
91142
|
+
*
|
|
91143
|
+
* A 256 MB clip (the tee's per-clip rail) is therefore refusable here and
|
|
91144
|
+
* playable through the route, which is stated rather than discovered.
|
|
91145
|
+
*/
|
|
91146
|
+
readClipBytes: async ({ deviceId, clipId, maxBytes }) => {
|
|
91147
|
+
const record = await findRecord(deviceId, clipId);
|
|
91148
|
+
const stem = stemOf(clipFileNames(clipId).clip);
|
|
91149
|
+
if (record.profile === void 0) {
|
|
91150
|
+
deps.logger.warn("videoclips: a HomeKit clip cannot say which profile it recorded from", {
|
|
91151
|
+
tags: { deviceId },
|
|
91152
|
+
meta: {
|
|
91153
|
+
clipId,
|
|
91154
|
+
branch: "no-profile"
|
|
91155
|
+
}
|
|
91156
|
+
});
|
|
91157
|
+
throw new Error(`videoclips: clip "${clipId}" does not record which profile HomeKit recorded from, so the twin it would be served as cannot be named`);
|
|
91158
|
+
}
|
|
91159
|
+
const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
|
|
91160
|
+
if (record.bytes > bound) throw new Error(`videoclips: clip "${clipId}" is ${String(record.bytes)} bytes, over the ${String(bound)}-byte bound for an inline read — fetch it from its playback URL`);
|
|
91161
|
+
const path = await deps.clipFilePath(deviceId, stem);
|
|
91162
|
+
if (path === null) throw new Error(`videoclips: the HomeKit clips location for device ${String(deviceId)} is not readable`);
|
|
91163
|
+
let bytes;
|
|
91164
|
+
try {
|
|
91165
|
+
bytes = await readFile(path);
|
|
91166
|
+
} catch (err) {
|
|
91167
|
+
deps.logger.warn("videoclips: the fMP4 of a listed HomeKit clip could not be read", {
|
|
91168
|
+
tags: { deviceId },
|
|
91169
|
+
meta: {
|
|
91170
|
+
clipId,
|
|
91171
|
+
branch: CLIP_FILE_MISSING,
|
|
91172
|
+
error: errorText(err)
|
|
91173
|
+
}
|
|
91174
|
+
});
|
|
91175
|
+
throw new Error(`videoclips: clip "${clipId}" has a sidecar but no readable bytes (${CLIP_FILE_MISSING})`, { cause: err });
|
|
91176
|
+
}
|
|
91177
|
+
deps.logger.info("videoclips: served a HomeKit clip inline", {
|
|
91178
|
+
tags: { deviceId },
|
|
91179
|
+
meta: {
|
|
91180
|
+
clipId,
|
|
91181
|
+
bytes: bytes.byteLength,
|
|
91182
|
+
served: record.profile
|
|
91183
|
+
}
|
|
91184
|
+
});
|
|
91185
|
+
return {
|
|
91186
|
+
base64: bytes.toString("base64"),
|
|
91187
|
+
contentType: "video/mp4",
|
|
91188
|
+
name: `${stem}.mp4`,
|
|
91189
|
+
bytes: bytes.byteLength,
|
|
91190
|
+
served: record.profile,
|
|
91191
|
+
...record.durationMs > 0 ? { durationMs: record.durationMs } : {}
|
|
91192
|
+
};
|
|
91193
|
+
}
|
|
91194
|
+
};
|
|
91195
|
+
/**
|
|
91196
|
+
* The catalog row for one clip, or a THROW naming which of the three things
|
|
91197
|
+
* went wrong: not ours, not held, or held with no bytes behind it. Shared by
|
|
91198
|
+
* the two byte paths so they can never disagree about what a clip id means.
|
|
91199
|
+
*/
|
|
91200
|
+
async function findRecord(deviceId, clipId) {
|
|
91201
|
+
if (!clipId.startsWith(`hksv:`)) throw new Error(`videoclips: clip id "${clipId}" was not minted by the HomeKit source`);
|
|
91202
|
+
if (!clipId.startsWith(`hksv:${String(deviceId)}:`)) throw new Error(`videoclips: clip id "${clipId}" belongs to another camera — ids are self-contained and name the device that teed them`);
|
|
91203
|
+
const measured = await measure(deviceId);
|
|
91204
|
+
if (measured === null) throw new Error(`videoclips: device ${String(deviceId)} has no HomeKit clip source — it is not exported to HomeKit, or HomeKit recording is off for it`);
|
|
91205
|
+
const catalog = measured.catalog;
|
|
91206
|
+
if (catalog.kind !== "catalog") throw new Error(`videoclips: the HomeKit clips for device ${String(deviceId)} cannot be read — the source is "${measured.availability.state}"`);
|
|
91207
|
+
const record = catalog.records.find((r) => r.clipId === clipId) ?? null;
|
|
91208
|
+
if (record === null) {
|
|
91209
|
+
deps.logger.warn("videoclips: asked for a HomeKit clip the catalog does not hold", {
|
|
91210
|
+
tags: { deviceId },
|
|
91211
|
+
meta: {
|
|
91212
|
+
clipId,
|
|
91213
|
+
branch: "no-catalog-row"
|
|
91214
|
+
}
|
|
91215
|
+
});
|
|
91216
|
+
throw new Error(`videoclips: no clip "${clipId}" is held for device ${String(deviceId)}`);
|
|
91217
|
+
}
|
|
91218
|
+
if (!catalog.files.has(record.file)) {
|
|
91219
|
+
deps.logger.warn("videoclips: the fMP4 for a listed HomeKit clip is gone", {
|
|
91220
|
+
tags: { deviceId },
|
|
91221
|
+
meta: {
|
|
91222
|
+
clipId,
|
|
91223
|
+
branch: CLIP_FILE_MISSING,
|
|
91224
|
+
file: record.file
|
|
91225
|
+
}
|
|
91226
|
+
});
|
|
91227
|
+
throw new Error(`videoclips: clip "${clipId}" has a sidecar but no bytes (${CLIP_FILE_MISSING})`);
|
|
91228
|
+
}
|
|
91229
|
+
return record;
|
|
91230
|
+
}
|
|
91231
|
+
}
|
|
91232
|
+
function errorText(err) {
|
|
91233
|
+
return err instanceof Error ? err.message : String(err);
|
|
91234
|
+
}
|
|
91235
|
+
/** `<stem>.mp4` → `<stem>`. The routes address a stem; the store owns the rest. */
|
|
91236
|
+
function stemOf(fileName) {
|
|
91237
|
+
const dot = fileName.lastIndexOf(".");
|
|
91238
|
+
return dot === -1 ? fileName : fileName.slice(0, dot);
|
|
89886
91239
|
}
|
|
89887
91240
|
//#endregion
|
|
89888
91241
|
//#region src/mappers/builders/generic/characteristic-update.ts
|
|
@@ -104711,7 +106064,8 @@ var ClipTeeRunner = class {
|
|
|
104711
106064
|
prebufferSpanMs: this.request.prebufferSpanMs,
|
|
104712
106065
|
width: this.input.width,
|
|
104713
106066
|
height: this.input.height,
|
|
104714
|
-
fragmentMs: this.input.fragmentMs
|
|
106067
|
+
fragmentMs: this.input.fragmentMs,
|
|
106068
|
+
...this.input.profile === void 0 ? {} : { profile: this.input.profile }
|
|
104715
106069
|
});
|
|
104716
106070
|
if (open === null) {
|
|
104717
106071
|
this.request.subscription.release();
|
|
@@ -105679,9 +107033,26 @@ async function buildHksvRecording(input) {
|
|
|
105679
107033
|
const { bctx } = input;
|
|
105680
107034
|
const { ctx, numericDeviceId } = bctx;
|
|
105681
107035
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
107036
|
+
/**
|
|
107037
|
+
* Record the verdict for the clip source, next to the log line that already
|
|
107038
|
+
* states it. A camera HomeKit never recorded is not "a camera with no clips",
|
|
107039
|
+
* and `listSources` can only say which it is if the refusal was written down
|
|
107040
|
+
* (D571). Every `return null` below passes through here.
|
|
107041
|
+
*/
|
|
107042
|
+
const note = (withheld, detail) => {
|
|
107043
|
+
const outcome = {
|
|
107044
|
+
deviceId: numericDeviceId,
|
|
107045
|
+
advertised: withheld === null,
|
|
107046
|
+
withheld,
|
|
107047
|
+
detail,
|
|
107048
|
+
atMs: Date.now()
|
|
107049
|
+
};
|
|
107050
|
+
bctx.options.noteHksvOutcome?.(outcome);
|
|
107051
|
+
};
|
|
105682
107052
|
const entries = await readProfileEntries(bctx);
|
|
105683
107053
|
if (entries === null) {
|
|
105684
107054
|
log.warn("export-hap: HKSV withheld — could not read the camera profiles", {});
|
|
107055
|
+
note("profiles-unreadable", "cameraStreams.getProfileRtspEntries could not be read");
|
|
105685
107056
|
return null;
|
|
105686
107057
|
}
|
|
105687
107058
|
const choice = pickRecordingSource(entries);
|
|
@@ -105690,6 +107061,7 @@ async function buildHksvRecording(input) {
|
|
|
105690
107061
|
refusal: choice.refusal,
|
|
105691
107062
|
reason: refusalReason(choice.refusal)
|
|
105692
107063
|
} });
|
|
107064
|
+
note("no-recordable-stream", `${choice.refusal}: ${refusalReason(choice.refusal)}`);
|
|
105693
107065
|
return null;
|
|
105694
107066
|
}
|
|
105695
107067
|
const source = choice.source;
|
|
@@ -105700,8 +107072,11 @@ async function buildHksvRecording(input) {
|
|
|
105700
107072
|
gopMs,
|
|
105701
107073
|
brokerId: source.brokerId
|
|
105702
107074
|
} });
|
|
107075
|
+
note("key-frame-interval", `gopMs=${String(gopMs ?? "unknown")}`);
|
|
105703
107076
|
return null;
|
|
105704
107077
|
}
|
|
107078
|
+
const parsedProfile = CamProfileSchema.safeParse(source.profile);
|
|
107079
|
+
const recordedProfile = parsedProfile.success ? parsedProfile.data : null;
|
|
105705
107080
|
const fps = resolveFps(input.fpsByProfile, source.profile);
|
|
105706
107081
|
const options = buildRecordingOptions({
|
|
105707
107082
|
width: source.width,
|
|
@@ -105717,7 +107092,8 @@ async function buildHksvRecording(input) {
|
|
|
105717
107092
|
store: clipStore,
|
|
105718
107093
|
width: source.width,
|
|
105719
107094
|
height: source.height,
|
|
105720
|
-
fragmentMs: fragmentLengthMs
|
|
107095
|
+
fragmentMs: fragmentLengthMs,
|
|
107096
|
+
...recordedProfile === null ? {} : { profile: recordedProfile }
|
|
105721
107097
|
});
|
|
105722
107098
|
const delegate = new HksvRecordingDelegate({
|
|
105723
107099
|
logger: log,
|
|
@@ -105749,6 +107125,7 @@ async function buildHksvRecording(input) {
|
|
|
105749
107125
|
clipsKept: openClipTee !== void 0,
|
|
105750
107126
|
clipsSwitchedOn: keepClips
|
|
105751
107127
|
} });
|
|
107128
|
+
note(null, null);
|
|
105752
107129
|
return {
|
|
105753
107130
|
options,
|
|
105754
107131
|
delegate,
|
|
@@ -107041,6 +108418,32 @@ function resolveHksvRecording(settings) {
|
|
|
107041
108418
|
function resolveKeepHomekitClips(settings) {
|
|
107042
108419
|
return settings?.keepClips !== false;
|
|
107043
108420
|
}
|
|
108421
|
+
/** This addon's id, as the manifest declares it and the registry knows it. */
|
|
108422
|
+
var EXPORT_HAP_ADDON_ID = "export-hap";
|
|
108423
|
+
/**
|
|
108424
|
+
* Whether a camera HAS a HomeKit clip source, read from the authorities that
|
|
108425
|
+
* already own each half.
|
|
108426
|
+
*
|
|
108427
|
+
* The source exists because the camera is EXPORTED to HomeKit and HomeKit
|
|
108428
|
+
* RECORDING is on for it, and it goes away when either goes off (D550, D569 §
|
|
108429
|
+
* 5). There is no third switch: a knob of this source's own would be a second
|
|
108430
|
+
* authority over somebody else's decision (D62), and this function is the only
|
|
108431
|
+
* place the two are read together.
|
|
108432
|
+
*
|
|
108433
|
+
* `keepClips` is deliberately NOT part of existence. It governs the TEE, and
|
|
108434
|
+
* the clips already kept are still the truth about this camera — so the row
|
|
108435
|
+
* stays and says the copies are off, which is D62's other half: an off switch
|
|
108436
|
+
* is REPORTED off, never made to look like a broken camera.
|
|
108437
|
+
*/
|
|
108438
|
+
function describeHksvCamera(entry, lastBuild) {
|
|
108439
|
+
const isCamera = entry !== null && entry.mapperKind === "camera";
|
|
108440
|
+
return {
|
|
108441
|
+
exported: isCamera,
|
|
108442
|
+
recording: isCamera && resolveHksvRecording(entry.settings),
|
|
108443
|
+
keepingClips: isCamera && resolveKeepHomekitClips(entry.settings),
|
|
108444
|
+
lastBuild
|
|
108445
|
+
};
|
|
108446
|
+
}
|
|
107044
108447
|
/** What ships, and what an unset global resolves to. */
|
|
107045
108448
|
var DEFAULT_CLIP_RETENTION_DAYS = 14;
|
|
107046
108449
|
/** The rails on the retention itself. Zero would silently disable the tee. */
|
|
@@ -107167,6 +108570,16 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107167
108570
|
/** Resolves the declared `homekitClips` location; the doorbell invalidates it. */
|
|
107168
108571
|
hksvClipLocation = null;
|
|
107169
108572
|
/**
|
|
108573
|
+
* What `buildHksvRecording` decided per camera, in this process.
|
|
108574
|
+
*
|
|
108575
|
+
* A MIRROR of the build, written by it and read only to be REPORTED: the
|
|
108576
|
+
* clip source's `index-empty` ("the switch says record and the store holds
|
|
108577
|
+
* nothing") is only honest if it can name which refusal fired (D571).
|
|
108578
|
+
*/
|
|
108579
|
+
hksvBuilds = new HksvBuildOutcomes();
|
|
108580
|
+
/** The clip byte + still routes. `null` until `onInitialize` has served them. */
|
|
108581
|
+
hksvClipPlanes = null;
|
|
108582
|
+
/**
|
|
107170
108583
|
* THE bridge. Lazily created and published the first time a non-camera
|
|
107171
108584
|
* accessory needs it; never torn down while the addon runs, because an
|
|
107172
108585
|
* unpublish would drop a pairing the operator already entered a code for.
|
|
@@ -107200,11 +108613,18 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107200
108613
|
logger: this.ctx.logger.child("hksv-clips"),
|
|
107201
108614
|
resolveRoot: () => this.hksvClipLocation?.root() ?? Promise.resolve(null),
|
|
107202
108615
|
maxAgeMsFor: (deviceId) => resolveClipRetentionDays(this.findEntry(deviceId)?.settings, this.config.clipRetentionDays) * 24 * 60 * 60 * 1e3,
|
|
107203
|
-
mintThumbnail: createClipThumbnailMinter({ runner:
|
|
108616
|
+
mintThumbnail: createClipThumbnailMinter({ runner: createBoundedFfmpegRunner({
|
|
107204
108617
|
ffmpegBinaryPath: "ffmpeg",
|
|
107205
|
-
spawnFn: spawn
|
|
108618
|
+
spawnFn: spawn,
|
|
108619
|
+
timeoutMs: CLIP_THUMBNAIL_TIMEOUT_MS
|
|
108620
|
+
}) }),
|
|
108621
|
+
remuxClip: createClipRemuxer({ runner: createBoundedFfmpegRunner({
|
|
108622
|
+
ffmpegBinaryPath: "ffmpeg",
|
|
108623
|
+
spawnFn: spawn,
|
|
108624
|
+
timeoutMs: CLIP_REMUX_TIMEOUT_MS
|
|
107206
108625
|
}) })
|
|
107207
108626
|
});
|
|
108627
|
+
await this.serveHksvClipPlanes();
|
|
107208
108628
|
const validKinds = new Set(SUPPORTED_MAPPER_KINDS);
|
|
107209
108629
|
const cleaned = this.config.exposed.filter((entry) => {
|
|
107210
108630
|
if (validKinds.has(entry.mapperKind)) return true;
|
|
@@ -107233,35 +108653,49 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107233
108653
|
this.subscribeDeviceReadyForReconcile();
|
|
107234
108654
|
this.subscribeStorageLocationsForClips();
|
|
107235
108655
|
this.disposeSharedRebuildTimers();
|
|
108656
|
+
const provider = {
|
|
108657
|
+
getStatus: async () => {
|
|
108658
|
+
const anyPaired = Array.from(this.exposed.values()).some((m) => accessoryPaired(m.accessory));
|
|
108659
|
+
const linkState = this.lastError ? "error" : anyPaired ? "linked" : "unlinked";
|
|
108660
|
+
const setup = this.buildSetupBlock();
|
|
108661
|
+
return {
|
|
108662
|
+
linkState,
|
|
108663
|
+
exposedDeviceCount: this.exposed.size,
|
|
108664
|
+
...this.lastError ? { error: this.lastError } : {},
|
|
108665
|
+
...setup ? { setup } : {}
|
|
108666
|
+
};
|
|
108667
|
+
},
|
|
108668
|
+
listSupportedDeviceKinds: async () => [...HAP_EXPORTABLE_DEVICE_TYPES],
|
|
108669
|
+
listExposedDevices: async () => Array.from(this.exposed.entries()).map(([deviceId, m]) => {
|
|
108670
|
+
const entry = this.config.exposed.find((e) => e.deviceId === deviceId);
|
|
108671
|
+
return {
|
|
108672
|
+
deviceId,
|
|
108673
|
+
exposedAs: m.accessory.displayName,
|
|
108674
|
+
...entry?.capabilities ? { capabilities: [...entry.capabilities] } : {}
|
|
108675
|
+
};
|
|
108676
|
+
}),
|
|
108677
|
+
exposeDevice: async ({ deviceId, capabilities }) => this.exposeDevice(deviceId, capabilities),
|
|
108678
|
+
unexposeDevice: async ({ deviceId }) => this.unexposeDevice(deviceId),
|
|
108679
|
+
getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
|
|
108680
|
+
getDeviceLiveContribution: async () => null,
|
|
108681
|
+
applyDeviceSettingsPatch: (input) => this.applyDeviceSettingsPatch(input.deviceId, input.patch)
|
|
108682
|
+
};
|
|
108683
|
+
const clipsProvider = createHksvVideoclipsProvider({
|
|
108684
|
+
logger: this.ctx.logger.child("hksv-clips"),
|
|
108685
|
+
addonId: EXPORT_HAP_ADDON_ID,
|
|
108686
|
+
describeCamera: (deviceId) => describeHksvCamera(this.findEntry(deviceId), this.hksvBuilds.lastFor(deviceId)),
|
|
108687
|
+
readCatalog: async (deviceId) => await this.hksvClipStore?.listCatalog(deviceId) ?? { kind: "no-root" },
|
|
108688
|
+
locationRefusal: () => this.hksvClipLocation?.lastRefusal ?? null,
|
|
108689
|
+
mediaUrl: (deviceId, stem) => this.hksvClipUrl("media", deviceId, stem),
|
|
108690
|
+
thumbnailUrl: (deviceId, stem) => this.hksvClipUrl("thumb", deviceId, stem),
|
|
108691
|
+
clipFilePath: async (deviceId, stem) => await this.hksvClipStore?.artifactPath(deviceId, stem, "clip") ?? null
|
|
108692
|
+
});
|
|
107236
108693
|
return [{
|
|
107237
108694
|
capability: deviceExportCapability,
|
|
107238
|
-
provider
|
|
107239
|
-
|
|
107240
|
-
|
|
107241
|
-
|
|
107242
|
-
const setup = this.buildSetupBlock();
|
|
107243
|
-
return {
|
|
107244
|
-
linkState,
|
|
107245
|
-
exposedDeviceCount: this.exposed.size,
|
|
107246
|
-
...this.lastError ? { error: this.lastError } : {},
|
|
107247
|
-
...setup ? { setup } : {}
|
|
107248
|
-
};
|
|
107249
|
-
},
|
|
107250
|
-
listSupportedDeviceKinds: async () => [...HAP_EXPORTABLE_DEVICE_TYPES],
|
|
107251
|
-
listExposedDevices: async () => Array.from(this.exposed.entries()).map(([deviceId, m]) => {
|
|
107252
|
-
const entry = this.config.exposed.find((e) => e.deviceId === deviceId);
|
|
107253
|
-
return {
|
|
107254
|
-
deviceId,
|
|
107255
|
-
exposedAs: m.accessory.displayName,
|
|
107256
|
-
...entry?.capabilities ? { capabilities: [...entry.capabilities] } : {}
|
|
107257
|
-
};
|
|
107258
|
-
}),
|
|
107259
|
-
exposeDevice: async ({ deviceId, capabilities }) => this.exposeDevice(deviceId, capabilities),
|
|
107260
|
-
unexposeDevice: async ({ deviceId }) => this.unexposeDevice(deviceId),
|
|
107261
|
-
getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
|
|
107262
|
-
getDeviceLiveContribution: async () => null,
|
|
107263
|
-
applyDeviceSettingsPatch: (input) => this.applyDeviceSettingsPatch(input.deviceId, input.patch)
|
|
107264
|
-
}
|
|
108695
|
+
provider
|
|
108696
|
+
}, {
|
|
108697
|
+
capability: videoclipsCapability,
|
|
108698
|
+
provider: clipsProvider
|
|
107265
108699
|
}];
|
|
107266
108700
|
}
|
|
107267
108701
|
async onConfigChanged() {
|
|
@@ -107386,6 +108820,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107386
108820
|
if (next.length !== this.config.exposed.length) await this.updateGlobalSettings({ exposed: next });
|
|
107387
108821
|
if (options.clearPairing !== false) clearPairingFiles(accessoryUuidFor(mapperKind, numericId), this.ctx.logger);
|
|
107388
108822
|
await this.forgetFingerprint(numericId);
|
|
108823
|
+
this.hksvBuilds.forget(numericId);
|
|
107389
108824
|
log.info("export-hap: unexposed device");
|
|
107390
108825
|
}
|
|
107391
108826
|
async attachMapper(entry) {
|
|
@@ -107399,6 +108834,9 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
107399
108834
|
ptzPulseMs: this.config.ptzPulseMs,
|
|
107400
108835
|
decodeMemos: this.decodeMemos,
|
|
107401
108836
|
hksvClipStore: this.hksvClipStore,
|
|
108837
|
+
noteHksvOutcome: (outcome) => {
|
|
108838
|
+
this.hksvBuilds.note(outcome);
|
|
108839
|
+
},
|
|
107402
108840
|
hapDeviceSettings: {
|
|
107403
108841
|
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
107404
108842
|
hksvRecording: resolveHksvRecording(entrySettings),
|
|
@@ -108018,6 +109456,57 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
108018
109456
|
}
|
|
108019
109457
|
return { success: true };
|
|
108020
109458
|
}
|
|
109459
|
+
/**
|
|
109460
|
+
* Host the clip byte + still routes, and never let a host without the
|
|
109461
|
+
* facility take the addon down with it: a data plane is an enhancement to an
|
|
109462
|
+
* addon that already works, so a failure here degrades the clip surface and
|
|
109463
|
+
* nothing else. It is LOGGED, because a branch that drops work silently
|
|
109464
|
+
* reads as "never happened".
|
|
109465
|
+
*
|
|
109466
|
+
* `authenticated`, not `admin`, and NO TOKEN in either URL: the hub's
|
|
109467
|
+
* `/addon/<id>/<prefix>` proxy takes the session cookie, exactly as the
|
|
109468
|
+
* recorder's own playback plane does (D549 22).
|
|
109469
|
+
*/
|
|
109470
|
+
async serveHksvClipPlanes() {
|
|
109471
|
+
const store = this.hksvClipStore;
|
|
109472
|
+
if (store === null) return;
|
|
109473
|
+
const planes = createHksvClipPlanes({
|
|
109474
|
+
logger: this.ctx.logger.child("hksv-clips"),
|
|
109475
|
+
artifactPath: (deviceId, stem, artifact) => store.artifactPath(deviceId, stem, artifact)
|
|
109476
|
+
});
|
|
109477
|
+
try {
|
|
109478
|
+
const media = await this.ctx.dataPlane?.serve({
|
|
109479
|
+
prefix: planes.mediaPrefix,
|
|
109480
|
+
access: "authenticated",
|
|
109481
|
+
handler: planes.mediaHandler
|
|
109482
|
+
});
|
|
109483
|
+
const thumb = await this.ctx.dataPlane?.serve({
|
|
109484
|
+
prefix: planes.thumbPrefix,
|
|
109485
|
+
access: "authenticated",
|
|
109486
|
+
handler: planes.thumbHandler
|
|
109487
|
+
});
|
|
109488
|
+
this.hksvClipPlanes = planes;
|
|
109489
|
+
this.ctx.logger.info("export-hap: HomeKit clip data planes served", { meta: {
|
|
109490
|
+
mediaPath: `/addon/${EXPORT_HAP_ADDON_ID}/${planes.mediaPrefix}`,
|
|
109491
|
+
thumbPath: `/addon/${EXPORT_HAP_ADDON_ID}/${planes.thumbPrefix}`,
|
|
109492
|
+
mediaServed: media !== void 0,
|
|
109493
|
+
thumbServed: thumb !== void 0
|
|
109494
|
+
} });
|
|
109495
|
+
} catch (err) {
|
|
109496
|
+
this.ctx.logger.warn("export-hap: HomeKit clip data planes failed to serve — clips will list but not play", { meta: { error: errMsg(err) } });
|
|
109497
|
+
}
|
|
109498
|
+
}
|
|
109499
|
+
/**
|
|
109500
|
+
* The client path of one clip artifact.
|
|
109501
|
+
*
|
|
109502
|
+
* Composed from the SERVED prefixes when the planes are up, so a URL is
|
|
109503
|
+
* never minted for a route that does not exist; the constants are the
|
|
109504
|
+
* fallback for the one window between registration and `serve` returning.
|
|
109505
|
+
*/
|
|
109506
|
+
hksvClipUrl(kind, deviceId, stem) {
|
|
109507
|
+
const planes = this.hksvClipPlanes;
|
|
109508
|
+
return `/addon/${EXPORT_HAP_ADDON_ID}/${kind === "media" ? planes?.mediaPrefix ?? "hksv-clip" : planes?.thumbPrefix ?? "hksv-clip-thumb"}/${String(deviceId)}/${stem}.${kind === "media" ? "mp4" : "jpg"}`;
|
|
109509
|
+
}
|
|
108021
109510
|
findEntry(deviceId) {
|
|
108022
109511
|
const id = String(deviceId);
|
|
108023
109512
|
return this.config.exposed.find((e) => e.deviceId === id) ?? null;
|
|
@@ -108034,4 +109523,4 @@ function errMsg(err) {
|
|
|
108034
109523
|
return err instanceof Error ? err.message : String(err);
|
|
108035
109524
|
}
|
|
108036
109525
|
//#endregion
|
|
108037
|
-
export { DEFAULT_CLIP_RETENTION_DAYS, ExportHapAddon, ExportHapAddon as default, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveClipRetentionDays, resolveHksvRecording, resolveKeepHomekitClips, resolveMultiTierService, deriveUsername as t };
|
|
109526
|
+
export { DEFAULT_CLIP_RETENTION_DAYS, EXPORT_HAP_ADDON_ID, ExportHapAddon, ExportHapAddon as default, describeHksvCamera, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveClipRetentionDays, resolveHksvRecording, resolveKeepHomekitClips, resolveMultiTierService, deriveUsername as t };
|