@camstack/addon-provider-reolink 1.2.145 → 1.2.147

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM, r as __exportAll, t as __commonJSMin } from "./chunk-CNf5ZN-e.mjs";
2
- import { A as splitAnnexBToNalPayloads, C as extractSpsFromAnnexB, D as md5StrModern, E as isH265Irap, M as talkTraceLog, N as traceLog, O as normalizeDebugOptions, S as extractPpsFromAnnexB, T as getH265NalType, _ as convertToAnnexB, a as BC_MAGIC, b as deriveAesKey, c as BaichuanVideoStream, d as __require$1, f as aesDecrypt, g as bcHeaderHasPayloadOffset, h as bcEncrypt, i as BC_CLASS_MODERN_24, j as splitAnnexBToNalPayloads2, k as recordingsTraceLog, l as BcMediaAnnexBDecoder, m as bcDecrypt, n as BC_CLASS_LEGACY, o as BC_MAGIC_REV, p as aesEncrypt, r as BC_CLASS_MODERN_20, t as BC_CLASS_FILE_DOWNLOAD, v as convertToAnnexB2, w as extractVpsFromAnnexB, x as eventTraceLog, y as debugLog } from "./chunk-4KBXJ7U3-CCQfaWmi.mjs";
2
+ import { A as splitAnnexBToNalPayloads, C as extractSpsFromAnnexB, D as md5StrModern, E as isH265Irap, M as talkTraceLog, N as traceLog, O as normalizeDebugOptions, S as extractPpsFromAnnexB, T as getH265NalType, _ as convertToAnnexB, a as BC_MAGIC, b as deriveAesKey, c as BaichuanVideoStream, d as __require$1, f as aesDecrypt, g as bcHeaderHasPayloadOffset, h as bcEncrypt, i as BC_CLASS_MODERN_24, j as splitAnnexBToNalPayloads2, k as recordingsTraceLog, l as BcMediaAnnexBDecoder, m as bcDecrypt, n as BC_CLASS_LEGACY, o as BC_MAGIC_REV, p as aesEncrypt, r as BC_CLASS_MODERN_20, t as BC_CLASS_FILE_DOWNLOAD, v as convertToAnnexB2, w as extractVpsFromAnnexB, x as eventTraceLog, y as debugLog } from "./chunk-ZBACPERX-cDJK1KXE.mjs";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { EventEmitter } from "events";
5
5
  import * as fs2 from "fs";
@@ -5385,7 +5385,7 @@ var ZodIssueCode = {
5385
5385
  var ZodFirstPartyTypeKind;
5386
5386
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5387
5387
  //#endregion
5388
- //#region ../types/dist/sleep-CopaBJss.mjs
5388
+ //#region ../types/dist/sleep-CaB_hL5g.mjs
5389
5389
  /**
5390
5390
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5391
5391
  * window to float samples (D455).
@@ -5585,12 +5585,6 @@ Object.fromEntries([
5585
5585
  icon: "circle-dot",
5586
5586
  order: 40
5587
5587
  },
5588
- {
5589
- id: "clips",
5590
- label: "Clips",
5591
- icon: "clapperboard",
5592
- order: 41
5593
- },
5594
5588
  {
5595
5589
  id: "engine",
5596
5590
  label: "Inference Engine",
@@ -7438,6 +7432,8 @@ var EVENT_OWNER_TYPES = [
7438
7432
  * nothing failing until a caller asked.
7439
7433
  */
7440
7434
  var EventOwnerTypeSchema = _enum(EVENT_OWNER_TYPES);
7435
+ /** The same list as a Zod enum, for the cap input that carries it. */
7436
+ var MediaPresenceOwnerKindSchema = _enum([...EVENT_OWNER_TYPES, "track"]);
7441
7437
  new Set(EVENT_OWNER_TYPES);
7442
7438
  var EncodeProfileSchema = object({
7443
7439
  video: object({
@@ -14983,8 +14979,6 @@ method(object({
14983
14979
  deviceId: number(),
14984
14980
  capName: string(),
14985
14981
  wrapperAddonId: string(),
14986
- /** The `ClipSource.source` id to toggle. Absent = all of the addon's. */
14987
- sourceId: string().optional(),
14988
14982
  active: boolean()
14989
14983
  }), _void(), {
14990
14984
  kind: "mutation",
@@ -22013,7 +22007,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
22013
22007
  ownerType: EventOwnerTypeSchema,
22014
22008
  eventId: number().int(),
22015
22009
  deviceId: number()
22016
- }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
22010
+ }), array(MediaFileInfoSchema).readonly()), method(object({
22011
+ deviceId: number(),
22012
+ ownerKind: MediaPresenceOwnerKindSchema,
22013
+ ownerIds: array(string()).max(5e3)
22014
+ }), array(string()).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
22017
22015
  kind: "mutation",
22018
22016
  auth: "admin"
22019
22017
  }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
@@ -25111,6 +25109,24 @@ method(VectorDeclareIndexInputSchema, _void(), {
25111
25109
  kind: "mutation",
25112
25110
  auth: "admin"
25113
25111
  }), method(VectorStatsInputSchema, VectorStatsResultSchema, { auth: "admin" });
25112
+ _enum([
25113
+ "queue-full",
25114
+ "camera-backoff",
25115
+ "sleeping",
25116
+ "camera-refused",
25117
+ "no-keyframe",
25118
+ "no-catalog-row",
25119
+ "unsupported",
25120
+ "unknown-device",
25121
+ "uid-missing"
25122
+ ]);
25123
+ _enum(["deferred", "final"]);
25124
+ /** The camera's / provider's own words. Kept verbatim, never parsed. */
25125
+ var CLIP_STILL_REASON_HEADER = "x-camstack-reason";
25126
+ /** One {@link ClipStillRefusalCodeSchema} token, alone. */
25127
+ var CLIP_STILL_REASON_CODE_HEADER = "x-camstack-reason-code";
25128
+ /** One {@link ClipStillDispositionSchema} token, alone. */
25129
+ var CLIP_STILL_DISPOSITION_HEADER = "x-camstack-disposition";
25114
25130
  var ClipSchema = object({
25115
25131
  /** Opaque, provider-namespaced id. The default provider encodes the time
25116
25132
  * window so `getClipPlayback` is self-contained (no event re-query). */
@@ -25261,7 +25277,25 @@ var ClipSchema = object({
25261
25277
  */
25262
25278
  playable: boolean().optional(),
25263
25279
  /** Why {@link playable} is false, verbatim (`no-file-for-window`). */
25264
- unplayableReason: string().optional()
25280
+ unplayableReason: string().optional(),
25281
+ /**
25282
+ * This clip is STILL BEING WRITTEN, so {@link ClipSchema.timeRange}`.endMs`
25283
+ * is not its end.
25284
+ *
25285
+ * Absent — the common case — means the row is closed and its `endMs` is the
25286
+ * end of the recording. Present and `true` means the source told us the file
25287
+ * has no end yet, and the `endMs` we carry is whatever the camera's index
25288
+ * entry happened to hold: on a Reolink E1 Outdoor PoE (592, measured
25289
+ * 2026-09-20) the newest file `…_192927_000000_…_0.mp4` reported an `endTime`
25290
+ * of 19:29:56 and STILL reported it eight minutes later, while the file went
25291
+ * on growing. A surface that drew 29 s there was lying about a clip that
25292
+ * plays for minutes, on the one row the operator looks at first.
25293
+ *
25294
+ * `endMs` is deliberately still a number: it is the best bound anything has
25295
+ * for a mint window or a byte fetch, and every consumer already requires it.
25296
+ * This flag says what it is WORTH, and a duration is not drawn from it.
25297
+ */
25298
+ inProgress: boolean().optional()
25265
25299
  });
25266
25300
  var ClipPlaybackSchema = object({
25267
25301
  /**
@@ -25344,59 +25378,17 @@ var ClipSourceSchema = object({
25344
25378
  * alternative — a `native:reolink:* → provider-reolink` table inside the
25345
25379
  * widget — is a second authority on provider identity living in the one
25346
25380
  * package with no business knowing it, wrong the day a third source appears
25347
- * (D557). One addon may serve SEVERAL sources: `addon-provider-reolink`
25348
- * answers a hub child with both `native:reolink:onboard` and
25349
- * `native:reolink:hub`, which is why the per-device switch is keyed by
25350
- * SOURCE and not by this (D555).
25381
+ * (D557). One addon may serve SEVERAL sources, which is why the per-device
25382
+ * switch is keyed by SOURCE and not by this (D555) — `addon-provider-reolink`
25383
+ * served a hub child two of them until the two views were measured to be one
25384
+ * store read twice (D555) and then read ONE way (D565).
25351
25385
  *
25352
25386
  * Optional for version skew only. The collection dispatcher stamps it from
25353
25387
  * the registry, so a row that travelled through the fan-out carries the
25354
25388
  * authoritative id whatever the provider filled in.
25355
25389
  */
25356
25390
  addonId: string().optional(),
25357
- /**
25358
- * Which API this source resolved to FOR THIS CAMERA, when it has a choice.
25359
- *
25360
- * A source may cover one store through more than one surface — the Reolink
25361
- * provider reads a hub child through the parent's event log and a standalone
25362
- * through its own file list, because that is what each camera answers. The
25363
- * CHOICE is the provider's, made from what the camera is, and is never a row
25364
- * the operator has to understand; but it is REPORTED, because a source that
25365
- * silently reads a different API on two cameras and then behaves differently
25366
- * is the thing nobody can debug later. Absent when the source has only one
25367
- * way to read its store.
25368
- */
25369
- via: string().optional(),
25370
- availability: ClipSourceAvailabilitySchema,
25371
- /**
25372
- * The operator switched this source OFF for this camera.
25373
- *
25374
- * Deliberately NOT a member of {@link ClipSourceAvailabilitySchema}'s
25375
- * vocabulary. That enum models what the source CAN do — a sleeping camera, an
25376
- * unmounted card, an index that disagrees with its own calendar — and a
25377
- * switched-off source could answer perfectly well; the operator decided it
25378
- * should not. Folding the choice in is how `disabled` and `broken` stop being
25379
- * distinguishable, which is the D62 rule this repo has already paid for twice:
25380
- * an off switch is REPORTED off (`CameraStatus.switchedOff`, the same word),
25381
- * and disabled must never look like broken. It is also what lets every
25382
- * exhaustive consumer of the availability enum keep compiling.
25383
- *
25384
- * A switched-off source contributes NO clips (`listClips` never calls it) and
25385
- * its row carries no `catalogAsOf`: nothing confirms a catalog it is not
25386
- * allowed to serve, and a frozen age that can only grow draws a stalling
25387
- * source rather than an off switch.
25388
- *
25389
- * The row survives BECAUSE it is the control the operator switches the source
25390
- * back on from — D554 decision 4's rule ("a source that cannot answer
25391
- * produces a ROW, not an absence") applied to the one case D556 carved out of
25392
- * it, and D557's own kept property ("a source is never hidden, only its
25393
- * rows"). Absent means on.
25394
- *
25395
- * A provider never sets this — like {@link ClipSourceSchema.addonId} it is
25396
- * stamped by the collection dispatcher, which holds the registry's projection
25397
- * of the persisted authority (D556) and is the only place that knows it.
25398
- */
25399
- switchedOff: boolean().optional()
25391
+ availability: ClipSourceAvailabilitySchema
25400
25392
  });
25401
25393
  var videoclipsCapability = {
25402
25394
  name: "videoclips",
@@ -25416,17 +25408,31 @@ var videoclipsCapability = {
25416
25408
  * `device-config-widget-wrapped-binding.spec.ts` pins that a `kind:'wrapped'`
25417
25409
  * binding entry derives the same section a native one does.
25418
25410
  *
25419
- * `topTab` because the browser owns the whole pane (a day's clips, a source
25420
- * list and a player), exactly as `ptz` does; the widget itself is
25421
- * `host/clips-browser` in ui-library's `HOST_WIDGETS`.
25411
+ * **It is a SECTION of the Recording tab, at the end of it — not a tab of
25412
+ * its own.** It shipped as a `clips` top-tab and the operator rejected the
25413
+ * placement: *"utilizzerei la stessa tab recordings, lì abbiamo già tutto il
25414
+ * necessario, una nuova sezione alla fine per le clips"*. The Recording tab
25415
+ * already holds the recorder's panel, the schedule bands and the unified
25416
+ * retention policy; footage the camera itself holds is the same question,
25417
+ * asked of a different store. `order: 100` puts it after all of them with
25418
+ * room left in front. The `clips` entry in `WELL_KNOWN_TABS` went with it —
25419
+ * a well-known id nobody declares is an invitation to mint the tab again.
25420
+ *
25421
+ * `topTab` stays: the browser owns a pane (a day's tiles, a source list and
25422
+ * a player) and the Config tab's inner bar has no room for one. The widget
25423
+ * itself is `host/clips-browser` in ui-library's `HOST_WIDGETS`, and because
25424
+ * the admin Recordings page renders every `location:'top-tab'` +
25425
+ * `tab:'recording'` section behind its camera picker
25426
+ * (`CameraRecordingSettingsSection`), this declaration lands the section on
25427
+ * BOTH surfaces with no second wiring.
25422
25428
  */
25423
25429
  deviceConfig: { ui: {
25424
25430
  kind: "widget",
25425
25431
  widgetId: "host/clips-browser",
25426
- tab: "clips",
25432
+ tab: "recording",
25427
25433
  topTab: true,
25428
25434
  label: "Clips",
25429
- order: 0
25435
+ order: 100
25430
25436
  } },
25431
25437
  methods: {
25432
25438
  listClips: method(object({
@@ -25435,13 +25441,23 @@ var videoclipsCapability = {
25435
25441
  until: number(),
25436
25442
  limit: number().int().positive().optional(),
25437
25443
  /**
25438
- * View filter over {@link ClipSourceSchema.source} values — the
25439
- * picker's selection, forwarded so a provider need not list what
25440
- * nobody is looking at. ABSENT means every source this camera has,
25441
- * which is the honest default for a surface whose whole point is that
25442
- * nothing is hidden (D554 3). A provider with one source ignores it.
25444
+ * WHICH provider to ask — the `addonId` a {@link ClipSourceSchema} row
25445
+ * carries, never a source id and never a list.
25446
+ *
25447
+ * Absent means the device's BOUND provider, which is CamStack on every
25448
+ * camera today because that is what `deviceManager.getBindings`
25449
+ * answers with. A provider the device is not bound to is refused by
25450
+ * name rather than answered by another one (D552's
25451
+ * `rejectUnresolvedAddonPin` rule).
25452
+ *
25453
+ * It replaced `sources?: string[]`, a VIEW filter over source ids that
25454
+ * assumed the answer was a fan-out over everything a camera has. The
25455
+ * operator settled otherwise on 2026-09-20 — *"il get videoclips
25456
+ * prendere un provider opzionale, di default va su camstack"*,
25457
+ * *"l'utilizzatore è uno solo"* — so the list is asked of one provider
25458
+ * at a time and there is nothing to filter out of it.
25443
25459
  */
25444
- sources: array(string()).optional()
25460
+ provider: string().optional()
25445
25461
  }), array(ClipSchema).readonly(), {
25446
25462
  kind: "query",
25447
25463
  auth: "protected"
@@ -41419,6 +41435,12 @@ Object.freeze({
41419
41435
  addonId: null,
41420
41436
  access: "view"
41421
41437
  },
41438
+ "pipelineAnalytics.ownersWithMedia": {
41439
+ capName: "pipeline-analytics",
41440
+ capScope: "device",
41441
+ addonId: null,
41442
+ access: "view"
41443
+ },
41422
41444
  "pipelineAnalytics.pauseForStorageMigration": {
41423
41445
  capName: "pipeline-analytics",
41424
41446
  capScope: "device",
@@ -45319,6 +45341,11 @@ Object.freeze({
45319
45341
  form: "single",
45320
45342
  optional: false
45321
45343
  }],
45344
+ "pipelineAnalytics.ownersWithMedia": [{
45345
+ name: "deviceId",
45346
+ form: "single",
45347
+ optional: false
45348
+ }],
45322
45349
  "pipelineAnalytics.proposeRetrainAnnotations": [{
45323
45350
  name: "deviceId",
45324
45351
  form: "single",
@@ -69338,11 +69365,11 @@ var require_yazl = /* @__PURE__ */ __commonJSMin(((exports) => {
69338
69365
  }
69339
69366
  }));
69340
69367
  //#endregion
69341
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-W55OUIVZ.js
69368
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-UIQ527B6.js
69342
69369
  var import_undici = require_undici();
69343
69370
  var import_yazl = /* @__PURE__ */ __toESM(require_yazl(), 1);
69344
69371
  var formatterCache = /* @__PURE__ */ new Map();
69345
- function formatterFor$1(timeZone) {
69372
+ function formatterFor$2(timeZone) {
69346
69373
  const cached = formatterCache.get(timeZone);
69347
69374
  if (cached) return cached;
69348
69375
  const fmt = new Intl.DateTimeFormat("en-US", {
@@ -69367,7 +69394,7 @@ function wallClockParts(d, timeZone) {
69367
69394
  minute: d.getMinutes(),
69368
69395
  second: d.getSeconds()
69369
69396
  };
69370
- const parts = formatterFor$1(timeZone).formatToParts(d);
69397
+ const parts = formatterFor$2(timeZone).formatToParts(d);
69371
69398
  const pick = (type) => {
69372
69399
  const p = parts.find((x) => x.type === type);
69373
69400
  return Number.parseInt(p?.value ?? "", 10);
@@ -75824,7 +75851,7 @@ var require_lz4 = /* @__PURE__ */ __commonJSMin(((exports) => {
75824
75851
  };
75825
75852
  }));
75826
75853
  //#endregion
75827
- //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-BHO5QGTT.js
75854
+ //#region node_modules/@apocaliss92/nodelink-js/dist/chunk-5PJ6XPBF.js
75828
75855
  var import_fxp = require_fxp();
75829
75856
  var import_lz4 = /* @__PURE__ */ __toESM(require_lz4(), 1);
75830
75857
  function encodeHeader(h) {
@@ -83928,6 +83955,128 @@ function patchMotionSensitivityListXml(currentXml, bands) {
83928
83955
  const fragment = encodeMotionSensitivityListXml(bands);
83929
83956
  return currentXml.replace(/<sensitivityInfoList[\s\S]*?(?:\/>|<\/sensitivityInfoList>)/, fragment);
83930
83957
  }
83958
+ var ADTS_SAMPLES_PER_FRAME = 1024;
83959
+ var ADTS_SAMPLE_RATES = [
83960
+ 96e3,
83961
+ 88200,
83962
+ 64e3,
83963
+ 48e3,
83964
+ 44100,
83965
+ 32e3,
83966
+ 24e3,
83967
+ 22050,
83968
+ 16e3,
83969
+ 12e3,
83970
+ 11025,
83971
+ 8e3,
83972
+ 7350,
83973
+ null,
83974
+ null,
83975
+ null
83976
+ ];
83977
+ var ADTS_PROFILES = [
83978
+ "Main",
83979
+ "LC",
83980
+ "SSR",
83981
+ "LTP"
83982
+ ];
83983
+ var parseAdtsHeader = (frame) => {
83984
+ if (frame.length < 7) return null;
83985
+ const b0 = frame[0];
83986
+ const b1 = frame[1];
83987
+ if (b0 !== 255 || (b1 & 240) !== 240) return null;
83988
+ const b2 = frame[2];
83989
+ const b3 = frame[3];
83990
+ const b4 = frame[4];
83991
+ const b5 = frame[5];
83992
+ const profile = b2 >> 6 & 3;
83993
+ const sampleRateIndex = b2 >> 2 & 15;
83994
+ const channelConfig = (b2 & 1) << 2 | b3 >> 6 & 3;
83995
+ const frameLength = (b3 & 3) << 11 | b4 << 3 | b5 >> 5 & 7;
83996
+ if (frameLength < 7) return null;
83997
+ return {
83998
+ profile,
83999
+ profileName: ADTS_PROFILES[profile] ?? "unknown",
84000
+ sampleRate: ADTS_SAMPLE_RATES[sampleRateIndex] ?? null,
84001
+ channels: channelConfig > 0 ? channelConfig : null,
84002
+ frameLength
84003
+ };
84004
+ };
84005
+ var buildRecordingAudioTrack = (params) => {
84006
+ const { codec, frames } = params;
84007
+ if (codec == null || frames.length === 0) return null;
84008
+ const data = frames.length === 1 ? frames[0] : Buffer.concat([...frames]);
84009
+ if (codec === "Adpcm") return {
84010
+ codec,
84011
+ format: "adpcm",
84012
+ data,
84013
+ frames: frames.length,
84014
+ bytes: data.length,
84015
+ sampleRate: null,
84016
+ channels: null,
84017
+ durationSeconds: null
84018
+ };
84019
+ const head = parseAdtsHeader(frames[0]);
84020
+ if (head == null) return {
84021
+ codec,
84022
+ format: "unknown",
84023
+ data,
84024
+ frames: frames.length,
84025
+ bytes: data.length,
84026
+ sampleRate: null,
84027
+ channels: null,
84028
+ durationSeconds: null
84029
+ };
84030
+ const durationSeconds = head.sampleRate != null && head.sampleRate > 0 ? frames.length * ADTS_SAMPLES_PER_FRAME / head.sampleRate : null;
84031
+ return {
84032
+ codec,
84033
+ format: "adts",
84034
+ data,
84035
+ frames: frames.length,
84036
+ bytes: data.length,
84037
+ sampleRate: head.sampleRate,
84038
+ channels: head.channels,
84039
+ durationSeconds
84040
+ };
84041
+ };
84042
+ var roundRecordingFps = (fps) => {
84043
+ if (fps > 14 && fps < 16) return 15;
84044
+ if (fps > 23 && fps < 26) return 25;
84045
+ if (fps > 29 && fps < 31) return 30;
84046
+ return Math.round(fps * 100) / 100;
84047
+ };
84048
+ var estimateVideoTiming = (params) => {
84049
+ const { timestampsUs } = params;
84050
+ const infoFps = params.infoFps != null && params.infoFps > 0 ? params.infoFps : null;
84051
+ const accessUnits = timestampsUs.length;
84052
+ if (accessUnits >= 2) {
84053
+ const spanUs = timestampsUs[accessUnits - 1] - timestampsUs[0];
84054
+ if (spanUs > 0) {
84055
+ const durationSeconds = spanUs / 1e6;
84056
+ return {
84057
+ accessUnits,
84058
+ fps: roundRecordingFps((accessUnits - 1) / durationSeconds),
84059
+ durationSeconds,
84060
+ infoFps,
84061
+ fpsSource: "timestamps"
84062
+ };
84063
+ }
84064
+ }
84065
+ if (infoFps != null) return {
84066
+ accessUnits,
84067
+ fps: infoFps,
84068
+ durationSeconds: accessUnits > 0 ? accessUnits / infoFps : null,
84069
+ infoFps,
84070
+ fpsSource: "infoFps"
84071
+ };
84072
+ return {
84073
+ accessUnits,
84074
+ fps: null,
84075
+ durationSeconds: null,
84076
+ infoFps,
84077
+ fpsSource: "unknown"
84078
+ };
84079
+ };
83931
84080
  var buildDetectionClasses = (parsed, recordType) => {
83932
84081
  const classes = [];
83933
84082
  const flags = parsed?.flags;
@@ -88842,7 +88991,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
88842
88991
  return;
88843
88992
  }
88844
88993
  entry.startInFlight = (async () => {
88845
- const { BaichuanVideoStream: BaichuanVideoStream2 } = await import("./BaichuanVideoStream-GFJX465B-D_s-iWc1.mjs");
88994
+ const { BaichuanVideoStream: BaichuanVideoStream2 } = await import("./BaichuanVideoStream-FOE2DBVZ-94yIU75L.mjs");
88846
88995
  const sessionKey = `live:object-detections:ch${entry.channel}:${entry.profile}`;
88847
88996
  const dedicated = await this.createDedicatedSession(sessionKey);
88848
88997
  const stream = new BaichuanVideoStream2({
@@ -91955,25 +92104,46 @@ ${stderr}`));
91955
92104
  async downloadRecordingDemuxed(params) {
91956
92105
  const raw = await this.downloadRecording(params);
91957
92106
  const frames = [];
92107
+ const timestampsUs = [];
92108
+ const audioFrames = [];
91958
92109
  const decoder = new BcMediaAnnexBDecoder({
91959
92110
  strict: false,
91960
92111
  logger: this.logger,
91961
- onVideoAccessUnit: ({ annexB }) => {
92112
+ onVideoAccessUnit: ({ annexB, microseconds }) => {
91962
92113
  frames.push(annexB);
92114
+ timestampsUs.push(microseconds);
92115
+ },
92116
+ onAudioFrame: ({ data }) => {
92117
+ audioFrames.push(data);
91963
92118
  }
91964
92119
  });
91965
92120
  decoder.push(raw);
91966
92121
  const stats = decoder.getStats();
92122
+ const audio = buildRecordingAudioTrack({
92123
+ codec: stats.audioType,
92124
+ frames: audioFrames
92125
+ });
92126
+ const timing = estimateVideoTiming({
92127
+ timestampsUs,
92128
+ infoFps: stats.infos[0]?.fps ?? null
92129
+ });
92130
+ if (audio == null) this.logger?.debug?.(`[downloadRecordingDemuxed] no audio packet in ${stats.packets} BcMedia packets (${stats.bytesIn} bytes) for ${params.fileName}`);
91967
92131
  return {
91968
92132
  annexB: Buffer.concat(frames),
91969
92133
  videoType: stats.videoType,
92134
+ audio,
91970
92135
  stats: {
91971
92136
  bytesIn: stats.bytesIn,
91972
92137
  bytesOut: stats.bytesOut,
91973
92138
  packets: stats.packets,
91974
92139
  videoPackets: stats.videoPackets,
91975
92140
  audioPackets: stats.audioPackets,
91976
- keyframes: stats.keyframes
92141
+ audioBytes: stats.audioBytesOut,
92142
+ keyframes: stats.keyframes,
92143
+ fps: timing.fps,
92144
+ durationSeconds: timing.durationSeconds,
92145
+ infoFps: timing.infoFps,
92146
+ fpsSource: timing.fpsSource
91977
92147
  }
91978
92148
  };
91979
92149
  }
@@ -92019,28 +92189,12 @@ ${stderr}`));
92019
92189
  decoder.push(raw);
92020
92190
  const decoderStats = decoder.getStats();
92021
92191
  const videoCodec = decoderStats.videoType ?? "H264";
92022
- let fps;
92023
- let durationSeconds;
92024
- if (videoFrames.length >= 2) {
92025
- const firstTs = videoFrames[0].microseconds;
92026
- const durationUs = videoFrames[videoFrames.length - 1].microseconds - firstTs;
92027
- if (durationUs > 0) {
92028
- durationSeconds = durationUs / 1e6;
92029
- fps = (videoFrames.length - 1) / durationSeconds;
92030
- } else {
92031
- const infoFps = decoderStats.infos[0]?.fps;
92032
- fps = infoFps && infoFps > 0 ? infoFps : 15;
92033
- durationSeconds = videoFrames.length / fps;
92034
- }
92035
- } else {
92036
- const infoFps = decoderStats.infos[0]?.fps;
92037
- fps = infoFps && infoFps > 0 ? infoFps : 15;
92038
- durationSeconds = videoFrames.length / fps;
92039
- }
92040
- if (fps > 14 && fps < 16) fps = 15;
92041
- else if (fps > 23 && fps < 26) fps = 25;
92042
- else if (fps > 29 && fps < 31) fps = 30;
92043
- else fps = Math.round(fps * 100) / 100;
92192
+ const timing = estimateVideoTiming({
92193
+ timestampsUs: videoFrames.map((f) => f.microseconds),
92194
+ infoFps: decoderStats.infos[0]?.fps ?? null
92195
+ });
92196
+ const fps = timing.fps ?? 15;
92197
+ const durationSeconds = timing.durationSeconds ?? videoFrames.length / fps;
92044
92198
  const videoData = Buffer.concat(videoFrames.map((f) => f.annexB));
92045
92199
  const audioData = audioFrames.length > 0 ? Buffer.concat(audioFrames) : null;
92046
92200
  const hasAudio = audioData != null && audioData.length > 0;
@@ -94993,7 +95147,7 @@ ${xml}`);
94993
95147
  * @returns Test results for all stream types and profiles
94994
95148
  */
94995
95149
  async testChannelStreams(channel, logger) {
94996
- const { testChannelStreams } = await import("./DiagnosticsTools-DZ5RWXSM-9NV95vRN.mjs");
95150
+ const { testChannelStreams } = await import("./DiagnosticsTools-ZC7I7QM6-9NV95vRN.mjs");
94997
95151
  return await testChannelStreams({
94998
95152
  api: this,
94999
95153
  channel: this.normalizeChannel(channel),
@@ -95009,7 +95163,7 @@ ${xml}`);
95009
95163
  * @returns Complete diagnostics for all channels and streams
95010
95164
  */
95011
95165
  async collectMultifocalDiagnostics(logger) {
95012
- const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-DZ5RWXSM-9NV95vRN.mjs");
95166
+ const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-ZC7I7QM6-9NV95vRN.mjs");
95013
95167
  return await collectMultifocalDiagnostics({
95014
95168
  api: this,
95015
95169
  logger
@@ -144071,7 +144225,7 @@ function buildAacAudioSpecificConfigHex(params) {
144071
144225
  if (!Number.isFinite(audioObjectType) || audioObjectType <= 0 || audioObjectType > 31) return;
144072
144226
  return ((audioObjectType & 31) << 11 | (samplingFreqIndex & 15) << 7 | (channels & 15) << 3).toString(16).padStart(4, "0");
144073
144227
  }
144074
- function parseAdtsHeader(adtsFrame) {
144228
+ function parseAdtsHeader2(adtsFrame) {
144075
144229
  if (adtsFrame.length < 7) return null;
144076
144230
  if (adtsFrame[0] !== 255 || (adtsFrame[1] & 240) !== 240) return null;
144077
144231
  const protectionAbsent = (adtsFrame[1] & 1) === 1;
@@ -144176,7 +144330,7 @@ function packetizeH265(nal, rtp, opts, markerOnLast, isLastNal) {
144176
144330
  return out;
144177
144331
  }
144178
144332
  function packetizeAacAdtsFrame(adts, rtp) {
144179
- const parsed = parseAdtsHeader(adts);
144333
+ const parsed = parseAdtsHeader2(adts);
144180
144334
  if (!parsed) return { packets: [] };
144181
144335
  const raw = adts.subarray(parsed.headerLength);
144182
144336
  if (!raw.length) return { packets: [] };
@@ -145826,7 +145980,7 @@ async function createRfc4571TcpServerInternal(options) {
145826
145980
  }, isCompositeStream ? Math.min(1e4, keyframeTimeoutMs) : 5e3);
145827
145981
  const onAudio = (frame) => {
145828
145982
  sawAnyAudio = true;
145829
- const parsed = parseAdtsHeader(frame);
145983
+ const parsed = parseAdtsHeader2(frame);
145830
145984
  if (!parsed) {
145831
145985
  if (debugLogsLeft-- > 0) {
145832
145986
  const head = frame.subarray(0, Math.min(16, frame.length)).toString("hex");
@@ -147009,6 +147163,23 @@ var reolinkCameraSchema = object({
147009
147163
  motionEnabled: boolean().optional(),
147010
147164
  motionSensitivity: number().int().min(1).max(50).optional(),
147011
147165
  /**
147166
+ * Per-device clip-catalog refresh interval, in minutes — the OVERRIDE half
147167
+ * of the cascade whose global default lives in the addon store under the
147168
+ * same key. Absent means "use the global", which is the same thing a reset
147169
+ * means (`resolveClipCatalogRefreshMinutes`, D562). `0` turns the timer off
147170
+ * for this camera. What the timer DOES with it differs by topology and the
147171
+ * field description says both: a free read on a hub child and on mains, and
147172
+ * stale-marking only on a standalone battery camera.
147173
+ */
147174
+ clipCatalogRefreshMinutes: number().int().min(0).max(1440).optional(),
147175
+ /**
147176
+ * Let the clip timer WAKE this standalone battery camera. Off by default and
147177
+ * per device only — a 15-minute forced timer is 96 wakes/day against a
147178
+ * natural rate of 1–20 clips/day on this fleet (D562). Inert on every other
147179
+ * topology, which is why it is one switch and not a second meaning.
147180
+ */
147181
+ clipCatalogForcedWake: boolean().optional(),
147182
+ /**
147012
147183
  * Per-AI-class sensitivity overrides — pushed via
147013
147184
  * `setAiDetection(aiType, sensitivity)` (cmdId=343). Reolink
147014
147185
  * range 0..100 — same scale the reolink_aio CGI exposes
@@ -147450,6 +147621,21 @@ var ClipArtifactStore = class {
147450
147621
  }
147451
147622
  };
147452
147623
  //#endregion
147624
+ //#region src/clips/clip-addressing.ts
147625
+ /** Does addressing this camera's catalog REQUIRE a persisted uid? */
147626
+ function uidIsRequired(subject) {
147627
+ return subject.isHubChild;
147628
+ }
147629
+ /**
147630
+ * Is this camera unaddressable for want of a uid? The gate every catalog,
147631
+ * still and byte read runs — and the only one that may answer `uid-missing`.
147632
+ */
147633
+ function uidIsMissing(subject) {
147634
+ return uidIsRequired(subject) && subject.uid === void 0;
147635
+ }
147636
+ /** The one wording for the refusal, so two surfaces cannot disagree about it. */
147637
+ var UID_MISSING_REASON = "uid-missing: the hub selects a child by uid, and this camera has none persisted.";
147638
+ //#endregion
147453
147639
  //#region src/clips/clip-catalog-cache.ts
147454
147640
  /**
147455
147641
  * The per-`(device, day)` clip catalog cache.
@@ -147538,6 +147724,20 @@ var ClipCatalogCache = class ClipCatalogCache {
147538
147724
  }
147539
147725
  return null;
147540
147726
  }
147727
+ /**
147728
+ * Every day key this device holds a listing for, in no particular order.
147729
+ *
147730
+ * It is what lets an availability probe say WHEN a catalog was last
147731
+ * confirmed without listing anything: a camera nobody may wake reports the
147732
+ * age of what is held, and a source with nothing held says so rather than
147733
+ * claiming to be current.
147734
+ */
147735
+ daysHeld(deviceId) {
147736
+ const out = [];
147737
+ const prefix = `${deviceId}|`;
147738
+ for (const key of this.slots.keys()) if (key.startsWith(prefix)) out.push(key.slice(prefix.length));
147739
+ return out;
147740
+ }
147541
147741
  /** Every row held for a device, in no particular order. The prefetch sorts. */
147542
147742
  allRows(deviceId) {
147543
147743
  const out = [];
@@ -147591,6 +147791,57 @@ var ClipCatalogCache = class ClipCatalogCache {
147591
147791
  }
147592
147792
  };
147593
147793
  //#endregion
147794
+ //#region src/clips/clip-still-refusal.ts
147795
+ /** "You are in the queue." */
147796
+ function deferStill(code, reason, retryAfterMs) {
147797
+ return {
147798
+ ok: false,
147799
+ code,
147800
+ disposition: "deferred",
147801
+ reason,
147802
+ retryAfterMs
147803
+ };
147804
+ }
147805
+ /** "This will not happen." */
147806
+ function refuseStill(code, reason) {
147807
+ return {
147808
+ ok: false,
147809
+ code,
147810
+ disposition: "final",
147811
+ reason
147812
+ };
147813
+ }
147814
+ /**
147815
+ * The queue's own refusal — the only failure that is about the LANE rather
147816
+ * than about the camera, and therefore the only one that is deferred.
147817
+ *
147818
+ * It carries its own `retryAfterMs` because the queue is the only thing that
147819
+ * knows it: the depth it is holding, or the exact instant a host's backoff
147820
+ * closes.
147821
+ */
147822
+ var ThumbQueueRefusedError = class extends Error {
147823
+ code;
147824
+ retryAfterMs;
147825
+ constructor(code, message, retryAfterMs) {
147826
+ super(message);
147827
+ this.name = "ThumbQueueRefusedError";
147828
+ this.code = code;
147829
+ this.retryAfterMs = retryAfterMs;
147830
+ }
147831
+ };
147832
+ /**
147833
+ * A thrown value from the queue, as a wire answer — or `null` when it was not
147834
+ * the queue at all.
147835
+ *
147836
+ * `null` is not a default: it is what makes the caller name `camera-refused`
147837
+ * explicitly, instead of a socket close quietly inheriting "come back later"
147838
+ * and a tile retrying four times against a camera that is not there.
147839
+ */
147840
+ function queueRefusal(err) {
147841
+ if (!(err instanceof ThumbQueueRefusedError)) return null;
147842
+ return deferStill(err.code, err.message, err.retryAfterMs);
147843
+ }
147844
+ //#endregion
147594
147845
  //#region src/clips/clip-data-plane.ts
147595
147846
  /**
147596
147847
  * The addon's two data-plane handlers — the still and the bytes.
@@ -147612,6 +147863,19 @@ var ClipCatalogCache = class ClipCatalogCache {
147612
147863
  * which. Waiting instead would hold a request open behind a camera that is
147613
147864
  * asleep for the night.
147614
147865
  *
147866
+ * **And it says whether to COME BACK.** Those reasons are two kinds of answer:
147867
+ * `queue-full` and `camera-backoff` mean the still is still coming, the rest
147868
+ * mean it will not. The branch that refused decides — nothing here maps a code
147869
+ * to a disposition, and no client parses one out of the prose — and the answer
147870
+ * travels as its own headers (`x-camstack-reason-code`,
147871
+ * `x-camstack-disposition`, plus `retry-after` on a deferred one) beside the
147872
+ * prose, which stays verbatim because a `camera-refused` is only ever
147873
+ * explained by the camera's own words.
147874
+ *
147875
+ * A response with no disposition header is read as FINAL by every surface,
147876
+ * which is exactly how this route behaved before: an older addon under a newer
147877
+ * admin UI keeps the old behaviour instead of gaining a retry loop.
147878
+ *
147615
147879
  * ## Why Range is hand-rolled here
147616
147880
  *
147617
147881
  * One file, one path, no root mapping — and `createReadStream().pipe(res)`
@@ -147654,26 +147918,45 @@ function createClipThumbHandler(deps) {
147654
147918
  try {
147655
147919
  minted = await deps.mint(deviceId, clipKey);
147656
147920
  } catch (err) {
147657
- minted = {
147658
- ok: false,
147659
- reason: err instanceof Error ? err.message : String(err)
147660
- };
147921
+ minted = refuseStill("camera-refused", err instanceof Error ? err.message : String(err));
147661
147922
  }
147662
147923
  if (!minted.ok) {
147663
- deps.logger.debug("videoclips: thumbnail not minted", {
147664
- tags: { deviceId },
147665
- meta: {
147666
- clipKey,
147667
- reason: minted.reason
147668
- }
147669
- });
147670
- res.writeHead(204, { "x-camstack-reason": minted.reason.slice(0, 500) });
147671
- res.end();
147924
+ writeRefusal(res, minted, deviceId, deps.logger, clipKey);
147672
147925
  return;
147673
147926
  }
147674
147927
  writeJpeg(res, minted.bytes, req.method === "HEAD");
147675
147928
  };
147676
147929
  }
147930
+ /**
147931
+ * The 204, with everything the tile needs to decide what to draw next.
147932
+ *
147933
+ * A FINAL refusal is work that was dropped, so it is warned, per camera. A
147934
+ * DEFERRED one dropped nothing — the still is queued behind another — and
147935
+ * warning it would put 43 lines on the log for one 60-tile page against a
147936
+ * 16-deep lane, which is how a real drop stops being visible.
147937
+ */
147938
+ function writeRefusal(res, refusal, deviceId, logger, clipKey) {
147939
+ const line = "videoclips: thumbnail not minted";
147940
+ const extras = {
147941
+ tags: { deviceId },
147942
+ meta: {
147943
+ clipKey,
147944
+ code: refusal.code,
147945
+ disposition: refusal.disposition,
147946
+ reason: refusal.reason
147947
+ }
147948
+ };
147949
+ if (refusal.disposition === "final") logger.warn(line, extras);
147950
+ else logger.debug(line, extras);
147951
+ const retryAfterMs = refusal.disposition === "deferred" ? refusal.retryAfterMs : void 0;
147952
+ res.writeHead(204, {
147953
+ [CLIP_STILL_REASON_HEADER]: refusal.reason.slice(0, 500),
147954
+ [CLIP_STILL_REASON_CODE_HEADER]: refusal.code,
147955
+ [CLIP_STILL_DISPOSITION_HEADER]: refusal.disposition,
147956
+ ...retryAfterMs === void 0 ? {} : { "retry-after": String(Math.max(1, Math.ceil(retryAfterMs / 1e3))) }
147957
+ });
147958
+ res.end();
147959
+ }
147677
147960
  function writeJpeg(res, bytes, headOnly) {
147678
147961
  res.writeHead(200, {
147679
147962
  "content-type": "image/jpeg",
@@ -147792,21 +148075,73 @@ function createClipPlaybackHandler(deps) {
147792
148075
  //#region src/clips/clip-mux.ts
147793
148076
  /** A clip that has not transferred by here is not going to. */
147794
148077
  var CLIP_MUX_TIMEOUT_MS = 6e4;
148078
+ /**
148079
+ * Decide what to do with the track the download returned.
148080
+ *
148081
+ * The gate is `format === 'adts'` and NOT `audio !== null`: an `adpcm` or
148082
+ * `unknown` track stream-copied into MP4 produces a file whose audio track is
148083
+ * garbage, which is worse than a clip that is honestly silent. A track that
148084
+ * arrived with no frames is no track — counting it would promise sound the
148085
+ * file does not have.
148086
+ */
148087
+ function decideClipAudio(audio) {
148088
+ if (audio === null) return {
148089
+ kind: "silent",
148090
+ reason: "no-audio-track",
148091
+ format: null
148092
+ };
148093
+ if (audio.frames === 0 || audio.bytes === 0) return {
148094
+ kind: "silent",
148095
+ reason: "no-audio-track",
148096
+ format: audio.format
148097
+ };
148098
+ if (audio.format !== "adts") return {
148099
+ kind: "silent",
148100
+ reason: "audio-not-copyable",
148101
+ format: audio.format
148102
+ };
148103
+ return {
148104
+ kind: "copy",
148105
+ format: "adts",
148106
+ data: audio.data
148107
+ };
148108
+ }
148109
+ function usableFps(fps) {
148110
+ if (fps === null || fps === void 0) return null;
148111
+ return Number.isFinite(fps) && fps > 0 ? fps : null;
148112
+ }
147795
148113
  function buildClipMuxArgs(input) {
148114
+ const demuxer = input.videoType === "H265" ? "hevc" : "h264";
148115
+ const fps = usableFps(input.fps);
147796
148116
  return [
147797
148117
  "-hide_banner",
147798
148118
  "-loglevel",
147799
148119
  "error",
147800
148120
  "-nostdin",
147801
148121
  "-y",
148122
+ ...fps !== null ? ["-r", String(fps)] : [],
147802
148123
  "-f",
147803
- input.videoType === "H265" ? "hevc" : "h264",
148124
+ demuxer,
147804
148125
  "-i",
147805
148126
  input.inputPath,
147806
- "-c",
148127
+ ...input.audio !== void 0 ? [
148128
+ "-f",
148129
+ "aac",
148130
+ "-i",
148131
+ input.audio.path
148132
+ ] : [],
148133
+ "-c:v",
147807
148134
  "copy",
148135
+ ...input.audio !== void 0 ? [
148136
+ "-c:a",
148137
+ "copy",
148138
+ "-bsf:a",
148139
+ "aac_adtstoasc"
148140
+ ] : [],
147808
148141
  "-movflags",
147809
148142
  "+faststart",
148143
+ "-f",
148144
+ "mp4",
147810
148145
  input.outputPath
147811
148146
  ];
147812
148147
  }
@@ -147893,6 +148228,40 @@ function describeMuxFailure(run) {
147893
148228
  if (run.code !== 0) return `mux-failed (exit ${String(run.code)}): ${run.stderr.slice(0, 300)}`;
147894
148229
  return null;
147895
148230
  }
148231
+ //#endregion
148232
+ //#region src/clips/clip-thumb-queue.ts
148233
+ /**
148234
+ * The provider's OWN CoverPreview lane — two queues, one in flight per host.
148235
+ *
148236
+ * The library already serialises CoverPreview: one in flight per api instance,
148237
+ * a 50-deep FIFO that throws past that, and a process-wide host-keyed backoff
148238
+ * that doubles on a 400. What a FIFO cannot express is PRIORITY, and the two
148239
+ * kinds of mint this feature has are not equal — an operator staring at a tile
148240
+ * must not wait behind twenty prefetches for clips nobody opened.
148241
+ *
148242
+ * So the provider keeps two queues of its own, hands the library at most ONE
148243
+ * CoverPreview per host at a time, drains `interactive` before `prefetch`, and
148244
+ * re-checks between every prefetch mint (D549 16).
148245
+ *
148246
+ * ## Two things it refuses to do
148247
+ *
148248
+ * **Wait when the queue is full.** A request past the bound is REFUSED with
148249
+ * `queue-full`, so the route can answer 204 with a reason. A request held open
148250
+ * behind forty-nine others is a spinner the operator cannot cancel, and the
148251
+ * camera will have rolled the clip over before it is served.
148252
+ *
148253
+ * **Let a prefetch probe a sulking host.** While a host's 400 backoff is open
148254
+ * a prefetch is skipped entirely; only an interactive request may probe it,
148255
+ * because only an interactive request has somebody waiting for the answer.
148256
+ *
148257
+ * The backoff mirrors the library's (1 s doubling to 30 s) rather than
148258
+ * replacing it: this one decides whether to ASK at all, the library's decides
148259
+ * how long to wait once asked.
148260
+ */
148261
+ /** How long one waiting mint is worth, as a retry hint. Coarse on purpose:
148262
+ * the client floors it with its own backoff and caps its own attempts, so a
148263
+ * wrong hint can neither cause a hammer nor a hang. */
148264
+ var THUMB_DRAIN_HINT_PER_WAITER_MS = 1e3;
147896
148265
  var THUMB_BACKOFF_INITIAL_MS = 1e3;
147897
148266
  var THUMB_BACKOFF_MAX_MS = 3e4;
147898
148267
  /**
@@ -147913,6 +148282,28 @@ var ClipThumbQueue = class {
147913
148282
  const state = this.hosts.get(host);
147914
148283
  return state !== void 0 && state.backoffUntilMs > this.now();
147915
148284
  }
148285
+ /**
148286
+ * Is this host busy with, or owed, operator-facing work?
148287
+ *
148288
+ * The periodic catalog refresh asks before it spends the host's one lane:
148289
+ * an operator staring at a tile must never wait behind a timer, and a host
148290
+ * whose 400 backoff is open must not be probed by anything that has nobody
148291
+ * waiting for the answer — the same rule that already keeps a PREFETCH off
148292
+ * a sulking host.
148293
+ *
148294
+ * Either a mint is in flight or the host is sulking. There is deliberately
148295
+ * no third clause for "an interactive request is queued": a queued request
148296
+ * means a mint is running, so `running` already covers it, and a clause
148297
+ * that can never be the reason for the answer is a leftover that reads as
148298
+ * verification. The lane is one per host, so a PREFETCH holding it defers
148299
+ * the refresh too — which is right: the refresh is the lowest-priority
148300
+ * thing on this host.
148301
+ */
148302
+ busy(host) {
148303
+ const state = this.hosts.get(host);
148304
+ if (state === void 0) return false;
148305
+ return state.backoffUntilMs > this.now() || state.running;
148306
+ }
147916
148307
  backoffUntil(host) {
147917
148308
  return this.hosts.get(host)?.backoffUntilMs ?? 0;
147918
148309
  }
@@ -147935,8 +148326,8 @@ var ClipThumbQueue = class {
147935
148326
  */
147936
148327
  async submit(job) {
147937
148328
  const state = this.stateFor(job.host);
147938
- if (job.lane === "prefetch" && state.backoffUntilMs > this.now()) throw new Error(`camera-backoff: host ${job.host} refused a CoverPreview recently; prefetch skipped until the backoff closes (only an interactive request probes it)`);
147939
- if (state.waiting.length >= 16) throw new Error(`queue-full: ${state.waiting.length} thumbnail mints already waiting for host ${job.host}`);
148329
+ if (job.lane === "prefetch" && state.backoffUntilMs > this.now()) throw new ThumbQueueRefusedError("camera-backoff", `camera-backoff: host ${job.host} refused a CoverPreview recently; prefetch skipped until the backoff closes (only an interactive request probes it)`, state.backoffUntilMs - this.now());
148330
+ if (state.waiting.length >= 16) throw new ThumbQueueRefusedError("queue-full", `queue-full: ${state.waiting.length} thumbnail mints already waiting for host ${job.host}`, state.waiting.length * THUMB_DRAIN_HINT_PER_WAITER_MS);
147940
148331
  return new Promise((resolve, reject) => {
147941
148332
  state.waiting.push({
147942
148333
  lane: job.lane,
@@ -147956,7 +148347,7 @@ var ClipThumbQueue = class {
147956
148347
  if (next === void 0) return;
147957
148348
  state.waiting.splice(pick, 1);
147958
148349
  if (next.lane === "prefetch" && state.backoffUntilMs > this.now()) {
147959
- next.reject(/* @__PURE__ */ new Error(`camera-backoff: host ${host} is backing off; prefetch skipped`));
148350
+ next.reject(new ThumbQueueRefusedError("camera-backoff", `camera-backoff: host ${host} is backing off; prefetch skipped`, state.backoffUntilMs - this.now()));
147960
148351
  this.drain(host);
147961
148352
  return;
147962
148353
  }
@@ -148009,12 +148400,13 @@ var ClipThumbQueue = class {
148009
148400
  * exactly — 29 events, 28 inside the 20 listed files, and all 20 files
148010
148401
  * carrying an event. They are two VIEWS of one store.
148011
148402
  *
148012
- * So the operator collapsed them (2026-09-20): one source, which picks the
148013
- * best API for what the camera IS. Which view it resolved to is REPORTED
148014
- * (`ClipSource.via`, and the listing log line) — a source that silently reads
148015
- * a different API on two cameras and then behaves differently is the thing
148016
- * nobody can debug later — but it is never a row the operator has to
148017
- * understand.
148403
+ * So the operator collapsed them (2026-09-20): one source, which picked the
148404
+ * best API for what the camera IS, and REPORTED the pick on `ClipSource.via`.
148405
+ * Later the same day the pick went too (D565): `FileInfoList` is the catalog
148406
+ * on every topology, so `via` had one value and is gone. What is left is one
148407
+ * source reading one API — and `clipKeyFor` below is still keyed off
148408
+ * `(channel, fileName)` rather than the source, which is now simply true
148409
+ * rather than a concession.
148018
148410
  */
148019
148411
  var CLIP_SOURCE_ONBOARD = "native:reolink:onboard";
148020
148412
  /** Every source this provider can name. Order is the picker's order. */
@@ -148065,10 +148457,10 @@ function parseClipId(clipId) {
148065
148457
  }
148066
148458
  /**
148067
148459
  * The on-disk key for a clip's derived artifacts (its thumbnail, its muxed
148068
- * MP4). Derived from `(channel, fileName)` and NOT from the source: the two
148069
- * views of a hub child's store are two views of the SAME file, so a thumbnail
148070
- * minted for the files view is the same picture the events view wants and
148071
- * minting it twice would cost the camera a second CoverPreview.
148460
+ * MP4). Derived from `(channel, fileName)` and NOT from the source: a
148461
+ * thumbnail belongs to the FILE, so any surface that names the same file
148462
+ * finds the same picture and the camera is never asked for a second
148463
+ * CoverPreview of it.
148072
148464
  *
148073
148465
  * A hash rather than the encoded name because a hub `<Id>` is a full path and
148074
148466
  * the encoded form is long enough to hit a file-name limit.
@@ -148217,7 +148609,7 @@ function startOf(file) {
148217
148609
  return file.startTime?.getTime() ?? 0;
148218
148610
  }
148219
148611
  var partsCache = /* @__PURE__ */ new Map();
148220
- function formatterFor(timeZone) {
148612
+ function formatterFor$1(timeZone) {
148221
148613
  const cached = partsCache.get(timeZone);
148222
148614
  if (cached !== void 0) return cached;
148223
148615
  const made = new Intl.DateTimeFormat("en-US", {
@@ -148234,7 +148626,7 @@ function formatterFor(timeZone) {
148234
148626
  return made;
148235
148627
  }
148236
148628
  function partsAt(epochMs, timeZone) {
148237
- const parts = formatterFor(timeZone).formatToParts(new Date(epochMs));
148629
+ const parts = formatterFor$1(timeZone).formatToParts(new Date(epochMs));
148238
148630
  const read = (type) => {
148239
148631
  const found = parts.find((p) => p.type === type);
148240
148632
  return found === void 0 ? 0 : Number(found.value);
@@ -148305,36 +148697,6 @@ function enumerateWallClockDays(sinceMs, untilMs, timeZone) {
148305
148697
  //#endregion
148306
148698
  //#region src/clips/clip-catalog.ts
148307
148699
  /**
148308
- * Reading a Reolink camera's own clip catalog: the cheap question, the day
148309
- * index, then one day's files.
148310
- *
148311
- * ## The order is the point
148312
- *
148313
- * 1. **Is there a card at all?** `getHddInfoList` (cmd 102). 640 answers
148314
- * `mount 0, capacity 0` — no SD card — and searching it is 400s and wasted
148315
- * wake budget. "The cheap question comes first" (D549 4 / D54).
148316
- * 2. **Which days hold footage?** `getDayRecords` (cmd 142) once per month.
148317
- * The per-day listing loop of the earlier plan is deleted, not kept as a
148318
- * fallback (D549 8): 19 days in 50 ms beats 30 searches.
148319
- * 3. **What is in one day?** `FileInfoList` (cmd 14/15/16), sub AND main, then
148320
- * twinned (D549 14b). A clip IS a file: its name is its id, its bytes
148321
- * (cmd 5) and its still (cmd 298) are addressed from it (D549 10).
148322
- *
148323
- * This is the API a STANDALONE camera's one source resolves to — a standalone
148324
- * answers `findEventLog` with an empty body. A hub child resolves to the event
148325
- * view instead (`clip-event-catalog.ts`), and still uses this reader for the
148326
- * day's files, which is what an event's window is joined against (D555).
148327
- *
148328
- * ## What an empty answer means
148329
- *
148330
- * A day the CALENDAR names and the INDEX answers with nothing is a defect on
148331
- * the camera, measured on 618: the calendar marks 3–20 September, days 6–20
148332
- * list zero files on both streams, on both filters, on a hand-built window,
148333
- * with 177 GB free. The reader reports `indexEmpty` for that day so the
148334
- * provider can say `index-empty` — never "no clips", which would be a lie
148335
- * about a card that is full of them (D549 18).
148336
- */
148337
- /**
148338
148700
  * Locate the disk record structurally.
148339
148701
  *
148340
148702
  * The library's declared `HddInfoListConfig` and the captured wire disagree —
@@ -148419,6 +148781,30 @@ function monthsBetween(sinceMs, untilMs, timeZone) {
148419
148781
  }
148420
148782
  return out;
148421
148783
  }
148784
+ /**
148785
+ * Is this file still being written?
148786
+ *
148787
+ * A Reolink recording is named `<prefix>_DST<date>_<start>_<end>_<hex>_<hex>`,
148788
+ * and while it is open the camera writes the END stamp as `000000` and the
148789
+ * size as `0` — measured on 592, 2026-09-20:
148790
+ * `RecS03_DST20260920_192927_000000_2B1E808_0.mp4`. Its `<endTime>` block is
148791
+ * FROZEN at whatever the index entry held when it was created (19:29:56, read
148792
+ * three times over fifteen minutes while the file grew), so a duration drawn
148793
+ * from it is a lie about the one row the operator sees first.
148794
+ *
148795
+ * BOTH signals are required. A recording that happens to end at midnight is
148796
+ * named `_000000_` and is closed; its bytes are what separate the two. Nothing
148797
+ * on the wire distinguishes them otherwise, and a false positive here would
148798
+ * replace a correct duration with `Recording…` for ever. Re-measured
148799
+ * read-only the same evening: of 355 sub and 355 main files on that camera's
148800
+ * day, zero carried a `_000000_` segment and zero carried a size of 0.
148801
+ */
148802
+ function isFileStillBeingWritten(file) {
148803
+ if (file.sizeBytes !== void 0 && file.sizeBytes > 0) return false;
148804
+ return OPEN_END_STAMP.test(file.fileName);
148805
+ }
148806
+ /** The end-stamp field of an open recording, between its own separators. */
148807
+ var OPEN_END_STAMP = /_000000_/;
148422
148808
  function handleOf(file) {
148423
148809
  const id = file.id;
148424
148810
  return id !== void 0 && id !== "" ? id : file.fileName;
@@ -148459,139 +148845,123 @@ async function listDayRows(api, input) {
148459
148845
  ...pair.sub.sizeBytes !== void 0 ? { subBytes: pair.sub.sizeBytes } : {},
148460
148846
  ...pair.main !== void 0 ? { mainFileName: handleOf(pair.main) } : {},
148461
148847
  ...pair.main?.sizeBytes !== void 0 ? { mainBytes: pair.main.sizeBytes } : {},
148848
+ ...pair.main?.startTime !== void 0 ? { mainStartMs: pair.main.startTime.getTime() } : {},
148462
148849
  supportSub: pair.sub.supportSub !== false,
148463
- nativeTypes: parseNativeTypes(pair.sub.recordType)
148850
+ nativeTypes: parseNativeTypes(pair.sub.recordType),
148851
+ ...isFileStillBeingWritten(pair.sub) ? { inProgress: true } : {}
148464
148852
  };
148465
148853
  }),
148466
148854
  indexEmpty: subs.length === 0,
148467
148855
  ...mainSearchFailed !== void 0 ? { mainSearchFailed } : {}
148468
148856
  };
148469
148857
  }
148470
- //#endregion
148471
- //#region src/clips/clip-event-catalog.ts
148472
- /**
148473
- * The HUB events view — the API a HUB CHILD's one source resolves to (D549 19,
148474
- * D555).
148475
- *
148476
- * On a hub child the catalog is the EVENT list, not the file list: 29 events
148477
- * in 293 ms against 20 files in 503 ms, and it is the ONLY class source there
148478
- * because a hub child's files come back with `recordType none`. The file view
148479
- * reads the SAME store from the other side — the hub's uid addresses no
148480
- * catalog at all while the child's addresses all of it, and cmd 5 serves
148481
- * identical bytes either way. Two views, one store. They were two SOURCES for
148482
- * a few hours; the operator collapsed them once that measurement landed, so
148483
- * this module is now one of the two ways the single source reads itself, and
148484
- * the file reader is the other (`clip-catalog.ts`).
148485
- *
148486
- * ## An event is not a promise of a clip
148487
- *
148488
- * A row whose `hasRecFile` is false has nothing to play, and both of the wrong
148489
- * things are easy: dropping it under-reports a camera's events, and showing it
148490
- * as playable fails on click. It is listed with `playable: false` and a
148491
- * reason, which is what `ClipSchema.unplayableReason` exists for.
148492
- *
148493
- * **How often that bites is a per-camera fact.** Measured 2026-09-20 over 20
148494
- * days: 3628 → 222 rows with 0 orphans, 3629 → 1 059 rows with 0 orphans, and
148495
- * the orphans D549 19 describes are on 3825 — the child whose uid was never
148496
- * persisted, which this provider cannot address at all today. So one camera's
148497
- * events are all playable and another's are all not, and a surface built for
148498
- * either extreme is wrong on the other.
148499
- *
148500
- * ## Where the file comes from
148501
- *
148502
- * `findEventLog` rows carry no file name — they name events, not files. So the
148503
- * file is resolved by WINDOW CONTAINMENT against the day's `FileInfoList`
148504
- * rows, which the `onboard` reader already fetches: an event whose
148505
- * `[start, end]` lies inside a file's `[start − 3 s, end + 3 s]`. 28 of 29 on
148506
- * the measured day; the one that did not is a real orphan (the hub refused its
148507
- * CoverPreview too), not a join bug.
148508
- *
148509
- * Deliberately NOT used here: `searchAlarmVideos` (272/273/274). It does carry
148510
- * a `fileName`, and its containment is measured at 1271/1271 — but it is
148511
- * clamped to ONE camera-local day (an 18→19 September window silently answered
148512
- * 462 windows all dated the 18th) where `searchEventLog` spans the whole
148513
- * window in one open, and D549 19 chose the event list on the measured timing.
148514
- * Its place is the per-window label upgrade for a clip's DETAIL (D549 20), and
148515
- * that is a separate slice.
148516
- *
148517
- * ## `devices` is the selector; `chnbits` is inert
148518
- *
148519
- * Measured: `chnbits: 3` answered exactly the same 9 rows as the default, and
148520
- * OMITTING `devices` is refused outright by the library ("no devices to
148521
- * search") rather than answered emptily — a hub that has pushed nothing yet is
148522
- * a refusal, not an empty result. This module always passes `devices`, never
148523
- * sends `chnbits`, and never derives one from the other. With one child named,
148524
- * foreign rows in the reply were 0; the uid filter below is belt and braces.
148525
- */
148526
- /**
148527
- * Every event for ONE child in the window, newest first, in ONE call.
148528
- *
148529
- * The event list is multi-day by construction — a per-day loop would pay the
148530
- * open/close for each day and answer the same rows. The bound the caller owes
148531
- * is on the WINDOW, and it has a number behind it: measured on one hub child,
148532
- * ONE day is 267 ms and 20 days is 3.6–5.4 s. A day-range default is therefore
148533
- * a UI decision, not a free one.
148534
- */
148535
- async function listEventRows(api, input) {
148536
- const entries = await api.searchEventLog({
148537
- devices: [{ uid: input.uid }],
148538
- start: new Date(input.since),
148539
- end: new Date(input.until),
148540
- timeZone: input.timeZone
148858
+ /**
148859
+ * Every alarm window of ONE camera-local day.
148860
+ *
148861
+ * The one-day clamp is the firmware's, not ours: an 18→19 September ask
148862
+ * answered 462 windows all dated the 18th. The caller owns the iteration, and
148863
+ * this is asked from inside the day loop the listing already runs.
148864
+ */
148865
+ async function readAlarmWindows(api, input) {
148866
+ const { startMs, endMs } = dayBounds(input.day, input.timeZone);
148867
+ return await api.searchAlarmVideos({
148868
+ channel: input.channel,
148869
+ start: new Date(startMs),
148870
+ end: new Date(endMs),
148871
+ streamType: 1,
148872
+ timeZone: input.timeZone,
148873
+ ...input.uid !== void 0 ? { uid: input.uid } : {}
148541
148874
  });
148542
- const rows = [];
148543
- for (const entry of entries) {
148544
- if (entry.uid !== input.uid) continue;
148545
- const startMs = entry.startTime?.getTime();
148546
- if (startMs === void 0 || !Number.isFinite(startMs)) continue;
148547
- rows.push({
148548
- channel: input.channel,
148549
- startMs,
148550
- endMs: entry.endTime?.getTime() ?? startMs,
148551
- nativeTypes: [...entry.alarmTypes],
148552
- hasRecFile: entry.hasRecFile
148553
- });
148875
+ }
148876
+ /** The camera declined to class this file: `none`, or no `recordType` at all. */
148877
+ function fileCarriesNoClass(nativeTypes) {
148878
+ return nativeTypes.every((type) => type === "none");
148879
+ }
148880
+ /**
148881
+ * Should this day's labels be asked for separately?
148882
+ *
148883
+ * Only when NOTHING in the day carried a class. One file saying `md` is proof
148884
+ * that this camera writes `recordType`, and then the free answer is the
148885
+ * answer — paying 8–9 s to restate it is the cheap-question rule inverted.
148886
+ */
148887
+ function dayNeedsAlarmLabels(rows) {
148888
+ return rows.length > 0 && rows.every((row) => fileCarriesNoClass(row.nativeTypes));
148889
+ }
148890
+ /**
148891
+ * Attach each window's `alarmType`s to the file whose START it names.
148892
+ *
148893
+ * A row the camera already classed is returned untouched: the file's own
148894
+ * `recordType` is the authority wherever it exists, and a per-window answer
148895
+ * must not narrow it. A window that names no listed file contributes nothing
148896
+ * and is COUNTED — it is never attached to the nearest neighbour, which is how
148897
+ * a surface ends up showing one recording's class on another's row.
148898
+ */
148899
+ function joinAlarmLabels(rows, windows, timeZone) {
148900
+ const byStamp = /* @__PURE__ */ new Map();
148901
+ for (const window of windows) {
148902
+ const key = window.fileName.slice(-STAMP_LENGTH);
148903
+ const existing = byStamp.get(key);
148904
+ const target = existing ?? [];
148905
+ if (existing === void 0) byStamp.set(key, target);
148906
+ for (const type of window.alarmTypes) if (type !== "" && !target.includes(type)) target.push(type);
148554
148907
  }
148555
- return rows.toSorted((a, b) => b.startMs - a.startMs);
148908
+ const claimed = /* @__PURE__ */ new Set();
148909
+ let matched = 0;
148910
+ return {
148911
+ rows: rows.map((row) => {
148912
+ if (!fileCarriesNoClass(row.nativeTypes)) return row;
148913
+ const subKey = alarmJoinKey(row.startMs, timeZone);
148914
+ const mainKey = row.mainStartMs !== void 0 ? alarmJoinKey(row.mainStartMs, timeZone) : void 0;
148915
+ const key = byStamp.has(subKey) ? subKey : mainKey !== void 0 && byStamp.has(mainKey) ? mainKey : void 0;
148916
+ if (key === void 0) return row;
148917
+ const types = byStamp.get(key);
148918
+ if (types === void 0 || types.length === 0) return row;
148919
+ claimed.add(key);
148920
+ matched += 1;
148921
+ return {
148922
+ ...row,
148923
+ nativeTypes: [...types]
148924
+ };
148925
+ }),
148926
+ matched,
148927
+ unmatchedWindows: byStamp.size - claimed.size
148928
+ };
148556
148929
  }
148557
- function joinEventsToFiles(events, files) {
148558
- return events.map((event) => {
148559
- if (!event.hasRecFile) return {
148560
- ...event,
148561
- clipKey: eventKey(event),
148562
- playable: false,
148563
- unplayableReason: "no-rec-file"
148564
- };
148565
- const file = files.find((candidate) => event.startMs >= candidate.startMs - 3e3 && event.endMs <= candidate.endMs + 3e3);
148566
- if (file === void 0) return {
148567
- ...event,
148568
- clipKey: eventKey(event),
148569
- playable: false,
148570
- unplayableReason: "no-file-for-window"
148571
- };
148572
- return {
148573
- ...event,
148574
- clipKey: file.clipKey,
148575
- fileName: file.subFileName,
148576
- playable: true
148577
- };
148930
+ /** `YYYYMMDDHHMMSS` — 14 digits. */
148931
+ var STAMP_LENGTH = 14;
148932
+ var FORMATTER_BY_ZONE = /* @__PURE__ */ new Map();
148933
+ function formatterFor(timeZone) {
148934
+ const cached = FORMATTER_BY_ZONE.get(timeZone);
148935
+ if (cached !== void 0) return cached;
148936
+ const made = new Intl.DateTimeFormat("en-CA", {
148937
+ timeZone,
148938
+ year: "numeric",
148939
+ month: "2-digit",
148940
+ day: "2-digit",
148941
+ hour: "2-digit",
148942
+ minute: "2-digit",
148943
+ second: "2-digit",
148944
+ hour12: false
148578
148945
  });
148946
+ FORMATTER_BY_ZONE.set(timeZone, made);
148947
+ return made;
148948
+ }
148949
+ /**
148950
+ * The 14-digit tail of the short name a window carries — the file's start in
148951
+ * the CAMERA's wall clock. Never a UTC stamp: 00:30 Rome on the 17th is 22:30
148952
+ * UTC on the 16th, and a UTC key would file it under the wrong day and join it
148953
+ * to nothing.
148954
+ */
148955
+ function alarmJoinKey(atMs, timeZone) {
148956
+ const parts = new Map(formatterFor(timeZone).formatToParts(new Date(atMs)).map((part) => [part.type, part.value]));
148957
+ return `${parts.get("year") ?? ""}${parts.get("month") ?? ""}${parts.get("day") ?? ""}${normaliseHour(parts.get("hour"))}${parts.get("minute") ?? ""}${parts.get("second") ?? ""}`;
148579
148958
  }
148580
- /** A key for a row with no file — stable, and its OWN. */
148581
- function eventKey(event) {
148582
- return clipKeyFor(event.channel, `event:${event.startMs}-${event.endMs}`);
148959
+ /** `hourCycle: h23` still renders midnight as `24` in some ICU builds. */
148960
+ function normaliseHour(hour) {
148961
+ return hour === "24" ? "00" : hour ?? "";
148583
148962
  }
148584
148963
  //#endregion
148585
148964
  //#region src/clips/reolink-videoclips-provider.ts
148586
- /**
148587
- * Which API the ONE source resolved to for a camera — reported on
148588
- * `ClipSource.via` and on the listing log line.
148589
- *
148590
- * The CHOICE is made from what the camera is (`viaFor`); naming it is what
148591
- * makes "why do these two cameras behave differently" answerable at all.
148592
- */
148593
- var CATALOG_VIA_EVENTS = "events";
148594
- var CATALOG_VIA_FILES = "files";
148595
148965
  function createReolinkVideoclipsProvider(deps) {
148596
148966
  /** Last known availability per `deviceId|source`, written by every read. */
148597
148967
  const health = /* @__PURE__ */ new Map();
@@ -148628,7 +148998,7 @@ function createReolinkVideoclipsProvider(deps) {
148628
148998
  });
148629
148999
  return cached;
148630
149000
  }
148631
- if (device.uid === void 0) {
149001
+ if (uidIsMissing(device)) {
148632
149002
  deps.logger.warn("videoclips: no uid for this camera — its catalog cannot be addressed", {
148633
149003
  tags,
148634
149004
  meta: {
@@ -148638,7 +149008,7 @@ function createReolinkVideoclipsProvider(deps) {
148638
149008
  });
148639
149009
  noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, {
148640
149010
  state: "unreachable",
148641
- reason: "uid-missing: this camera has no persisted uid, and on a hub the uid is what selects the child (the hub’s own uid addresses no catalog)."
149011
+ reason: UID_MISSING_REASON
148642
149012
  });
148643
149013
  return [];
148644
149014
  }
@@ -148715,7 +149085,7 @@ function createReolinkVideoclipsProvider(deps) {
148715
149085
  error: result.mainSearchFailed
148716
149086
  }
148717
149087
  });
148718
- return result.rows;
149088
+ return await labelDay(device, api, day, result.rows);
148719
149089
  });
148720
149090
  if (entry.clips.length === 0) emptyDays.push(day);
148721
149091
  rows.push(...entry.clips);
@@ -148743,11 +149113,7 @@ function createReolinkVideoclipsProvider(deps) {
148743
149113
  days: emptyDays
148744
149114
  }
148745
149115
  });
148746
- noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, {
148747
- state: "index-empty",
148748
- reason: `The camera’s calendar marks ${emptyDays.length} day(s) with footage that its own index lists no files for: ${emptyDays.join(", ")}.`,
148749
- catalogAsOf: deps.now()
148750
- });
149116
+ noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, indexEmpty(emptyDays));
148751
149117
  } else noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, {
148752
149118
  state: "ok",
148753
149119
  catalogAsOf: deps.now()
@@ -148755,139 +149121,55 @@ function createReolinkVideoclipsProvider(deps) {
148755
149121
  return rows;
148756
149122
  };
148757
149123
  /**
148758
- * Read the HUB's event view for one child.
149124
+ * Give a day's rows a class when the FILES carried none.
148759
149125
  *
148760
- * ONE `searchEventLog` open for the whole window — the event list is
148761
- * multi-day by construction, so a per-day loop would pay the open/close per
148762
- * day for the same rows. The bound is the WINDOW, and it has a number
148763
- * behind it: 59 events over 3 children and 3 days took 2 372 ms, 1 886
148764
- * events over 20 days took 11 908 ms. The elapsed time and the row count
148765
- * are logged per camera so "why is this camera slow" is answerable.
149126
+ * The gate is what the day actually said, never what the camera is: a hub
149127
+ * child's files are `recordType: none` on every row (measured), a
149128
+ * standalone's carry `md,people,dog_cat,sched`, and a camera that changes
149129
+ * firmware changes which of those it is without this code learning a new
149130
+ * topology. The cheap question comes first (D54) — on 592 an alarm search
149131
+ * costs 8.2–9.5 s for 1 271 windows to restate labels already on the row.
148766
149132
  *
148767
- * The day's FILE rows come from the `onboard` reader, cache-backed, because
148768
- * `findEventLog` names events and not files: the join is by window
148769
- * containment. Losing the file list costs PLAYABILITY, not the events — the
148770
- * rows still list, each saying why it cannot play.
149133
+ * A refusal costs the LABELS and nothing else. Losing the day because its
149134
+ * class could not be read would be the upgrade destroying the thing it
149135
+ * upgrades — the same rule the MAIN search already lives by.
148771
149136
  */
148772
- const readHubEvents = async (device, since, until) => {
149137
+ const labelDay = async (device, api, day, rows) => {
148773
149138
  const tags = { deviceId: device.deviceId };
148774
- if (!device.isHubChild) return [];
148775
- if (device.uid === void 0) {
148776
- deps.logger.warn("videoclips: no uid for this camera — the hub cannot be asked about it", {
148777
- tags,
148778
- meta: {
148779
- branch: "uid-missing",
148780
- via: CATALOG_VIA_EVENTS
148781
- }
148782
- });
148783
- noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, {
148784
- state: "unreachable",
148785
- reason: "uid-missing: the hub selects a child by uid, and this camera has none persisted."
148786
- });
148787
- return [];
148788
- }
148789
- let api;
148790
- try {
148791
- api = await device.getApi();
148792
- } catch (err) {
148793
- const reason = err instanceof Error ? err.message : String(err);
148794
- deps.logger.warn("videoclips: could not reach the hub", {
148795
- tags,
148796
- meta: {
148797
- branch: "camera-refused",
148798
- via: CATALOG_VIA_EVENTS,
148799
- error: reason
148800
- }
148801
- });
148802
- noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, {
148803
- state: "unreachable",
148804
- reason
148805
- });
148806
- return [];
148807
- }
149139
+ if (!dayNeedsAlarmLabels(rows)) return rows;
148808
149140
  const startedAt = deps.now();
148809
- let events = [];
148810
- let joined = [];
149141
+ let windows;
148811
149142
  try {
148812
- const rows = await listEventRows(api, {
148813
- uid: device.uid,
149143
+ windows = await readAlarmWindows(api, {
148814
149144
  channel: device.channel,
149145
+ uid: device.uid,
148815
149146
  timeZone: device.timeZone,
148816
- since,
148817
- until
149147
+ day
148818
149148
  });
148819
- events = rows;
148820
- joined = joinEventsToFiles(rows, await readOnboard(device, since, until).catch(() => []));
148821
149149
  } catch (err) {
148822
- const reason = err instanceof Error ? err.message : String(err);
148823
- deps.logger.warn("videoclips: the hub event log refused", {
149150
+ deps.logger.warn("videoclips: the label search refused — the day lists without classes", {
148824
149151
  tags,
148825
149152
  meta: {
148826
149153
  branch: "camera-refused",
148827
- via: CATALOG_VIA_EVENTS,
148828
- error: reason
149154
+ day,
149155
+ error: err instanceof Error ? err.message : String(err)
148829
149156
  }
148830
149157
  });
148831
- noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, {
148832
- state: "unreachable",
148833
- reason
148834
- });
148835
- return [];
149158
+ return rows;
148836
149159
  }
148837
- const unplayable = joined.filter((row) => !row.playable).length;
148838
- deps.logger.info("videoclips: read the hub event log", {
149160
+ const joined = joinAlarmLabels(rows, windows, device.timeZone);
149161
+ deps.logger.info("videoclips: read the classes a day of files does not carry", {
148839
149162
  tags,
148840
149163
  meta: {
148841
- via: CATALOG_VIA_EVENTS,
148842
- events: events.length,
148843
- unplayable,
148844
- elapsedMs: deps.now() - startedAt,
148845
- windowDays: Math.max(1, Math.round((until - since) / 864e5))
149164
+ day,
149165
+ windows: windows.length,
149166
+ rows: rows.length,
149167
+ labelled: joined.matched,
149168
+ unmatchedWindows: joined.unmatchedWindows,
149169
+ elapsedMs: deps.now() - startedAt
148846
149170
  }
148847
149171
  });
148848
- noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, {
148849
- state: "ok",
148850
- catalogAsOf: deps.now()
148851
- });
148852
- return joined;
148853
- };
148854
- /**
148855
- * An events-view row as a `Clip`.
148856
- *
148857
- * The id carries the event WINDOW as well as the file, because a file holds
148858
- * 1–13 windows and two events of one recording must not collapse into one
148859
- * row. A row with nothing to play carries `playable: false` and its reason,
148860
- * and offers no thumbnail mint — the hub refused the CoverPreview for
148861
- * exactly that orphan event, so an offer here would be a tile that spins and
148862
- * then fails.
148863
- */
148864
- const toEventClip = async (device, row, catalogAsOf) => {
148865
- const classification = classifyNativeTypes(row.nativeTypes);
148866
- const still = await deps.hasVouchedThumbnail(device.deviceId, row.clipKey) ? { thumbnail: deps.thumbnailUrl(device.deviceId, row.clipKey) } : row.playable ? { thumbnailMint: deps.thumbnailMintUrl(device.deviceId, row.clipKey) } : { thumbnailUnavailable: { reason: "no-keyframe" } };
148867
- return {
148868
- id: mintClipId({
148869
- source: CLIP_SOURCE_ONBOARD,
148870
- channel: row.channel,
148871
- fileName: row.fileName ?? `event:${row.startMs}-${row.endMs}`,
148872
- window: {
148873
- startMs: row.startMs,
148874
- endMs: row.endMs
148875
- }
148876
- }),
148877
- source: CLIP_SOURCE_ONBOARD,
148878
- kind: classification.kind,
148879
- timeRange: {
148880
- startMs: row.startMs,
148881
- endMs: row.endMs
148882
- },
148883
- ...classification.labels.length > 0 ? { labels: [...classification.labels] } : {},
148884
- ...classification.behaviours.length > 0 ? { behaviours: [...classification.behaviours] } : {},
148885
- ...classification.nativeTypes.length > 0 ? { nativeTypes: [...classification.nativeTypes] } : {},
148886
- ...still,
148887
- ...catalogAsOf !== void 0 ? { catalogAsOf } : {},
148888
- playable: row.playable,
148889
- ...row.unplayableReason !== void 0 ? { unplayableReason: row.unplayableReason } : {}
148890
- };
149172
+ return joined.rows;
148891
149173
  };
148892
149174
  const toClip = async (device, source, row, catalogAsOf) => {
148893
149175
  rowsByKey.set(`${device.deviceId}|${row.clipKey}`, row);
@@ -148906,6 +149188,7 @@ function createReolinkVideoclipsProvider(deps) {
148906
149188
  startMs: row.startMs,
148907
149189
  endMs: row.endMs
148908
149190
  },
149191
+ ...row.inProgress === true ? { inProgress: true } : {},
148909
149192
  ...classification.labels.length > 0 ? { labels: [...classification.labels] } : {},
148910
149193
  ...classification.behaviours.length > 0 ? { behaviours: [...classification.behaviours] } : {},
148911
149194
  ...classification.nativeTypes.length > 0 ? { nativeTypes: [...classification.nativeTypes] } : {},
@@ -148936,37 +149219,26 @@ function createReolinkVideoclipsProvider(deps) {
148936
149219
  listSources: async ({ deviceId }) => {
148937
149220
  const device = await deps.resolveDevice(deviceId);
148938
149221
  if (device === null) return [];
148939
- const via = viaFor(device);
148940
149222
  return [{
148941
149223
  source: CLIP_SOURCE_ONBOARD,
148942
149224
  addonId: REOLINK_ADDON_ID,
148943
149225
  label: "Camera storage",
148944
- via,
148945
- availability: await resolveAvailability(device, via)
149226
+ availability: await resolveAvailability(device)
148946
149227
  }];
148947
149228
  },
148948
- listClips: async ({ deviceId, since, until, limit, sources }) => {
149229
+ listClips: async ({ deviceId, since, until, limit }) => {
148949
149230
  const device = await deps.resolveDevice(deviceId);
148950
149231
  if (device === null) return [];
148951
149232
  const tags = { deviceId };
148952
- if (sources !== void 0 && !sources.includes("native:reolink:onboard")) return [];
148953
- const via = viaFor(device);
148954
- const ordered = (via === "events" ? await (async () => {
148955
- const events = await readHubEvents(device, since, until);
148956
- const asOf = health.get(healthKey(deviceId, CLIP_SOURCE_ONBOARD))?.availability.catalogAsOf;
148957
- return await Promise.all(events.map((row) => toEventClip(device, row, asOf)));
148958
- })() : await (async () => {
148959
- const rows = await readOnboard(device, since, until);
148960
- const asOf = health.get(healthKey(deviceId, CLIP_SOURCE_ONBOARD))?.availability.catalogAsOf;
148961
- return await Promise.all(rows.toSorted((a, b) => b.startMs - a.startMs).map((row) => toClip(device, CLIP_SOURCE_ONBOARD, row, asOf)));
148962
- })()).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
149233
+ const rows = await readOnboard(device, since, until);
149234
+ const asOf = health.get(healthKey(deviceId, CLIP_SOURCE_ONBOARD))?.availability.catalogAsOf;
149235
+ const ordered = (await Promise.all(rows.toSorted((a, b) => b.startMs - a.startMs).map((row) => toClip(device, CLIP_SOURCE_ONBOARD, row, asOf)))).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
148963
149236
  const capped = limit !== void 0 ? ordered.slice(0, limit) : ordered;
148964
149237
  const vouched = capped.filter((c) => c.thumbnail !== void 0).length;
148965
149238
  deps.logger.info("videoclips: listed a Reolink catalog", {
148966
149239
  tags,
148967
149240
  meta: {
148968
149241
  source: CLIP_SOURCE_ONBOARD,
148969
- via,
148970
149242
  clips: capped.length,
148971
149243
  truncated: limit !== void 0 && ordered.length > limit,
148972
149244
  unplayable: capped.filter((c) => c.playable === false).length,
@@ -148975,6 +149247,18 @@ function createReolinkVideoclipsProvider(deps) {
148975
149247
  availability: health.get(healthKey(deviceId, CLIP_SOURCE_ONBOARD))?.availability.state
148976
149248
  }
148977
149249
  });
149250
+ const availability = health.get(healthKey(deviceId, CLIP_SOURCE_ONBOARD))?.availability;
149251
+ if (capped.length === 0 && availability !== void 0 && availability.state !== "ok") {
149252
+ deps.logger.warn("videoclips: nothing to list, and the source cannot answer", {
149253
+ tags,
149254
+ meta: {
149255
+ branch: "refused-empty",
149256
+ state: availability.state,
149257
+ reason: availability.reason ?? null
149258
+ }
149259
+ });
149260
+ throw new Error(`videoclips: device ${deviceId} listed no clips, and that is not an empty day — the source is "${availability.state}"` + (availability.reason !== void 0 ? `: ${availability.reason}` : "."));
149261
+ }
148978
149262
  return capped;
148979
149263
  },
148980
149264
  getClipPlayback: async ({ deviceId, clipId, profile }) => {
@@ -148983,7 +149267,6 @@ function createReolinkVideoclipsProvider(deps) {
148983
149267
  const device = await deps.resolveDevice(deviceId);
148984
149268
  if (device === null) throw new Error(`videoclips: device ${deviceId} is not a Reolink camera`);
148985
149269
  if (device.isBattery && device.sleeping && !device.isHubChild) throw new Error(`videoclips: device ${deviceId} is a standalone battery camera and is sleeping — fetching a clip would wake it. Use "Wake and refresh" first.`);
148986
- if (parsed.fileName.startsWith("event:")) throw new Error(`videoclips: clip ${clipId} is an event with no recording file (no-rec-file) — the hub listed the event, not a clip.`);
148987
149270
  const row = rowsByKey.get(`${deviceId}|${clipKeyFor(parsed.channel, parsed.fileName)}`) ?? null;
148988
149271
  const wantsMain = profile === "high" && row?.mainFileName !== void 0;
148989
149272
  const fileName = wantsMain ? row?.mainFileName ?? parsed.fileName : parsed.fileName;
@@ -149002,46 +149285,350 @@ function createReolinkVideoclipsProvider(deps) {
149002
149285
  }
149003
149286
  };
149004
149287
  /**
149005
- * Which API this camera's ONE source reads.
149006
- *
149007
- * From what the camera IS, never from a stored preference and never from a
149008
- * row the operator ticked: a hub child is served by its parent's event log
149009
- * (faster, and the only class source there), a standalone by its own file
149010
- * list (a standalone answers `findEventLog` with an empty body). Pure, so
149011
- * the choice is the same on every call and the reported `via` cannot drift
149012
- * from the API that actually ran.
149013
- */
149014
- function viaFor(device) {
149015
- return device.isHubChild ? CATALOG_VIA_EVENTS : CATALOG_VIA_FILES;
149016
- }
149017
- async function resolveAvailability(device, via) {
149288
+ * What a source is saying right now — MEASURED, never defaulted.
149289
+ *
149290
+ * A listing that already ran is the better answer and wins: `listClips`
149291
+ * asked the whole window and this probe asks one day, so re-deciding from
149292
+ * the narrower question would throw away 618's other nineteen empty days.
149293
+ * With nothing established, the probe runs — and `ok` is only ever returned
149294
+ * because something was really read.
149295
+ *
149296
+ * ## What it costs, per call, and why
149297
+ *
149298
+ * This used to answer `ok` after at most one storage command, and `ok` is
149299
+ * the one state whose list may be read as complete. On the live hub, on the
149300
+ * cold runners a deploy had just produced, that default made 640 (no SD
149301
+ * card) and 618 (an index that lists nothing for days its own calendar
149302
+ * marks) both answer `ok` and then an empty list — the exact lie this line
149303
+ * of work exists to end. So `listSources` pays for its answer:
149304
+ *
149305
+ * - **a standalone battery camera: NOTHING, ever.** No `getApi`, no command,
149306
+ * not even the cheap one — on UDP the login IS the wake. `sleeping`, with
149307
+ * the age of whatever is cached. The gate is `isBattery`, never the
149308
+ * BELIEF `sleeping`: that belief is `state.battery.sleeping ?? false`, so
149309
+ * a slice that has not restored reads as awake, and a probe on it is a
149310
+ * wake nobody asked for.
149311
+ * - **a hub child: one `getDayRecords` through the parent's existing socket**
149312
+ * (65 ms measured on 3628). The child never hears it; the storage question
149313
+ * is the HUB's and is not asked.
149314
+ * - **a mains standalone: `getHddInfoList` (ms) + `getDayRecords`
149315
+ * (38 ms–1.9 s) + ONE day listing of the NEWEST day the calendar claims**
149316
+ * (0.3–6 s, and cache-backed — `serveAndRevalidate`, so a day already
149317
+ * listed costs nothing and a day listed here is free for the `listClips`
149318
+ * that follows). The day listing is what distinguishes `index-empty` from
149319
+ * `ok`, and nothing cheaper can: `no-storage` is the cheap question and it
149320
+ * comes first precisely so a camera with no card never pays the rest.
149321
+ *
149322
+ * The calendar is read for the CURRENT month only. A picker asks "can this
149323
+ * source answer", not "what does it hold since April", and the measured
149324
+ * defect (618) is in the month the operator is looking at.
149325
+ */
149326
+ async function resolveAvailability(device) {
149018
149327
  const known = health.get(healthKey(device.deviceId, CLIP_SOURCE_ONBOARD))?.availability;
149019
149328
  if (known !== void 0) return known;
149020
- if (via === "events") return device.uid === void 0 ? {
149021
- state: "unreachable",
149022
- reason: "uid-missing: the hub selects a child by uid, and this camera has none persisted."
149023
- } : { state: "ok" };
149024
- if (device.isBattery && device.sleeping && !device.isHubChild) return {
149025
- state: "sleeping",
149026
- reason: "A read would wake this camera. Nothing has been listed yet."
149329
+ const availability = await probeAvailability(device);
149330
+ noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, availability);
149331
+ return availability;
149332
+ }
149333
+ async function probeAvailability(device) {
149334
+ const tags = { deviceId: device.deviceId };
149335
+ if (device.isBattery && !device.isHubChild) {
149336
+ const asOf = newestCachedAsOf(device.deviceId);
149337
+ deps.logger.warn("videoclips: not probing a standalone battery camera — login is the wake", {
149338
+ tags,
149339
+ meta: {
149340
+ branch: "sleeping",
149341
+ believedSleeping: device.sleeping,
149342
+ confirmedAt: asOf
149343
+ }
149344
+ });
149345
+ return {
149346
+ state: "sleeping",
149347
+ reason: asOf === void 0 ? "A read would wake this standalone battery camera. Nothing has been confirmed yet." : "A read would wake this standalone battery camera; showing the catalog as last confirmed.",
149348
+ ...asOf !== void 0 ? { catalogAsOf: asOf } : {}
149349
+ };
149350
+ }
149351
+ if (uidIsMissing(device)) {
149352
+ deps.logger.warn("videoclips: no uid for this camera — its catalog cannot be addressed", {
149353
+ tags,
149354
+ meta: {
149355
+ branch: "uid-missing",
149356
+ probe: true
149357
+ }
149358
+ });
149359
+ return {
149360
+ state: "unreachable",
149361
+ reason: UID_MISSING_REASON
149362
+ };
149363
+ }
149364
+ let api;
149365
+ try {
149366
+ api = await device.getApi();
149367
+ } catch (err) {
149368
+ return unreachableFrom(device, "camera-refused", err);
149369
+ }
149370
+ if (!device.isHubChild) {
149371
+ const storage = await readStorageState(api);
149372
+ if (storage.state === "absent") {
149373
+ deps.logger.info("videoclips: no SD card mounted — nothing to list", {
149374
+ tags,
149375
+ meta: {
149376
+ branch: "no-storage",
149377
+ probe: true
149378
+ }
149379
+ });
149380
+ return {
149381
+ state: "no-storage",
149382
+ reason: "The camera reports no mounted storage (HddInfo mount 0)."
149383
+ };
149384
+ }
149385
+ if (storage.state === "unknown") {
149386
+ deps.logger.warn("videoclips: the storage question was not answered", {
149387
+ tags,
149388
+ meta: {
149389
+ branch: "camera-refused",
149390
+ probe: true,
149391
+ error: storage.reason ?? null
149392
+ }
149393
+ });
149394
+ return {
149395
+ state: "unreachable",
149396
+ reason: storage.reason ?? "The camera did not answer the storage question (HddInfo unreadable)."
149397
+ };
149398
+ }
149399
+ }
149400
+ const today = wallClockDayKey(deps.now(), device.timeZone);
149401
+ let calendar;
149402
+ try {
149403
+ calendar = await readDayIndex(api, {
149404
+ channel: device.channel,
149405
+ uid: device.uid,
149406
+ months: [{
149407
+ year: Number(today.slice(0, 4)),
149408
+ month: Number(today.slice(5, 7))
149409
+ }]
149410
+ });
149411
+ } catch (err) {
149412
+ return unreachableFrom(device, "camera-refused", err);
149413
+ }
149414
+ const claimed = [...calendar].toSorted().toReversed();
149415
+ if (claimed.length === 0) return {
149416
+ state: "ok",
149417
+ catalogAsOf: deps.now()
149027
149418
  };
149028
- if (device.uid === void 0) return {
149419
+ const newest = claimed[0];
149420
+ if (newest === void 0) return {
149421
+ state: "ok",
149422
+ catalogAsOf: deps.now()
149423
+ };
149424
+ try {
149425
+ await deps.cache.serveAndRevalidate(device.deviceId, newest, { closed: newest !== today }, async () => (await listDayRows(api, {
149426
+ channel: device.channel,
149427
+ uid: device.uid,
149428
+ timeZone: device.timeZone,
149429
+ day: newest
149430
+ })).rows);
149431
+ } catch (err) {
149432
+ return unreachableFrom(device, "camera-refused", err);
149433
+ }
149434
+ const emptyClaimed = claimed.filter((day) => deps.cache.peek(device.deviceId, day)?.clips.length === 0);
149435
+ if (emptyClaimed.length > 0) {
149436
+ deps.logger.warn("videoclips: the calendar marks days the index will not list", {
149437
+ tags,
149438
+ meta: {
149439
+ branch: "index-empty",
149440
+ probe: true,
149441
+ days: emptyClaimed
149442
+ }
149443
+ });
149444
+ return indexEmpty(emptyClaimed);
149445
+ }
149446
+ return {
149447
+ state: "ok",
149448
+ catalogAsOf: deps.now()
149449
+ };
149450
+ }
149451
+ function unreachableFrom(device, branch, err) {
149452
+ const reason = err instanceof Error ? err.message : String(err);
149453
+ deps.logger.warn("videoclips: the source could not be probed", {
149454
+ tags: { deviceId: device.deviceId },
149455
+ meta: {
149456
+ branch,
149457
+ probe: true,
149458
+ error: reason
149459
+ }
149460
+ });
149461
+ return {
149029
149462
  state: "unreachable",
149030
- reason: "uid-missing: this camera has no persisted uid to address its catalog with."
149463
+ reason
149031
149464
  };
149465
+ }
149466
+ /** When any day of this device's catalog was last confirmed, newest first. */
149467
+ function newestCachedAsOf(deviceId) {
149468
+ let newest;
149469
+ for (const day of deps.cache.daysHeld(deviceId)) {
149470
+ const asOf = deps.cache.peek(deviceId, day)?.catalogAsOf;
149471
+ if (asOf !== void 0 && (newest === void 0 || asOf > newest)) newest = asOf;
149472
+ }
149473
+ return newest;
149474
+ }
149475
+ /** The one wording for "the camera's calendar and its own index disagree". */
149476
+ function indexEmpty(days) {
149477
+ return {
149478
+ state: "index-empty",
149479
+ reason: `The camera’s calendar marks ${days.length} day(s) with footage that its own index lists no files for: ${days.join(", ")}.`,
149480
+ catalogAsOf: deps.now()
149481
+ };
149482
+ }
149483
+ }
149484
+ //#endregion
149485
+ //#region src/clips/clip-refresh.ts
149486
+ /** The settings key, global default and per-device override (D562). */
149487
+ var CLIP_CATALOG_REFRESH_MINUTES_KEY = "clipCatalogRefreshMinutes";
149488
+ /** The settings key, per device only, OFF by default (D562). */
149489
+ var CLIP_CATALOG_FORCED_WAKE_KEY = "clipCatalogForcedWake";
149490
+ /** A day. Past this the catalog's own TTLs are the thing doing the work. */
149491
+ var CLIP_CATALOG_REFRESH_MINUTES_MAX = 1440;
149492
+ /**
149493
+ * How much of the catalog one refresh confirms.
149494
+ *
149495
+ * A day. The listing cost is per DAY (0.3–6 s on a standalone, 267 ms for one
149496
+ * child-day on the hub) and a 20-day window measured 3.6–5.4 s per child, so a
149497
+ * timer that re-confirmed everything would spend the whole budget re-reading
149498
+ * days that cannot change. Older days are immutable and the cache holds them
149499
+ * under their own 24 h TTL.
149500
+ */
149501
+ var CLIP_CATALOG_REFRESH_WINDOW_MS = 1440 * 6e4;
149502
+ /**
149503
+ * The mode for one camera. Pure, and decided from what the camera IS — never
149504
+ * from a belief about whether it is asleep right now: the gate has to hold for
149505
+ * a camera whose sleep slice has not restored, which reads "awake" and is how
149506
+ * a picker draw once logged into 640 (D561).
149507
+ */
149508
+ function resolveClipRefreshMode(topology) {
149509
+ if (topology.isHubChild) return "via-hub";
149510
+ if (!topology.isBattery) return "direct";
149511
+ return topology.forcedWake ? "forced-wake" : "mark-stale";
149512
+ }
149513
+ /**
149514
+ * The refresh interval for one camera: the per-device override on top of the
149515
+ * global default, with **reset == unset == the default**.
149516
+ *
149517
+ * Never throws on a bad blob. An invalid value at either level resolves to the
149518
+ * level above it, so a store written by an older release cannot stop the timer
149519
+ * — the same cascade `stationary-settings.ts` uses, for the same reason.
149520
+ */
149521
+ function resolveClipCatalogRefreshMinutes(globalValue, deviceValue) {
149522
+ return asRefreshMinutes(deviceValue) ?? asRefreshMinutes(globalValue) ?? 30;
149523
+ }
149524
+ function asRefreshMinutes(value) {
149525
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
149526
+ const rounded = Math.round(value);
149527
+ if (rounded === 0) return 0;
149528
+ if (rounded < 5) return null;
149529
+ if (rounded > 1440) return null;
149530
+ return rounded;
149531
+ }
149532
+ /** Is the operator's forced-wake switch on for this camera? */
149533
+ function resolveClipCatalogForcedWake(value) {
149534
+ return value === true;
149535
+ }
149536
+ /**
149537
+ * The timer. `tick()` is the whole surface, so a test drives it from COLD and
149538
+ * the scheduler owns no clock of its own — the addon supplies the interval.
149539
+ */
149540
+ var ClipCatalogRefreshScheduler = class {
149541
+ deps;
149542
+ /** Last time an interval was SPENT on a camera, successful or not. */
149543
+ lastRunAt = /* @__PURE__ */ new Map();
149544
+ /** Cameras whose refresh has not come back yet. */
149545
+ inFlight = /* @__PURE__ */ new Set();
149546
+ constructor(deps) {
149547
+ this.deps = deps;
149548
+ }
149549
+ async tick() {
149550
+ const now = this.deps.now();
149551
+ const due = this.deps.listTargets().filter((target) => this.isDue(target, now));
149552
+ for (const target of due) await this.run(target, now);
149553
+ }
149554
+ isDue(target, now) {
149555
+ if (target.intervalMinutes <= 0) return false;
149556
+ if (this.inFlight.has(target.deviceId)) return false;
149557
+ const last = this.lastRunAt.get(target.deviceId);
149558
+ if (last === void 0) return true;
149559
+ return now - last >= target.intervalMinutes * 6e4;
149560
+ }
149561
+ async run(target, now) {
149562
+ const tags = { deviceId: target.deviceId };
149563
+ const mode = resolveClipRefreshMode(target);
149564
+ if (this.deps.hostBusy(target.hostKey)) {
149565
+ this.deps.logger.warn("videoclips: catalog refresh deferred — the host is serving a read", {
149566
+ tags,
149567
+ meta: {
149568
+ branch: "deferred-interactive",
149569
+ mode,
149570
+ host: target.hostKey
149571
+ }
149572
+ });
149573
+ return;
149574
+ }
149575
+ if (mode === "mark-stale") {
149576
+ this.deps.markStale(target.deviceId);
149577
+ this.lastRunAt.set(target.deviceId, now);
149578
+ this.deps.logger.info("videoclips: catalog marked stale — a timer cannot read a standalone battery camera", {
149579
+ tags,
149580
+ meta: {
149581
+ branch: "mark-stale",
149582
+ mode,
149583
+ refreshesOn: "onWakeTransition",
149584
+ intervalMinutes: target.intervalMinutes
149585
+ }
149586
+ });
149587
+ return;
149588
+ }
149589
+ this.lastRunAt.set(target.deviceId, now);
149590
+ this.inFlight.add(target.deviceId);
149032
149591
  try {
149033
- if ((await readStorageState(await device.getApi())).state === "absent") return {
149034
- state: "no-storage",
149035
- reason: "The camera reports no mounted storage (HddInfo mount 0)."
149036
- };
149037
- return { state: "ok" };
149592
+ if (mode === "forced-wake") try {
149593
+ await this.deps.wake(target.deviceId);
149594
+ } catch (err) {
149595
+ this.deps.markStale(target.deviceId);
149596
+ this.deps.logger.warn("videoclips: the authorised wake did not bring the camera up — catalog left stale", {
149597
+ tags,
149598
+ meta: {
149599
+ branch: "forced-wake-failed",
149600
+ mode,
149601
+ error: errMessage$1(err)
149602
+ }
149603
+ });
149604
+ return;
149605
+ }
149606
+ await this.deps.refresh(target.deviceId);
149607
+ this.deps.logger.info("videoclips: catalog refreshed", {
149608
+ tags,
149609
+ meta: {
149610
+ branch: "refreshed",
149611
+ mode,
149612
+ elapsedMs: this.deps.now() - now
149613
+ }
149614
+ });
149038
149615
  } catch (err) {
149039
- return {
149040
- state: "unreachable",
149041
- reason: err instanceof Error ? err.message : String(err)
149042
- };
149616
+ this.deps.markStale(target.deviceId);
149617
+ this.deps.logger.warn("videoclips: catalog refresh failed — the catalog stays as it was", {
149618
+ tags,
149619
+ meta: {
149620
+ branch: "refresh-failed",
149621
+ mode,
149622
+ error: errMessage$1(err)
149623
+ }
149624
+ });
149625
+ } finally {
149626
+ this.inFlight.delete(target.deviceId);
149043
149627
  }
149044
149628
  }
149629
+ };
149630
+ function errMessage$1(err) {
149631
+ return err instanceof Error ? err.message : String(err);
149045
149632
  }
149046
149633
  function createClipService(deps) {
149047
149634
  const now = deps.now ?? (() => Date.now());
@@ -149072,23 +149659,11 @@ function createClipService(deps) {
149072
149659
  };
149073
149660
  const mintThumbnail = async (deviceId, clipKey, lane) => {
149074
149661
  const device = await deps.resolveDevice(deviceId);
149075
- if (device === null) return {
149076
- ok: false,
149077
- reason: `unknown-device: ${deviceId} is not a Reolink camera here`
149078
- };
149079
- if (device.isBattery && device.sleeping && !device.isHubChild) return {
149080
- ok: false,
149081
- reason: "sleeping: a still would wake this standalone battery camera"
149082
- };
149083
- if (device.uid === void 0) return {
149084
- ok: false,
149085
- reason: "uid-missing: this camera’s stills cannot be addressed"
149086
- };
149662
+ if (device === null) return refuseStill("unknown-device", `unknown-device: ${deviceId} is not a Reolink camera here`);
149663
+ if (device.isBattery && device.sleeping && !device.isHubChild) return refuseStill("sleeping", "sleeping: a still would wake this standalone battery camera");
149664
+ if (uidIsMissing(device)) return refuseStill("uid-missing", UID_MISSING_REASON);
149087
149665
  const row = rowFor(deviceId, clipKey);
149088
- if (row === null) return {
149089
- ok: false,
149090
- reason: "no-catalog-row: list this day before asking for its stills"
149091
- };
149666
+ if (row === null) return refuseStill("no-catalog-row", "no-catalog-row: list this day before asking for its stills");
149092
149667
  try {
149093
149668
  const bytes = await queue.submit({
149094
149669
  host: device.hostKey,
@@ -149099,16 +149674,13 @@ function createClipService(deps) {
149099
149674
  time: new Date(row.startMs),
149100
149675
  endTime: new Date(row.endMs),
149101
149676
  snapType: "sub",
149102
- uid: device.uid,
149677
+ ...device.uid === void 0 ? {} : { uid: device.uid },
149103
149678
  isNvr: device.isHubChild,
149104
149679
  timeZone: device.timeZone
149105
149680
  });
149106
149681
  }
149107
149682
  });
149108
- if (bytes.byteLength === 0) return {
149109
- ok: false,
149110
- reason: "no-keyframe: the camera returned an empty frame"
149111
- };
149683
+ if (bytes.byteLength === 0) return refuseStill("no-keyframe", "no-keyframe: the camera returned an empty frame");
149112
149684
  await thumbs.put(deviceId, clipKey, bytes);
149113
149685
  return {
149114
149686
  ok: true,
@@ -149116,6 +149688,19 @@ function createClipService(deps) {
149116
149688
  };
149117
149689
  } catch (err) {
149118
149690
  const reason = err instanceof Error ? err.message : String(err);
149691
+ const deferred = queueRefusal(err);
149692
+ if (deferred !== null) {
149693
+ deps.logger.debug("videoclips: a thumbnail mint was deferred", {
149694
+ tags: { deviceId },
149695
+ meta: {
149696
+ clipKey,
149697
+ lane,
149698
+ code: deferred.code,
149699
+ retryAfterMs: deferred.retryAfterMs
149700
+ }
149701
+ });
149702
+ return deferred;
149703
+ }
149119
149704
  deps.logger.warn("videoclips: a thumbnail mint refused", {
149120
149705
  tags: { deviceId },
149121
149706
  meta: {
@@ -149125,10 +149710,7 @@ function createClipService(deps) {
149125
149710
  error: reason
149126
149711
  }
149127
149712
  });
149128
- return {
149129
- ok: false,
149130
- reason
149131
- };
149713
+ return refuseStill("camera-refused", reason);
149132
149714
  }
149133
149715
  };
149134
149716
  const prepareClip = async (deviceId, clipKey) => {
@@ -149177,15 +149759,43 @@ function createClipService(deps) {
149177
149759
  }),
149178
149760
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
149179
149761
  });
149762
+ const audio = decideClipAudio(downloaded.audio);
149763
+ if (audio.kind === "silent") deps.logger.warn("videoclips: serving a clip with no sound", {
149764
+ tags: { deviceId },
149765
+ meta: {
149766
+ clipKey,
149767
+ branch: audio.reason,
149768
+ audioFormat: audio.format,
149769
+ audioPackets: downloaded.stats.audioPackets,
149770
+ audioBytes: downloaded.stats.audioBytes
149771
+ }
149772
+ });
149773
+ if (downloaded.stats.fpsSource !== "timestamps") deps.logger.warn("videoclips: no measured frame rate for this clip", {
149774
+ tags: { deviceId },
149775
+ meta: {
149776
+ clipKey,
149777
+ branch: "fps-unmeasured",
149778
+ fpsSource: downloaded.stats.fpsSource,
149779
+ fps: downloaded.stats.fps,
149780
+ infoFps: downloaded.stats.infoFps
149781
+ }
149782
+ });
149180
149783
  const scratch = await mkdtemp(join$1(tmpdir(), "camstack-clip-mux-"));
149181
149784
  try {
149182
149785
  const rawPath = join$1(scratch, "clip.h264");
149786
+ const audioPath = join$1(scratch, "clip.aac");
149183
149787
  const outPath = join$1(scratch, "clip.mp4");
149184
149788
  await writeFile(rawPath, downloaded.annexB);
149789
+ if (audio.kind === "copy") await writeFile(audioPath, audio.data);
149185
149790
  const failure = describeMuxFailure(await muxRunner(buildClipMuxArgs({
149186
149791
  inputPath: rawPath,
149187
149792
  outputPath: outPath,
149188
- videoType: downloaded.videoType
149793
+ videoType: downloaded.videoType,
149794
+ fps: downloaded.stats.fps,
149795
+ ...audio.kind === "copy" ? { audio: {
149796
+ path: audioPath,
149797
+ format: audio.format
149798
+ } } : {}
149189
149799
  })));
149190
149800
  if (failure !== null) {
149191
149801
  deps.logger.warn("videoclips: the clip mux failed", {
@@ -149207,21 +149817,31 @@ function createClipService(deps) {
149207
149817
  });
149208
149818
  }
149209
149819
  };
149210
- return {
149211
- provider: createReolinkVideoclipsProvider({
149212
- now,
149213
- cache,
149214
- resolveDevice: deps.resolveDevice,
149215
- hasVouchedThumbnail: (deviceId, clipKey) => thumbs.has(deviceId, clipKey),
149216
- thumbnailUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
149217
- thumbnailMintUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
149218
- preparePlayback: async ({ device, clipKey, profile }) => ({
149219
- playbackUrl: `/addon/${REOLINK_ADDON_ID}/${CLIP_MEDIA_PREFIX}/${device.deviceId}/${clipKey}.mp4`,
149220
- served: profile === "high" ? "high" : "low"
149221
- }),
149222
- logger: deps.logger
149820
+ const provider = createReolinkVideoclipsProvider({
149821
+ now,
149822
+ cache,
149823
+ resolveDevice: deps.resolveDevice,
149824
+ hasVouchedThumbnail: (deviceId, clipKey) => thumbs.has(deviceId, clipKey),
149825
+ thumbnailUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
149826
+ thumbnailMintUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
149827
+ preparePlayback: async ({ device, clipKey, profile }) => ({
149828
+ playbackUrl: `/addon/${REOLINK_ADDON_ID}/${CLIP_MEDIA_PREFIX}/${device.deviceId}/${clipKey}.mp4`,
149829
+ served: profile === "high" ? "high" : "low"
149223
149830
  }),
149831
+ logger: deps.logger
149832
+ });
149833
+ return {
149834
+ provider,
149224
149835
  cache,
149836
+ refreshCatalog: async (deviceId) => {
149837
+ const until = now();
149838
+ await provider.listClips({
149839
+ deviceId,
149840
+ since: until - CLIP_CATALOG_REFRESH_WINDOW_MS,
149841
+ until
149842
+ });
149843
+ },
149844
+ hostBusy: (hostKey) => queue.busy(hostKey),
149225
149845
  thumbPrefix: CLIP_THUMB_PREFIX,
149226
149846
  mediaPrefix: CLIP_MEDIA_PREFIX,
149227
149847
  thumbHandler: createClipThumbHandler({
@@ -149246,6 +149866,79 @@ function createClipService(deps) {
149246
149866
  };
149247
149867
  }
149248
149868
  //#endregion
149869
+ //#region src/clips/clip-refresh-settings.ts
149870
+ /** The section id, so the addon's declaration and this schema cannot drift. */
149871
+ var CLIP_REFRESH_SECTION_ID = "clip-catalog-refresh";
149872
+ /**
149873
+ * The one sentence the interval must always carry.
149874
+ *
149875
+ * It does TWO different things depending on what the camera is, and a knob
149876
+ * that silently means two things on two cameras is how this repo has shipped
149877
+ * settings nobody could reason about. So the difference is in the operator's
149878
+ * own text, at both levels, not only in a record.
149879
+ */
149880
+ var CLIP_CATALOG_REFRESH_MINUTES_DESCRIPTION = "How often CamStack re-reads what a Reolink camera holds on its own storage. It costs almost nothing on a camera it can reach: a hub child is read through the hub on the session that is already open (measured 65 ms, and the camera itself never hears it), and a mains camera is read on its own socket. On a STANDALONE BATTERY camera the timer cannot read anything at all — logging in is what wakes such a camera — so there it only marks the catalog as ageing, and the real re-read happens the next time the camera wakes up for its own reasons (motion, a stream, something you did). Set 0 to switch the timer off.";
149881
+ var CLIP_CATALOG_FORCED_WAKE_DESCRIPTION = "Let the refresh timer WAKE this camera. Off unless you mean it: on a standalone battery camera every read is a login and every login is a wake, so a 15-minute timer is 96 wakes a day (24 at 60 minutes) against the 1–20 clips a day these cameras actually record — five to a hundred times their normal wake count, and the battery pays for all of it. One interval buys exactly one wake, successful or not. It has no effect on a hub child or a mains camera, which are never woken by this timer.";
149882
+ /**
149883
+ * The addon-level (global) default. Per-camera overrides live on the device,
149884
+ * under the SAME key, and a device that sets nothing — or resets — resolves to
149885
+ * this value (`resolveClipCatalogRefreshMinutes`).
149886
+ */
149887
+ function buildClipRefreshSettingsSchema() {
149888
+ return { sections: [{
149889
+ id: CLIP_REFRESH_SECTION_ID,
149890
+ title: "Clip catalog refresh",
149891
+ description: "The default for every Reolink camera. Each camera can override it in its own Recording settings; a camera that overrides nothing uses this value.",
149892
+ columns: 1,
149893
+ fields: [{
149894
+ type: "number",
149895
+ key: CLIP_CATALOG_REFRESH_MINUTES_KEY,
149896
+ label: "Refresh interval (min)",
149897
+ description: CLIP_CATALOG_REFRESH_MINUTES_DESCRIPTION,
149898
+ default: 30,
149899
+ min: 0,
149900
+ max: CLIP_CATALOG_REFRESH_MINUTES_MAX,
149901
+ step: 5
149902
+ }]
149903
+ }] };
149904
+ }
149905
+ /**
149906
+ * The per-camera section: the interval override, and — only where it can mean
149907
+ * anything — the forced wake.
149908
+ *
149909
+ * `forcedWake` is a plain persisted switch on purpose. It is NOT a
149910
+ * `device-action-button` and there is no custom action behind it, so nothing
149911
+ * that dispatches device actions (a schedule, an automation, a rule) can spend
149912
+ * a battery camera's wakes; the only way to turn it on is an operator editing
149913
+ * this camera's settings.
149914
+ */
149915
+ function buildClipRefreshDeviceSection(input) {
149916
+ const forcedWakeApplies = input.isBattery && !input.isHubChild;
149917
+ return [{
149918
+ id: CLIP_REFRESH_SECTION_ID,
149919
+ tab: "recording",
149920
+ title: "Clip catalog refresh",
149921
+ description: "How often this camera’s own clip list is re-read. Leave the interval empty to use the default from the Reolink addon settings.",
149922
+ columns: 1,
149923
+ fields: [{
149924
+ type: "number",
149925
+ key: CLIP_CATALOG_REFRESH_MINUTES_KEY,
149926
+ label: "Refresh interval (min)",
149927
+ description: CLIP_CATALOG_REFRESH_MINUTES_DESCRIPTION,
149928
+ min: 0,
149929
+ max: CLIP_CATALOG_REFRESH_MINUTES_MAX,
149930
+ step: 5
149931
+ }, ...forcedWakeApplies ? [{
149932
+ type: "boolean",
149933
+ key: CLIP_CATALOG_FORCED_WAKE_KEY,
149934
+ label: "Wake this camera to refresh",
149935
+ description: CLIP_CATALOG_FORCED_WAKE_DESCRIPTION,
149936
+ default: false,
149937
+ style: "switch"
149938
+ }] : []]
149939
+ }];
149940
+ }
149941
+ //#endregion
149249
149942
  //#region src/intercom-failure-report.ts
149250
149943
  /**
149251
149944
  * Per-camera talk-back counters, published through `failure-contribution`.
@@ -154323,6 +155016,73 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
154323
155016
  * provider still treats a missing `uid` as a dropped branch rather than as
154324
155017
  * an empty catalog.
154325
155018
  */
155019
+ /**
155020
+ * Re-read this camera's clip catalog. Set by the addon for every camera it
155021
+ * owns; `null` until it is wired (a redeploy re-registers the device, and
155022
+ * the addon re-wires on `DeviceRegistered` AND `DeviceMetaChanged` — the
155023
+ * same fan-out the email-push resolver needed, for the same reason).
155024
+ */
155025
+ clipCatalogRefresher = null;
155026
+ setClipCatalogRefresher(refresh) {
155027
+ this.clipCatalogRefresher = refresh;
155028
+ }
155029
+ /**
155030
+ * This camera's OVERRIDE of the clip-refresh interval, or `undefined` when
155031
+ * it overrides nothing — which is what a reset leaves behind too, and is
155032
+ * why the resolver takes `unknown` rather than a number (D562).
155033
+ */
155034
+ getClipCatalogRefreshMinutes() {
155035
+ return this.config.get("clipCatalogRefreshMinutes");
155036
+ }
155037
+ /** Whether the operator authorised the clip timer to wake this camera. */
155038
+ getClipCatalogForcedWake() {
155039
+ return this.config.get("clipCatalogForcedWake");
155040
+ }
155041
+ /**
155042
+ * The clip refresh the TIMER cannot do.
155043
+ *
155044
+ * On a standalone battery camera a catalog read is a login and a login is
155045
+ * the wake, so the periodic refresh only marks the catalog stale (D562).
155046
+ * This is the other half: the moment the camera confirmed itself awake for
155047
+ * its own reasons — a camera event, a stream, an operator action — is a free
155048
+ * read, and it is the same moment that already warms the stream catalog and
155049
+ * every settings slice. Called from `onWakeTransition`, best-effort, last:
155050
+ * a clip listing must never delay the live-stream catalog the operator is
155051
+ * waiting for.
155052
+ */
155053
+ async refreshClipCatalogOnWake() {
155054
+ const refresh = this.clipCatalogRefresher;
155055
+ if (refresh === null) {
155056
+ this.ctx.logger.debug("onWakeTransition: no clip catalog refresher wired", {
155057
+ tags: { deviceId: this.id },
155058
+ meta: { branch: "clip-refresher-unwired" }
155059
+ });
155060
+ return;
155061
+ }
155062
+ try {
155063
+ await refresh();
155064
+ } catch (err) {
155065
+ this.ctx.logger.warn("onWakeTransition: the clip catalog refresh failed", {
155066
+ tags: { deviceId: this.id },
155067
+ meta: {
155068
+ branch: "clip-refresh-failed",
155069
+ error: err instanceof Error ? err.message : String(err)
155070
+ }
155071
+ });
155072
+ }
155073
+ }
155074
+ /**
155075
+ * Wake this camera because the OPERATOR authorised the clip timer to
155076
+ * (`clipCatalogForcedWake`, off by default — D562).
155077
+ *
155078
+ * The only path from the timer to a battery camera's radio, and it is the
155079
+ * same forced wake `wakeIfSleeping` already drives for a config button.
155080
+ * Throws when the camera did not come up, so the scheduler can say so and
155081
+ * leave the catalog stale rather than stamping it fresh.
155082
+ */
155083
+ async wakeForClipCatalogRefresh() {
155084
+ if (!await this.wakeIfSleeping()) throw new Error(`ReolinkCamera ${this.id}: the authorised clip-refresh wake did not bring the camera up`);
155085
+ }
154326
155086
  getClipDeviceRef() {
154327
155087
  const uid = this.config.get("uid");
154328
155088
  return {
@@ -158583,6 +159343,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
158583
159343
  await this.warmSnapshotOnWake();
158584
159344
  await this.alignAuxDevicesState("wake");
158585
159345
  await this.refreshParentSettingsSnapshot();
159346
+ await this.refreshClipCatalogOnWake();
158586
159347
  }
158587
159348
  static PING_INTERVAL_MS = 3e4;
158588
159349
  static PING_MAX_FAILURES = 3;
@@ -159768,7 +160529,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
159768
160529
  }),
159769
160530
  ...this.buildSessionsTabSections(),
159770
160531
  ...this.buildEmailPushSection(),
159771
- ...this.buildEmailTabSections()
160532
+ ...this.buildEmailTabSections(),
160533
+ ...buildClipRefreshDeviceSection({
160534
+ isHubChild: this.isHubChild(),
160535
+ isBattery: this.isBattery
160536
+ })
159772
160537
  ]
159773
160538
  };
159774
160539
  const emailSnap = cache?.emailConfigSnapshot;
@@ -159787,6 +160552,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
159787
160552
  emailRecipient3: emailSnap?.recipients?.[2] ?? "",
159788
160553
  emailSendNickname: emailSnap?.sendNickname ?? this.name,
159789
160554
  emailScheduleEnabled: emailSnap?.taskEnabled ?? false,
160555
+ clipCatalogRefreshMinutes: this.config.get("clipCatalogRefreshMinutes"),
160556
+ clipCatalogForcedWake: this.config.get("clipCatalogForcedWake") ?? false,
159790
160557
  debugGeneral: this.config.get("debugGeneral") ?? false,
159791
160558
  debugSocketLogs: this.config.get("debugSocketLogs") ?? [],
159792
160559
  motionEnabled: this.config.get("motionEnabled") ?? motionSnap.enabled ?? true,
@@ -163060,6 +163827,10 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
163060
163827
  throw new Error(`Reolink: ${reason}`);
163061
163828
  }
163062
163829
  async onShutdown() {
163830
+ if (this.clipRefreshTimer) {
163831
+ clearInterval(this.clipRefreshTimer);
163832
+ this.clipRefreshTimer = null;
163833
+ }
163063
163834
  this.logChannels?.stop();
163064
163835
  await this.emailPushServer?.stop().catch(() => {});
163065
163836
  await this.autodetectCache.dispose();
@@ -163070,6 +163841,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
163070
163841
  * logged inside the server and leaves it stopped until the next edit.
163071
163842
  */
163072
163843
  async onConfigChanged() {
163844
+ await this.refreshClipRefreshGlobalStore();
163073
163845
  await this.emailPushServer?.restart().catch((err) => {
163074
163846
  this.ctx.logger.warn("email-push: restart after settings change failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
163075
163847
  });
@@ -163089,7 +163861,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
163089
163861
  * for core to select from.
163090
163862
  */
163091
163863
  globalSettingsSchema() {
163092
- return buildEmailPushSettingsSchema(listLanHosts()[0] ?? "127.0.0.1");
163864
+ return { sections: [...buildEmailPushSettingsSchema(listLanHosts()[0] ?? "127.0.0.1").sections, ...buildClipRefreshSettingsSchema().sections] };
163093
163865
  }
163094
163866
  /**
163095
163867
  * Both email-push sections ARE this integration's configuration — one SMTP
@@ -163136,6 +163908,9 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
163136
163908
  capability: videoclipsCapability,
163137
163909
  provider: this.clipService.provider
163138
163910
  });
163911
+ await this.refreshClipRefreshGlobalStore();
163912
+ this.startClipCatalogRefresh(this.clipService);
163913
+ this.wireClipCatalogRefreshers();
163139
163914
  await this.serveClipPlanes(this.clipService);
163140
163915
  this.ensureEmailPushServer().start().catch((err) => {
163141
163916
  this.ctx.logger.warn("email-push: SMTP intake failed to start at init", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -163148,6 +163923,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
163148
163923
  if (dev instanceof ReolinkCamera) {
163149
163924
  const server = this.ensureEmailPushServer();
163150
163925
  dev.setEmailPushParamsResolver(() => server.buildCameraSetupParams(dev));
163926
+ this.wireClipCatalogRefreshers();
163151
163927
  this.ctx.logger.debug("email-push: resolver wired", {
163152
163928
  tags: { deviceId: data.deviceId },
163153
163929
  meta: { via }
@@ -163161,6 +163937,102 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
163161
163937
  }
163162
163938
  /** The clip catalog, its thumbnail cache and its two routes (D549). */
163163
163939
  clipService = null;
163940
+ clipRefreshTimer = null;
163941
+ /**
163942
+ * The addon store as the refresh timer last saw it.
163943
+ *
163944
+ * `listTargets` is synchronous on purpose — a tick that awaited a settings
163945
+ * read per camera would be a store round-trip per camera per tick — so the
163946
+ * global half of the cascade is mirrored here and refreshed at init and on
163947
+ * every `onConfigChanged`. An empty mirror resolves to the DEFAULT, never to
163948
+ * "off": a settings read that has not happened yet must not switch the timer
163949
+ * off for the whole fleet.
163950
+ */
163951
+ clipRefreshGlobalStore = {};
163952
+ /**
163953
+ * Give every camera the wake-side refresh.
163954
+ *
163955
+ * Idempotent, and re-run on both device lifecycle events for the reason the
163956
+ * email-push resolver documents: an addon-only redeploy re-registers a
163957
+ * device the hub already knows, which emits `DeviceMetaChanged` and NOT
163958
+ * `DeviceRegistered`, and subscribing to one left every battery camera
163959
+ * unwired.
163960
+ */
163961
+ wireClipCatalogRefreshers() {
163962
+ const service = this.clipService;
163963
+ if (service === null) return;
163964
+ for (const camera of this.listReolinkCameras()) {
163965
+ const deviceId = camera.getClipDeviceRef().deviceId;
163966
+ camera.setClipCatalogRefresher(async () => {
163967
+ await service.refreshCatalog(deviceId);
163968
+ });
163969
+ }
163970
+ }
163971
+ /** Re-read the global half of the clip-refresh cascade. */
163972
+ async refreshClipRefreshGlobalStore() {
163973
+ try {
163974
+ this.clipRefreshGlobalStore = await this.resolveGlobalStore();
163975
+ } catch (err) {
163976
+ this.ctx.logger.warn("videoclips: could not read the clip refresh default — keeping the last", { meta: { error: err instanceof Error ? err.message : String(err) } });
163977
+ }
163978
+ }
163979
+ /**
163980
+ * Arm the clip-catalog refresh.
163981
+ *
163982
+ * The scheduler owns no clock: it is ticked, so its rules are testable from
163983
+ * COLD (`clip-refresh.spec.ts` — the discipline D561 cost us). The tick
163984
+ * cadence is the FLOOR of what any camera can be configured to, not the
163985
+ * interval itself; each camera's own interval is decided inside the tick
163986
+ * from the cascade, so changing a setting takes effect without re-arming.
163987
+ */
163988
+ startClipCatalogRefresh(service) {
163989
+ const scheduler = new ClipCatalogRefreshScheduler({
163990
+ now: () => Date.now(),
163991
+ listTargets: () => this.clipRefreshTargets(),
163992
+ refresh: async (deviceId) => {
163993
+ await service.refreshCatalog(deviceId);
163994
+ },
163995
+ markStale: (deviceId) => {
163996
+ service.cache.markStale(deviceId);
163997
+ },
163998
+ wake: async (deviceId) => {
163999
+ const device = this.ctx.kernel.deviceRegistry?.getById(deviceId);
164000
+ if (!(device instanceof ReolinkCamera)) throw new Error(`clip refresh: device ${String(deviceId)} is not a Reolink camera here`);
164001
+ await device.wakeForClipCatalogRefresh();
164002
+ },
164003
+ hostBusy: (hostKey) => service.hostBusy(hostKey),
164004
+ logger: this.ctx.logger
164005
+ });
164006
+ this.clipRefreshTimer = setInterval(() => {
164007
+ scheduler.tick().catch((err) => {
164008
+ this.ctx.logger.warn("videoclips: the catalog refresh tick failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
164009
+ });
164010
+ }, 5 * 6e4);
164011
+ if (typeof this.clipRefreshTimer.unref === "function") this.clipRefreshTimer.unref();
164012
+ }
164013
+ /**
164014
+ * Every Reolink CAMERA this process owns, with its cascade resolved.
164015
+ *
164016
+ * Cameras only — the Home Hub (639) is `type: hub` and never binds
164017
+ * `videoclips` (`deviceTypes: [Camera]`); its catalog IS its children's and
164018
+ * is refreshed per child, on its one session. Accessories are not cameras.
164019
+ */
164020
+ clipRefreshTargets() {
164021
+ const globalMinutes = this.clipRefreshGlobalStore[CLIP_CATALOG_REFRESH_MINUTES_KEY];
164022
+ const out = [];
164023
+ for (const device of this.listReolinkCameras()) {
164024
+ const ref = device.getClipDeviceRef();
164025
+ out.push({
164026
+ deviceId: ref.deviceId,
164027
+ hostKey: ref.hostKey,
164028
+ isHubChild: ref.isHubChild,
164029
+ isBattery: ref.isBattery,
164030
+ forcedWake: resolveClipCatalogForcedWake(device.getClipCatalogForcedWake()),
164031
+ intervalMinutes: resolveClipCatalogRefreshMinutes(globalMinutes, device.getClipCatalogRefreshMinutes())
164032
+ });
164033
+ }
164034
+ return out;
164035
+ }
163164
164036
  /**
163165
164037
  * Host the thumbnail and media planes, and never let a host without the
163166
164038
  * facility take the addon down with it — a data plane is an enhancement to