@camstack/addon-provider-amcrest 0.2.118 → 0.2.119

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.
Files changed (3) hide show
  1. package/dist/addon.js +3753 -129
  2. package/dist/addon.mjs +3753 -129
  3. package/package.json +7 -1
package/dist/addon.js CHANGED
@@ -5,6 +5,12 @@ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).expor
5
5
  let node_crypto = require("node:crypto");
6
6
  let node_http = require("node:http");
7
7
  let node_https = require("node:https");
8
+ let node_fs_promises = require("node:fs/promises");
9
+ let node_path = require("node:path");
10
+ let node_fs = require("node:fs");
11
+ let node_child_process = require("node:child_process");
12
+ let node_stream = require("node:stream");
13
+ let node_stream_promises = require("node:stream/promises");
8
14
  let node_os = require("node:os");
9
15
  //#region ../types/dist/event-category-BVDXG4tB.mjs
10
16
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
@@ -5367,7 +5373,7 @@ var ZodIssueCode = {
5367
5373
  var ZodFirstPartyTypeKind;
5368
5374
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5369
5375
  //#endregion
5370
- //#region ../types/dist/sleep-i3eUVc-d.mjs
5376
+ //#region ../types/dist/sleep-COWaSCAi.mjs
5371
5377
  /**
5372
5378
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5373
5379
  * window to float samples (D455).
@@ -7525,6 +7531,24 @@ object({
7525
7531
  unreachable: number()
7526
7532
  })
7527
7533
  });
7534
+ /** The wire shape of {@link PeerBytesTicket} — see the type for what it is. */
7535
+ var PeerBytesTicketSchema = object({
7536
+ /** `http://127.0.0.1:<port>/<token>`. One `GET` takes it. */
7537
+ url: string().min(1),
7538
+ /**
7539
+ * The HOST node this URL means something on — the hub or a named agent,
7540
+ * never a runner. {@link AddonPeerBytes.open} compares it to its own and
7541
+ * refuses `cross-node` by name when they differ, without dialling.
7542
+ */
7543
+ hostNodeId: string().min(1),
7544
+ expiresAtMs: number().int().nonnegative(),
7545
+ /**
7546
+ * What the producer DECLARED the body to be, when it knows — `null` when it
7547
+ * does not. Never `0` for unknown (D393): a consumer sizing a bound off this
7548
+ * must be able to tell "the producer did not say" from "the body is empty".
7549
+ */
7550
+ declaredBytes: number().int().nonnegative().nullable()
7551
+ });
7528
7552
  /**
7529
7553
  * Adoption job — the background form of `device-adoption.adopt`.
7530
7554
  *
@@ -24698,6 +24722,7 @@ _enum([
24698
24722
  "sleeping",
24699
24723
  "camera-refused",
24700
24724
  "no-keyframe",
24725
+ "decode-failed",
24701
24726
  "no-catalog-row",
24702
24727
  "unsupported",
24703
24728
  "unknown-device",
@@ -24941,6 +24966,49 @@ var ClipPlaybackSchema = object({
24941
24966
  */
24942
24967
  var ClipWakeSchema = _enum(["authorised"]);
24943
24968
  /**
24969
+ * Hard ceiling on ONE {@link videoclipsCapability.methods.readClipBytes} — the
24970
+ * same 50 MiB `RECORDING_EXPORT_MAX_READ_BYTES` uses, and for the same second
24971
+ * reason: the envelope is unary, so a base64 payload is held whole (~1.33× its
24972
+ * size) in the provider AND in the caller, on a hub this repo has already
24973
+ * OOM'd once (D9/D18).
24974
+ *
24975
+ * Measured clips sit far below it — 92 KB–2.29 MB for a sub twin, 455 KB for a
24976
+ * 16 s main clip — so the bound bites rarely. "Rarely" is not "never": a long
24977
+ * 4K main twin can exceed it, and above the bound the provider REFUSES with
24978
+ * the size in the message, never truncates. Half a video is worse than an
24979
+ * honest refusal.
24980
+ *
24981
+ * **Superseded as the way a clip export gets its bytes** (D613). The bound is
24982
+ * a property of the ENVELOPE, not of clips, so the cure was a transport with
24983
+ * no envelope: {@link videoclipsCapability.methods.offerClipBytes} hands back
24984
+ * a one-shot `peerBytes` ticket and the consumer streams it straight to disk,
24985
+ * holding nothing. This method stays for a consumer that genuinely wants the
24986
+ * bytes in hand, and for a provider not yet redeployed — with this bound,
24987
+ * which for an inline read is not going to move.
24988
+ */
24989
+ var VIDEOCLIPS_MAX_READ_BYTES = 50 * 1024 * 1024;
24990
+ /**
24991
+ * Hard ceiling on ONE {@link videoclipsCapability.methods.offerClipBytes}
24992
+ * transfer — ten times {@link VIDEOCLIPS_MAX_READ_BYTES}, and the reasoning is
24993
+ * not "ten times more comfortable".
24994
+ *
24995
+ * The inline bound is set by what is HELD: a base64 envelope is the payload at
24996
+ * ~1.33× in the provider AND the same again in the caller, so 50 MiB of clip
24997
+ * is ~133 MiB of heap across two processes. Over `peerBytes` the consumer
24998
+ * holds one socket chunk at a time and writes straight through to the export
24999
+ * file, so its contribution is flat whatever the clip weighs. What is left is
25000
+ * the PRODUCER's own copy, and that is a property of how each provider makes a
25001
+ * clip rather than of this transport: Hikvision's fetch writes to a scratch
25002
+ * FILE and offers it (nothing is held), Reolink's cmd-5 transfer is collected
25003
+ * in memory and offered from there (one copy, the one it already had).
25004
+ *
25005
+ * So this number bounds the worst provider, not the transport, and it is named
25006
+ * separately so that lifting it is a statement about a provider rather than
25007
+ * about clips. Above it the provider REFUSES with the size in the message and
25008
+ * never truncates: half a video is worse than an honest refusal.
25009
+ */
25010
+ var VIDEOCLIPS_MAX_OFFER_BYTES = 500 * 1024 * 1024;
25011
+ /**
24944
25012
  * A clip's finished bytes, inline — the twin of `recordingExport.readExportBytes`.
24945
25013
  *
24946
25014
  * `bytes` is the DECODED length, so nobody infers it from the base64 length,
@@ -24972,6 +25040,32 @@ var ClipBytesSchema = object({
24972
25040
  durationMs: number().positive().optional()
24973
25041
  });
24974
25042
  /**
25043
+ * Where a clip's finished bytes can be TAKEN (D613) — the answer to
25044
+ * {@link videoclipsCapability.methods.offerClipBytes}.
25045
+ *
25046
+ * Everything {@link ClipBytesSchema} carries except the bytes themselves, plus
25047
+ * the one-shot ticket that leads to them. The metadata is answered BEFORE the
25048
+ * transfer on purpose: a consumer learns which twin it got, what to call the
25049
+ * file and how long the clip runs without having to read a byte, so a decision
25050
+ * it would make on that metadata (a wrong twin, an implausible duration) costs
25051
+ * no transfer at all.
25052
+ */
25053
+ var ClipBytesOfferSchema = object({
25054
+ /**
25055
+ * One shot, seconds-long, loopback, on the PROVIDER's own host. Open it with
25056
+ * `ctx.peerBytes.open(...)`, which refuses a ticket from another node by
25057
+ * name rather than dialling a port that means something else here.
25058
+ */
25059
+ ticket: PeerBytesTicketSchema,
25060
+ contentType: string(),
25061
+ /** Suggested filename, extension included. */
25062
+ name: string(),
25063
+ /** Which twin was actually served — see {@link ClipBytesSchema.served}. */
25064
+ served: CamProfileSchema,
25065
+ /** See {@link ClipBytesSchema.durationMs}. Absent when nothing measured it. */
25066
+ durationMs: number().positive().optional()
25067
+ });
25068
+ /**
24975
25069
  * Where a clip's STREAM can be dialled (D597) — the answer to
24976
25070
  * {@link videoclipsCapability.methods.dialClipStream}.
24977
25071
  *
@@ -25047,6 +25141,105 @@ var ClipStreamDialSchema = object({
25047
25141
  /** Why `servedAudio` is `none` although sound was asked for. */
25048
25142
  audioReason: ClipStreamAudioReasonSchema.optional()
25049
25143
  });
25144
+ /**
25145
+ * The playback rates a clip can be DELIVERED at, ascending, always with `1`.
25146
+ *
25147
+ * These are the BROKER's: it re-paces frames it has already demuxed — the
25148
+ * `MonotonicClock` divides source elapsed time by the factor and the pacer
25149
+ * pushes that much faster — so the domain is the recorded one, {0} ∪ [0.25, 4],
25150
+ * and this is the discrete ladder drawn from it. `1.5` is in it because a
25151
+ * re-pacer has no reason to refuse it.
25152
+ *
25153
+ * `8` and `16` are deliberately NOT here. They are the CAMERA's own
25154
+ * `<playSpeed>` (Reolink cmd-5 replay), a different mechanism, still unwired
25155
+ * (D597, D600) — a rate change there costs a new dial and a new stream, and
25156
+ * the camera's 8x would need a resample the clip audio path has no decoder
25157
+ * for. Offering them today accepts a rate and delivers 1x, which is the whole
25158
+ * defect this list exists to end (D612). When `playSpeed` IS wired its rates
25159
+ * join THIS array — a second list elsewhere is the second authority D62
25160
+ * forbids.
25161
+ *
25162
+ * Every entry must survive the broker's `clampPlaybackRate` unchanged: a set
25163
+ * that offers what the clamp then moves is the same lie one step later.
25164
+ */
25165
+ var CLIP_BROKER_PACED_RATES = [
25166
+ .25,
25167
+ .5,
25168
+ 1,
25169
+ 1.5,
25170
+ 2,
25171
+ 4
25172
+ ];
25173
+ /**
25174
+ * The rates a REALTIME-BOUND stream can be delivered at.
25175
+ *
25176
+ * **Measured, and it is the whole reason this second ladder exists.** A
25177
+ * Hikvision RTSP replay is not a fast fetch: it delivers the recording at the
25178
+ * speed it was recorded. On 1436, 2026-09-23, one stream carried 176 access
25179
+ * units in 14 274 ms of wall clock at a measured 12.5 fps — 14.08 s of media
25180
+ * in 14.27 s, **1.01× realtime** — and a whole 18 s clip took 22.3 s to fetch
25181
+ * end to end. Reolink's cmd-5 transfer, which {@link CLIP_BROKER_PACED_RATES}
25182
+ * was written for, lands the same bytes at 49–62× and therefore has the entire
25183
+ * clip in hand within the first second.
25184
+ *
25185
+ * The broker's pacer consumes `rate` seconds of media per second of wall
25186
+ * clock while the socket supplies one. So at any rate above 1 the buffer
25187
+ * drains at `(rate − 1)×` and a stream that is only seconds old runs dry
25188
+ * almost at once — the viewer stops, the queue empties, and the provider's
25189
+ * own drain gate eventually reports `peer-stalled`. Nothing about that is a
25190
+ * bug to be fixed with a bigger buffer: there is no buffer, because the bytes
25191
+ * do not exist yet.
25192
+ *
25193
+ * So the honest answer is the DECLARATION (D612's own rule, applied to the
25194
+ * case it did not yet have): a rate this transport cannot deliver is never
25195
+ * offered. Below 1 is free — a slower pacer only lets the buffer grow.
25196
+ *
25197
+ * When a clip is served from a FILE the constraint is gone with the transport,
25198
+ * which is why `file` keeps the full ladder.
25199
+ */
25200
+ var CLIP_REALTIME_SOURCE_RATES = [
25201
+ .25,
25202
+ .5,
25203
+ 1
25204
+ ];
25205
+ /**
25206
+ * What a surface may DRAW for this provider's clips — the answer to
25207
+ * {@link videoclipsCapability.methods.getPlaybackOptions} (D612).
25208
+ *
25209
+ * The envelope is a PROVIDER fact, not a clip fact, and that is measured, not
25210
+ * assumed: the broker's `chooseClipPath` reads exactly two inputs — whether
25211
+ * `dialClipStream` and `readClipBytes` are wired — and both are constants of
25212
+ * the broker's own closure over the provider's methods. The `profile` it is
25213
+ * handed is explicitly not read. So every clip of a provider is served the
25214
+ * same way, and a per-clip channel carried a value that could not vary. The
25215
+ * per-clip `clipTransport` server message was removed for exactly that reason.
25216
+ *
25217
+ * Queried per camera, before a clip is picked, so a control is rendered or
25218
+ * DISABLED rather than offered and refused at play time (D62: a disabled
25219
+ * control reads as unavailable, one that undoes the gesture reads as broken).
25220
+ */
25221
+ var ClipPlaybackOptionsSchema = object({
25222
+ /**
25223
+ * How this provider's clips reach the player. `stream` is the provider's
25224
+ * forward-only fMP4 (D597); `file` is one bounded by-handle fetch of the
25225
+ * whole clip, `stbl` indexed (D575).
25226
+ */
25227
+ transport: _enum(["stream", "file"]),
25228
+ /** `forward` = only ahead of the playhead. `free` = anywhere. */
25229
+ seek: _enum(["forward", "free"]),
25230
+ /** Frame-step BACKWARD is meaningful. Forward always is. */
25231
+ stepBack: boolean(),
25232
+ /** Whether the scrub gesture is served, as opposed to refused by name. */
25233
+ scrub: boolean(),
25234
+ /**
25235
+ * The rates that can be delivered, ascending, always containing `1`. The
25236
+ * viewer draws its picker from this and from nothing else — a constant it
25237
+ * keeps instead is the second authority that produced the defect: `8` and
25238
+ * `16` were offered, the broker clamped them to `4`, and no line anywhere
25239
+ * said so. `0` is not a member: pause is the absence of a rate.
25240
+ */
25241
+ rates: array(number().positive()).min(1).readonly()
25242
+ });
25050
25243
  var ClipSourceAvailabilitySchema = object({
25051
25244
  state: _enum([
25052
25245
  "ok",
@@ -25096,138 +25289,389 @@ var ClipSourceSchema = object({
25096
25289
  addonId: string().optional(),
25097
25290
  availability: ClipSourceAvailabilitySchema
25098
25291
  });
25099
- DeviceType.Camera, method(object({
25100
- deviceId: number(),
25101
- since: number(),
25102
- until: number(),
25103
- limit: number().int().positive().optional(),
25104
- /**
25105
- * WHICH provider to ask — the `addonId` a {@link ClipSourceSchema} row
25106
- * carries, never a source id and never a list. **Required** (D554 amended).
25107
- *
25108
- * A provider the device is not bound to is refused by name rather than
25109
- * answered by another one (D552's `rejectUnresolvedAddonPin` rule).
25110
- *
25111
- * It was optional, documented as "absent means the device's BOUND
25112
- * provider, which is CamStack on every camera". No code implemented
25113
- * that. Measured on the live hub 2026-09-20 — device 592, bound to
25114
- * `recorder` AND `provider-reolink` — a bare call with `limit: 3`
25115
- * answered SIX rows, three from each source, merged newest-first:
25116
- * `device-collection-dispatch.ts` simply left the fan-out un-narrowed,
25117
- * so absence bought the union this method exists not to be, and
25118
- * `limit` meant `limit × sources`.
25119
- *
25120
- * There is nothing to restore the default to. `getBindings` answers a
25121
- * collection cap with a DERIVED, PLURAL set (D554 amended, step 0);
25122
- * `setWrapperActive` — the singleton authority that could name one —
25123
- * throws for a collection cap by design. Naming CamStack here instead
25124
- * would privilege one addon by id inside a surface whose premise is
25125
- * that sources are peers, and would be wrong on the first camera with
25126
- * no recorder binding. So absence is REFUSED, in the schema, where the
25127
- * generated types make it unomittable rather than merely discouraged.
25128
- *
25129
- * The default belongs to the SURFACE, which has the `listSources` rows
25130
- * and can say which one it picked (D569 § 1–3; the viewer already
25131
- * always sends this).
25132
- *
25133
- * It replaced `sources?: string[]`, a VIEW filter over source ids that
25134
- * assumed the answer was a fan-out over everything a camera has. The
25135
- * operator settled otherwise on 2026-09-20 — *"l'utilizzatore è uno
25136
- * solo"* — so the list is asked of one provider at a time and there is
25137
- * nothing to filter out of it.
25138
- */
25139
- provider: string().min(1)
25140
- }), array(ClipSchema).readonly(), {
25141
- kind: "query",
25142
- auth: "protected"
25143
- }), method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
25144
- kind: "query",
25145
- auth: "protected"
25146
- }), method(object({
25147
- deviceId: number(),
25148
- clipId: string(),
25149
- /**
25150
- * Which twin to serve, on the ONE quality scale the system already has
25151
- * (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
25152
- * twin; both ids are already on the row so this never re-searches the
25153
- * camera. Absent means the provider's own default (the sub file, which
25154
- * every source is measured to hold).
25155
- *
25156
- * `auto` is deliberately NOT accepted here: a stored file has no
25157
- * broker session, so the adaptive tier cannot be resolved for it. The
25158
- * viewer resolves `auto` to a profile the same way live does, before
25159
- * it calls (D549 15).
25160
- */
25161
- profile: CamProfileSchema.optional()
25162
- }), ClipPlaybackSchema, {
25163
- kind: "query",
25164
- auth: "protected"
25165
- }), optionalMethod(object({
25166
- deviceId: number(),
25167
- clipId: string().min(1),
25168
- /**
25169
- * WHICH provider holds the bytes — the `addonId` a
25170
- * {@link ClipSourceSchema} row carries. **Required**, for the reason
25171
- * `listClips` states one method above and for a second one this method
25172
- * learned the hard way.
25173
- *
25174
- * The caller is the stream broker, and the broker lives in
25175
- * `addon-pipeline` — the same addon that registers the RECORDER's
25176
- * videoclips provider. With no provider named, the in-process
25177
- * resolution handed it its own sibling, which implements this method
25178
- * solely to explain that an analytics visit has no file behind it. So
25179
- * every onboard clip answered with that sentence, a flash of video and
25180
- * nothing more, while the SAME call from outside the addon reached the
25181
- * right provider and returned the bytes (measured on 3628,
25182
- * 2026-09-21).
25183
- */
25184
- provider: string().min(1),
25185
- /**
25186
- * Which twin to fetch, on the mapping D549 15 already fixed:
25187
- * `low | mid` → the sub file, `high` → the main twin. `auto` is not
25188
- * accepted here for the same reason it is not accepted by
25189
- * `getClipPlayback` — a stored file has no broker session, so the
25190
- * adaptive tier cannot be resolved for it.
25191
- */
25192
- profile: CamProfileSchema.optional(),
25193
- /**
25194
- * The CALLER's byte bound, so an over-size clip is refused before it is
25195
- * read and encoded rather than after. Capped by
25196
- * {@link VIDEOCLIPS_MAX_READ_BYTES} whatever is passed; absent means
25197
- * that ceiling.
25198
- */
25199
- maxBytes: number().int().positive().optional(),
25292
+ var videoclipsCapability = {
25293
+ name: "videoclips",
25294
+ scope: "device",
25295
+ mode: "collection",
25296
+ kind: "wrapper",
25297
+ defaultActive: true,
25298
+ /** A clip is a window over a camera's footage — the cap is meaningless on a
25299
+ * sensor, a button or an event emitter, and the `defaultActive` auto-bind
25300
+ * reads this to decide which devices it may claim. */
25301
+ deviceTypes: [DeviceType.Camera],
25200
25302
  /**
25201
- * The operator's authorisation to wake a sleeping camera for this
25202
- * read. Absent — the default — means a sleeping standalone battery
25203
- * camera is REFUSED by name, before any session is opened.
25303
+ * The Clips section of a camera's device details is FRAMEWORK-DERIVED (D14):
25304
+ * the aggregator turns this declaration into the `type:'widget'` section and
25305
+ * `DeviceDetail.tsx` is never edited. `videoclips` is the first WRAPPER cap
25306
+ * to declare one — every previous `host/` widget cap is `deviceNative` — so
25307
+ * `device-config-widget-wrapped-binding.spec.ts` pins that a `kind:'wrapped'`
25308
+ * binding entry derives the same section a native one does.
25309
+ *
25310
+ * **It is a SECTION of the Recording tab, at the end of it — not a tab of
25311
+ * its own.** It shipped as a `clips` top-tab and the operator rejected the
25312
+ * placement: *"utilizzerei la stessa tab recordings, lì abbiamo già tutto il
25313
+ * necessario, una nuova sezione alla fine per le clips"*. The Recording tab
25314
+ * already holds the recorder's panel, the schedule bands and the unified
25315
+ * retention policy; footage the camera itself holds is the same question,
25316
+ * asked of a different store. `order: 100` puts it after all of them with
25317
+ * room left in front. The `clips` entry in `WELL_KNOWN_TABS` went with it —
25318
+ * a well-known id nobody declares is an invitation to mint the tab again.
25319
+ *
25320
+ * `topTab` stays: the browser owns a pane (a day's tiles, a source list and
25321
+ * a player) and the Config tab's inner bar has no room for one. The widget
25322
+ * itself is `host/clips-browser` in ui-library's `HOST_WIDGETS`, and because
25323
+ * the admin Recordings page renders every `location:'top-tab'` +
25324
+ * `tab:'recording'` section behind its camera picker
25325
+ * (`CameraRecordingSettingsSection`), this declaration lands the section on
25326
+ * BOTH surfaces with no second wiring.
25204
25327
  */
25205
- wake: ClipWakeSchema.optional()
25206
- }), ClipBytesSchema, {
25207
- kind: "query",
25208
- auth: "protected"
25209
- }), optionalMethod(object({
25210
- deviceId: number(),
25211
- clipId: string().min(1),
25212
- /** WHICH provider holds the camera — see `readClipBytes.provider`. */
25213
- provider: string().min(1),
25214
- /** Which twin — `high` → the main file, everything else the sub (D599). */
25215
- profile: CamProfileSchema.optional(),
25328
+ deviceConfig: { ui: {
25329
+ kind: "widget",
25330
+ widgetId: "host/clips-browser",
25331
+ tab: "recording",
25332
+ topTab: true,
25333
+ label: "Clips",
25334
+ order: 100
25335
+ } },
25336
+ methods: {
25337
+ listClips: method(object({
25338
+ deviceId: number(),
25339
+ since: number(),
25340
+ until: number(),
25341
+ limit: number().int().positive().optional(),
25342
+ /**
25343
+ * WHICH provider to ask — the `addonId` a {@link ClipSourceSchema} row
25344
+ * carries, never a source id and never a list. **Required** (D554 amended).
25345
+ *
25346
+ * A provider the device is not bound to is refused by name rather than
25347
+ * answered by another one (D552's `rejectUnresolvedAddonPin` rule).
25348
+ *
25349
+ * It was optional, documented as "absent means the device's BOUND
25350
+ * provider, which is CamStack on every camera". No code implemented
25351
+ * that. Measured on the live hub 2026-09-20 — device 592, bound to
25352
+ * `recorder` AND `provider-reolink` — a bare call with `limit: 3`
25353
+ * answered SIX rows, three from each source, merged newest-first:
25354
+ * `device-collection-dispatch.ts` simply left the fan-out un-narrowed,
25355
+ * so absence bought the union this method exists not to be, and
25356
+ * `limit` meant `limit × sources`.
25357
+ *
25358
+ * There is nothing to restore the default to. `getBindings` answers a
25359
+ * collection cap with a DERIVED, PLURAL set (D554 amended, step 0);
25360
+ * `setWrapperActive` — the singleton authority that could name one —
25361
+ * throws for a collection cap by design. Naming CamStack here instead
25362
+ * would privilege one addon by id inside a surface whose premise is
25363
+ * that sources are peers, and would be wrong on the first camera with
25364
+ * no recorder binding. So absence is REFUSED, in the schema, where the
25365
+ * generated types make it unomittable rather than merely discouraged.
25366
+ *
25367
+ * The default belongs to the SURFACE, which has the `listSources` rows
25368
+ * and can say which one it picked (D569 § 1–3; the viewer already
25369
+ * always sends this).
25370
+ *
25371
+ * It replaced `sources?: string[]`, a VIEW filter over source ids that
25372
+ * assumed the answer was a fan-out over everything a camera has. The
25373
+ * operator settled otherwise on 2026-09-20 — *"l'utilizzatore è uno
25374
+ * solo"* — so the list is asked of one provider at a time and there is
25375
+ * nothing to filter out of it.
25376
+ */
25377
+ provider: string().min(1)
25378
+ }), array(ClipSchema).readonly(), {
25379
+ kind: "query",
25380
+ auth: "protected"
25381
+ }),
25382
+ /**
25383
+ * The sources this camera has, WITH the reason any of them cannot answer.
25384
+ *
25385
+ * Asked separately from `listClips` because an empty clip list is
25386
+ * ambiguous and this is the only place the ambiguity is resolved: every
25387
+ * bound provider contributes its own rows, and a provider that could not
25388
+ * be reached at all still produces one row saying so. A surface that draws
25389
+ * "no clips" without reading this is drawing a guess.
25390
+ */
25391
+ listSources: method(object({ deviceId: number() }), array(ClipSourceSchema).readonly(), {
25392
+ kind: "query",
25393
+ auth: "protected"
25394
+ }),
25395
+ getClipPlayback: method(object({
25396
+ deviceId: number(),
25397
+ clipId: string(),
25398
+ /**
25399
+ * Which twin to serve, on the ONE quality scale the system already has
25400
+ * (`CamProfileSchema`). `low | mid` → the sub file, `high` → the main
25401
+ * twin; both ids are already on the row so this never re-searches the
25402
+ * camera. Absent means the provider's own default (the sub file, which
25403
+ * every source is measured to hold).
25404
+ *
25405
+ * `auto` is deliberately NOT accepted here: a stored file has no
25406
+ * broker session, so the adaptive tier cannot be resolved for it. The
25407
+ * viewer resolves `auto` to a profile the same way live does, before
25408
+ * it calls (D549 15).
25409
+ */
25410
+ profile: CamProfileSchema.optional()
25411
+ }), ClipPlaybackSchema, {
25412
+ kind: "query",
25413
+ auth: "protected"
25414
+ }),
25415
+ /**
25416
+ * This clip's BYTES, base64, bounded — the by-handle read a clip EXPORT
25417
+ * pulls once (D558).
25418
+ *
25419
+ * `getClipPlayback` is the right answer for a player: it hands back a URL
25420
+ * on a plane the hub serves `access:'authenticated'`, which a browser and a
25421
+ * viewer session satisfy. It is the wrong answer for another ADDON: the
25422
+ * hub's proxy in front of that plane takes only a user credential, which
25423
+ * an addon does not hold, so a recorder that wants a camera's clip cannot
25424
+ * fetch that URL. This method is the shape that answer forced — the same
25425
+ * one (and the same bound) as `recordingExport.readExportBytes`.
25426
+ *
25427
+ * **It is no longer the only seam.** Until D613 there was no addon→addon
25428
+ * byte transport at all; there is now
25429
+ * ({@link offerClipBytes}, over `ctx.peerBytes`), it holds nothing on
25430
+ * either side, and it is what a clip EXPORT uses. This method remains for
25431
+ * a consumer that genuinely wants the bytes in hand, and as the named
25432
+ * fallback for a provider not yet redeployed.
25433
+ *
25434
+ * Routing needs no `provider` pin: the id is source-prefixed and
25435
+ * self-contained, so `device-collection-dispatch.ts` rule 3 hands the call
25436
+ * to the source that claims it — and an id nobody claims is REFUSED rather
25437
+ * than answered by another source.
25438
+ *
25439
+ * The producer reuses the fetch path it already has, completion rules
25440
+ * included: a clip is taken by cmd 5 and finished on a short idle window
25441
+ * whose result is PROVED against the catalog row's own span, retried once
25442
+ * when it comes up materially short, and served-and-named when it is still
25443
+ * short (D568). A second fetch with different completion rules is exactly
25444
+ * the second authority D558 refuses to create.
25445
+ *
25446
+ * Every refusal THROWS with its reason and none of them is silent — the
25447
+ * sleep gate (decided before `getApi()`, liftable only by
25448
+ * {@link ClipWakeSchema}), a catalog row nobody claims, a clip with no
25449
+ * bytes behind it, a mux that failed, and the size bound. The caller turns
25450
+ * that reason into an operator-facing one; a truncated file is never an
25451
+ * answer.
25452
+ */
25453
+ readClipBytes: optionalMethod(object({
25454
+ deviceId: number(),
25455
+ clipId: string().min(1),
25456
+ /**
25457
+ * WHICH provider holds the bytes — the `addonId` a
25458
+ * {@link ClipSourceSchema} row carries. **Required**, for the reason
25459
+ * `listClips` states one method above and for a second one this method
25460
+ * learned the hard way.
25461
+ *
25462
+ * The caller is the stream broker, and the broker lives in
25463
+ * `addon-pipeline` — the same addon that registers the RECORDER's
25464
+ * videoclips provider. With no provider named, the in-process
25465
+ * resolution handed it its own sibling, which implements this method
25466
+ * solely to explain that an analytics visit has no file behind it. So
25467
+ * every onboard clip answered with that sentence, a flash of video and
25468
+ * nothing more, while the SAME call from outside the addon reached the
25469
+ * right provider and returned the bytes (measured on 3628,
25470
+ * 2026-09-21).
25471
+ */
25472
+ provider: string().min(1),
25473
+ /**
25474
+ * Which twin to fetch, on the mapping D549 15 already fixed:
25475
+ * `low | mid` → the sub file, `high` → the main twin. `auto` is not
25476
+ * accepted here for the same reason it is not accepted by
25477
+ * `getClipPlayback` — a stored file has no broker session, so the
25478
+ * adaptive tier cannot be resolved for it.
25479
+ */
25480
+ profile: CamProfileSchema.optional(),
25481
+ /**
25482
+ * The CALLER's byte bound, so an over-size clip is refused before it is
25483
+ * read and encoded rather than after. Capped by
25484
+ * {@link VIDEOCLIPS_MAX_READ_BYTES} whatever is passed; absent means
25485
+ * that ceiling.
25486
+ */
25487
+ maxBytes: number().int().positive().optional(),
25488
+ /**
25489
+ * The operator's authorisation to wake a sleeping camera for this
25490
+ * read. Absent — the default — means a sleeping standalone battery
25491
+ * camera is REFUSED by name, before any session is opened.
25492
+ */
25493
+ wake: ClipWakeSchema.optional()
25494
+ }), ClipBytesSchema, {
25495
+ kind: "query",
25496
+ auth: "protected"
25497
+ }),
25498
+ /**
25499
+ * Where this clip's finished bytes can be TAKEN — the by-handle read a
25500
+ * clip EXPORT pulls, over the addon→addon byte transport (D613).
25501
+ *
25502
+ * This is {@link readClipBytes} with the envelope removed. Same gates,
25503
+ * same vocabulary, same completion rules, same `served` contract — the
25504
+ * only difference is that the bytes travel over a one-shot loopback
25505
+ * socket instead of inside a base64 field, so neither side holds the
25506
+ * payload whole and the 50 MiB refusal on a long `high` twin stops
25507
+ * existing. The bound that remains is
25508
+ * {@link VIDEOCLIPS_MAX_OFFER_BYTES}, and it bounds the PRODUCER's own
25509
+ * copy rather than the transport.
25510
+ *
25511
+ * **The ticket is loopback and same-host.** A provider on an agent mints a
25512
+ * URL that means nothing on the hub, and `ctx.peerBytes.open` refuses it
25513
+ * `cross-node` by name rather than dialling whatever else holds that port
25514
+ * here. A consumer that can be on the other side of a node boundary from
25515
+ * its provider must be able to read that refusal and say so; it must not
25516
+ * treat it as "no bytes".
25517
+ *
25518
+ * **A ticket is a one-shot bearer credential with a seconds-long life.**
25519
+ * Take it immediately, never persist it, never log its `url`. An untaken
25520
+ * ticket costs the provider one map entry until its TTL, and outstanding
25521
+ * tickets are bounded — which is also what makes a per-frame misuse of
25522
+ * this method refuse by name rather than work slowly (D9/D18: this is a
25523
+ * by-handle fetch of finished media, not a frame pipe).
25524
+ *
25525
+ * Optional on the provider for the same reason `readClipBytes` is: a
25526
+ * source with no camera socket behind it has no bytes. A provider that
25527
+ * predates this method answers `NOT_IMPLEMENTED`, and a consumer may fall
25528
+ * back to `readClipBytes` — but it says so in the log, with the deploy
25529
+ * hint, because that fallback re-imposes the 50 MiB refusal and an
25530
+ * operator who sees `too-large-to-transfer` after this shipped is looking
25531
+ * at a stale addon, not at a clip that cannot be exported.
25532
+ */
25533
+ offerClipBytes: optionalMethod(object({
25534
+ deviceId: number(),
25535
+ clipId: string().min(1),
25536
+ /** WHICH provider holds the bytes — see `readClipBytes.provider`. */
25537
+ provider: string().min(1),
25538
+ /** Which twin — `low | mid` → the sub file, `high` → the main twin. */
25539
+ profile: CamProfileSchema.optional(),
25540
+ /**
25541
+ * The CALLER's byte bound, so an over-size clip is refused before the
25542
+ * camera is touched rather than after. Capped by
25543
+ * {@link VIDEOCLIPS_MAX_OFFER_BYTES} whatever is passed; absent means
25544
+ * that ceiling.
25545
+ */
25546
+ maxBytes: number().int().positive().optional(),
25547
+ /**
25548
+ * The operator's authorisation to wake a sleeping camera for this
25549
+ * read. Absent — the default — means a sleeping standalone battery
25550
+ * camera is REFUSED by name, before any session is opened.
25551
+ */
25552
+ wake: ClipWakeSchema.optional()
25553
+ }), ClipBytesOfferSchema, {
25554
+ kind: "query",
25555
+ auth: "protected"
25556
+ }),
25557
+ /**
25558
+ * Where this clip's STREAM can be dialled (D597) — the forward-only
25559
+ * fMP4 the provider writes from the first muxed byte, for the broker to
25560
+ * play through the same WebRTC session as recorded footage, with the
25561
+ * first frame at the first fragment instead of after the last byte.
25562
+ *
25563
+ * Nothing is fetched here: the answer is a one-shot URL on the
25564
+ * provider's own listener, and the camera is touched when it is dialled.
25565
+ * The gates the file path runs (`readClipBytes`) run on the dial too —
25566
+ * the id, the row, a clip still being written — and refuse in the same
25567
+ * vocabulary, THROWN with `clipExportFailure(<token>, <prose>)`. Every
25568
+ * profile is served (D599) and the answer states which twin it carries;
25569
+ * `high` is not refused. Optional on the provider for the same reason
25570
+ * `readClipBytes` is: a source with no camera socket behind it (the
25571
+ * recorder's analytics window, a HomeKit tee) has no stream.
25572
+ */
25573
+ dialClipStream: optionalMethod(object({
25574
+ deviceId: number(),
25575
+ clipId: string().min(1),
25576
+ /** WHICH provider holds the camera — see `readClipBytes.provider`. */
25577
+ provider: string().min(1),
25578
+ /** Which twin — `high` → the main file, everything else the sub (D599). */
25579
+ profile: CamProfileSchema.optional(),
25580
+ /**
25581
+ * Which audio codec the caller's session negotiated, so the provider
25582
+ * can mux it (D600). The ask travels on the DIAL because the mux is
25583
+ * spawned once, before the first byte, and cannot gain a track later.
25584
+ *
25585
+ * ABSENT IS `none`, never a guess: a caller that does not state an
25586
+ * ask is served the video-only stream that shipped first, so a broker
25587
+ * older than this field keeps working unchanged (D382 — an unstated
25588
+ * ask is zero).
25589
+ */
25590
+ audio: ClipStreamAudioSchema.optional()
25591
+ }), ClipStreamDialSchema, {
25592
+ kind: "query",
25593
+ auth: "protected"
25594
+ }),
25595
+ /**
25596
+ * What a surface may DRAW for this provider's clips: the rates it can be
25597
+ * played at, whether scrub is served, how far a position may be moved,
25598
+ * and whether a backward frame-step means anything (D612).
25599
+ *
25600
+ * **The only authority.** The per-clip `clipTransport` server message
25601
+ * that used to carry the same answer was removed: the broker's transport
25602
+ * choice reads nothing that varies per clip, so the clip level had no
25603
+ * information the provider does not already have, and two channels that
25604
+ * can disagree are worse than one (D62).
25605
+ *
25606
+ * Asked per camera and per provider, so it must stay CHEAP — it is a
25607
+ * statement about wiring, answered from a constant, never a call to the
25608
+ * camera. A provider answers with one of {@link CLIP_PLAYBACK_OPTIONS}
25609
+ * and never composes an envelope of its own.
25610
+ *
25611
+ * Optional, and absence is load-bearing: a provider that has not answered
25612
+ * has not restricted anything, and a viewer reads it as the freedom it
25613
+ * always had. See D612 on the rollout order that absence implies.
25614
+ */
25615
+ getPlaybackOptions: optionalMethod(object({
25616
+ deviceId: number(),
25617
+ /** WHICH provider to ask — the `addonId` a {@link ClipSourceSchema}
25618
+ * row carries. Required for the same reason `listClips` requires it:
25619
+ * a collection cap has no "the bound one" to resolve to (D554). */
25620
+ provider: string().min(1)
25621
+ }), ClipPlaybackOptionsSchema, {
25622
+ kind: "query",
25623
+ auth: "protected"
25624
+ })
25625
+ }
25626
+ };
25627
+ /**
25628
+ * NOTE ON PLACEMENT: this block lives AFTER the capability definition on
25629
+ * purpose. `scripts/lib/parse-cap.ts` finds a cap by the FIRST
25630
+ * `export const <X> = {` in the file, so an object literal declared above
25631
+ * `videoclipsCapability` is silently taken for the capability itself and
25632
+ * codegen emits a `DeviceProxy` entry that does not type-check. Found the
25633
+ * hard way, 2026-09-23.
25634
+ */
25635
+ /**
25636
+ * The two envelopes, spelled ONCE.
25637
+ *
25638
+ * A provider answers with the entry for the transport its own wiring buys —
25639
+ * `stream` if it implements `dialClipStream`, `file` if it implements only
25640
+ * `readClipBytes` — and never composes one of its own. One table is what
25641
+ * makes "the provider may not promise what no clip can be given" structural
25642
+ * instead of a discipline: there is nothing else to promise.
25643
+ */
25644
+ var CLIP_PLAYBACK_OPTIONS = {
25645
+ stream: {
25646
+ transport: "stream",
25647
+ seek: "forward",
25648
+ stepBack: false,
25649
+ scrub: false,
25650
+ rates: CLIP_BROKER_PACED_RATES
25651
+ },
25216
25652
  /**
25217
- * Which audio codec the caller's session negotiated, so the provider
25218
- * can mux it (D600). The ask travels on the DIAL because the mux is
25219
- * spawned once, before the first byte, and cannot gain a track later.
25653
+ * A `stream` whose SOURCE runs at realtime — a Hikvision RTSP replay.
25220
25654
  *
25221
- * ABSENT IS `none`, never a guess: a caller that does not state an
25222
- * ask is served the video-only stream that shipped first, so a broker
25223
- * older than this field keeps working unchanged (D382 — an unstated
25224
- * ask is zero).
25655
+ * Same verbs as `stream`, a shorter rate ladder, and the difference is a
25656
+ * measurement rather than a preference: see
25657
+ * {@link CLIP_REALTIME_SOURCE_RATES}. A third entry rather than a parameter,
25658
+ * because a provider must still be able to do nothing but NAME one of these.
25225
25659
  */
25226
- audio: ClipStreamAudioSchema.optional()
25227
- }), ClipStreamDialSchema, {
25228
- kind: "query",
25229
- auth: "protected"
25230
- });
25660
+ realtimeStream: {
25661
+ transport: "stream",
25662
+ seek: "forward",
25663
+ stepBack: false,
25664
+ scrub: false,
25665
+ rates: CLIP_REALTIME_SOURCE_RATES
25666
+ },
25667
+ file: {
25668
+ transport: "file",
25669
+ seek: "free",
25670
+ stepBack: true,
25671
+ scrub: true,
25672
+ rates: CLIP_BROKER_PACED_RATES
25673
+ }
25674
+ };
25231
25675
  /**
25232
25676
  * Optional client-side hints sent at session creation to help the provider
25233
25677
  * pick the best native source. All fields optional — a viewer that knows
@@ -27728,6 +28172,108 @@ var recordingOnboardCapability = {
27728
28172
  * whether persisting is worth a SQLite commit. */
27729
28173
  volatileStateFields: ["lastFetchedAt"]
27730
28174
  };
28175
+ /** Minutes in a day. `endMinute === 1440` means "to end of day". */
28176
+ var MINUTES_PER_DAY = 1440;
28177
+ /**
28178
+ * Day-of-week names in the cap's index order (0 = Monday), for messages
28179
+ * an operator reads and for Hikvision's `<DayOfWeek>` element.
28180
+ */
28181
+ var DAY_NAMES = [
28182
+ "Monday",
28183
+ "Tuesday",
28184
+ "Wednesday",
28185
+ "Thursday",
28186
+ "Friday",
28187
+ "Saturday",
28188
+ "Sunday"
28189
+ ];
28190
+ /**
28191
+ * Does `patch` ask this camera for something it cannot do?
28192
+ *
28193
+ * Returns the operator-readable reason, or `null` when every field in
28194
+ * the patch is within what `options` says this camera accepts. A
28195
+ * provider calls this BEFORE touching the camera and throws the string:
28196
+ * refusing is the point, and refusing identically on both vendors is why
28197
+ * this is one function.
28198
+ *
28199
+ * The order of checks is the order an operator would read them: the
28200
+ * scalar knobs first, then the schedule, because a schedule complaint is
28201
+ * longer and a scalar one is usually the real problem.
28202
+ */
28203
+ function describeOnboardRefusal(options, patch) {
28204
+ if (patch.enabled !== void 0 && !options.enabled.writable) return unwritable("the master recording switch", options.enabled.reason);
28205
+ if (patch.overwriteWhenFull !== void 0 && !options.overwriteWhenFull.writable) return unwritable("overwrite-when-full", options.overwriteWhenFull.reason);
28206
+ const preRefusal = refuseNumeric("pre-record seconds", patch.preRecordSec, options.preRecordSec, options.preRecordSecRange, options.preRecordSecAllowed);
28207
+ if (preRefusal) return preRefusal;
28208
+ const postRefusal = refuseNumeric("post-record seconds", patch.postRecordSec, options.postRecordSec, options.postRecordSecRange, options.postRecordSecAllowed);
28209
+ if (postRefusal) return postRefusal;
28210
+ const segmentRefusal = refuseNumeric("segment length (minutes)", patch.segmentMinutes, options.segmentMinutes, options.segmentMinutesRange, options.segmentMinutesAllowed);
28211
+ if (segmentRefusal) return segmentRefusal;
28212
+ if (patch.windows !== void 0) {
28213
+ const scheduleRefusal = refuseSchedule(options, patch.windows);
28214
+ if (scheduleRefusal) return scheduleRefusal;
28215
+ }
28216
+ return null;
28217
+ }
28218
+ function unwritable(what, reason) {
28219
+ return reason ? `this camera cannot change ${what}: ${reason}` : `this camera cannot change ${what}`;
28220
+ }
28221
+ function refuseNumeric(what, value, support, range, allowed) {
28222
+ if (value === void 0) return null;
28223
+ if (!support.writable) return unwritable(what, support.reason);
28224
+ if (!Number.isFinite(value)) return `${what} must be a number, got ${String(value)}`;
28225
+ if (allowed) {
28226
+ if (allowed.values.includes(value)) return null;
28227
+ const say = (v) => allowed.sentinel !== void 0 && v === allowed.sentinel.value ? allowed.sentinel.meaning === "no-limit" ? "no limit" : "off" : String(v);
28228
+ return `this camera accepts ${what} only as ${allowed.values.map(say).join(", ")}; ${say(value)} is not one of them`;
28229
+ }
28230
+ if (!range) return null;
28231
+ if (value < range.min || value > range.max) return `this camera accepts ${what} between ${String(range.min)} and ${String(range.max)}; ${String(value)} is outside that`;
28232
+ if (range.step > 0 && (value - range.min) % range.step !== 0) return `this camera accepts ${what} in steps of ${String(range.step)} from ${String(range.min)}; ${String(value)} is not one of them`;
28233
+ return null;
28234
+ }
28235
+ function refuseSchedule(options, windows) {
28236
+ const schedule = options.schedule;
28237
+ if (!schedule.support.writable) return unwritable("the recording schedule", schedule.support.reason);
28238
+ const allowed = new Set(schedule.triggers);
28239
+ for (const window of windows) {
28240
+ if (!allowed.has(window.trigger)) {
28241
+ const offer = schedule.triggers.length > 0 ? schedule.triggers.join(", ") : "none";
28242
+ return `this camera cannot record on "${window.trigger}"; it records on: ${offer}`;
28243
+ }
28244
+ if (window.endMinute <= window.startMinute) return `a window on ${dayName(window.day)} ends at or before it starts (${String(window.startMinute)} → ${String(window.endMinute)})`;
28245
+ const step = schedule.granularityMinutes;
28246
+ if (step > 1 && (window.startMinute % step !== 0 || window.endMinute % step !== 0)) return `this camera's schedule only moves in ${String(step)}-minute steps; the ${dayName(window.day)} window ${formatMinutes(window.startMinute)}–${formatMinutes(window.endMinute)} is not aligned to them`;
28247
+ }
28248
+ if (!schedule.supportsOverlappingTriggers) {
28249
+ const clash = findOverlapWithDifferentTrigger(windows);
28250
+ if (clash) return `this camera stores one trigger per time range, so "${clash.a.trigger}" and "${clash.b.trigger}" cannot both cover ${dayName(clash.a.day)} ${formatMinutes(Math.max(clash.a.startMinute, clash.b.startMinute))}–${formatMinutes(Math.min(clash.a.endMinute, clash.b.endMinute))}`;
28251
+ }
28252
+ return null;
28253
+ }
28254
+ function findOverlapWithDifferentTrigger(windows) {
28255
+ for (let i = 0; i < windows.length; i += 1) for (let j = i + 1; j < windows.length; j += 1) {
28256
+ const a = windows[i];
28257
+ const b = windows[j];
28258
+ if (!a || !b) continue;
28259
+ if (a.day !== b.day) continue;
28260
+ if (a.trigger === b.trigger) continue;
28261
+ if (a.startMinute < b.endMinute && b.startMinute < a.endMinute) return {
28262
+ a,
28263
+ b
28264
+ };
28265
+ }
28266
+ return null;
28267
+ }
28268
+ function dayName(day) {
28269
+ return DAY_NAMES[day] ?? `day ${String(day)}`;
28270
+ }
28271
+ /** `510` → `08:30`. For messages, not for the wire. */
28272
+ function formatMinutes(minute) {
28273
+ const hour = Math.floor(minute / 60);
28274
+ const rest = minute % 60;
28275
+ return `${String(hour).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
28276
+ }
27731
28277
  /**
27732
28278
  * Generic device-level status snapshot. Auto-registered by `BaseDevice`
27733
28279
  * for every device, regardless of provider — the kernel needs a uniform
@@ -33667,6 +34213,10 @@ _enum([
33667
34213
  "too-large-to-transfer",
33668
34214
  "wake-expired"
33669
34215
  ]);
34216
+ /** `<token>: <prose>` — the ONE composition of a clip export's failure string. */
34217
+ function clipExportFailure(code, detail) {
34218
+ return detail.length > 0 ? `${code}: ${detail}` : code;
34219
+ }
33670
34220
  /** Candidate download URLs (LAN first, then operator extra hosts). */
33671
34221
  var ExportDownloadSchema = object({
33672
34222
  url: string(),
@@ -46258,6 +46808,12 @@ Object.freeze({
46258
46808
  addonId: null,
46259
46809
  access: "view"
46260
46810
  },
46811
+ "videoclips.getPlaybackOptions": {
46812
+ capName: "videoclips",
46813
+ capScope: "device",
46814
+ addonId: null,
46815
+ access: "view"
46816
+ },
46261
46817
  "videoclips.listClips": {
46262
46818
  capName: "videoclips",
46263
46819
  capScope: "device",
@@ -46270,6 +46826,12 @@ Object.freeze({
46270
46826
  addonId: null,
46271
46827
  access: "view"
46272
46828
  },
46829
+ "videoclips.offerClipBytes": {
46830
+ capName: "videoclips",
46831
+ capScope: "device",
46832
+ addonId: null,
46833
+ access: "view"
46834
+ },
46273
46835
  "videoclips.readClipBytes": {
46274
46836
  capName: "videoclips",
46275
46837
  capScope: "device",
@@ -48316,6 +48878,11 @@ Object.freeze({
48316
48878
  form: "single",
48317
48879
  optional: false
48318
48880
  }],
48881
+ "videoclips.getPlaybackOptions": [{
48882
+ name: "deviceId",
48883
+ form: "single",
48884
+ optional: false
48885
+ }],
48319
48886
  "videoclips.listClips": [{
48320
48887
  name: "deviceId",
48321
48888
  form: "single",
@@ -48326,6 +48893,11 @@ Object.freeze({
48326
48893
  form: "single",
48327
48894
  optional: false
48328
48895
  }],
48896
+ "videoclips.offerClipBytes": [{
48897
+ name: "deviceId",
48898
+ form: "single",
48899
+ optional: false
48900
+ }],
48329
48901
  "videoclips.readClipBytes": [{
48330
48902
  name: "deviceId",
48331
48903
  form: "single",
@@ -49236,6 +49808,14 @@ var AmcrestCgiClient = class AmcrestCgiClient {
49236
49808
  * Write config values. Keys are raw Dahua paths (e.g.
49237
49809
  * `VideoColor[0][0].Brightness`); brackets are passed through unescaped
49238
49810
  * (Dahua requires them literal) and only values are URL-encoded.
49811
+ *
49812
+ * **Every key must belong to the SAME config table.** Measured on an
49813
+ * IP4M-1041B (2026-09-23): `MediaGlobal.PacketLength=6&Record[0].PreRecord=4`
49814
+ * answered `200 OK` and applied only `MediaGlobal`; with the operands
49815
+ * swapped, only `Record` changed. Within one table the write IS atomic —
49816
+ * two keys both land, and one bad key rejects the pair with a body of
49817
+ * `Error` under HTTP 200, which is why success is decided on the body
49818
+ * and never on the status code.
49239
49819
  */
49240
49820
  async setConfig(params) {
49241
49821
  const query = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&");
@@ -49243,6 +49823,78 @@ var AmcrestCgiClient = class AmcrestCgiClient {
49243
49823
  if (!/\bOK\b/i.test(body)) throw new Error(`setConfig rejected: ${body.trim().slice(0, 200)}`);
49244
49824
  }
49245
49825
  /**
49826
+ * Read the camera's own storage inventory.
49827
+ *
49828
+ * `storageDevice.cgi?action=getDeviceAllInfo` — verified 200 on an
49829
+ * IP4M-1041B, serving the same rows RPC2's `storage.getDeviceAllInfo`
49830
+ * returns, so this cap needs no RPC2 session. (`action=getCaps` on the
49831
+ * same CGI is 501 on this firmware; the record caps come from
49832
+ * {@link getRecordManagerCaps} instead.)
49833
+ */
49834
+ async getStorageDeviceAllInfo() {
49835
+ return AmcrestCgiClient.parseTable(await this.getText("/cgi-bin/storageDevice.cgi?action=getDeviceAllInfo"));
49836
+ }
49837
+ /**
49838
+ * Open a recorded FILE for reading — `GET /cgi-bin/RPC_Loadfile<FilePath>`.
49839
+ *
49840
+ * Measured on device 3836 (2026-09-23): **200**, a `content-length` that is
49841
+ * byte-exact against the catalog row's `Length`, TTFB 142–211 ms, 2.87–3.5
49842
+ * MB/s, and a genuine self-contained ISO MP4 with an AAC track. Three
49843
+ * simultaneous pulls of three files all completed byte-exact and nothing
49844
+ * wedged afterwards — the opposite of the Reolink behaviour D611 records.
49845
+ *
49846
+ * Two hazards a caller MUST handle, both measured:
49847
+ *
49848
+ * - **`Range` is ignored.** 200 with the whole body and no `content-range`,
49849
+ * on every form tried. There is no partial read of a recorded file.
49850
+ * - **A path that does not exist closes the TCP socket with zero bytes and
49851
+ * NO HTTP status.** A real path answers 200 on the same endpoint, so the
49852
+ * bare close specifically means "not found" — and it reaches the caller as
49853
+ * a thrown transport error, which must become a NAMED refusal and never an
49854
+ * absence (D611).
49855
+ *
49856
+ * `content-type` is `application/http` — not a media type. It is deliberately
49857
+ * not returned: a consumer that trusted it would mislabel an MP4.
49858
+ */
49859
+ async openRecordedFile(filePath, timeoutMs) {
49860
+ const encoded = filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
49861
+ const res = await this.send(`/cgi-bin/RPC_Loadfile${encoded}`, timeoutMs);
49862
+ const declared = res.headers.get("content-length");
49863
+ const contentLength = declared === null ? null : Number(declared);
49864
+ return {
49865
+ status: res.status,
49866
+ contentLength: contentLength !== null && Number.isFinite(contentLength) && contentLength > 0 ? contentLength : null,
49867
+ body: res.body
49868
+ };
49869
+ }
49870
+ /**
49871
+ * What time the camera thinks it is, VERBATIM — `result=2026-09-23 11:29:27`.
49872
+ *
49873
+ * Its own LOCAL wall clock, with no zone on it, and the only thing that can
49874
+ * say what that clock is: measured 2026-09-23 answering `11:29:27` against a
49875
+ * host UTC of `09:29:27`. Everything the clip catalog sends or reads is in
49876
+ * this clock, so `clip-clock.ts` turns this string into the one offset the
49877
+ * whole clip path uses. Parsed there, never here: a read that failed must
49878
+ * reach the caller as a failure, not as a zero offset that looks like UTC.
49879
+ */
49880
+ async getCurrentTime() {
49881
+ return await this.getText("/cgi-bin/global.cgi?action=getCurrentTime");
49882
+ }
49883
+ /**
49884
+ * The camera's own statement of what it accepts for recording —
49885
+ * `MaxPreRecordTime`, `PacketLengthRange[0..1]`, `SupportHoliday`.
49886
+ */
49887
+ async getRecordManagerCaps() {
49888
+ return AmcrestCgiClient.parseTable(await this.getText("/cgi-bin/recordManager.cgi?action=getCaps"));
49889
+ }
49890
+ /**
49891
+ * The camera's own statement of what an event handler accepts —
49892
+ * `RecordLatch[0..1]` is the post-record bound.
49893
+ */
49894
+ async getEventManagerCaps() {
49895
+ return AmcrestCgiClient.parseTable(await this.getText("/cgi-bin/eventManager.cgi?action=getCaps"));
49896
+ }
49897
+ /**
49246
49898
  * Open the Dahua CGI event-manager attach stream — a long-lived
49247
49899
  * `multipart/x-mixed-replace` GET that pushes camera events
49248
49900
  * (`VideoMotion` Start/Stop, heartbeats, …) until the connection drops
@@ -49627,6 +50279,689 @@ function capExposureModeToAmcrest(mode) {
49627
50279
  return mode === "manual" ? 4 : 0;
49628
50280
  }
49629
50281
  //#endregion
50282
+ //#region src/clips/clip-availability.ts
50283
+ /**
50284
+ * What the storage read means for this SOURCE.
50285
+ *
50286
+ * `null` means "nothing to report" — the card is there and readable, so the
50287
+ * next question may be asked. Every other answer is terminal and the camera is
50288
+ * not searched.
50289
+ */
50290
+ function availabilityFromStorage(storage) {
50291
+ switch (storage.kind) {
50292
+ case "absent": return {
50293
+ state: "no-storage",
50294
+ reason: storage.reason
50295
+ };
50296
+ case "unknown": return {
50297
+ state: "unreachable",
50298
+ reason: storage.reason
50299
+ };
50300
+ case "present": return null;
50301
+ }
50302
+ }
50303
+ /**
50304
+ * The second gate, and it is not optional.
50305
+ *
50306
+ * Every time this provider sends the camera travels as the camera's LOCAL wall
50307
+ * clock. Without a measured offset there is no window to ask for — and the
50308
+ * failure mode of guessing is not an error, it is footage from two hours away
50309
+ * presented as the footage that was asked for. So a clock that could not be
50310
+ * read is `unreachable` by name, before a search is built.
50311
+ */
50312
+ function availabilityFromClock(clock) {
50313
+ if (clock !== null) return null;
50314
+ return {
50315
+ state: "unreachable",
50316
+ reason: "The camera did not answer what time it thinks it is, and its recordings are stamped in its own local clock — a window asked for without that offset would return the wrong footage."
50317
+ };
50318
+ }
50319
+ //#endregion
50320
+ //#region src/recording-onboard-mapping.ts
50321
+ /**
50322
+ * Bit position → the cap's trigger vocabulary.
50323
+ *
50324
+ * Every `TimeSection` entry is `"<mask> HH:MM:SS-HH:MM:SS"`, and `<mask>`
50325
+ * is the SET of triggers that time range records on. 3836 carries
50326
+ * `393222` = `0x60006` on all seven weekday rows: bits 1, 2, 17 and 18.
50327
+ *
50328
+ * Only these three bits are NAMED here, because only these three are
50329
+ * defensible from the Dahua CGI specification. Bits 17 and 18 are real
50330
+ * on this firmware and this repo cannot say what they mean — most likely
50331
+ * two IVS/smart kinds, and "most likely" is exactly the guess D608
50332
+ * forbids. An unnamed bit is therefore COUNTED as an unreadable window
50333
+ * and the schedule is reported unwritable (D391), which on 3836 it is.
50334
+ *
50335
+ * To lift that: identify bits 17 and 18 — set one of them alone on a
50336
+ * slot, observe which event actually starts a recording — and add them
50337
+ * here. Nothing else in this module changes.
50338
+ */
50339
+ var TRIGGER_BY_BIT = {
50340
+ 0: "continuous",
50341
+ 1: "motion",
50342
+ 2: "alarmInput"
50343
+ };
50344
+ /** Triggers an Amcrest schedule can be written with. */
50345
+ var AMCREST_TRIGGERS = [
50346
+ "continuous",
50347
+ "motion",
50348
+ "alarmInput"
50349
+ ];
50350
+ /** The cap's trigger vocabulary → its bit position. */
50351
+ var BIT_BY_TRIGGER = {
50352
+ continuous: 0,
50353
+ motion: 1,
50354
+ alarmInput: 2
50355
+ };
50356
+ /**
50357
+ * Dahua row index → the cap's day index.
50358
+ *
50359
+ * **Layout: row 0 is SUNDAY**, which the Dahua CGI specification states
50360
+ * and every Dahua/Amcrest UI renders Sunday-first; the cap's day 0 is
50361
+ * Monday (ISO). This is an ASSUMPTION in exactly the sense D608 records
50362
+ * for Reolink's mask: all seven weekday rows on 3836 are identical, and a
50363
+ * uniform schedule reads the same under either layout, so the reads
50364
+ * shipped here are exact and the assumption is unfalsifiable from this
50365
+ * camera. {@link weekdayRowsAreUniform} names the case. To settle it: set
50366
+ * one weekday only from the Amcrest app and re-read `Record`.
50367
+ */
50368
+ function rowToCapDay(row) {
50369
+ return (row + 6) % 7;
50370
+ }
50371
+ /** The cap's day index → the Dahua row index. Inverse of {@link rowToCapDay}. */
50372
+ function capDayToRow(day) {
50373
+ return (day + 1) % 7;
50374
+ }
50375
+ /** The Dahua key for one schedule slot. */
50376
+ function timeSectionKey(channel, row, slot) {
50377
+ return `Record[${String(channel)}].TimeSection[${String(row)}][${String(slot)}]`;
50378
+ }
50379
+ /**
50380
+ * A number the camera stated, or null when it did not state one.
50381
+ *
50382
+ * Never 0 for a missing or unparseable value: a measurement that failed
50383
+ * is not a measurement (D393). `TotalBytes` arrives as
50384
+ * `134202327040.000000` over CGI, so this parses a float and does not
50385
+ * demand an integer spelling.
50386
+ */
50387
+ function numberAt(table, key) {
50388
+ const raw = table.get(key);
50389
+ if (raw === void 0 || raw.trim() === "") return null;
50390
+ const value = Number(raw);
50391
+ return Number.isFinite(value) ? value : null;
50392
+ }
50393
+ /** A boolean the camera stated, or null when it did not state one. */
50394
+ function booleanAt(table, key) {
50395
+ const raw = table.get(key);
50396
+ if (raw === void 0) return null;
50397
+ if (raw === "true") return true;
50398
+ if (raw === "false") return false;
50399
+ return null;
50400
+ }
50401
+ var BYTES_PER_MB = 1024 * 1024;
50402
+ /** Bytes → whole MB, or null when the byte count was never stated (D393). */
50403
+ function toMegabytes(bytes) {
50404
+ if (bytes === null) return null;
50405
+ return Math.round(bytes / BYTES_PER_MB);
50406
+ }
50407
+ var VOLUME_STATUS_BY_STATE = {
50408
+ Success: "ok",
50409
+ Running: "ok",
50410
+ Error: "error",
50411
+ Unformatted: "unformatted",
50412
+ NoExist: "offline",
50413
+ Offline: "offline",
50414
+ Sleeping: "ok"
50415
+ };
50416
+ /**
50417
+ * Read `storageDevice.cgi?action=getDeviceAllInfo`.
50418
+ *
50419
+ * Verbatim from 3836 with the card fitted:
50420
+ *
50421
+ * ```
50422
+ * list.info[0].Name=/dev/mmc0
50423
+ * list.info[0].State=Success
50424
+ * list.info[0].Detail[0].Path=/mnt/sd
50425
+ * list.info[0].Detail[0].TotalBytes=134202327040.000000
50426
+ * list.info[0].Detail[0].UsedBytes=345309184.000000
50427
+ * list.info[0].Detail[0].Type=ReadWrite
50428
+ * list.info[0].Detail[0].IsError=false
50429
+ * ```
50430
+ *
50431
+ * EVERY detail row is returned, not the first usable one — an operator
50432
+ * asking what is in this camera is owed the camera's whole answer. A
50433
+ * table with no `info` row at all is `absent`: that is what a camera with
50434
+ * no card answers, and it is the read that turns
50435
+ * {@link RecordingOnboardStatus.recordingToNowhere} back on. An
50436
+ * unreadable answer is `unknown`, never folded into `absent` (D393).
50437
+ */
50438
+ function parseOnboardStorage(table) {
50439
+ const deviceIndices = /* @__PURE__ */ new Set();
50440
+ for (const key of table.keys()) {
50441
+ const match = /^list\.info\[(\d+)\]\./.exec(key);
50442
+ if (match?.[1] !== void 0) deviceIndices.add(Number(match[1]));
50443
+ }
50444
+ if (deviceIndices.size === 0) return {
50445
+ kind: "absent",
50446
+ reason: "The camera reports no storage device — there is no card in it."
50447
+ };
50448
+ const volumes = [];
50449
+ for (const device of [...deviceIndices].toSorted((a, b) => a - b)) {
50450
+ const prefix = `list.info[${String(device)}]`;
50451
+ const name = table.get(`${prefix}.Name`) ?? `device ${String(device)}`;
50452
+ const state = table.get(`${prefix}.State`);
50453
+ const detailIndices = /* @__PURE__ */ new Set();
50454
+ for (const key of table.keys()) {
50455
+ const match = new RegExp(`^list\\.info\\[${String(device)}\\]\\.Detail\\[(\\d+)\\]\\.`).exec(key);
50456
+ if (match?.[1] !== void 0) detailIndices.add(Number(match[1]));
50457
+ }
50458
+ if (detailIndices.size === 0) {
50459
+ volumes.push({
50460
+ id: name,
50461
+ label: name,
50462
+ status: statusOf(state, null),
50463
+ capacityMb: null,
50464
+ freeMb: null
50465
+ });
50466
+ continue;
50467
+ }
50468
+ for (const detail of [...detailIndices].toSorted((a, b) => a - b)) {
50469
+ const dPrefix = `${prefix}.Detail[${String(detail)}]`;
50470
+ const path = table.get(`${dPrefix}.Path`);
50471
+ const total = numberAt(table, `${dPrefix}.TotalBytes`);
50472
+ const used = numberAt(table, `${dPrefix}.UsedBytes`);
50473
+ const type = table.get(`${dPrefix}.Type`);
50474
+ const isError = booleanAt(table, `${dPrefix}.IsError`);
50475
+ const volume = {
50476
+ id: path === void 0 ? `${name}#${String(detail)}` : `${name}:${path}`,
50477
+ ...path === void 0 ? {} : { label: path },
50478
+ status: statusOf(state, isError),
50479
+ capacityMb: toMegabytes(total),
50480
+ freeMb: total === null || used === null ? null : toMegabytes(total - used),
50481
+ ...type === void 0 ? {} : { writable: type === "ReadWrite" }
50482
+ };
50483
+ volumes.push(volume);
50484
+ }
50485
+ }
50486
+ if (!volumes.some((volume) => volume.status === "ok")) return {
50487
+ kind: "absent",
50488
+ reason: "The camera lists storage, but no volume it can write to — the card is missing, unformatted or failed."
50489
+ };
50490
+ return {
50491
+ kind: "present",
50492
+ volumes
50493
+ };
50494
+ }
50495
+ function statusOf(state, isError) {
50496
+ if (isError === true) return "error";
50497
+ if (state === void 0) return "unknown";
50498
+ return VOLUME_STATUS_BY_STATE[state] ?? "unknown";
50499
+ }
50500
+ /**
50501
+ * `"393222 00:00:00-23:59:59"` → its three parts, or null when the slot
50502
+ * is not something this reader understands.
50503
+ *
50504
+ * `23:59:59` is the firmware's spelling of end-of-day and becomes 1440,
50505
+ * exactly as Hikvision's `24:00` does — collapsing it to 1439 would shave
50506
+ * a minute off every whole-day window, and to 0 would empty it. Any OTHER
50507
+ * non-zero seconds value is refused rather than rounded: the cap's
50508
+ * resolution is minutes and rounding is how an operator's 06:30:30
50509
+ * becomes 06:30 with nothing saying so.
50510
+ */
50511
+ function parseTimeSectionSlot(raw) {
50512
+ const match = /^\s*(-?\d+)\s+(\d{2}):(\d{2}):(\d{2})-(\d{2}):(\d{2}):(\d{2})\s*$/.exec(raw);
50513
+ if (!match) return null;
50514
+ const mask = Number(match[1]);
50515
+ if (!Number.isInteger(mask) || mask < 0) return null;
50516
+ const start = clockToMinutes(match[2], match[3], match[4]);
50517
+ const end = clockToMinutes(match[5], match[6], match[7]);
50518
+ if (start === null || end === null) return null;
50519
+ return {
50520
+ mask,
50521
+ startMinute: start,
50522
+ endMinute: end
50523
+ };
50524
+ }
50525
+ function clockToMinutes(hour, minute, second) {
50526
+ if (hour === void 0 || minute === void 0 || second === void 0) return null;
50527
+ const h = Number(hour);
50528
+ const m = Number(minute);
50529
+ const s = Number(second);
50530
+ if (h > 23 || m > 59 || s > 59) return null;
50531
+ if (h === 23 && m === 59 && s === 59) return MINUTES_PER_DAY;
50532
+ if (s !== 0) return null;
50533
+ return h * 60 + m;
50534
+ }
50535
+ /** Every bit set in `mask`, low to high. */
50536
+ function setBits(mask) {
50537
+ const out = [];
50538
+ for (let bit = 0; bit < 32; bit += 1) if ((mask & 1 << bit) !== 0) out.push(bit);
50539
+ return out;
50540
+ }
50541
+ /**
50542
+ * Expand the `Record[ch].TimeSection[row][slot]` grid into windows.
50543
+ *
50544
+ * One slot can name SEVERAL triggers at once — that is what the bitfield
50545
+ * is — so one slot becomes one window per NAMED bit. An unnamed bit, a
50546
+ * slot that will not parse, and every bit of the holiday row are counted
50547
+ * as unreadable: each is coverage the camera holds and the operator is
50548
+ * not being shown, and a schedule that silently shows fewer rows than the
50549
+ * camera holds is how a shorter one gets saved back (D391).
50550
+ */
50551
+ function parseSchedule(table, channel) {
50552
+ const windows = [];
50553
+ let unreadable = 0;
50554
+ let anyCoverage = false;
50555
+ for (let row = 0; row <= 7; row += 1) for (let slot = 0; slot < 6; slot += 1) {
50556
+ const raw = table.get(timeSectionKey(channel, row, slot));
50557
+ if (raw === void 0) continue;
50558
+ const parsed = parseTimeSectionSlot(raw);
50559
+ if (parsed === null) {
50560
+ unreadable += 1;
50561
+ anyCoverage = true;
50562
+ continue;
50563
+ }
50564
+ if (parsed.mask === 0) continue;
50565
+ anyCoverage = true;
50566
+ const bits = setBits(parsed.mask);
50567
+ if (row === 7) {
50568
+ unreadable += bits.length;
50569
+ continue;
50570
+ }
50571
+ if (parsed.endMinute <= parsed.startMinute) {
50572
+ unreadable += bits.length;
50573
+ continue;
50574
+ }
50575
+ for (const bit of bits) {
50576
+ const trigger = TRIGGER_BY_BIT[bit];
50577
+ if (trigger === void 0) {
50578
+ unreadable += 1;
50579
+ continue;
50580
+ }
50581
+ windows.push({
50582
+ trigger,
50583
+ day: rowToCapDay(row),
50584
+ startMinute: parsed.startMinute,
50585
+ endMinute: parsed.endMinute
50586
+ });
50587
+ }
50588
+ }
50589
+ return {
50590
+ windows,
50591
+ unreadable,
50592
+ anyCoverage
50593
+ };
50594
+ }
50595
+ /** Read both caps tables into {@link AmcrestRecordLimits}. */
50596
+ function parseRecordLimits(input) {
50597
+ const record = input.recordCaps;
50598
+ const event = input.eventCaps;
50599
+ const pair = (table, key) => {
50600
+ if (table === null) return null;
50601
+ const min = numberAt(table, `${key}[0]`);
50602
+ const max = numberAt(table, `${key}[1]`);
50603
+ if (min === null || max === null || max < min) return null;
50604
+ return {
50605
+ min,
50606
+ max
50607
+ };
50608
+ };
50609
+ return {
50610
+ maxPreRecordSec: record === null ? null : numberAt(record, "MaxPreRecordTime"),
50611
+ packetLengthMinutes: pair(record, "PacketLengthRange"),
50612
+ recordLatchSec: pair(event, "RecordLatch")
50613
+ };
50614
+ }
50615
+ /** Index of the local storage group, or null when the camera named none. */
50616
+ function findLocalStorageGroup(table) {
50617
+ for (const [key, value] of table) {
50618
+ const match = /^StorageGroup\[(\d+)\]\.Name$/.exec(key);
50619
+ if (match?.[1] !== void 0 && value === "ReadWrite") return Number(match[1]);
50620
+ }
50621
+ return null;
50622
+ }
50623
+ /**
50624
+ * Overwrite-when-full, from BOTH places this firmware keeps it.
50625
+ *
50626
+ * Measured: `MediaGlobal.OverWrite` and `StorageGroup[0].OverWrite` are
50627
+ * INDEPENDENT — setting the first to `false` left the second `true` — and
50628
+ * nothing on the camera says which one the recorder honours. The card is
50629
+ * 134 GB and settling it by filling it is not an experiment this repo is
50630
+ * going to run.
50631
+ *
50632
+ * So the read is the two authorities' AGREEMENT: equal → that value;
50633
+ * different → null, which the status renders as "the camera did not say"
50634
+ * rather than picking a winner. The WRITE sets both (one request per
50635
+ * table), so a value CamStack wrote can never be the disagreeing case.
50636
+ */
50637
+ function readOverwriteWhenFull(input) {
50638
+ const global = input.mediaGlobal === null ? null : booleanAt(input.mediaGlobal, "MediaGlobal.OverWrite");
50639
+ const groupIndex = input.storageGroup === null ? null : findLocalStorageGroup(input.storageGroup);
50640
+ const group = input.storageGroup === null || groupIndex === null ? null : booleanAt(input.storageGroup, `StorageGroup[${String(groupIndex)}].OverWrite`);
50641
+ if (global === null) return group;
50642
+ if (group === null) return global;
50643
+ return global === group ? global : null;
50644
+ }
50645
+ /**
50646
+ * Post-record seconds, from every event handler that records.
50647
+ *
50648
+ * Dahua keeps post-record per EVENT HANDLER (`…EventHandler.RecordLatch`),
50649
+ * not once per channel. 3836 exposes exactly one — `MotionDetect[0]`;
50650
+ * `SmartMotionDetect`, `AlarmLocal`, `VideoBlind` and `VideoLoss` all
50651
+ * answer HTTP 400 on this firmware, and its single `VideoAnalyseRule`
50652
+ * (`LeTrack`, disabled) carries no `EventHandler`.
50653
+ *
50654
+ * Where several DO exist and disagree, there is no single number to
50655
+ * report: this returns null and the field says why, rather than picking
50656
+ * one handler's value and calling it the camera's.
50657
+ */
50658
+ function readPostRecordSec(motionDetect) {
50659
+ if (motionDetect === null) return {
50660
+ value: null,
50661
+ handlers: 0,
50662
+ disagree: false
50663
+ };
50664
+ const values = [];
50665
+ for (const [key, raw] of motionDetect) {
50666
+ if (!key.endsWith(".EventHandler.RecordLatch")) continue;
50667
+ const value = Number(raw);
50668
+ if (Number.isFinite(value)) values.push(value);
50669
+ }
50670
+ if (values.length === 0) return {
50671
+ value: null,
50672
+ handlers: 0,
50673
+ disagree: false
50674
+ };
50675
+ const first = values[0] ?? null;
50676
+ const disagree = values.some((value) => value !== first);
50677
+ return {
50678
+ value: disagree ? null : first,
50679
+ handlers: values.length,
50680
+ disagree
50681
+ };
50682
+ }
50683
+ /**
50684
+ * Fold the reads into the cap's status.
50685
+ *
50686
+ * `tracks` is empty and `primaryTrackId` null, as on Reolink: a Dahua
50687
+ * channel exposes no track list, and inventing one synthetic row would
50688
+ * put a fact in the UI the camera never stated.
50689
+ *
50690
+ * `enabled` is the CONJUNCTION of the two switches that both have to be
50691
+ * on for the camera to record on its schedule — `RecordMode[ch].Mode`
50692
+ * (0 auto / 1 manual / 2 off) and `Record[ch].Enable`. Either one off
50693
+ * means nothing is recorded, so reporting only one of them would call a
50694
+ * silent camera enabled.
50695
+ */
50696
+ function buildOnboardStatus(input) {
50697
+ const { reads } = input;
50698
+ const channel = reads.channel;
50699
+ const schedule = reads.record === null ? {
50700
+ windows: [],
50701
+ unreadable: 0,
50702
+ anyCoverage: false
50703
+ } : parseSchedule(reads.record, channel);
50704
+ const mode = reads.recordMode === null ? null : numberAt(reads.recordMode, `RecordMode[${String(channel)}].Mode`);
50705
+ const recordEnable = reads.record === null ? null : booleanAt(reads.record, `Record[${String(channel)}].Enable`);
50706
+ const enabled = mode === null || recordEnable === null ? null : mode !== 2 && recordEnable;
50707
+ const post = readPostRecordSec(reads.motionDetect);
50708
+ return {
50709
+ storage: reads.storage,
50710
+ tracks: [],
50711
+ primaryTrackId: null,
50712
+ enabled,
50713
+ overwriteWhenFull: readOverwriteWhenFull({
50714
+ mediaGlobal: reads.mediaGlobal,
50715
+ storageGroup: reads.storageGroup
50716
+ }),
50717
+ preRecordSec: reads.record === null ? null : numberAt(reads.record, `Record[${String(channel)}].PreRecord`),
50718
+ postRecordSec: post.value,
50719
+ segmentMinutes: reads.mediaGlobal === null ? null : numberAt(reads.mediaGlobal, "MediaGlobal.PacketLength"),
50720
+ windows: [...schedule.windows],
50721
+ unreadableWindows: schedule.unreadable,
50722
+ recordingToNowhere: enabled === true && schedule.anyCoverage && reads.storage.kind === "absent",
50723
+ lastFetchedAt: input.now
50724
+ };
50725
+ }
50726
+ /** Why nothing can be written before CamStack has read the camera. */
50727
+ var AMCREST_NOT_READ_REASON = "CamStack has not read this camera yet";
50728
+ /** Why a field is closed when the camera never stated its limits. */
50729
+ var AMCREST_NO_LIMITS_REASON = "this camera did not state the values it accepts for this setting, and it was measured storing values outside its own limits without complaint";
50730
+ /** Why the schedule is closed when part of it could not be read. */
50731
+ var AMCREST_PARTIAL_SCHEDULE_REASON = "CamStack could not read every recording window this camera holds — it records on triggers this version cannot name — so replacing them would delete the ones it could not show";
50732
+ /** Why post-record is closed when the handlers disagree. */
50733
+ var AMCREST_POST_RECORD_SPLIT_REASON = "this camera keeps post-record separately per event type and they do not agree, so there is no single value to show or write";
50734
+ /**
50735
+ * What THIS Amcrest accepts.
50736
+ *
50737
+ * Writability comes from the camera's OWN statement, never a vendor
50738
+ * default: `recordManager.cgi?action=getCaps` bounds pre-record and
50739
+ * segment length, `eventManager.cgi?action=getCaps` bounds post-record.
50740
+ * A caps read that failed closes its field, because this firmware was
50741
+ * measured answering `200 OK` to `PacketLength=99` and `RecordLatch=999`
50742
+ * — values its own caps forbid. Nothing but the refusal before the wire
50743
+ * stops those.
50744
+ */
50745
+ function buildOnboardOptions(input) {
50746
+ const reached = input.status !== null;
50747
+ const closed = (reason) => ({
50748
+ readable: reached,
50749
+ writable: false,
50750
+ reason: reached ? reason : AMCREST_NOT_READ_REASON
50751
+ });
50752
+ const open = {
50753
+ readable: true,
50754
+ writable: true
50755
+ };
50756
+ const gate = (allowed, reason) => reached && allowed ? open : closed(reason);
50757
+ const limits = input.limits;
50758
+ const preOk = limits.maxPreRecordSec !== null;
50759
+ const segmentOk = limits.packetLengthMinutes !== null;
50760
+ const postOk = limits.recordLatchSec !== null && input.postRecord.handlers > 0 && !input.postRecord.disagree;
50761
+ const scheduleReadable = (input.status?.unreadableWindows ?? 0) === 0;
50762
+ return {
50763
+ enabled: gate(true, AMCREST_NOT_READ_REASON),
50764
+ overwriteWhenFull: gate(true, AMCREST_NOT_READ_REASON),
50765
+ preRecordSec: gate(preOk, AMCREST_NO_LIMITS_REASON),
50766
+ ...limits.maxPreRecordSec === null ? {} : { preRecordSecRange: {
50767
+ min: 0,
50768
+ max: limits.maxPreRecordSec,
50769
+ step: 1
50770
+ } },
50771
+ postRecordSec: gate(postOk, input.postRecord.disagree ? AMCREST_POST_RECORD_SPLIT_REASON : AMCREST_NO_LIMITS_REASON),
50772
+ ...limits.recordLatchSec === null ? {} : { postRecordSecRange: {
50773
+ min: limits.recordLatchSec.min,
50774
+ max: limits.recordLatchSec.max,
50775
+ step: 1
50776
+ } },
50777
+ segmentMinutes: gate(segmentOk, AMCREST_NO_LIMITS_REASON),
50778
+ ...limits.packetLengthMinutes === null ? {} : { segmentMinutesRange: {
50779
+ min: limits.packetLengthMinutes.min,
50780
+ max: limits.packetLengthMinutes.max,
50781
+ step: 1
50782
+ } },
50783
+ schedule: {
50784
+ support: gate(scheduleReadable, AMCREST_PARTIAL_SCHEDULE_REASON),
50785
+ granularityMinutes: 1,
50786
+ triggers: [...AMCREST_TRIGGERS],
50787
+ supportsOverlappingTriggers: true
50788
+ }
50789
+ };
50790
+ }
50791
+ /**
50792
+ * Turn a cap patch into one request per config table.
50793
+ *
50794
+ * `windows` is the COMPLETE new set, so every weekday row is rewritten —
50795
+ * including a day the new set does not mention, whose slots must be
50796
+ * written back as `0 00:00:00-23:59:59` or the camera keeps recording on
50797
+ * a day the operator just cleared. Slots beyond the new windows are
50798
+ * cleared the same way. The HOLIDAY row is never touched: the cap cannot
50799
+ * express it, so rewriting it would delete coverage nobody was shown.
50800
+ *
50801
+ * `enabled` is read-modify-write on `RecordMode`: turning recording back
50802
+ * ON restores `auto` only when the camera was `off`, so a camera the
50803
+ * operator had put in `manual` stays in `manual`.
50804
+ */
50805
+ function buildOnboardWrites(patch, current) {
50806
+ const ch = String(current.channel);
50807
+ const writes = [];
50808
+ const recordParams = {};
50809
+ const recordVerify = {};
50810
+ if (patch.enabled !== void 0) {
50811
+ recordParams[`Record[${ch}].Enable`] = patch.enabled;
50812
+ recordVerify["enabled"] = {
50813
+ key: `Record[${ch}].Enable`,
50814
+ expect: patch.enabled ? "true" : "false"
50815
+ };
50816
+ }
50817
+ if (patch.preRecordSec !== void 0) {
50818
+ recordParams[`Record[${ch}].PreRecord`] = patch.preRecordSec;
50819
+ recordVerify["preRecordSec"] = {
50820
+ key: `Record[${ch}].PreRecord`,
50821
+ expect: String(patch.preRecordSec)
50822
+ };
50823
+ }
50824
+ if (patch.windows !== void 0) {
50825
+ const slots = windowsToTimeSections(patch.windows);
50826
+ for (let row = 0; row < 7; row += 1) for (let slot = 0; slot < 6; slot += 1) {
50827
+ const value = slots[row]?.[slot] ?? "0 00:00:00-23:59:59";
50828
+ const key = timeSectionKey(current.channel, row, slot);
50829
+ recordParams[key] = value;
50830
+ recordVerify[`windows[${String(row)}][${String(slot)}]`] = {
50831
+ key,
50832
+ expect: value
50833
+ };
50834
+ }
50835
+ }
50836
+ if (Object.keys(recordParams).length > 0) writes.push({
50837
+ table: "Record",
50838
+ params: recordParams,
50839
+ verify: recordVerify
50840
+ });
50841
+ if (patch.enabled !== void 0) {
50842
+ const mode = patch.enabled ? current.recordMode === 2 || current.recordMode === null ? 0 : current.recordMode : 2;
50843
+ writes.push({
50844
+ table: "RecordMode",
50845
+ params: { [`RecordMode[${ch}].Mode`]: mode },
50846
+ verify: { enabledMode: {
50847
+ key: `RecordMode[${ch}].Mode`,
50848
+ expect: String(mode)
50849
+ } }
50850
+ });
50851
+ }
50852
+ const mediaParams = {};
50853
+ const mediaVerify = {};
50854
+ if (patch.overwriteWhenFull !== void 0) {
50855
+ mediaParams["MediaGlobal.OverWrite"] = patch.overwriteWhenFull;
50856
+ mediaVerify["overwriteWhenFull"] = {
50857
+ key: "MediaGlobal.OverWrite",
50858
+ expect: patch.overwriteWhenFull ? "true" : "false"
50859
+ };
50860
+ }
50861
+ if (patch.segmentMinutes !== void 0) {
50862
+ mediaParams["MediaGlobal.PacketLength"] = patch.segmentMinutes;
50863
+ mediaVerify["segmentMinutes"] = {
50864
+ key: "MediaGlobal.PacketLength",
50865
+ expect: String(patch.segmentMinutes)
50866
+ };
50867
+ }
50868
+ if (Object.keys(mediaParams).length > 0) writes.push({
50869
+ table: "MediaGlobal",
50870
+ params: mediaParams,
50871
+ verify: mediaVerify
50872
+ });
50873
+ if (patch.overwriteWhenFull !== void 0 && current.storageGroupIndex !== null) {
50874
+ const key = `StorageGroup[${String(current.storageGroupIndex)}].OverWrite`;
50875
+ writes.push({
50876
+ table: "StorageGroup",
50877
+ params: { [key]: patch.overwriteWhenFull },
50878
+ verify: { overwriteWhenFullGroup: {
50879
+ key,
50880
+ expect: patch.overwriteWhenFull ? "true" : "false"
50881
+ } }
50882
+ });
50883
+ }
50884
+ if (patch.postRecordSec !== void 0) {
50885
+ const key = `MotionDetect[${ch}].EventHandler.RecordLatch`;
50886
+ writes.push({
50887
+ table: "MotionDetect",
50888
+ params: { [key]: patch.postRecordSec },
50889
+ verify: { postRecordSec: {
50890
+ key,
50891
+ expect: String(patch.postRecordSec)
50892
+ } }
50893
+ });
50894
+ }
50895
+ return writes;
50896
+ }
50897
+ /**
50898
+ * Windows → the weekday rows' slots.
50899
+ *
50900
+ * Windows sharing a day and a time range are MERGED into one slot whose
50901
+ * mask carries every trigger — that is what the bitfield is, and emitting
50902
+ * one slot per trigger would burn through the six the camera has.
50903
+ *
50904
+ * A day needing more than {@link SLOTS_PER_ROW} distinct ranges yields
50905
+ * only the first six; the caller refuses that case before the wire rather
50906
+ * than letting the extra ones fall off here. A trigger with no bit is
50907
+ * skipped for the same reason — `describeOnboardRefusal` has already
50908
+ * refused it against `options.schedule.triggers`.
50909
+ */
50910
+ function windowsToTimeSections(windows) {
50911
+ const rows = Array.from({ length: 7 }, () => []);
50912
+ for (let day = 0; day < 7; day += 1) {
50913
+ const byRange = /* @__PURE__ */ new Map();
50914
+ for (const window of windows) {
50915
+ if (window.day !== day) continue;
50916
+ const bit = BIT_BY_TRIGGER[window.trigger];
50917
+ if (bit === void 0) continue;
50918
+ const range = `${minutesToClock(window.startMinute)}-${minutesToClock(window.endMinute)}`;
50919
+ byRange.set(range, (byRange.get(range) ?? 0) | 1 << bit);
50920
+ }
50921
+ const row = capDayToRow(day);
50922
+ rows[row] = [...byRange.entries()].toSorted((a, b) => a[0].localeCompare(b[0])).map(([range, mask]) => `${String(mask)} ${range}`);
50923
+ }
50924
+ return rows;
50925
+ }
50926
+ /**
50927
+ * The one refusal `describeOnboardRefusal` cannot make for this vendor.
50928
+ *
50929
+ * A Dahua weekday row holds {@link SLOTS_PER_ROW} time ranges and no
50930
+ * more. A day asking for more would be written as its first six and the
50931
+ * rest would vanish into a `200 OK` — the silent drop this whole cap
50932
+ * exists to prevent — so it is refused by name, before the wire, with the
50933
+ * same voice the shared vocabulary uses.
50934
+ *
50935
+ * Returns null when the set fits.
50936
+ */
50937
+ function describeAmcrestScheduleRefusal(windows) {
50938
+ for (const [day, needed] of slotsNeededPerDay(windows)) if (needed > 6) return `this camera holds ${String(6)} recording periods per day and ${DAY_NAMES[day] ?? `day ${String(day)}`} asks for ${String(needed)}`;
50939
+ return null;
50940
+ }
50941
+ /** How many distinct time ranges a day would need. For the refusal. */
50942
+ function slotsNeededPerDay(windows) {
50943
+ const out = /* @__PURE__ */ new Map();
50944
+ for (let day = 0; day < 7; day += 1) {
50945
+ const ranges = /* @__PURE__ */ new Set();
50946
+ for (const window of windows) {
50947
+ if (window.day !== day) continue;
50948
+ ranges.add(`${String(window.startMinute)}-${String(window.endMinute)}`);
50949
+ }
50950
+ out.set(day, ranges.size);
50951
+ }
50952
+ return out;
50953
+ }
50954
+ /**
50955
+ * `1440` → `23:59:59`, the firmware's own end-of-day; everything else
50956
+ * `HH:MM:00`.
50957
+ */
50958
+ function minutesToClock(minute) {
50959
+ if (minute >= 1440) return "23:59:59";
50960
+ const hour = Math.floor(minute / 60);
50961
+ const rest = minute % 60;
50962
+ return `${String(hour).padStart(2, "0")}:${String(rest).padStart(2, "0")}:00`;
50963
+ }
50964
+ //#endregion
49630
50965
  //#region src/extra-settings.ts
49631
50966
  /**
49632
50967
  * Driver-authored device-settings sections for the camera facts that no
@@ -50106,6 +51441,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
50106
51441
  this.registerOsdCap();
50107
51442
  this.registerDayNightCap();
50108
51443
  this.registerImageSettingsCap();
51444
+ this.registerRecordingOnboardCap();
50109
51445
  }
50110
51446
  /** Dahua CGI `eventManager` attach codes — VideoMotion is the base
50111
51447
  * onboard-motion signal (Start/Stop). Smart sub-codes
@@ -50145,6 +51481,8 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
50145
51481
  dayNightRefreshInFlight = null;
50146
51482
  /** Single-flight guard for the `image-settings` camera refresh. */
50147
51483
  imageSettingsRefreshInFlight = null;
51484
+ /** Single-flight guard for the `recording-onboard` camera refresh. */
51485
+ recordingOnboardRefreshInFlight = null;
50148
51486
  /** Channel is 1-based; defaults to the schema's configured value. */
50149
51487
  get channel() {
50150
51488
  return this.config.get("channel") ?? 1;
@@ -50167,6 +51505,47 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
50167
51505
  source: "operator"
50168
51506
  };
50169
51507
  }
51508
+ /**
51509
+ * What the clip service needs to reach this camera's own recordings.
51510
+ *
51511
+ * The CGI client is the DEVICE's, not a second one: it carries a warm
51512
+ * digest state whose nonce-count is monotonic, and a second client for the
51513
+ * clip path would advance a second one against the same camera.
51514
+ *
51515
+ * `readStorage` is the SAME read and the SAME parser the `recording-onboard`
51516
+ * cap uses (D614) — `storageDevice.cgi?action=getDeviceAllInfo` through
51517
+ * `parseOnboardStorage` — mapped into the clip cap's own three states. There
51518
+ * is one storage reader in this addon and this is a caller of it, not a
51519
+ * second one. It is read live rather than taken off the cap's runtime slice
51520
+ * because that slice is `unknown` until the cap's first refresh, and
51521
+ * "CamStack has not read this camera yet" must not reach an operator as
51522
+ * "this camera cannot be reached".
51523
+ */
51524
+ getClipCamera() {
51525
+ const https = this.config.get("https") ?? false;
51526
+ return {
51527
+ deviceId: this.id,
51528
+ host: this.config.get("host"),
51529
+ port: this.config.get("port") ?? (https ? 443 : 80),
51530
+ https,
51531
+ username: this.config.get("username") ?? "admin",
51532
+ password: this.config.get("password"),
51533
+ channel: this.channel,
51534
+ client: this.ensureClient(),
51535
+ readStorage: async () => {
51536
+ let table;
51537
+ try {
51538
+ table = await this.ensureClient().getStorageDeviceAllInfo();
51539
+ } catch (err) {
51540
+ return {
51541
+ state: "unreachable",
51542
+ reason: `The camera did not answer the storage read: ${err instanceof Error ? err.message : String(err)}`
51543
+ };
51544
+ }
51545
+ return availabilityFromStorage(parseOnboardStorage(table));
51546
+ }
51547
+ };
51548
+ }
50170
51549
  ensureClient() {
50171
51550
  if (this.client) return this.client;
50172
51551
  this.client = this.buildClient();
@@ -51673,6 +53052,238 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
51673
53052
  this.ctx.registerNativeCap(imageSettingsCapability, provider);
51674
53053
  this.ctx.logger.info("amcrest: image-settings cap registered", { tags: { deviceId: this.id } });
51675
53054
  }
53055
+ /**
53056
+ * Register the vendor-neutral `recording-onboard` cap.
53057
+ *
53058
+ * The camera's own card and the camera's own schedule — not CamStack's
53059
+ * recorder. The whole surface rides the existing digest CGI client:
53060
+ * `storageDevice.cgi?action=getDeviceAllInfo` serves the card,
53061
+ * `configManager.cgi` serves `Record` / `RecordMode` / `MediaGlobal` /
53062
+ * `StorageGroup` / `MotionDetect`, and `recordManager.cgi` +
53063
+ * `eventManager.cgi` serve the limits. No RPC2 session is needed.
53064
+ *
53065
+ * Writes follow D610 whole, and this firmware earns every word of it:
53066
+ *
53067
+ * - **Refused before the wire**, against what the camera STATED —
53068
+ * because `PacketLength=99` and `RecordLatch=999` were both stored
53069
+ * although the camera's own caps forbid them.
53070
+ * - **One request per config TABLE**, because a `setConfig` naming
53071
+ * two answers `200 OK` and applies only the first.
53072
+ * - **Verified after it by re-reading**, per field. Success is the
53073
+ * read-back agreeing, never the absence of a thrown error — the
53074
+ * body reads `Error` under HTTP 200 when a key is refused.
53075
+ */
53076
+ registerRecordingOnboardCap() {
53077
+ const CAP_NAME = "recording-onboard";
53078
+ const configChannel = Math.max(0, this.channel - 1);
53079
+ const emptyStatus = () => ({
53080
+ storage: {
53081
+ kind: "unknown",
53082
+ reason: "CamStack has not read this camera yet."
53083
+ },
53084
+ tracks: [],
53085
+ primaryTrackId: null,
53086
+ enabled: null,
53087
+ overwriteWhenFull: null,
53088
+ preRecordSec: null,
53089
+ postRecordSec: null,
53090
+ segmentMinutes: null,
53091
+ windows: [],
53092
+ unreadableWindows: 0,
53093
+ recordingToNowhere: false,
53094
+ lastFetchedAt: 0
53095
+ });
53096
+ /** The camera's own statement of what it accepts. */
53097
+ let lastLimits = {
53098
+ maxPreRecordSec: null,
53099
+ packetLengthMinutes: null,
53100
+ recordLatchSec: null
53101
+ };
53102
+ /** How many event handlers hold a post-record, and whether they agree. */
53103
+ let lastPostRecord = {
53104
+ handlers: 0,
53105
+ disagree: false
53106
+ };
53107
+ /** `RecordMode[ch].Mode`, so turning recording back ON can preserve `manual`. */
53108
+ let lastRecordMode = null;
53109
+ /** Which `StorageGroup` row is the local card. */
53110
+ let lastStorageGroupIndex = null;
53111
+ /** Read every table this cap needs. A table that failed is null, never empty. */
53112
+ const readAll = async () => {
53113
+ const client = this.ensureClient();
53114
+ const [storageTable, record, recordMode, mediaGlobal, storageGroup, motionDetect] = await Promise.all([
53115
+ client.getStorageDeviceAllInfo().catch(() => null),
53116
+ client.getConfig("Record").catch(() => null),
53117
+ client.getConfig("RecordMode").catch(() => null),
53118
+ client.getConfig("MediaGlobal").catch(() => null),
53119
+ client.getConfig("StorageGroup").catch(() => null),
53120
+ client.getConfig("MotionDetect").catch(() => null)
53121
+ ]);
53122
+ return {
53123
+ storage: storageTable === null ? {
53124
+ kind: "unknown",
53125
+ reason: "The camera did not answer the storage read."
53126
+ } : parseOnboardStorage(storageTable),
53127
+ record,
53128
+ recordMode,
53129
+ mediaGlobal,
53130
+ storageGroup,
53131
+ motionDetect,
53132
+ channel: configChannel
53133
+ };
53134
+ };
53135
+ const applyReads = (reads) => {
53136
+ lastRecordMode = reads.recordMode === null ? null : Number(reads.recordMode.get(`RecordMode[${String(configChannel)}].Mode`) ?? NaN);
53137
+ if (lastRecordMode !== null && !Number.isFinite(lastRecordMode)) lastRecordMode = null;
53138
+ lastStorageGroupIndex = reads.storageGroup === null ? null : findLocalStorageGroup(reads.storageGroup);
53139
+ const post = readPostRecordSec(reads.motionDetect);
53140
+ lastPostRecord = {
53141
+ handlers: post.handlers,
53142
+ disagree: post.disagree
53143
+ };
53144
+ return buildOnboardStatus({
53145
+ reads,
53146
+ now: Date.now()
53147
+ });
53148
+ };
53149
+ const refreshFromCamera = async () => {
53150
+ if (this.recordingOnboardRefreshInFlight) return this.recordingOnboardRefreshInFlight;
53151
+ const promise = (async () => {
53152
+ try {
53153
+ const client = this.ensureClient();
53154
+ const [reads, recordCaps, eventCaps] = await Promise.all([
53155
+ readAll(),
53156
+ client.getRecordManagerCaps().catch(() => null),
53157
+ client.getEventManagerCaps().catch(() => null)
53158
+ ]);
53159
+ lastLimits = parseRecordLimits({
53160
+ recordCaps,
53161
+ eventCaps
53162
+ });
53163
+ const next = applyReads(reads);
53164
+ this.runtimeState.setCapState(CAP_NAME, next);
53165
+ if (next.recordingToNowhere) this.ctx.logger.warn("amcrest onboard recording: camera is scheduled to record and has no usable storage", {
53166
+ tags: { deviceId: this.id },
53167
+ meta: {
53168
+ windows: next.windows.length,
53169
+ unreadable: next.unreadableWindows
53170
+ }
53171
+ });
53172
+ if (next.unreadableWindows > 0) this.ctx.logger.warn("amcrest onboard recording: some schedule slots could not be read — the schedule shown is incomplete and cannot be written", {
53173
+ tags: { deviceId: this.id },
53174
+ meta: {
53175
+ unreadableWindows: next.unreadableWindows,
53176
+ readWindows: next.windows.length
53177
+ }
53178
+ });
53179
+ } catch (err) {
53180
+ this.ctx.logger.debug("amcrest onboard recording refresh failed — keeping last slice", {
53181
+ tags: { deviceId: this.id },
53182
+ meta: { error: err instanceof Error ? err.message : String(err) }
53183
+ });
53184
+ }
53185
+ })();
53186
+ this.recordingOnboardRefreshInFlight = promise;
53187
+ try {
53188
+ await promise;
53189
+ } finally {
53190
+ this.recordingOnboardRefreshInFlight = null;
53191
+ }
53192
+ };
53193
+ const bridge = createRuntimeStateBridge({
53194
+ runtimeState: this.runtimeState,
53195
+ cap: recordingOnboardCapability,
53196
+ ownDeviceId: this.id,
53197
+ refresh: refreshFromCamera,
53198
+ staleMs: OPERATOR_WRITTEN_STALE_MS,
53199
+ staleRead: "serve-and-revalidate",
53200
+ logger: this.ctx.logger,
53201
+ empty: emptyStatus
53202
+ });
53203
+ const readOptions = () => buildOnboardOptions({
53204
+ status: this.runtimeState.getCapState(CAP_NAME) ?? null,
53205
+ limits: lastLimits,
53206
+ postRecord: lastPostRecord
53207
+ });
53208
+ const provider = {
53209
+ getStatus: bridge.getStatus,
53210
+ getOptions: async ({ deviceId }) => {
53211
+ if (deviceId !== this.id) throw new Error(`AmcrestCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
53212
+ await bridge.ensureFresh();
53213
+ return readOptions();
53214
+ },
53215
+ setSettings: async ({ deviceId, settings }) => {
53216
+ if (deviceId !== this.id) return;
53217
+ if (Object.keys(settings).length === 0) return;
53218
+ await bridge.ensureFresh();
53219
+ const refusal = describeOnboardRefusal(readOptions(), settings) ?? (settings.windows === void 0 ? null : describeAmcrestScheduleRefusal(settings.windows));
53220
+ if (refusal !== null) {
53221
+ this.ctx.logger.warn("amcrest onboard recording: refusing a write this camera cannot take", {
53222
+ tags: { deviceId: this.id },
53223
+ meta: {
53224
+ refusal,
53225
+ fields: Object.keys(settings)
53226
+ }
53227
+ });
53228
+ throw new Error(`device ${String(this.id)}: ${refusal}`);
53229
+ }
53230
+ const writes = buildOnboardWrites(settings, {
53231
+ channel: configChannel,
53232
+ recordMode: lastRecordMode,
53233
+ storageGroupIndex: lastStorageGroupIndex
53234
+ });
53235
+ const client = this.ensureClient();
53236
+ const rejected = [];
53237
+ for (const write of writes) try {
53238
+ await client.setConfig(write.params);
53239
+ } catch (err) {
53240
+ rejected.push(`${write.table}: ${err instanceof Error ? err.message : String(err)}`);
53241
+ }
53242
+ const after = await readAll();
53243
+ this.runtimeState.setCapState(CAP_NAME, applyReads(after));
53244
+ const byTable = {
53245
+ Record: after.record,
53246
+ RecordMode: after.recordMode,
53247
+ MediaGlobal: after.mediaGlobal,
53248
+ StorageGroup: after.storageGroup,
53249
+ MotionDetect: after.motionDetect
53250
+ };
53251
+ const discarded = [];
53252
+ for (const write of writes) {
53253
+ const table = byTable[write.table] ?? null;
53254
+ if (table === null) {
53255
+ discarded.push(`${write.table}: the camera would not say what it stored`);
53256
+ continue;
53257
+ }
53258
+ for (const [field, check] of Object.entries(write.verify)) {
53259
+ const stored = table.get(check.key);
53260
+ if (stored === check.expect) continue;
53261
+ discarded.push(`${field}: asked ${check.expect}, camera holds ${stored ?? "(nothing)"}`);
53262
+ }
53263
+ }
53264
+ if (rejected.length > 0 || discarded.length > 0) {
53265
+ this.ctx.logger.warn("amcrest onboard recording: the camera did not keep part of the write", {
53266
+ tags: { deviceId: this.id },
53267
+ meta: {
53268
+ rejected,
53269
+ discarded,
53270
+ fields: Object.keys(settings)
53271
+ }
53272
+ });
53273
+ throw new Error(`device ${String(this.id)}: the camera kept its own values — ${[...rejected, ...discarded].join("; ")}`);
53274
+ }
53275
+ this.ctx.logger.info("amcrest onboard recording: write applied and verified", {
53276
+ tags: { deviceId: this.id },
53277
+ meta: {
53278
+ fields: Object.keys(settings),
53279
+ requests: writes.length
53280
+ }
53281
+ });
53282
+ }
53283
+ };
53284
+ this.ctx.registerNativeCap(recordingOnboardCapability, provider);
53285
+ this.ctx.logger.info("amcrest: recording-onboard cap registered", { tags: { deviceId: this.id } });
53286
+ }
51676
53287
  };
51677
53288
  /** Normalize Dahua's `Video.Compression` string (`'H.264'`, `'H.265'`,
51678
53289
  * `'MJPG'`, …) into the cap's `'h264' | 'h265'` enum. Returns
@@ -51705,6 +53316,1987 @@ function collectAudioEnableKeys(table) {
51705
53316
  for (const key of table.keys()) if (/^Encode\[0\]\.(?:MainFormat|ExtraFormat)\[\d+\]\.AudioEnable$/.test(key)) out.push(key);
51706
53317
  return out.toSorted();
51707
53318
  }
53319
+ var EMPTY_PARAMS = Object.freeze({});
53320
+ function isRecord$1(value) {
53321
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53322
+ }
53323
+ /**
53324
+ * Error codes that mean "your session is gone", so one silent re-login is the
53325
+ * right answer rather than a refusal the operator reads.
53326
+ *
53327
+ * Deliberately a SHORT list. An unknown code is a refusal — guessing that any
53328
+ * failure is a stale session turns one camera refusal into two round-trips and
53329
+ * the same refusal, and `findFile`'s 285409284 (which means "nothing matched,
53330
+ * or your condition was malformed") would then be retried on every empty day.
53331
+ */
53332
+ var RPC2_SESSION_LOST_CODES = new Set([268632064, 268632081]);
53333
+ function md5Upper(input) {
53334
+ return (0, node_crypto.createHash)("md5").update(input, "utf8").digest("hex").toUpperCase();
53335
+ }
53336
+ /**
53337
+ * A minimal Dahua RPC2 client. One per camera.
53338
+ *
53339
+ * It exposes exactly what the clip catalog needs — `call`, plus the login
53340
+ * `call` performs for itself. There is no generic "raw request" escape hatch:
53341
+ * a second way into this protocol is a second place to get the session
53342
+ * discipline wrong.
53343
+ */
53344
+ var AmcrestRpc2Client = class {
53345
+ base;
53346
+ username;
53347
+ password;
53348
+ timeoutMs;
53349
+ fetchImpl;
53350
+ now;
53351
+ session = null;
53352
+ nextId = 0;
53353
+ loginInFlight = null;
53354
+ constructor(opts) {
53355
+ this.base = `${opts.https ? "https" : "http"}://${opts.host}:${String(opts.port)}`;
53356
+ this.username = opts.username;
53357
+ this.password = opts.password;
53358
+ this.timeoutMs = opts.defaultTimeoutMs ?? 15e3;
53359
+ this.fetchImpl = opts.fetchImpl ?? fetch;
53360
+ this.now = opts.now ?? (() => Date.now());
53361
+ }
53362
+ /**
53363
+ * One RPC2 call, logging in first when there is no live session.
53364
+ *
53365
+ * `object` is the find-object handle for the `mediaFileFind.*` methods that
53366
+ * take one; absent for the factory and for `global.*`.
53367
+ */
53368
+ async call(method, params, object) {
53369
+ const session = await this.ensureSession();
53370
+ if (session.kind === "unreachable") return {
53371
+ kind: "unreachable",
53372
+ detail: session.detail
53373
+ };
53374
+ if (session.kind === "refused") return {
53375
+ kind: "refused",
53376
+ error: session.error,
53377
+ params: EMPTY_PARAMS,
53378
+ session: null
53379
+ };
53380
+ const first = await this.post("/RPC2", method, params, object, session.session);
53381
+ if (first.kind === "refused" && first.error.code !== null && RPC2_SESSION_LOST_CODES.has(first.error.code)) {
53382
+ this.session = null;
53383
+ const fresh = await this.ensureSession();
53384
+ if (fresh.kind !== "ok") return first;
53385
+ return await this.post("/RPC2", method, params, object, fresh.session);
53386
+ }
53387
+ return first;
53388
+ }
53389
+ /** Drop the session and tell the camera, best-effort. For a client whose
53390
+ * device is going away. */
53391
+ async logout() {
53392
+ const session = this.session;
53393
+ this.session = null;
53394
+ if (session === null) return;
53395
+ await this.post("/RPC2", "global.logout", null, void 0, session.id);
53396
+ }
53397
+ /** Forget the cached session WITHOUT telling the camera — for a client whose
53398
+ * host or credentials just changed under it, where the old session id means
53399
+ * nothing at the new address. */
53400
+ reset() {
53401
+ this.session = null;
53402
+ }
53403
+ async ensureSession() {
53404
+ const live = this.session;
53405
+ if (live !== null && this.now() - live.mintedAtMs < 3e4) return {
53406
+ kind: "ok",
53407
+ session: live.id
53408
+ };
53409
+ this.session = null;
53410
+ const inFlight = this.loginInFlight;
53411
+ if (inFlight !== null) return await inFlight;
53412
+ const promise = this.login();
53413
+ this.loginInFlight = promise;
53414
+ try {
53415
+ return await promise;
53416
+ } finally {
53417
+ this.loginInFlight = null;
53418
+ }
53419
+ }
53420
+ async login() {
53421
+ const challenge = await this.post("/RPC2_Login", "global.login", {
53422
+ userName: this.username,
53423
+ password: "",
53424
+ clientType: "Web3.0"
53425
+ }, void 0, null);
53426
+ if (challenge.kind === "unreachable") return {
53427
+ kind: "unreachable",
53428
+ detail: challenge.detail
53429
+ };
53430
+ const sessionId = challenge.session;
53431
+ const realm = challenge.params["realm"];
53432
+ const random = challenge.params["random"];
53433
+ if (sessionId === null || typeof realm !== "string" || typeof random !== "string" || realm === "" || random === "") return {
53434
+ kind: "refused",
53435
+ error: challenge.kind === "refused" ? challenge.error : {
53436
+ code: null,
53437
+ message: "the login challenge carried no realm/random"
53438
+ }
53439
+ };
53440
+ const inner = md5Upper(`${this.username}:${realm}:${this.password}`);
53441
+ const hashed = md5Upper(`${this.username}:${random}:${inner}`);
53442
+ const confirmed = await this.post("/RPC2_Login", "global.login", {
53443
+ userName: this.username,
53444
+ password: hashed,
53445
+ clientType: "Web3.0",
53446
+ loginType: "Direct",
53447
+ authorityType: "Default",
53448
+ passwordType: "Default"
53449
+ }, void 0, sessionId);
53450
+ if (confirmed.kind === "unreachable") return {
53451
+ kind: "unreachable",
53452
+ detail: confirmed.detail
53453
+ };
53454
+ if (confirmed.kind === "refused") return {
53455
+ kind: "refused",
53456
+ error: confirmed.error
53457
+ };
53458
+ this.session = {
53459
+ id: sessionId,
53460
+ mintedAtMs: this.now()
53461
+ };
53462
+ return {
53463
+ kind: "ok",
53464
+ session: sessionId
53465
+ };
53466
+ }
53467
+ async post(path, method, params, object, session) {
53468
+ this.nextId += 1;
53469
+ const body = {
53470
+ method,
53471
+ params,
53472
+ id: this.nextId
53473
+ };
53474
+ if (object !== void 0) body["object"] = object;
53475
+ if (session !== null) body["session"] = session;
53476
+ const controller = new AbortController();
53477
+ const timer = setTimeout(() => {
53478
+ controller.abort();
53479
+ }, this.timeoutMs);
53480
+ try {
53481
+ const res = await this.fetchImpl(`${this.base}${path}`, {
53482
+ method: "POST",
53483
+ headers: { "content-type": "application/json" },
53484
+ body: JSON.stringify(body),
53485
+ signal: controller.signal
53486
+ });
53487
+ const text = await res.text();
53488
+ let parsed;
53489
+ try {
53490
+ parsed = JSON.parse(text);
53491
+ } catch {
53492
+ return {
53493
+ kind: "unreachable",
53494
+ detail: `RPC2 ${method} answered HTTP ${String(res.status)} with a body that is not JSON`
53495
+ };
53496
+ }
53497
+ if (!isRecord$1(parsed)) return {
53498
+ kind: "unreachable",
53499
+ detail: `RPC2 ${method} answered a non-object body`
53500
+ };
53501
+ const answeredParams = isRecord$1(parsed["params"]) ? parsed["params"] : EMPTY_PARAMS;
53502
+ const answeredSession = readSessionId(parsed["session"]);
53503
+ const answeredResult = parsed["result"];
53504
+ if (answeredResult !== false && answeredResult !== void 0 && answeredResult !== null) return {
53505
+ kind: "ok",
53506
+ params: answeredParams,
53507
+ session: answeredSession,
53508
+ result: answeredResult
53509
+ };
53510
+ return {
53511
+ kind: "refused",
53512
+ error: readError(parsed["error"]),
53513
+ params: answeredParams,
53514
+ session: answeredSession
53515
+ };
53516
+ } catch (err) {
53517
+ return {
53518
+ kind: "unreachable",
53519
+ detail: err instanceof Error ? err.message : String(err)
53520
+ };
53521
+ } finally {
53522
+ clearTimeout(timer);
53523
+ }
53524
+ }
53525
+ };
53526
+ function readSessionId(raw) {
53527
+ if (typeof raw === "string" && raw !== "") return raw;
53528
+ if (typeof raw === "number") return String(raw);
53529
+ return null;
53530
+ }
53531
+ function readError(raw) {
53532
+ if (!isRecord$1(raw)) return {
53533
+ code: null,
53534
+ message: ""
53535
+ };
53536
+ return {
53537
+ code: typeof raw["code"] === "number" ? raw["code"] : null,
53538
+ message: typeof raw["message"] === "string" ? raw["message"] : ""
53539
+ };
53540
+ }
53541
+ /**
53542
+ * A clip key is `<startSec>-<endSec>` (`clip-identity.ts`). Anything else is
53543
+ * REFUSED rather than joined: a key reaches this from a URL path segment, and
53544
+ * a `..` in it is a write outside the scratch.
53545
+ */
53546
+ var SAFE_KEY = /^[0-9]{1,20}-[0-9]{1,20}$/;
53547
+ var KIND = "clip-media";
53548
+ var EXTENSION = ".mp4";
53549
+ var ClipArtifactStore = class {
53550
+ options;
53551
+ maxFiles;
53552
+ maxBytes;
53553
+ constructor(options) {
53554
+ this.options = options;
53555
+ this.maxFiles = options.maxFilesPerDevice ?? 8;
53556
+ this.maxBytes = options.maxBytesPerDevice ?? 419430400;
53557
+ }
53558
+ /** Is this a key this store will touch at all? Exposed so a ROUTE can refuse
53559
+ * a bad segment by name before it reaches the filesystem. */
53560
+ static isSafeKey(clipKey) {
53561
+ return SAFE_KEY.test(clipKey);
53562
+ }
53563
+ dirFor(deviceId) {
53564
+ return (0, node_path.join)(this.options.root, KIND, String(deviceId));
53565
+ }
53566
+ pathFor(deviceId, clipKey) {
53567
+ return (0, node_path.join)(this.dirFor(deviceId), `${clipKey}${EXTENSION}`);
53568
+ }
53569
+ /** A scratch path to write INTO, with its directory made. */
53570
+ async reserve(deviceId, clipKey) {
53571
+ if (!SAFE_KEY.test(clipKey)) throw new Error(`clip artifact store: refused key "${clipKey}" — not a safe artifact key`);
53572
+ await (0, node_fs_promises.mkdir)(this.dirFor(deviceId), { recursive: true });
53573
+ return `${this.pathFor(deviceId, clipKey)}.${String(process.pid)}.part`;
53574
+ }
53575
+ /** Present AND carrying bytes — never a memory of having written one. */
53576
+ async pathIfPresent(deviceId, clipKey) {
53577
+ if (!SAFE_KEY.test(clipKey)) return null;
53578
+ const path = this.pathFor(deviceId, clipKey);
53579
+ try {
53580
+ const info = await (0, node_fs_promises.stat)(path);
53581
+ return info.isFile() && info.size > 0 ? path : null;
53582
+ } catch {
53583
+ return null;
53584
+ }
53585
+ }
53586
+ /**
53587
+ * Publish a reserved path under its key and enforce the bounds.
53588
+ *
53589
+ * A key already present wins: artifacts are immutable, and the temp file is
53590
+ * removed rather than renamed over it.
53591
+ */
53592
+ async publish(deviceId, clipKey, tempPath) {
53593
+ const existing = await this.pathIfPresent(deviceId, clipKey);
53594
+ if (existing !== null) {
53595
+ await (0, node_fs_promises.rm)(tempPath, { force: true });
53596
+ return existing;
53597
+ }
53598
+ const path = this.pathFor(deviceId, clipKey);
53599
+ await (0, node_fs_promises.rename)(tempPath, path);
53600
+ await this.enforceBounds(deviceId);
53601
+ return path;
53602
+ }
53603
+ async forgetDevice(deviceId) {
53604
+ await (0, node_fs_promises.rm)(this.dirFor(deviceId), {
53605
+ recursive: true,
53606
+ force: true
53607
+ });
53608
+ }
53609
+ async listEntries(deviceId) {
53610
+ let names;
53611
+ try {
53612
+ names = await (0, node_fs_promises.readdir)(this.dirFor(deviceId));
53613
+ } catch {
53614
+ return [];
53615
+ }
53616
+ const out = [];
53617
+ for (const name of names) {
53618
+ if (!name.endsWith(EXTENSION)) continue;
53619
+ const path = (0, node_path.join)(this.dirFor(deviceId), name);
53620
+ try {
53621
+ const info = await (0, node_fs_promises.stat)(path);
53622
+ if (info.isFile()) out.push({
53623
+ path,
53624
+ bytes: info.size,
53625
+ mtimeMs: info.mtimeMs
53626
+ });
53627
+ } catch {}
53628
+ }
53629
+ return out;
53630
+ }
53631
+ /** Oldest-first eviction until BOTH bounds hold. */
53632
+ async enforceBounds(deviceId) {
53633
+ const entries = [...await this.listEntries(deviceId)].toSorted((a, b) => a.mtimeMs - b.mtimeMs);
53634
+ let files = entries.length;
53635
+ let bytes = entries.reduce((sum, entry) => sum + entry.bytes, 0);
53636
+ for (const entry of entries) {
53637
+ if (files <= this.maxFiles && bytes <= this.maxBytes) break;
53638
+ try {
53639
+ await (0, node_fs_promises.rm)(entry.path, { force: true });
53640
+ files -= 1;
53641
+ bytes -= entry.bytes;
53642
+ } catch {}
53643
+ }
53644
+ }
53645
+ };
53646
+ //#endregion
53647
+ //#region src/clips/clip-clock.ts
53648
+ /**
53649
+ * The camera's clock, and the ONE place a time crosses between it and ours.
53650
+ *
53651
+ * **This is the trap that would put every clip two hours away.** Hikvision
53652
+ * stamps its catalog in UTC with a `Z`. Dahua does not: `mediaFileFind`'s
53653
+ * `StartTime`/`EndTime`, and the `starttime`/`endtime` that `/cam/playback`
53654
+ * accepts, are the camera's own LOCAL wall clock with no zone on them.
53655
+ * Measured 2026-09-23 on device 3836: `global.cgi?action=getCurrentTime`
53656
+ * answered `2026-09-23 11:29:27` while the host's UTC was `09:29:27.711`.
53657
+ *
53658
+ * ## Why the offset is READ and never inferred
53659
+ *
53660
+ * Two shortcuts both fail:
53661
+ *
53662
+ * - **The node's own timezone.** The addon runs wherever the runner runs; a
53663
+ * hub in a UTC container would read the camera's strings two hours early
53664
+ * and ask `/cam/playback` for footage that does not exist yet.
53665
+ * - **The camera's configured `NTP.TimeZone`.** It is a Dahua zone INDEX, it
53666
+ * says nothing about DST on the day in question, and this addon already
53667
+ * knows one Dahua index table is not a fact about the wire.
53668
+ *
53669
+ * So the offset is MEASURED: `getCurrentTime` against our own `Date.now()`.
53670
+ *
53671
+ * ## The offset is RAW, not rounded to a timezone
53672
+ *
53673
+ * It is tempting to round the measured difference to the nearest 15 minutes
53674
+ * and call it a UTC offset. That is wrong for this purpose. What the catalog
53675
+ * needs is a map between *the camera's clock* and *ours* — and a camera whose
53676
+ * clock has drifted ten minutes stamps its files ten minutes off. Carrying the
53677
+ * drift is what makes "the window the operator is looking at" and "the window
53678
+ * the camera will serve" the same window. Rounding it away would reintroduce
53679
+ * exactly the error it was meant to remove.
53680
+ *
53681
+ * A LARGE disagreement is still worth saying out loud, because it is an
53682
+ * operator-visible fault on the camera and not a fact about clips — see
53683
+ * {@link CAMERA_CLOCK_DISAGREEMENT_WARN_MS}. It is not a refusal: the map is
53684
+ * self-consistent at any offset, and refusing would break a working camera
53685
+ * over a cosmetic complaint.
53686
+ *
53687
+ * Pure. No I/O — the caller does the read and hands the two numbers in.
53688
+ */
53689
+ /**
53690
+ * How long one clock read is trusted.
53691
+ *
53692
+ * Long enough that a listing does not pay for it every time, short enough that
53693
+ * a DST rollover — which moves the offset by a whole hour — is corrected
53694
+ * within one refresh rather than sending every subsequent search an hour
53695
+ * astray.
53696
+ */
53697
+ var CAMERA_CLOCK_STALE_MS = 10 * 6e4;
53698
+ /**
53699
+ * Past this, the camera's clock and ours disagree by more than any timezone
53700
+ * plus a plausible drift, so the difference is a camera fault worth a line.
53701
+ * 15 hours: the widest real UTC offset is +14, and this leaves room for it.
53702
+ */
53703
+ var CAMERA_CLOCK_DISAGREEMENT_WARN_MS = 900 * 6e4;
53704
+ /**
53705
+ * The raw body of `global.cgi?action=getCurrentTime`, which answers
53706
+ * `result=2026-09-23 11:29:27`. `null` when there is no timestamp in it — a
53707
+ * read that failed is not a reading (D393), and the caller must refuse rather
53708
+ * than fall back to its own clock.
53709
+ */
53710
+ function parseCurrentTimeBody(body) {
53711
+ return /result\s*=\s*(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/.exec(body)?.[1]?.replace("T", " ") ?? null;
53712
+ }
53713
+ /**
53714
+ * A Dahua wall-clock string read AS IF it were UTC — the intermediate every
53715
+ * conversion goes through. `null` on anything that is not the exact shape,
53716
+ * including the empty string and a date the calendar does not hold.
53717
+ */
53718
+ function parseWallClockAsUtcMs(wallClock) {
53719
+ const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/.exec(wallClock.trim());
53720
+ if (match === null) return null;
53721
+ const [, y, mo, d, h, mi, s] = match;
53722
+ if (y === void 0 || mo === void 0 || d === void 0) return null;
53723
+ if (h === void 0 || mi === void 0 || s === void 0) return null;
53724
+ const year = Number(y);
53725
+ const month = Number(mo);
53726
+ const day = Number(d);
53727
+ const hour = Number(h);
53728
+ const minute = Number(mi);
53729
+ const second = Number(s);
53730
+ if (month < 1 || month > 12 || day < 1 || day > 31) return null;
53731
+ if (hour > 23 || minute > 59 || second > 59) return null;
53732
+ const ms = Date.UTC(year, month - 1, day, hour, minute, second);
53733
+ const back = new Date(ms);
53734
+ if (back.getUTCFullYear() !== year || back.getUTCMonth() !== month - 1) return null;
53735
+ if (back.getUTCDate() !== day) return null;
53736
+ return ms;
53737
+ }
53738
+ /**
53739
+ * The measured map. `null` when the camera's answer could not be parsed —
53740
+ * never a zero offset, which is the same shape as "the camera is on UTC" and
53741
+ * would silently move an archive by the timezone it really has.
53742
+ */
53743
+ function deriveCameraClock(input) {
53744
+ const wall = parseCurrentTimeBody(input.currentTimeBody);
53745
+ if (wall === null) return null;
53746
+ const asUtc = parseWallClockAsUtcMs(wall);
53747
+ if (asUtc === null) return null;
53748
+ const hostAt = ((input.hostUtcBeforeMs ?? input.hostUtcAfterMs) + input.hostUtcAfterMs) / 2;
53749
+ return {
53750
+ offsetMs: Math.round((asUtc + 500 - hostAt) / 1e3) * 1e3,
53751
+ readAtMs: input.hostUtcAfterMs
53752
+ };
53753
+ }
53754
+ /** Is this measurement still worth using? */
53755
+ function cameraClockIsFresh(clock, nowMs) {
53756
+ return nowMs - clock.readAtMs < CAMERA_CLOCK_STALE_MS;
53757
+ }
53758
+ /** Does the difference deserve an operator-facing line? */
53759
+ function cameraClockDisagrees(clock) {
53760
+ return Math.abs(clock.offsetMs) > CAMERA_CLOCK_DISAGREEMENT_WARN_MS;
53761
+ }
53762
+ /** An epoch instant as the camera's own wall clock — what a search condition
53763
+ * and a `/cam/playback` query string both take. */
53764
+ function toCameraWallClock(epochMs, clock) {
53765
+ const shifted = new Date(epochMs + clock.offsetMs);
53766
+ const pad = (value, width) => String(value).padStart(width, "0");
53767
+ return `${pad(shifted.getUTCFullYear(), 4)}-${pad(shifted.getUTCMonth() + 1, 2)}-${pad(shifted.getUTCDate(), 2)} ${pad(shifted.getUTCHours(), 2)}:${pad(shifted.getUTCMinutes(), 2)}:${pad(shifted.getUTCSeconds(), 2)}`;
53768
+ }
53769
+ /** The camera's own wall clock as an epoch instant. `null` on a string this
53770
+ * provider could not read — a row with an unreadable time is dropped BY NAME
53771
+ * rather than placed at the epoch. */
53772
+ function fromCameraWallClock(wallClock, clock) {
53773
+ const asUtc = parseWallClockAsUtcMs(wallClock);
53774
+ if (asUtc === null) return null;
53775
+ return asUtc - clock.offsetMs;
53776
+ }
53777
+ //#endregion
53778
+ //#region src/clips/clip-identity.ts
53779
+ /**
53780
+ * An Amcrest clip's IDENTITY — and it is a TIME WINDOW, which is the one thing
53781
+ * that makes this provider different from its two siblings.
53782
+ *
53783
+ * `native:amcrest:onboard:<startSec>:<endSec>`, epoch seconds, OUR clock.
53784
+ *
53785
+ * ## Why the window and not the file name
53786
+ *
53787
+ * Because the camera accepts two different handles for the same recording and
53788
+ * they are not interchangeable (measured 2026-09-23, device 3836):
53789
+ *
53790
+ * - **`/cam/playback?channel=1&starttime=…&endtime=…`** — RTSP playback. The
53791
+ * ONLY form that serves: `?filename=<FilePath>` answers 404 and so does the
53792
+ * bare path form `rtsp://…/mnt/sd/…mp4`.
53793
+ * - **`/cgi-bin/RPC_Loadfile<FilePath>`** — the bytes. Takes the file path and
53794
+ * nothing else.
53795
+ *
53796
+ * So the window is the identity and the path is a property of the row. A
53797
+ * provider that conflated them would address playback by a handle playback
53798
+ * refuses.
53799
+ *
53800
+ * ## The id is self-contained and the row is still required
53801
+ *
53802
+ * The window in the id is enough to build a playback URI, which makes a
53803
+ * deep-link readable and a log line diagnosable. It deliberately does NOT make
53804
+ * the id sufficient: every method still resolves the catalog ROW, and an id
53805
+ * whose row this node has not listed is `catalog-miss` — the truth ("I have
53806
+ * never heard of it"), not a failure. Otherwise a caller could mint an id for
53807
+ * a window nobody checked exists, and the camera would answer a `DESCRIBE`
53808
+ * 404 that this firmware ALSO uses for "all three playback slots are busy".
53809
+ *
53810
+ * Pure. No I/O, no clock.
53811
+ */
53812
+ /** The camera's own recordings — the one Amcrest source. */
53813
+ var CLIP_SOURCE_ONBOARD = "native:amcrest:onboard";
53814
+ /** Every source this provider can name. Order is the picker's order. */
53815
+ var AMCREST_CLIP_SOURCES = [CLIP_SOURCE_ONBOARD];
53816
+ /**
53817
+ * The key a row is filed under, and the segment a data-plane URL carries.
53818
+ *
53819
+ * `<startSec>-<endSec>` rather than a hash: it is already short, already
53820
+ * unique (two files cannot own the same window on one channel), URL-safe
53821
+ * without encoding, and readable in a log line — which a sha1 is not, on a
53822
+ * provider whose whole identity question is "which window".
53823
+ */
53824
+ function clipKeyFor(startMs, endMs) {
53825
+ return `${String(Math.floor(startMs / 1e3))}-${String(Math.floor(endMs / 1e3))}`;
53826
+ }
53827
+ function mintClipId(parts) {
53828
+ return `${parts.source}:${clipKeyFor(parts.startMs, parts.endMs)}`;
53829
+ }
53830
+ /**
53831
+ * Parse an id this provider minted. `null` for anything else — including an id
53832
+ * from the analytics, Reolink or Hikvision source, which is how the collection
53833
+ * dispatcher's "nobody claimed this id" branch stays honest.
53834
+ */
53835
+ function parseClipId(clipId) {
53836
+ const source = AMCREST_CLIP_SOURCES.find((candidate) => clipId.startsWith(`${candidate}:`));
53837
+ if (source === void 0) return null;
53838
+ const rest = clipId.slice(source.length + 1);
53839
+ const match = /^(\d+)-(\d+)$/.exec(rest);
53840
+ if (match === null) return null;
53841
+ const startSec = Number(match[1]);
53842
+ const endSec = Number(match[2]);
53843
+ if (!Number.isSafeInteger(startSec) || !Number.isSafeInteger(endSec)) return null;
53844
+ if (endSec <= startSec) return null;
53845
+ return {
53846
+ source,
53847
+ startMs: startSec * 1e3,
53848
+ endMs: endSec * 1e3
53849
+ };
53850
+ }
53851
+ /** The key a clip id names, without re-deriving the arithmetic. */
53852
+ function clipKeyOf(parts) {
53853
+ return clipKeyFor(parts.startMs, parts.endMs);
53854
+ }
53855
+ //#endregion
53856
+ //#region src/clips/clip-search.ts
53857
+ /**
53858
+ * One `mediaFileFind` row, read. Pure: no RPC, no clock read, no logger.
53859
+ *
53860
+ * ## `Summary` is never read, and that is a hard rule
53861
+ *
53862
+ * Every row this camera returns carries, on a plain `VideoMotion` recording in
53863
+ * a living room, on a camera with no ANPR licence:
53864
+ *
53865
+ * ```json
53866
+ * "Summary": { "TrafficCar": { "PlateNumber": " ", "PlateColor": "Yellow",
53867
+ * "PlateType": "Yellow", "Speed": 60,
53868
+ * "VehicleColor": "White" } }
53869
+ * ```
53870
+ *
53871
+ * `PlateNumber` is a single space and `Speed` is a constant 60 on all 60 rows
53872
+ * measured. It is an uninitialised struct serialised as data. A provider that
53873
+ * surfaced it would invent a licence-plate read and a 60 km/h vehicle on every
53874
+ * motion clip. {@link parseClipRow} reads `Events` and `Flags` and nothing
53875
+ * else about what happened.
53876
+ *
53877
+ * ## `Type` is an ECHO of the filter, not a fact about the file
53878
+ *
53879
+ * Measured 2026-09-23: the SAME recording comes back as `Type: "dav"` when the
53880
+ * search asked `Types: ["dav"]` and as `Type: "mp4"` when it asked
53881
+ * `Types: ["mp4"]` — identical `FilePath` in both, which really ends `.mp4`
53882
+ * and really is an ISO MP4. So the field is not carried onto the row at all.
53883
+ * The filter still matters enormously — see {@link buildFindCondition}.
53884
+ *
53885
+ * ## `Length` is the file, `Duration` is the WINDOW
53886
+ *
53887
+ * `Duration` is the wall-clock span between `StartTime` and `EndTime`. The
53888
+ * container inside runs about 7.5 % shorter (74 s row → 68.433 s media,
53889
+ * measured with `ffprobe`). Both are carried, separately, and a completion
53890
+ * check that compares the delivered media against the ROW naively would fire
53891
+ * on every clip this camera makes.
53892
+ */
53893
+ /**
53894
+ * The file kinds a CLIP may be.
53895
+ *
53896
+ * **This filter is not cosmetic.** An unfiltered `Flags: ["Event"]` search
53897
+ * over the same window returned 60 rows of which **45 were single JPEG
53898
+ * snapshots** — `Duration: 0`, `StartTime === EndTime`, ~250 KB each. Listing
53899
+ * them would hand the operator three quarters of a catalog that cannot be
53900
+ * played, with a zero-length window that `/cam/playback` has nothing to serve.
53901
+ * `Types: ["dav"]` returned exactly the 15 recordings.
53902
+ */
53903
+ var CLIP_FIND_TYPES = ["dav"];
53904
+ /**
53905
+ * The record reasons a clip may carry.
53906
+ *
53907
+ * All three, and not just `Event`, because the filter is what the camera
53908
+ * matches on: a camera scheduled for CONTINUOUS recording writes `Timing`
53909
+ * rows, and an `Event`-only search on such a camera would report an empty card
53910
+ * as confidently as a real one. Measured: a three-flag condition returns the
53911
+ * same 15 rows an `Event`-only one does on this camera, so the wider filter
53912
+ * costs nothing where it is not needed.
53913
+ */
53914
+ var CLIP_FIND_FLAGS = [
53915
+ "Timing",
53916
+ "Event",
53917
+ "Manual"
53918
+ ];
53919
+ function isRecord(value) {
53920
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53921
+ }
53922
+ function readStrings(value) {
53923
+ if (!Array.isArray(value)) return [];
53924
+ const out = [];
53925
+ for (const entry of value) if (typeof entry === "string" && entry !== "") out.push(entry);
53926
+ return out;
53927
+ }
53928
+ /**
53929
+ * A positive finite number, or `null`.
53930
+ *
53931
+ * `0` becomes `null` on purpose for `Length`: a zero-byte recording is not a
53932
+ * measurement, and a bound check that reads it as "comfortably under the
53933
+ * ceiling" would let an unmeasurable file through the one gate that exists to
53934
+ * stop it.
53935
+ */
53936
+ function readPositiveNumber(value) {
53937
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
53938
+ return value;
53939
+ }
53940
+ function parseClipRow(raw, clock) {
53941
+ if (!isRecord(raw)) return {
53942
+ kind: "rejected",
53943
+ reason: "no-file-path"
53944
+ };
53945
+ const filePath = raw["FilePath"];
53946
+ if (typeof filePath !== "string" || filePath === "") return {
53947
+ kind: "rejected",
53948
+ reason: "no-file-path"
53949
+ };
53950
+ const startRaw = raw["StartTime"];
53951
+ const endRaw = raw["EndTime"];
53952
+ const startMs = typeof startRaw === "string" ? fromCameraWallClock(startRaw, clock) : null;
53953
+ if (startMs === null) return {
53954
+ kind: "rejected",
53955
+ reason: "unreadable-start"
53956
+ };
53957
+ const endMs = typeof endRaw === "string" ? fromCameraWallClock(endRaw, clock) : null;
53958
+ if (endMs === null) return {
53959
+ kind: "rejected",
53960
+ reason: "unreadable-end"
53961
+ };
53962
+ if (endMs <= startMs) return {
53963
+ kind: "rejected",
53964
+ reason: "not-a-window"
53965
+ };
53966
+ const declaredDuration = readPositiveNumber(raw["Duration"]);
53967
+ const channelRaw = raw["Channel"];
53968
+ return {
53969
+ kind: "ok",
53970
+ row: {
53971
+ clipKey: clipKeyFor(startMs, endMs),
53972
+ startMs,
53973
+ endMs,
53974
+ windowMs: declaredDuration !== null ? declaredDuration * 1e3 : endMs - startMs,
53975
+ sizeBytes: readPositiveNumber(raw["Length"]),
53976
+ filePath,
53977
+ events: readStrings(raw["Events"]),
53978
+ flags: readStrings(raw["Flags"]),
53979
+ channel: typeof channelRaw === "number" && Number.isInteger(channelRaw) ? channelRaw : 0
53980
+ }
53981
+ };
53982
+ }
53983
+ function buildFindCondition(input) {
53984
+ return {
53985
+ Channel: input.channel,
53986
+ StartTime: input.startWallClock,
53987
+ EndTime: input.endWallClock,
53988
+ Flags: CLIP_FIND_FLAGS,
53989
+ Types: CLIP_FIND_TYPES
53990
+ };
53991
+ }
53992
+ /**
53993
+ * Has the walk reached the end?
53994
+ *
53995
+ * `found < count` and nothing else. **`mediaFileFind.getCount` must never be
53996
+ * called**: over CGI it is `501 Not Implemented`, and over RPC2 it is worse —
53997
+ * measured answering `{"params":{"count":0},"result":true}` on a find object
53998
+ * whose `findFile` had just succeeded and from which rows were then
53999
+ * enumerated. That is an authoritative-looking zero from a method that
54000
+ * answered successfully, which is the exact shape D393 forbids, produced by
54001
+ * the camera itself.
54002
+ */
54003
+ function findWalkIsComplete(found, asked) {
54004
+ return found < asked;
54005
+ }
54006
+ //#endregion
54007
+ //#region src/clips/clip-catalog.ts
54008
+ /**
54009
+ * The control window — the camera's own wall clock, wide enough that no card
54010
+ * can fall outside it. Measured accepted on this firmware (`findFile` true in
54011
+ * 361 ms).
54012
+ */
54013
+ var CLIP_CONTROL_WINDOW = {
54014
+ start: "1970-01-01 00:00:00",
54015
+ end: "2100-01-01 00:00:00"
54016
+ };
54017
+ function describeError(error) {
54018
+ const code = error.code === null ? "no code" : `code ${String(error.code)}`;
54019
+ return error.message === "" ? `${code}, no message` : `${code}: ${error.message}`;
54020
+ }
54021
+ async function runSearch(deps, input) {
54022
+ const tags = { deviceId: deps.deviceId };
54023
+ const created = await deps.rpc.call("mediaFileFind.factory.create", null);
54024
+ if (created.kind === "unreachable") return {
54025
+ kind: "unreachable",
54026
+ detail: created.detail
54027
+ };
54028
+ if (created.kind === "refused") return {
54029
+ kind: "unreachable",
54030
+ detail: `the camera refused a find object (${describeError(created.error)})`
54031
+ };
54032
+ const handle = created.result;
54033
+ if (typeof handle !== "number" || !Number.isFinite(handle)) return {
54034
+ kind: "unreachable",
54035
+ detail: "the camera answered a find-object handle this provider could not read"
54036
+ };
54037
+ try {
54038
+ const condition = buildFindCondition({
54039
+ channel: input.channel,
54040
+ startWallClock: input.startWallClock,
54041
+ endWallClock: input.endWallClock
54042
+ });
54043
+ const started = await deps.rpc.call("mediaFileFind.findFile", { condition }, handle);
54044
+ if (started.kind === "unreachable") return {
54045
+ kind: "unreachable",
54046
+ detail: started.detail
54047
+ };
54048
+ if (started.kind === "refused") return {
54049
+ kind: "refused",
54050
+ detail: describeError(started.error)
54051
+ };
54052
+ if (!input.enumerate) return {
54053
+ kind: "ok",
54054
+ rows: [],
54055
+ pages: 0,
54056
+ rejected: {},
54057
+ rejectedCount: 0
54058
+ };
54059
+ const rows = [];
54060
+ const rejected = {};
54061
+ let rejectedCount = 0;
54062
+ let pages = 0;
54063
+ for (; pages < 64;) {
54064
+ const page = await deps.rpc.call("mediaFileFind.findNextFile", { count: 64 }, handle);
54065
+ pages += 1;
54066
+ if (page.kind === "unreachable") return {
54067
+ kind: "unreachable",
54068
+ detail: page.detail
54069
+ };
54070
+ if (page.kind === "refused") return {
54071
+ kind: "unreachable",
54072
+ detail: `page ${String(pages)} of the search refused (${describeError(page.error)})`
54073
+ };
54074
+ const foundRaw = page.params["found"];
54075
+ const found = typeof foundRaw === "number" && Number.isFinite(foundRaw) ? foundRaw : 0;
54076
+ const infos = page.params["infos"];
54077
+ const list = Array.isArray(infos) ? infos : [];
54078
+ for (const raw of list) {
54079
+ const parsed = parseClipRow(raw, input.clock);
54080
+ if (parsed.kind === "ok") {
54081
+ rows.push(parsed.row);
54082
+ continue;
54083
+ }
54084
+ rejected[parsed.reason] = (rejected[parsed.reason] ?? 0) + 1;
54085
+ rejectedCount += 1;
54086
+ }
54087
+ if (findWalkIsComplete(found, 64)) break;
54088
+ if (list.length === 0) {
54089
+ deps.logger.warn("videoclips: a search page claimed more rows and carried none", {
54090
+ tags,
54091
+ meta: {
54092
+ branch: "find-empty-page",
54093
+ page: pages,
54094
+ found
54095
+ }
54096
+ });
54097
+ break;
54098
+ }
54099
+ }
54100
+ if (pages >= 64) deps.logger.warn("videoclips: a search never terminated — stopping at the page bound", {
54101
+ tags,
54102
+ meta: {
54103
+ branch: "find-page-bound",
54104
+ pages,
54105
+ rows: rows.length
54106
+ }
54107
+ });
54108
+ return {
54109
+ kind: "ok",
54110
+ rows,
54111
+ pages,
54112
+ rejected,
54113
+ rejectedCount
54114
+ };
54115
+ } finally {
54116
+ await releaseFindObject(deps, handle);
54117
+ }
54118
+ }
54119
+ async function releaseFindObject(deps, handle) {
54120
+ for (const method of ["mediaFileFind.close", "mediaFileFind.destroy"]) {
54121
+ const released = await deps.rpc.call(method, null, handle);
54122
+ if (released.kind === "ok") continue;
54123
+ deps.logger.warn("videoclips: a find object was not released", {
54124
+ tags: { deviceId: deps.deviceId },
54125
+ meta: {
54126
+ branch: "find-object-leak",
54127
+ method,
54128
+ detail: released.kind === "refused" ? describeError(released.error) : released.detail
54129
+ }
54130
+ });
54131
+ }
54132
+ }
54133
+ /**
54134
+ * Every row the camera holds in a window.
54135
+ *
54136
+ * The window travels as the CAMERA's wall clock, converted through the
54137
+ * measured offset. A UTC window would be two hours off in summer and one in
54138
+ * winter, and would silently return the wrong footage rather than an error.
54139
+ */
54140
+ async function readClipRows(deps, input) {
54141
+ const tags = { deviceId: deps.deviceId };
54142
+ const startWallClock = toCameraWallClock(input.sinceMs, input.clock);
54143
+ const endWallClock = toCameraWallClock(input.untilMs, input.clock);
54144
+ const asked = await runSearch(deps, {
54145
+ startWallClock,
54146
+ endWallClock,
54147
+ channel: input.channel,
54148
+ clock: input.clock,
54149
+ enumerate: true
54150
+ });
54151
+ if (asked.kind === "ok") {
54152
+ if (asked.rejectedCount > 0) deps.logger.warn("videoclips: the camera sent rows this provider could not read", {
54153
+ tags,
54154
+ meta: {
54155
+ branch: "rows-rejected",
54156
+ rejected: asked.rejected,
54157
+ rejectedCount: asked.rejectedCount,
54158
+ kept: asked.rows.length
54159
+ }
54160
+ });
54161
+ return asked;
54162
+ }
54163
+ if (asked.kind === "unreachable") return {
54164
+ kind: "unreachable",
54165
+ detail: asked.detail
54166
+ };
54167
+ const control = await runSearch(deps, {
54168
+ startWallClock: CLIP_CONTROL_WINDOW.start,
54169
+ endWallClock: CLIP_CONTROL_WINDOW.end,
54170
+ channel: input.channel,
54171
+ clock: input.clock,
54172
+ enumerate: false
54173
+ });
54174
+ if (control.kind === "unreachable") return {
54175
+ kind: "unreachable",
54176
+ detail: `the camera refused the search for ${startWallClock} … ${endWallClock} (${asked.detail}) and did not answer the control search (${control.detail})`
54177
+ };
54178
+ if (control.kind === "ok") {
54179
+ deps.logger.info("videoclips: the window holds nothing, proved against a control search", {
54180
+ tags,
54181
+ meta: {
54182
+ branch: "empty-window",
54183
+ startWallClock,
54184
+ endWallClock,
54185
+ refusal: asked.detail
54186
+ }
54187
+ });
54188
+ return { kind: "empty-window" };
54189
+ }
54190
+ return {
54191
+ kind: "index-empty",
54192
+ detail: `the camera refused both the asked window (${startWallClock} … ${endWallClock}: ${asked.detail}) and an unbounded control search (${CLIP_CONTROL_WINDOW.start} … ${CLIP_CONTROL_WINDOW.end}: ${control.detail}) — its index answers nothing anywhere`
54193
+ };
54194
+ }
54195
+ //#endregion
54196
+ //#region src/clips/clip-data-plane.ts
54197
+ /**
54198
+ * The addon's clip FILE route.
54199
+ *
54200
+ * Hosted by `ctx.dataPlane.serve({ access: 'authenticated' })`, so the hub
54201
+ * authenticates and reverse-proxies and the addon produces the bytes. **No
54202
+ * token in the URL**: the hub's `/addon/<id>/<prefix>` proxy reads the
54203
+ * credential from `authorization` or the session cookie, so a clip URL may sit
54204
+ * in history, in a `Referer` or in a shared log and open nothing (D549 22).
54205
+ *
54206
+ * It serves the re-indexed MP4 — `moov` first — with `Range`, which is what a
54207
+ * `<video>` element needs to show a duration and to seek. The camera's own
54208
+ * copy has the `moov` LAST and ignores `Range` outright, so the seekability
54209
+ * this route offers is bought by `clip-file.ts`'s one `-c copy` pass and is
54210
+ * not a property of the wire.
54211
+ *
54212
+ * There is deliberately **no STREAM route here**. The camera's forward-only
54213
+ * `/cam/playback` replay is real and is not wired — see the ADR: it is a
54214
+ * separate slice, it needs a provider-owned slot gate because the camera's own
54215
+ * exhaustion signal is a 404 that means "gone", and it cannot honestly answer
54216
+ * the broker's rate ladder.
54217
+ *
54218
+ * `HEAD` answers headers and touches no camera.
54219
+ */
54220
+ /** URL namespace under `/addon/provider-amcrest/`. */
54221
+ var CLIP_MEDIA_PREFIX = "clip";
54222
+ /**
54223
+ * A clip key is `<startSec>-<endSec>` and nothing else may reach the
54224
+ * filesystem. The store refuses the same shape a second time — two walls,
54225
+ * because this one is reachable from a URL.
54226
+ */
54227
+ var MEDIA_PATH = /^\/(\d+)\/(\d{1,20}-\d{1,20})\.mp4$/;
54228
+ function pathnameOf(req) {
54229
+ const raw = req.url ?? "/";
54230
+ const query = raw.indexOf("?");
54231
+ return query === -1 ? raw : raw.slice(0, query);
54232
+ }
54233
+ /**
54234
+ * `bytes=a-b` / `bytes=a-` / `bytes=-n`.
54235
+ *
54236
+ * `'unsatisfiable'` is a 416 rather than a silently empty 206: a player that
54237
+ * asked past the end must learn it did, not receive zero bytes that look like
54238
+ * the end of the file.
54239
+ */
54240
+ function parseRange(header, size) {
54241
+ if (header === void 0) return null;
54242
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
54243
+ if (match === null) return null;
54244
+ const [, rawStart, rawEnd] = match;
54245
+ if (rawStart === "" && rawEnd === "") return "unsatisfiable";
54246
+ if (rawStart === "") {
54247
+ const length = Number(rawEnd);
54248
+ if (length <= 0) return "unsatisfiable";
54249
+ return {
54250
+ start: Math.max(0, size - length),
54251
+ end: size - 1
54252
+ };
54253
+ }
54254
+ const start = Number(rawStart);
54255
+ if (start >= size) return "unsatisfiable";
54256
+ const end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
54257
+ if (end < start) return "unsatisfiable";
54258
+ return {
54259
+ start,
54260
+ end
54261
+ };
54262
+ }
54263
+ function createClipPlaybackHandler(deps) {
54264
+ return async (req, res) => {
54265
+ if (req.method !== "GET" && req.method !== "HEAD") {
54266
+ res.writeHead(405, { allow: "GET, HEAD" });
54267
+ res.end();
54268
+ return;
54269
+ }
54270
+ const match = MEDIA_PATH.exec(pathnameOf(req));
54271
+ if (match === null) {
54272
+ res.writeHead(400);
54273
+ res.end();
54274
+ return;
54275
+ }
54276
+ const deviceId = Number(match[1]);
54277
+ const clipKey = match[2] ?? "";
54278
+ let path;
54279
+ try {
54280
+ path = await deps.resolvePath(deviceId, clipKey);
54281
+ } catch (err) {
54282
+ deps.logger.warn("videoclips: could not prepare a clip for playback", {
54283
+ tags: { deviceId },
54284
+ meta: {
54285
+ clipKey,
54286
+ branch: "prepare-threw",
54287
+ error: err instanceof Error ? err.message : String(err)
54288
+ }
54289
+ });
54290
+ res.writeHead(500);
54291
+ res.end();
54292
+ return;
54293
+ }
54294
+ if (path === null) {
54295
+ res.writeHead(404);
54296
+ res.end();
54297
+ return;
54298
+ }
54299
+ const size = (await (0, node_fs_promises.stat)(path)).size;
54300
+ const base = {
54301
+ "content-type": "video/mp4",
54302
+ "accept-ranges": "bytes",
54303
+ "cache-control": "private, max-age=0, must-revalidate"
54304
+ };
54305
+ const range = parseRange(req.headers.range, size);
54306
+ if (range === "unsatisfiable") {
54307
+ res.writeHead(416, {
54308
+ ...base,
54309
+ "content-range": `bytes */${String(size)}`
54310
+ });
54311
+ res.end();
54312
+ return;
54313
+ }
54314
+ if (range === null) {
54315
+ res.writeHead(200, {
54316
+ ...base,
54317
+ "content-length": String(size)
54318
+ });
54319
+ if (req.method === "HEAD") {
54320
+ res.end();
54321
+ return;
54322
+ }
54323
+ (0, node_fs.createReadStream)(path).pipe(res);
54324
+ return;
54325
+ }
54326
+ res.writeHead(206, {
54327
+ ...base,
54328
+ "content-range": `bytes ${String(range.start)}-${String(range.end)}/${String(size)}`,
54329
+ "content-length": String(range.end - range.start + 1)
54330
+ });
54331
+ if (req.method === "HEAD") {
54332
+ res.end();
54333
+ return;
54334
+ }
54335
+ (0, node_fs.createReadStream)(path, {
54336
+ start: range.start,
54337
+ end: range.end
54338
+ }).pipe(res);
54339
+ };
54340
+ }
54341
+ /** How long one remux may run before it is killed. A `-c copy` pass over
54342
+ * 56 MB is seconds; a minute is a bound on a hang, not a budget. */
54343
+ var CLIP_FILE_REMUX_TIMEOUT_MS = 6e4;
54344
+ /**
54345
+ * ffmpeg's own line for the container it opened —
54346
+ * `Duration: 00:01:08.43, start: …`. MEASURED, off the file we are about to
54347
+ * serve, which is the only thing that can say how long it really runs.
54348
+ */
54349
+ function parseFfmpegDurationMs(stderr) {
54350
+ const match = /Duration:\s*(\d+):(\d{2}):(\d{2})\.(\d{1,3})/.exec(stderr);
54351
+ if (match === null) return null;
54352
+ const [, h, m, s, frac] = match;
54353
+ if (h === void 0 || m === void 0 || s === void 0 || frac === void 0) return null;
54354
+ const ms = Number(frac.padEnd(3, "0"));
54355
+ const total = ((Number(h) * 60 + Number(m)) * 60 + Number(s)) * 1e3 + ms;
54356
+ return total > 0 ? total : null;
54357
+ }
54358
+ /** The `-c copy -movflags +faststart` pass, spelled once. */
54359
+ function buildClipFaststartArgs(source, target) {
54360
+ return [
54361
+ "-hide_banner",
54362
+ "-loglevel",
54363
+ "info",
54364
+ "-y",
54365
+ "-i",
54366
+ source,
54367
+ "-c",
54368
+ "copy",
54369
+ "-movflags",
54370
+ "+faststart",
54371
+ "-f",
54372
+ "mp4",
54373
+ target
54374
+ ];
54375
+ }
54376
+ async function fetchRecording(input, deps, target, maxFetchBytes) {
54377
+ let download;
54378
+ try {
54379
+ download = await deps.client.openRecordedFile(input.row.filePath);
54380
+ } catch (err) {
54381
+ return {
54382
+ kind: "refused",
54383
+ bytes: 0,
54384
+ code: "camera-refused",
54385
+ detail: `the camera closed the connection for "${input.row.filePath}" without answering (${err instanceof Error ? err.message : String(err)}) — on this firmware that is how a recording it no longer holds is refused`
54386
+ };
54387
+ }
54388
+ if (download.status !== 200) return {
54389
+ kind: "refused",
54390
+ bytes: 0,
54391
+ code: "camera-refused",
54392
+ detail: `the camera answered HTTP ${String(download.status)} for "${input.row.filePath}"`
54393
+ };
54394
+ if (download.contentLength !== null && download.contentLength > maxFetchBytes) return {
54395
+ kind: "refused",
54396
+ bytes: 0,
54397
+ code: "too-large-to-transfer",
54398
+ detail: `the camera declares "${input.row.filePath}" as ${String(download.contentLength)} bytes, over the ${String(maxFetchBytes)}-byte fetch ceiling`
54399
+ };
54400
+ const body = download.body;
54401
+ if (body === null) return {
54402
+ kind: "refused",
54403
+ bytes: 0,
54404
+ code: "fetch-failed",
54405
+ detail: `the camera answered 200 for "${input.row.filePath}" and sent no body`
54406
+ };
54407
+ let bytes = 0;
54408
+ let overflowed = false;
54409
+ const source = node_stream.Readable.fromWeb(body);
54410
+ source.on("data", (chunk) => {
54411
+ bytes += chunk.byteLength;
54412
+ if (bytes > maxFetchBytes && !overflowed) {
54413
+ overflowed = true;
54414
+ source.destroy(/* @__PURE__ */ new Error(`fetch ceiling of ${String(maxFetchBytes)} bytes passed`));
54415
+ }
54416
+ });
54417
+ try {
54418
+ await (0, node_stream_promises.pipeline)(source, (0, node_fs.createWriteStream)(target));
54419
+ } catch (err) {
54420
+ if (overflowed) return {
54421
+ kind: "refused",
54422
+ bytes,
54423
+ code: "too-large-to-transfer",
54424
+ detail: `"${input.row.filePath}" passed the ${String(maxFetchBytes)}-byte fetch ceiling before the camera finished it`
54425
+ };
54426
+ return {
54427
+ kind: "refused",
54428
+ bytes,
54429
+ code: "fetch-failed",
54430
+ detail: `the transfer of "${input.row.filePath}" ended after ${String(bytes)} bytes: ${err instanceof Error ? err.message : String(err)}`
54431
+ };
54432
+ }
54433
+ if (bytes === 0) return {
54434
+ kind: "refused",
54435
+ bytes: 0,
54436
+ code: "fetch-failed",
54437
+ detail: `the camera answered 200 for "${input.row.filePath}" and delivered no bytes`
54438
+ };
54439
+ if (download.contentLength !== null && bytes !== download.contentLength) return {
54440
+ kind: "refused",
54441
+ bytes,
54442
+ code: "fetch-failed",
54443
+ detail: `the camera declared ${String(download.contentLength)} bytes for "${input.row.filePath}" and delivered ${String(bytes)}`
54444
+ };
54445
+ return {
54446
+ kind: "ok",
54447
+ bytes
54448
+ };
54449
+ }
54450
+ /**
54451
+ * Fetch a whole recording and re-index it, or answer the cached file.
54452
+ *
54453
+ * Never throws for a gated branch: a refusal carries its code and its prose so
54454
+ * the caller can turn it into the cap's own vocabulary.
54455
+ */
54456
+ async function prepareClipFile(input, deps) {
54457
+ const tags = { deviceId: input.deviceId };
54458
+ const meta = { clipKey: input.row.clipKey };
54459
+ const cached = await deps.store.pathIfPresent(input.deviceId, input.row.clipKey);
54460
+ if (cached !== null) {
54461
+ const info = await (0, node_fs_promises.stat)(cached);
54462
+ deps.logger.debug("videoclips: served a clip file from the scratch", {
54463
+ tags,
54464
+ meta: {
54465
+ ...meta,
54466
+ bytes: info.size
54467
+ }
54468
+ });
54469
+ return {
54470
+ kind: "ok",
54471
+ path: cached,
54472
+ bytes: info.size,
54473
+ durationMs: null
54474
+ };
54475
+ }
54476
+ const maxFetchBytes = deps.maxFetchBytes ?? 536870912;
54477
+ const startedAt = Date.now();
54478
+ const raw = `${await deps.store.reserve(input.deviceId, input.row.clipKey)}.raw`;
54479
+ const temp = await deps.store.reserve(input.deviceId, input.row.clipKey);
54480
+ try {
54481
+ const fetched = await fetchRecording(input, deps, raw, maxFetchBytes);
54482
+ if (fetched.kind === "refused") {
54483
+ deps.logger.warn("videoclips: a clip fetch was refused", {
54484
+ tags,
54485
+ meta: {
54486
+ ...meta,
54487
+ branch: fetched.code ?? "fetch-failed",
54488
+ bytes: fetched.bytes
54489
+ }
54490
+ });
54491
+ return {
54492
+ kind: "refused",
54493
+ code: fetched.code ?? "fetch-failed",
54494
+ detail: fetched.detail ?? "the fetch failed"
54495
+ };
54496
+ }
54497
+ const fetchedAt = Date.now();
54498
+ const args = buildClipFaststartArgs(raw, temp);
54499
+ const child = deps.spawnMux?.([...args]) ?? (0, node_child_process.spawn)(deps.ffmpegBinaryPath ?? "ffmpeg", [...args], { stdio: [
54500
+ "ignore",
54501
+ "ignore",
54502
+ "pipe"
54503
+ ] });
54504
+ let stderr = "";
54505
+ child.stderr?.on("data", (chunk) => {
54506
+ stderr = `${stderr}${chunk.toString()}`.slice(-4e3);
54507
+ });
54508
+ const killer = setTimeout(() => {
54509
+ child.kill("SIGKILL");
54510
+ }, CLIP_FILE_REMUX_TIMEOUT_MS);
54511
+ const exit = await new Promise((resolve) => {
54512
+ child.on("close", (code) => {
54513
+ resolve(code);
54514
+ });
54515
+ child.on("error", () => {
54516
+ resolve(-1);
54517
+ });
54518
+ });
54519
+ clearTimeout(killer);
54520
+ if (exit !== 0) {
54521
+ deps.logger.warn("videoclips: a clip re-index failed", {
54522
+ tags,
54523
+ meta: {
54524
+ ...meta,
54525
+ branch: "fetch-failed",
54526
+ exit,
54527
+ stderr: stderr.trim().slice(-300)
54528
+ }
54529
+ });
54530
+ return {
54531
+ kind: "refused",
54532
+ code: "fetch-failed",
54533
+ detail: `ffmpeg exited ${String(exit)}: ${stderr.trim().slice(-300)}`
54534
+ };
54535
+ }
54536
+ const durationMs = parseFfmpegDurationMs(stderr);
54537
+ const path = await deps.store.publish(input.deviceId, input.row.clipKey, temp);
54538
+ const info = await (0, node_fs_promises.stat)(path);
54539
+ deps.logger.info("videoclips: prepared an Amcrest clip file", {
54540
+ tags,
54541
+ meta: {
54542
+ ...meta,
54543
+ fetchedBytes: fetched.bytes,
54544
+ listedBytes: input.row.sizeBytes,
54545
+ bytes: info.size,
54546
+ durationMs,
54547
+ rowWindowMs: input.row.windowMs,
54548
+ fetchMs: fetchedAt - startedAt,
54549
+ remuxMs: Date.now() - fetchedAt
54550
+ }
54551
+ });
54552
+ return {
54553
+ kind: "ok",
54554
+ path,
54555
+ bytes: info.size,
54556
+ durationMs
54557
+ };
54558
+ } finally {
54559
+ await (0, node_fs_promises.rm)(raw, { force: true });
54560
+ await (0, node_fs_promises.rm)(temp, { force: true });
54561
+ }
54562
+ }
54563
+ //#endregion
54564
+ //#region src/clips/clip-twins.ts
54565
+ /**
54566
+ * The one mapping. An ABSENT profile is not an upgrade: a caller that stated
54567
+ * no preference stated no ask, and D382's rule is that an unstated ask is
54568
+ * zero — there is nothing for a line to be about.
54569
+ */
54570
+ function chooseClipQuality(profile) {
54571
+ return {
54572
+ twin: "main",
54573
+ served: "high",
54574
+ upgraded: profile !== void 0 && profile !== "high"
54575
+ };
54576
+ }
54577
+ /**
54578
+ * One-per-(camera, asked profile) bookkeeping for the upgrade line.
54579
+ *
54580
+ * Deliberately a plain object rather than a module-level map: a second runner
54581
+ * of this addon must not inherit another's "already said that".
54582
+ */
54583
+ var ClipUpgradeNotes = class {
54584
+ said = /* @__PURE__ */ new Map();
54585
+ /** Note one upgraded ask and say whether it is worth a line. */
54586
+ note(deviceId, asked) {
54587
+ const key = `${String(deviceId)}|${asked}`;
54588
+ const seen = this.said.get(key);
54589
+ if (seen === void 0) {
54590
+ this.said.set(key, 0);
54591
+ return {
54592
+ kind: "say",
54593
+ asked,
54594
+ suppressedSince: 0
54595
+ };
54596
+ }
54597
+ this.said.set(key, seen + 1);
54598
+ return { kind: "silent" };
54599
+ }
54600
+ /** How many asks this camera/profile pair has made since its one line. Read
54601
+ * by the listing log, so the quiet carries its own denominator. */
54602
+ suppressed(deviceId, asked) {
54603
+ return this.said.get(`${String(deviceId)}|${asked}`) ?? 0;
54604
+ }
54605
+ };
54606
+ //#endregion
54607
+ //#region src/clips/clip-window.ts
54608
+ /**
54609
+ * A row whose `endMs` is not after its `startMs` is treated as an INSTANT at
54610
+ * `startMs` — judging it by a bad end would drop a recording rather than
54611
+ * misplace it, and one of those is recoverable.
54612
+ */
54613
+ function clipOverlapsWindow(row, window) {
54614
+ const end = row.endMs > row.startMs ? row.endMs : row.startMs;
54615
+ return row.startMs < window.until && end > window.since;
54616
+ }
54617
+ function narrowClipsToWindow(rows, window) {
54618
+ const kept = rows.filter((row) => clipOverlapsWindow(row, window));
54619
+ return {
54620
+ kept,
54621
+ outOfWindow: rows.length - kept.length
54622
+ };
54623
+ }
54624
+ //#endregion
54625
+ //#region src/clips/amcrest-videoclips-provider.ts
54626
+ /**
54627
+ * The name an export carries away.
54628
+ *
54629
+ * The camera's own file name is `10.23.27-10.24.41[M][0@0][0].mp4` — square
54630
+ * brackets, an `@`, and a clock with no date. Every one of those is a hazard in
54631
+ * a downloads folder and none of them says WHEN. So the name is minted from the
54632
+ * window, in ISO, and the extension is re-stated because what we hand over is
54633
+ * an MP4 whatever the camera called it.
54634
+ */
54635
+ function clipFileLabel(row) {
54636
+ return `amcrest_${new Date(row.startMs).toISOString().replace(/[:.]/g, "-").slice(0, 19)}.mp4`;
54637
+ }
54638
+ /**
54639
+ * What the camera's own event strings mean in the cap's vocabulary.
54640
+ *
54641
+ * `VideoMotion` is the only value 60 of 60 measured rows carried, so this is a
54642
+ * mapping written against ONE observed string and it says so. Everything else
54643
+ * is `native` — the camera's words survive verbatim on `nativeTypes`, which is
54644
+ * the lossless copy D549 20 requires precisely because this table is lossy.
54645
+ */
54646
+ function clipKindFor(row) {
54647
+ if (row.events.some((event) => event.toLowerCase().includes("motion"))) return "motion";
54648
+ return "native";
54649
+ }
54650
+ function createAmcrestVideoclipsProvider(deps) {
54651
+ /** Last known availability per device, written by every read. */
54652
+ const health = /* @__PURE__ */ new Map();
54653
+ /** Every row this provider has listed, by `deviceId|clipKey`, so a byte
54654
+ * offer resolves without re-searching the camera. */
54655
+ const rowsByKey = /* @__PURE__ */ new Map();
54656
+ const upgrades = new ClipUpgradeNotes();
54657
+ const rowKey = (deviceId, clipKey) => `${String(deviceId)}|${clipKey}`;
54658
+ const remember = (deviceId, rows) => {
54659
+ for (const row of rows) rowsByKey.set(rowKey(deviceId, row.clipKey), row);
54660
+ };
54661
+ const toClip = (row, catalogAsOf) => {
54662
+ const id = mintClipId({
54663
+ source: CLIP_SOURCE_ONBOARD,
54664
+ startMs: row.startMs,
54665
+ endMs: row.endMs
54666
+ });
54667
+ return {
54668
+ id,
54669
+ source: CLIP_SOURCE_ONBOARD,
54670
+ kind: clipKindFor(row),
54671
+ timeRange: {
54672
+ startMs: row.startMs,
54673
+ endMs: row.endMs
54674
+ },
54675
+ ...row.events.length > 0 ? { nativeTypes: [...row.events] } : {},
54676
+ thumbnailUnavailable: { reason: "unsupported" },
54677
+ catalogAsOf,
54678
+ streams: { main: {
54679
+ id,
54680
+ ...row.sizeBytes !== null ? { bytes: row.sizeBytes } : {}
54681
+ } },
54682
+ supportSub: false
54683
+ };
54684
+ };
54685
+ const noteUpgrade = (deviceId, profile) => {
54686
+ if (profile === void 0) return;
54687
+ if (upgrades.note(deviceId, profile).kind !== "say") return;
54688
+ deps.logger.warn("videoclips: this camera archives only the main copy — every ask is served it", {
54689
+ tags: { deviceId },
54690
+ meta: {
54691
+ branch: "quality-upgraded",
54692
+ asked: profile,
54693
+ served: "high",
54694
+ note: "further upgraded asks for this camera and profile are not logged"
54695
+ }
54696
+ });
54697
+ };
54698
+ return {
54699
+ listSources: async ({ deviceId }) => {
54700
+ const device = await deps.resolveDevice(deviceId);
54701
+ if (device === null) return [];
54702
+ const availability = await deps.probeAvailability(device);
54703
+ health.set(deviceId, availability);
54704
+ return [{
54705
+ source: CLIP_SOURCE_ONBOARD,
54706
+ addonId: AMCREST_ADDON_ID,
54707
+ label: "Camera storage",
54708
+ availability
54709
+ }];
54710
+ },
54711
+ listClips: async ({ deviceId, since, until, limit }) => {
54712
+ const device = await deps.resolveDevice(deviceId);
54713
+ if (device === null) return [];
54714
+ const tags = { deviceId };
54715
+ const outcome = await deps.readCatalog({
54716
+ device,
54717
+ sinceMs: since,
54718
+ untilMs: until
54719
+ });
54720
+ if (outcome.kind === "refused") {
54721
+ health.set(deviceId, outcome.availability);
54722
+ deps.logger.warn("videoclips: nothing to list, and the source cannot answer", {
54723
+ tags,
54724
+ meta: {
54725
+ branch: "refused-empty",
54726
+ state: outcome.availability.state,
54727
+ reason: outcome.availability.reason ?? null
54728
+ }
54729
+ });
54730
+ throw new Error(`videoclips: device ${String(deviceId)} listed no clips, and that is not an empty window — the source is "${outcome.availability.state}"` + (outcome.availability.reason !== void 0 ? `: ${outcome.availability.reason}` : "."));
54731
+ }
54732
+ const catalogAsOf = deps.now();
54733
+ health.set(deviceId, {
54734
+ state: "ok",
54735
+ catalogAsOf
54736
+ });
54737
+ if (outcome.kind === "empty") {
54738
+ deps.logger.info("videoclips: the window holds no clips", {
54739
+ tags,
54740
+ meta: {
54741
+ branch: "empty-window",
54742
+ source: CLIP_SOURCE_ONBOARD,
54743
+ since,
54744
+ until
54745
+ }
54746
+ });
54747
+ return [];
54748
+ }
54749
+ remember(deviceId, outcome.rows);
54750
+ const narrowed = narrowClipsToWindow(outcome.rows, {
54751
+ since,
54752
+ until
54753
+ });
54754
+ if (narrowed.outOfWindow > 0) deps.logger.info("videoclips: narrowed a listing to the window it was asked for", {
54755
+ tags,
54756
+ meta: {
54757
+ branch: "listing-narrowed",
54758
+ listed: outcome.rows.length,
54759
+ kept: narrowed.kept.length,
54760
+ outOfWindow: narrowed.outOfWindow,
54761
+ since,
54762
+ until
54763
+ }
54764
+ });
54765
+ const ordered = narrowed.kept.toSorted((a, b) => b.startMs - a.startMs);
54766
+ const capped = limit !== void 0 ? ordered.slice(0, limit) : ordered;
54767
+ deps.logger.info("videoclips: listed an Amcrest catalog", {
54768
+ tags,
54769
+ meta: {
54770
+ source: CLIP_SOURCE_ONBOARD,
54771
+ clips: capped.length,
54772
+ truncated: limit !== void 0 && ordered.length > limit,
54773
+ findChannel: device.findChannel
54774
+ }
54775
+ });
54776
+ return capped.map((row) => toClip(row, catalogAsOf));
54777
+ },
54778
+ /**
54779
+ * The player's URL — a finished, `moov`-first MP4 on this addon's own
54780
+ * media plane, with `Range`, because the surface hands it to a `<video>`
54781
+ * element that has to show a duration and seek.
54782
+ */
54783
+ getClipPlayback: async ({ deviceId, clipId, profile }) => {
54784
+ const parsed = parseClipId(clipId);
54785
+ if (parsed === null) throw new Error(`videoclips: clip id "${clipId}" was not minted by the Amcrest provider`);
54786
+ if (await deps.resolveDevice(deviceId) === null) throw new Error(`videoclips: device ${String(deviceId)} is not an Amcrest camera`);
54787
+ const clipKey = clipKeyOf(parsed);
54788
+ if (!rowsByKey.has(rowKey(deviceId, clipKey))) throw new Error(clipExportFailure("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${String(deviceId)} — list the window that holds it first`));
54789
+ const quality = chooseClipQuality(profile);
54790
+ if (quality.upgraded) noteUpgrade(deviceId, profile);
54791
+ return {
54792
+ playbackUrl: deps.playbackUrl(deviceId, clipKey),
54793
+ format: "mp4",
54794
+ served: quality.served
54795
+ };
54796
+ },
54797
+ /**
54798
+ * This clip's BYTES, base64, bounded — the broker's file feeder (D575).
54799
+ *
54800
+ * **It is wired, and it will refuse about a fifth of this camera's
54801
+ * catalog, by name.** Ten measured clips ran 33.4–72.9 MiB and 2 of them
54802
+ * exceed `VIDEOCLIPS_MAX_READ_BYTES` (50 MiB); the worst the camera can
54803
+ * structurally produce — one motion episode cut at the 5-minute
54804
+ * `PacketLength` cap, at the peak achieved 6.64 Mbit/s — is 238 MiB. The
54805
+ * envelope is unary, so that payload is held at ~1.33× in the provider AND
54806
+ * in the caller, on a hub this repo has already OOM'd (D9/D18), and the
54807
+ * bound is not going to move for an inline read.
54808
+ *
54809
+ * It is wired anyway, and the alternative was measured rather than
54810
+ * assumed: the broker's `chooseClipPath` reads whether *the broker* has a
54811
+ * file read wired, not whether this provider implements one. Leaving the
54812
+ * method off would make the broker choose the file path and then call a
54813
+ * method that answers `NOT_IMPLEMENTED` — an unnamed failure in place of
54814
+ * the named refusal this returns. A named `too-large-to-transfer` that an
54815
+ * operator can read is strictly better than an error nobody can, and it is
54816
+ * also the honest measurement that argues for the camera's own
54817
+ * forward-only replay as the next slice.
54818
+ *
54819
+ * {@link offerClipBytes} is the path an EXPORT takes, holds nothing on
54820
+ * either side, and reaches 500 MiB — every clip this camera can make.
54821
+ *
54822
+ * The cheap questions come first and none of them touches the camera: the
54823
+ * id, the row, and the SIZE the catalog already declared (D54).
54824
+ */
54825
+ readClipBytes: async ({ deviceId, clipId, profile, maxBytes }) => {
54826
+ const tags = { deviceId };
54827
+ const refuse = (code, detail, meta) => {
54828
+ deps.logger.warn("videoclips: refusing a clip byte read", {
54829
+ tags,
54830
+ meta: {
54831
+ clipId,
54832
+ branch: code,
54833
+ ...meta
54834
+ }
54835
+ });
54836
+ throw new Error(clipExportFailure(code, detail));
54837
+ };
54838
+ const device = await deps.resolveDevice(deviceId);
54839
+ if (device === null) return refuse("catalog-miss", `device ${String(deviceId)} is not an Amcrest camera`, {});
54840
+ const parsed = parseClipId(clipId);
54841
+ if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Amcrest provider`, {});
54842
+ const clipKey = clipKeyOf(parsed);
54843
+ const row = rowsByKey.get(rowKey(deviceId, clipKey));
54844
+ if (row === void 0) return refuse("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${String(deviceId)} — list the window that holds it first`, { clipKey });
54845
+ const quality = chooseClipQuality(profile);
54846
+ if (quality.upgraded) noteUpgrade(deviceId, profile);
54847
+ const bound = Math.min(maxBytes ?? 52428800, VIDEOCLIPS_MAX_READ_BYTES);
54848
+ if (row.sizeBytes !== null && row.sizeBytes > bound) return refuse("too-large-to-transfer", `the camera lists clip "${clipId}" as ${String(row.sizeBytes)} bytes, over the ${String(bound)}-byte bound for an inline read — play it from its playback URL instead`, {
54849
+ clipKey,
54850
+ listedBytes: row.sizeBytes,
54851
+ bound
54852
+ });
54853
+ const outcome = await deps.prepareBytes({
54854
+ device,
54855
+ row,
54856
+ maxBytes: bound
54857
+ });
54858
+ if (outcome.kind === "refused") throw new Error(clipExportFailure(outcome.code, outcome.detail));
54859
+ if (outcome.bytes.byteLength > bound) return refuse("too-large-to-transfer", `clip "${clipId}" re-indexed to ${String(outcome.bytes.byteLength)} bytes, over the ${String(bound)}-byte bound for an inline read`, {
54860
+ clipKey,
54861
+ bytes: outcome.bytes.byteLength,
54862
+ bound
54863
+ });
54864
+ deps.logger.info("videoclips: served an Amcrest clip inline", {
54865
+ tags,
54866
+ meta: {
54867
+ clipId,
54868
+ clipKey,
54869
+ served: quality.served,
54870
+ asked: profile ?? null,
54871
+ bytes: outcome.bytes.byteLength,
54872
+ durationMs: outcome.durationMs
54873
+ }
54874
+ });
54875
+ return {
54876
+ base64: outcome.bytes.toString("base64"),
54877
+ contentType: "video/mp4",
54878
+ name: clipFileLabel(row),
54879
+ bytes: outcome.bytes.byteLength,
54880
+ served: quality.served,
54881
+ ...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
54882
+ };
54883
+ },
54884
+ /**
54885
+ * Where this clip's finished bytes can be TAKEN (D613) — the by-handle
54886
+ * read a clip EXPORT pulls.
54887
+ *
54888
+ * {@link readClipBytes} with the envelope removed, and on this camera that
54889
+ * is the difference between exporting a fifth of the catalog and all of
54890
+ * it: the bound here is `VIDEOCLIPS_MAX_OFFER_BYTES` (500 MiB), which
54891
+ * every measured clip (33.4–72.9 MiB) and the worst structurally possible
54892
+ * one (238 MiB) fit with room. Nothing is held on either side — the bytes
54893
+ * leave over a one-shot loopback socket, straight off the scratch file.
54894
+ *
54895
+ * The cheap questions come first and none of them touches the camera: the
54896
+ * id, the row, and the SIZE the catalog already declared (D54).
54897
+ */
54898
+ offerClipBytes: async ({ deviceId, clipId, profile, maxBytes }) => {
54899
+ const tags = { deviceId };
54900
+ const refuse = (code, detail, meta) => {
54901
+ deps.logger.warn("videoclips: refusing a clip byte offer", {
54902
+ tags,
54903
+ meta: {
54904
+ clipId,
54905
+ branch: code,
54906
+ ...meta
54907
+ }
54908
+ });
54909
+ throw new Error(clipExportFailure(code, detail));
54910
+ };
54911
+ const device = await deps.resolveDevice(deviceId);
54912
+ if (device === null) return refuse("catalog-miss", `device ${String(deviceId)} is not an Amcrest camera`, {});
54913
+ const parsed = parseClipId(clipId);
54914
+ if (parsed === null) return refuse("catalog-miss", `clip id "${clipId}" was not minted by the Amcrest provider`, {});
54915
+ const clipKey = clipKeyOf(parsed);
54916
+ const row = rowsByKey.get(rowKey(deviceId, clipKey));
54917
+ if (row === void 0) return refuse("catalog-miss", `clip "${clipId}" is not in this node's catalog for device ${String(deviceId)} — list the window that holds it first`, { clipKey });
54918
+ const quality = chooseClipQuality(profile);
54919
+ if (quality.upgraded) noteUpgrade(deviceId, profile);
54920
+ const bound = Math.min(maxBytes ?? 524288e3, VIDEOCLIPS_MAX_OFFER_BYTES);
54921
+ if (row.sizeBytes !== null && row.sizeBytes > bound) return refuse("too-large-to-transfer", `the camera lists clip "${clipId}" as ${String(row.sizeBytes)} bytes, over the ${String(bound)}-byte bound for one transfer`, {
54922
+ clipKey,
54923
+ listedBytes: row.sizeBytes,
54924
+ bound
54925
+ });
54926
+ const outcome = await deps.prepareOffer({
54927
+ device,
54928
+ row,
54929
+ maxBytes: bound
54930
+ });
54931
+ if (outcome.kind === "refused") throw new Error(clipExportFailure(outcome.code, outcome.detail));
54932
+ deps.logger.info("videoclips: offered an Amcrest clip by handle", {
54933
+ tags,
54934
+ meta: {
54935
+ clipId,
54936
+ clipKey,
54937
+ served: quality.served,
54938
+ asked: profile ?? null,
54939
+ bytes: outcome.bytes,
54940
+ durationMs: outcome.durationMs,
54941
+ expiresInMs: outcome.ticket.expiresAtMs - deps.now()
54942
+ }
54943
+ });
54944
+ return {
54945
+ ticket: outcome.ticket,
54946
+ contentType: "video/mp4",
54947
+ name: clipFileLabel(row),
54948
+ served: quality.served,
54949
+ ...outcome.durationMs !== null ? { durationMs: outcome.durationMs } : {}
54950
+ };
54951
+ },
54952
+ /**
54953
+ * What a surface may DRAW for this camera's clips (D612).
54954
+ *
54955
+ * A CONSTANT, and one of the two the contract spells — a provider never
54956
+ * composes an envelope of its own. `file`, because this provider reaches
54957
+ * the player through one bounded by-handle fetch of a whole, `stbl`-indexed
54958
+ * MP4: the camera hands over a self-contained ISO MP4 with the `moov` at
54959
+ * the end and no ranged read, so the clip is pulled once, made seekable
54960
+ * once, and then everything the `file` envelope promises — free seek,
54961
+ * backward frame-step, scrub, the broker's whole rate ladder — is true of
54962
+ * it.
54963
+ *
54964
+ * The camera's OWN forward-only replay (`/cam/playback` over RTSP) is real
54965
+ * and is deliberately NOT what this answers, because it is not wired: it
54966
+ * would need `dialClipStream`, and the one rate it could honestly offer is
54967
+ * `[1]` — `Scale` is accepted, never echoed, and a cold `Scale: 4.0` drain
54968
+ * delivered 10 % of the packets in 42 % of the time with the audio dropped
54969
+ * entirely. Answering `stream` before that path exists would promise a
54970
+ * transport with no producer behind it.
54971
+ */
54972
+ getPlaybackOptions: async () => CLIP_PLAYBACK_OPTIONS.file
54973
+ };
54974
+ }
54975
+ //#endregion
54976
+ //#region src/clips/clip-service.ts
54977
+ function createClipService(deps) {
54978
+ const now = deps.now ?? (() => Date.now());
54979
+ const logger = deps.logger;
54980
+ const store = new ClipArtifactStore({ root: deps.dataDir });
54981
+ /** One RPC2 client per camera. Minted lazily: a camera nobody asks for clips
54982
+ * about never logs in. */
54983
+ const rpcByDevice = /* @__PURE__ */ new Map();
54984
+ /** The measured camera-clock offset, per device. */
54985
+ const clockByDevice = /* @__PURE__ */ new Map();
54986
+ /** Every row the provider has listed, mirrored here so the media ROUTE —
54987
+ * which is reached without a cap call — resolves a key without the camera. */
54988
+ const rowsByKey = /* @__PURE__ */ new Map();
54989
+ const rowKey = (deviceId, clipKey) => `${String(deviceId)}|${clipKey}`;
54990
+ const rpcFor = (camera) => {
54991
+ const existing = rpcByDevice.get(camera.deviceId);
54992
+ if (existing !== void 0) return existing;
54993
+ const client = new AmcrestRpc2Client({
54994
+ host: camera.host,
54995
+ port: camera.port,
54996
+ https: camera.https,
54997
+ username: camera.username,
54998
+ password: camera.password,
54999
+ now
55000
+ });
55001
+ rpcByDevice.set(camera.deviceId, client);
55002
+ return client;
55003
+ };
55004
+ /**
55005
+ * The camera's clock, measured and cached.
55006
+ *
55007
+ * `null` when the read failed — never a zero offset, which is the same shape
55008
+ * as "this camera runs on UTC" and would move its whole archive by the
55009
+ * timezone it really has.
55010
+ */
55011
+ const clockFor = async (camera) => {
55012
+ const cached = clockByDevice.get(camera.deviceId);
55013
+ if (cached !== void 0 && cameraClockIsFresh(cached, now())) return cached;
55014
+ let body;
55015
+ const sentAt = now();
55016
+ try {
55017
+ body = await camera.client.getCurrentTime();
55018
+ } catch (err) {
55019
+ logger.warn("videoclips: the camera did not say what time it thinks it is", {
55020
+ tags: { deviceId: camera.deviceId },
55021
+ meta: {
55022
+ branch: "clock-unread",
55023
+ error: err instanceof Error ? err.message : String(err)
55024
+ }
55025
+ });
55026
+ return null;
55027
+ }
55028
+ const clock = deriveCameraClock({
55029
+ currentTimeBody: body,
55030
+ hostUtcBeforeMs: sentAt,
55031
+ hostUtcAfterMs: now()
55032
+ });
55033
+ if (clock === null) {
55034
+ logger.warn("videoclips: the camera answered a time this provider could not read", {
55035
+ tags: { deviceId: camera.deviceId },
55036
+ meta: { branch: "clock-unparsed" }
55037
+ });
55038
+ return null;
55039
+ }
55040
+ clockByDevice.set(camera.deviceId, clock);
55041
+ if (cameraClockDisagrees(clock)) logger.warn("videoclips: the camera's clock disagrees with the hub by more than any timezone", {
55042
+ tags: { deviceId: camera.deviceId },
55043
+ meta: {
55044
+ branch: "clock-disagrees",
55045
+ offsetMs: clock.offsetMs
55046
+ }
55047
+ });
55048
+ else logger.debug("videoclips: measured this camera’s clock offset", {
55049
+ tags: { deviceId: camera.deviceId },
55050
+ meta: { offsetMs: clock.offsetMs }
55051
+ });
55052
+ return clock;
55053
+ };
55054
+ const resolveDevice = async (deviceId) => {
55055
+ const camera = await deps.resolveCamera(deviceId);
55056
+ if (camera === null) return null;
55057
+ return {
55058
+ deviceId,
55059
+ findChannel: Math.max(0, camera.channel - 1)
55060
+ };
55061
+ };
55062
+ const fileFor = async (deviceId, clipKey) => {
55063
+ const camera = await deps.resolveCamera(deviceId);
55064
+ if (camera === null) return {
55065
+ kind: "refused",
55066
+ code: "fetch-failed",
55067
+ detail: `device ${String(deviceId)} is not an Amcrest camera`
55068
+ };
55069
+ const row = rowsByKey.get(rowKey(deviceId, clipKey));
55070
+ if (row === void 0) return {
55071
+ kind: "refused",
55072
+ code: "fetch-failed",
55073
+ detail: "no-catalog-row: list the window that holds this clip first"
55074
+ };
55075
+ return await prepareClipFile({
55076
+ deviceId,
55077
+ row
55078
+ }, {
55079
+ logger,
55080
+ store,
55081
+ client: camera.client,
55082
+ ...deps.ffmpegBinaryPath !== void 0 ? { ffmpegBinaryPath: deps.ffmpegBinaryPath } : {}
55083
+ });
55084
+ };
55085
+ return {
55086
+ provider: createAmcrestVideoclipsProvider({
55087
+ now,
55088
+ logger,
55089
+ resolveDevice,
55090
+ probeAvailability: async (device) => {
55091
+ const camera = await deps.resolveCamera(device.deviceId);
55092
+ if (camera === null) return {
55093
+ state: "unreachable",
55094
+ reason: "this device is not an Amcrest camera"
55095
+ };
55096
+ const tags = { deviceId: device.deviceId };
55097
+ const storage = await camera.readStorage();
55098
+ if (storage !== null) {
55099
+ logger.info("videoclips: the storage read settled this source", {
55100
+ tags,
55101
+ meta: {
55102
+ branch: storage.state,
55103
+ reason: storage.reason ?? null
55104
+ }
55105
+ });
55106
+ return storage;
55107
+ }
55108
+ const clockRefusal = availabilityFromClock(await clockFor(camera));
55109
+ if (clockRefusal !== null) return clockRefusal;
55110
+ return {
55111
+ state: "ok",
55112
+ catalogAsOf: now()
55113
+ };
55114
+ },
55115
+ readCatalog: async ({ device, sinceMs, untilMs }) => {
55116
+ const camera = await deps.resolveCamera(device.deviceId);
55117
+ if (camera === null) return {
55118
+ kind: "refused",
55119
+ availability: {
55120
+ state: "unreachable",
55121
+ reason: "this device is not an Amcrest camera"
55122
+ }
55123
+ };
55124
+ const storage = await camera.readStorage();
55125
+ if (storage !== null) return {
55126
+ kind: "refused",
55127
+ availability: storage
55128
+ };
55129
+ const clock = await clockFor(camera);
55130
+ const clockRefusal = availabilityFromClock(clock);
55131
+ if (clockRefusal !== null || clock === null) return {
55132
+ kind: "refused",
55133
+ availability: clockRefusal ?? {
55134
+ state: "unreachable",
55135
+ reason: "no camera clock"
55136
+ }
55137
+ };
55138
+ const outcome = await readClipRows({
55139
+ rpc: rpcFor(camera),
55140
+ logger,
55141
+ deviceId: device.deviceId
55142
+ }, {
55143
+ channel: device.findChannel,
55144
+ sinceMs,
55145
+ untilMs,
55146
+ clock
55147
+ });
55148
+ switch (outcome.kind) {
55149
+ case "unreachable":
55150
+ logger.warn("videoclips: the catalog search did not complete", {
55151
+ tags: { deviceId: device.deviceId },
55152
+ meta: {
55153
+ branch: "camera-refused",
55154
+ error: outcome.detail
55155
+ }
55156
+ });
55157
+ return {
55158
+ kind: "refused",
55159
+ availability: {
55160
+ state: "unreachable",
55161
+ reason: outcome.detail
55162
+ }
55163
+ };
55164
+ case "index-empty":
55165
+ logger.warn("videoclips: the camera’s index answers nothing anywhere", {
55166
+ tags: { deviceId: device.deviceId },
55167
+ meta: {
55168
+ branch: "index-empty",
55169
+ error: outcome.detail
55170
+ }
55171
+ });
55172
+ return {
55173
+ kind: "refused",
55174
+ availability: {
55175
+ state: "index-empty",
55176
+ reason: outcome.detail
55177
+ }
55178
+ };
55179
+ case "empty-window": return { kind: "empty" };
55180
+ case "ok":
55181
+ for (const row of outcome.rows) rowsByKey.set(rowKey(device.deviceId, row.clipKey), row);
55182
+ logger.info("videoclips: the camera answered a catalog search", {
55183
+ tags: { deviceId: device.deviceId },
55184
+ meta: {
55185
+ rows: outcome.rows.length,
55186
+ pages: outcome.pages,
55187
+ rejected: outcome.rejectedCount,
55188
+ findChannel: device.findChannel,
55189
+ oldestStartMs: outcome.rows[0]?.startMs ?? null,
55190
+ newestStartMs: outcome.rows[outcome.rows.length - 1]?.startMs ?? null
55191
+ }
55192
+ });
55193
+ return {
55194
+ kind: "ok",
55195
+ rows: outcome.rows
55196
+ };
55197
+ }
55198
+ },
55199
+ playbackUrl: (deviceId, clipKey) => `/addon/${AMCREST_ADDON_ID}/${CLIP_MEDIA_PREFIX}/${String(deviceId)}/${clipKey}.mp4`,
55200
+ prepareBytes: async ({ device, row, maxBytes }) => {
55201
+ rowsByKey.set(rowKey(device.deviceId, row.clipKey), row);
55202
+ const file = await fileFor(device.deviceId, row.clipKey);
55203
+ if (file.kind === "refused") return {
55204
+ kind: "refused",
55205
+ code: file.code,
55206
+ detail: file.detail
55207
+ };
55208
+ if (file.bytes > maxBytes) return {
55209
+ kind: "refused",
55210
+ code: "too-large-to-transfer",
55211
+ detail: `clip "${row.clipKey}" is ${String(file.bytes)} bytes, over the ${String(maxBytes)}-byte bound for an inline read — play it from its playback URL`
55212
+ };
55213
+ return {
55214
+ kind: "ok",
55215
+ bytes: await (0, node_fs_promises.readFile)(file.path),
55216
+ durationMs: file.durationMs
55217
+ };
55218
+ },
55219
+ prepareOffer: async ({ device, row, maxBytes }) => {
55220
+ const peerBytes = deps.peerBytes;
55221
+ if (peerBytes === void 0) {
55222
+ logger.warn("videoclips: no peer-bytes transport on this node", {
55223
+ tags: { deviceId: device.deviceId },
55224
+ meta: {
55225
+ clipKey: row.clipKey,
55226
+ branch: "no-peer-bytes"
55227
+ }
55228
+ });
55229
+ return {
55230
+ kind: "refused",
55231
+ code: "fetch-failed",
55232
+ detail: "this node has no addon-to-addon byte transport wired"
55233
+ };
55234
+ }
55235
+ rowsByKey.set(rowKey(device.deviceId, row.clipKey), row);
55236
+ const file = await fileFor(device.deviceId, row.clipKey);
55237
+ if (file.kind === "refused") return {
55238
+ kind: "refused",
55239
+ code: file.code,
55240
+ detail: file.detail
55241
+ };
55242
+ if (file.bytes > maxBytes) return {
55243
+ kind: "refused",
55244
+ code: "too-large-to-transfer",
55245
+ detail: `clip "${row.clipKey}" is ${String(file.bytes)} bytes, over the ${String(maxBytes)}-byte bound for one transfer`
55246
+ };
55247
+ const offered = await peerBytes.offerFile({
55248
+ path: file.path,
55249
+ contentType: "video/mp4",
55250
+ label: "amcrest-clip",
55251
+ deviceId: device.deviceId
55252
+ });
55253
+ if (offered.kind === "refused") {
55254
+ logger.warn("videoclips: the clip byte offer could not be minted", {
55255
+ tags: { deviceId: device.deviceId },
55256
+ meta: {
55257
+ clipKey: row.clipKey,
55258
+ branch: offered.code,
55259
+ detail: offered.detail
55260
+ }
55261
+ });
55262
+ return {
55263
+ kind: "refused",
55264
+ code: "fetch-failed",
55265
+ detail: `${offered.code}: ${offered.detail}`
55266
+ };
55267
+ }
55268
+ return {
55269
+ kind: "ok",
55270
+ ticket: offered.ticket,
55271
+ durationMs: file.durationMs,
55272
+ bytes: file.bytes
55273
+ };
55274
+ }
55275
+ }),
55276
+ mediaPrefix: CLIP_MEDIA_PREFIX,
55277
+ mediaHandler: createClipPlaybackHandler({
55278
+ logger,
55279
+ resolvePath: async (deviceId, clipKey) => {
55280
+ const file = await fileFor(deviceId, clipKey);
55281
+ if (file.kind === "ok") return file.path;
55282
+ logger.warn("videoclips: a clip file was refused", {
55283
+ tags: { deviceId },
55284
+ meta: {
55285
+ clipKey,
55286
+ branch: file.code,
55287
+ reason: file.detail
55288
+ }
55289
+ });
55290
+ return null;
55291
+ }
55292
+ }),
55293
+ forgetDevice: (deviceId) => {
55294
+ rpcByDevice.get(deviceId)?.reset();
55295
+ rpcByDevice.delete(deviceId);
55296
+ clockByDevice.delete(deviceId);
55297
+ }
55298
+ };
55299
+ }
51708
55300
  //#endregion
51709
55301
  //#region ../../node_modules/xml2js/lib/defaults.js
51710
55302
  var require_defaults = /* @__PURE__ */ __commonJSMin(((exports) => {
@@ -60770,7 +64362,39 @@ var AmcrestProviderAddon = class extends BaseDeviceProvider {
60770
64362
  throw new Error(`Amcrest: probe on ${host || "(unknown host)"} resolved neither mac nor host address — cannot persist a stable row key. Verify network reachability + credentials, then retry.`);
60771
64363
  }
60772
64364
  async onInitialize() {
60773
- return await super.onInitialize();
64365
+ const regs = await super.onInitialize();
64366
+ this.clipService = createClipService({
64367
+ dataDir: this.ctx.dataDir,
64368
+ logger: this.ctx.logger,
64369
+ ...this.ctx.peerBytes !== void 0 ? { peerBytes: this.ctx.peerBytes } : {},
64370
+ resolveCamera: async (deviceId) => {
64371
+ const device = this.ctx.kernel.deviceRegistry?.getById(deviceId);
64372
+ return device instanceof AmcrestCamera ? device.getClipCamera() : null;
64373
+ }
64374
+ });
64375
+ regs.push({
64376
+ capability: videoclipsCapability,
64377
+ provider: this.clipService.provider
64378
+ });
64379
+ await this.serveClipPlane(this.clipService);
64380
+ return regs;
64381
+ }
64382
+ /** The clip service: catalog, the player's file route, the export's bytes. */
64383
+ clipService = null;
64384
+ async serveClipPlane(service) {
64385
+ try {
64386
+ const media = await this.ctx.dataPlane?.serve({
64387
+ prefix: service.mediaPrefix,
64388
+ access: "authenticated",
64389
+ handler: service.mediaHandler
64390
+ });
64391
+ this.ctx.logger.info("Amcrest clip data plane served", { meta: {
64392
+ mediaPath: `/addon/${AMCREST_ADDON_ID}/${service.mediaPrefix}`,
64393
+ mediaServed: media !== void 0
64394
+ } });
64395
+ } catch (err) {
64396
+ this.ctx.logger.warn("Amcrest clip data plane failed to serve — clips will not play", { meta: { error: err instanceof Error ? err.message : String(err) } });
64397
+ }
60774
64398
  }
60775
64399
  async supportsDiscovery() {
60776
64400
  return true;