@camstack/addon-provider-reolink 1.2.146 → 1.2.148
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/BaichuanVideoStream-FOE2DBVZ-94yIU75L.mjs +2 -0
- package/dist/BaichuanVideoStream-FOE2DBVZ-DJcEkVak.js +3 -0
- package/dist/addon.js +1632 -649
- package/dist/addon.mjs +1473 -490
- package/dist/{chunk-4KBXJ7U3-BpV_ui8q.js → chunk-ZBACPERX-BM6Ll-rh.js} +6 -8
- package/dist/{chunk-4KBXJ7U3-CCQfaWmi.mjs → chunk-ZBACPERX-cDJK1KXE.mjs} +6 -8
- package/package.json +2 -2
- package/dist/BaichuanVideoStream-GFJX465B-D_s-iWc1.mjs +0 -2
- package/dist/BaichuanVideoStream-GFJX465B-EThalnJS.js +0 -3
- /package/dist/{DiagnosticsTools-DZ5RWXSM-9NV95vRN.mjs → DiagnosticsTools-ZC7I7QM6-9NV95vRN.mjs} +0 -0
- /package/dist/{DiagnosticsTools-DZ5RWXSM-CD4rRF8a.js → DiagnosticsTools-ZC7I7QM6-CD4rRF8a.js} +0 -0
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-
|
|
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-
|
|
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).
|
|
@@ -7432,6 +7432,8 @@ var EVENT_OWNER_TYPES = [
|
|
|
7432
7432
|
* nothing failing until a caller asked.
|
|
7433
7433
|
*/
|
|
7434
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"]);
|
|
7435
7437
|
new Set(EVENT_OWNER_TYPES);
|
|
7436
7438
|
var EncodeProfileSchema = object({
|
|
7437
7439
|
video: object({
|
|
@@ -14977,8 +14979,6 @@ method(object({
|
|
|
14977
14979
|
deviceId: number(),
|
|
14978
14980
|
capName: string(),
|
|
14979
14981
|
wrapperAddonId: string(),
|
|
14980
|
-
/** The `ClipSource.source` id to toggle. Absent = all of the addon's. */
|
|
14981
|
-
sourceId: string().optional(),
|
|
14982
14982
|
active: boolean()
|
|
14983
14983
|
}), _void(), {
|
|
14984
14984
|
kind: "mutation",
|
|
@@ -22007,7 +22007,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
22007
22007
|
ownerType: EventOwnerTypeSchema,
|
|
22008
22008
|
eventId: number().int(),
|
|
22009
22009
|
deviceId: number()
|
|
22010
|
-
}), array(MediaFileInfoSchema).readonly()), method(
|
|
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, {
|
|
22011
22015
|
kind: "mutation",
|
|
22012
22016
|
auth: "admin"
|
|
22013
22017
|
}), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
|
|
@@ -25105,6 +25109,24 @@ method(VectorDeclareIndexInputSchema, _void(), {
|
|
|
25105
25109
|
kind: "mutation",
|
|
25106
25110
|
auth: "admin"
|
|
25107
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";
|
|
25108
25130
|
var ClipSchema = object({
|
|
25109
25131
|
/** Opaque, provider-namespaced id. The default provider encodes the time
|
|
25110
25132
|
* window so `getClipPlayback` is self-contained (no event re-query). */
|
|
@@ -25255,7 +25277,25 @@ var ClipSchema = object({
|
|
|
25255
25277
|
*/
|
|
25256
25278
|
playable: boolean().optional(),
|
|
25257
25279
|
/** Why {@link playable} is false, verbatim (`no-file-for-window`). */
|
|
25258
|
-
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()
|
|
25259
25299
|
});
|
|
25260
25300
|
var ClipPlaybackSchema = object({
|
|
25261
25301
|
/**
|
|
@@ -25338,59 +25378,17 @@ var ClipSourceSchema = object({
|
|
|
25338
25378
|
* alternative — a `native:reolink:* → provider-reolink` table inside the
|
|
25339
25379
|
* widget — is a second authority on provider identity living in the one
|
|
25340
25380
|
* package with no business knowing it, wrong the day a third source appears
|
|
25341
|
-
* (D557). One addon may serve SEVERAL sources
|
|
25342
|
-
*
|
|
25343
|
-
*
|
|
25344
|
-
*
|
|
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).
|
|
25345
25385
|
*
|
|
25346
25386
|
* Optional for version skew only. The collection dispatcher stamps it from
|
|
25347
25387
|
* the registry, so a row that travelled through the fan-out carries the
|
|
25348
25388
|
* authoritative id whatever the provider filled in.
|
|
25349
25389
|
*/
|
|
25350
25390
|
addonId: string().optional(),
|
|
25351
|
-
|
|
25352
|
-
* Which API this source resolved to FOR THIS CAMERA, when it has a choice.
|
|
25353
|
-
*
|
|
25354
|
-
* A source may cover one store through more than one surface — the Reolink
|
|
25355
|
-
* provider reads a hub child through the parent's event log and a standalone
|
|
25356
|
-
* through its own file list, because that is what each camera answers. The
|
|
25357
|
-
* CHOICE is the provider's, made from what the camera is, and is never a row
|
|
25358
|
-
* the operator has to understand; but it is REPORTED, because a source that
|
|
25359
|
-
* silently reads a different API on two cameras and then behaves differently
|
|
25360
|
-
* is the thing nobody can debug later. Absent when the source has only one
|
|
25361
|
-
* way to read its store.
|
|
25362
|
-
*/
|
|
25363
|
-
via: string().optional(),
|
|
25364
|
-
availability: ClipSourceAvailabilitySchema,
|
|
25365
|
-
/**
|
|
25366
|
-
* The operator switched this source OFF for this camera.
|
|
25367
|
-
*
|
|
25368
|
-
* Deliberately NOT a member of {@link ClipSourceAvailabilitySchema}'s
|
|
25369
|
-
* vocabulary. That enum models what the source CAN do — a sleeping camera, an
|
|
25370
|
-
* unmounted card, an index that disagrees with its own calendar — and a
|
|
25371
|
-
* switched-off source could answer perfectly well; the operator decided it
|
|
25372
|
-
* should not. Folding the choice in is how `disabled` and `broken` stop being
|
|
25373
|
-
* distinguishable, which is the D62 rule this repo has already paid for twice:
|
|
25374
|
-
* an off switch is REPORTED off (`CameraStatus.switchedOff`, the same word),
|
|
25375
|
-
* and disabled must never look like broken. It is also what lets every
|
|
25376
|
-
* exhaustive consumer of the availability enum keep compiling.
|
|
25377
|
-
*
|
|
25378
|
-
* A switched-off source contributes NO clips (`listClips` never calls it) and
|
|
25379
|
-
* its row carries no `catalogAsOf`: nothing confirms a catalog it is not
|
|
25380
|
-
* allowed to serve, and a frozen age that can only grow draws a stalling
|
|
25381
|
-
* source rather than an off switch.
|
|
25382
|
-
*
|
|
25383
|
-
* The row survives BECAUSE it is the control the operator switches the source
|
|
25384
|
-
* back on from — D554 decision 4's rule ("a source that cannot answer
|
|
25385
|
-
* produces a ROW, not an absence") applied to the one case D556 carved out of
|
|
25386
|
-
* it, and D557's own kept property ("a source is never hidden, only its
|
|
25387
|
-
* rows"). Absent means on.
|
|
25388
|
-
*
|
|
25389
|
-
* A provider never sets this — like {@link ClipSourceSchema.addonId} it is
|
|
25390
|
-
* stamped by the collection dispatcher, which holds the registry's projection
|
|
25391
|
-
* of the persisted authority (D556) and is the only place that knows it.
|
|
25392
|
-
*/
|
|
25393
|
-
switchedOff: boolean().optional()
|
|
25391
|
+
availability: ClipSourceAvailabilitySchema
|
|
25394
25392
|
});
|
|
25395
25393
|
var videoclipsCapability = {
|
|
25396
25394
|
name: "videoclips",
|
|
@@ -25444,22 +25442,40 @@ var videoclipsCapability = {
|
|
|
25444
25442
|
limit: number().int().positive().optional(),
|
|
25445
25443
|
/**
|
|
25446
25444
|
* WHICH provider to ask — the `addonId` a {@link ClipSourceSchema} row
|
|
25447
|
-
* carries, never a source id and never a list.
|
|
25445
|
+
* carries, never a source id and never a list. **Required** (D554 amended).
|
|
25448
25446
|
*
|
|
25449
|
-
*
|
|
25450
|
-
*
|
|
25451
|
-
*
|
|
25452
|
-
*
|
|
25453
|
-
*
|
|
25447
|
+
* A provider the device is not bound to is refused by name rather than
|
|
25448
|
+
* answered by another one (D552's `rejectUnresolvedAddonPin` rule).
|
|
25449
|
+
*
|
|
25450
|
+
* It was optional, documented as "absent means the device's BOUND
|
|
25451
|
+
* provider, which is CamStack on every camera". No code implemented
|
|
25452
|
+
* that. Measured on the live hub 2026-09-20 — device 592, bound to
|
|
25453
|
+
* `recorder` AND `provider-reolink` — a bare call with `limit: 3`
|
|
25454
|
+
* answered SIX rows, three from each source, merged newest-first:
|
|
25455
|
+
* `device-collection-dispatch.ts` simply left the fan-out un-narrowed,
|
|
25456
|
+
* so absence bought the union this method exists not to be, and
|
|
25457
|
+
* `limit` meant `limit × sources`.
|
|
25458
|
+
*
|
|
25459
|
+
* There is nothing to restore the default to. `getBindings` answers a
|
|
25460
|
+
* collection cap with a DERIVED, PLURAL set (D554 amended, step 0);
|
|
25461
|
+
* `setWrapperActive` — the singleton authority that could name one —
|
|
25462
|
+
* throws for a collection cap by design. Naming CamStack here instead
|
|
25463
|
+
* would privilege one addon by id inside a surface whose premise is
|
|
25464
|
+
* that sources are peers, and would be wrong on the first camera with
|
|
25465
|
+
* no recorder binding. So absence is REFUSED, in the schema, where the
|
|
25466
|
+
* generated types make it unomittable rather than merely discouraged.
|
|
25467
|
+
*
|
|
25468
|
+
* The default belongs to the SURFACE, which has the `listSources` rows
|
|
25469
|
+
* and can say which one it picked (D569 § 1–3; the viewer already
|
|
25470
|
+
* always sends this).
|
|
25454
25471
|
*
|
|
25455
25472
|
* It replaced `sources?: string[]`, a VIEW filter over source ids that
|
|
25456
25473
|
* assumed the answer was a fan-out over everything a camera has. The
|
|
25457
|
-
* operator settled otherwise on 2026-09-20 — *"
|
|
25458
|
-
*
|
|
25459
|
-
*
|
|
25460
|
-
* at a time and there is nothing to filter out of it.
|
|
25474
|
+
* operator settled otherwise on 2026-09-20 — *"l'utilizzatore è uno
|
|
25475
|
+
* solo"* — so the list is asked of one provider at a time and there is
|
|
25476
|
+
* nothing to filter out of it.
|
|
25461
25477
|
*/
|
|
25462
|
-
provider: string().
|
|
25478
|
+
provider: string().min(1)
|
|
25463
25479
|
}), array(ClipSchema).readonly(), {
|
|
25464
25480
|
kind: "query",
|
|
25465
25481
|
auth: "protected"
|
|
@@ -41437,6 +41453,12 @@ Object.freeze({
|
|
|
41437
41453
|
addonId: null,
|
|
41438
41454
|
access: "view"
|
|
41439
41455
|
},
|
|
41456
|
+
"pipelineAnalytics.ownersWithMedia": {
|
|
41457
|
+
capName: "pipeline-analytics",
|
|
41458
|
+
capScope: "device",
|
|
41459
|
+
addonId: null,
|
|
41460
|
+
access: "view"
|
|
41461
|
+
},
|
|
41440
41462
|
"pipelineAnalytics.pauseForStorageMigration": {
|
|
41441
41463
|
capName: "pipeline-analytics",
|
|
41442
41464
|
capScope: "device",
|
|
@@ -45337,6 +45359,11 @@ Object.freeze({
|
|
|
45337
45359
|
form: "single",
|
|
45338
45360
|
optional: false
|
|
45339
45361
|
}],
|
|
45362
|
+
"pipelineAnalytics.ownersWithMedia": [{
|
|
45363
|
+
name: "deviceId",
|
|
45364
|
+
form: "single",
|
|
45365
|
+
optional: false
|
|
45366
|
+
}],
|
|
45340
45367
|
"pipelineAnalytics.proposeRetrainAnnotations": [{
|
|
45341
45368
|
name: "deviceId",
|
|
45342
45369
|
form: "single",
|
|
@@ -69356,11 +69383,11 @@ var require_yazl = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
69356
69383
|
}
|
|
69357
69384
|
}));
|
|
69358
69385
|
//#endregion
|
|
69359
|
-
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-
|
|
69386
|
+
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-UIQ527B6.js
|
|
69360
69387
|
var import_undici = require_undici();
|
|
69361
69388
|
var import_yazl = /* @__PURE__ */ __toESM(require_yazl(), 1);
|
|
69362
69389
|
var formatterCache = /* @__PURE__ */ new Map();
|
|
69363
|
-
function formatterFor$
|
|
69390
|
+
function formatterFor$2(timeZone) {
|
|
69364
69391
|
const cached = formatterCache.get(timeZone);
|
|
69365
69392
|
if (cached) return cached;
|
|
69366
69393
|
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
@@ -69385,7 +69412,7 @@ function wallClockParts(d, timeZone) {
|
|
|
69385
69412
|
minute: d.getMinutes(),
|
|
69386
69413
|
second: d.getSeconds()
|
|
69387
69414
|
};
|
|
69388
|
-
const parts = formatterFor$
|
|
69415
|
+
const parts = formatterFor$2(timeZone).formatToParts(d);
|
|
69389
69416
|
const pick = (type) => {
|
|
69390
69417
|
const p = parts.find((x) => x.type === type);
|
|
69391
69418
|
return Number.parseInt(p?.value ?? "", 10);
|
|
@@ -75842,7 +75869,7 @@ var require_lz4 = /* @__PURE__ */ __commonJSMin(((exports) => {
|
|
|
75842
75869
|
};
|
|
75843
75870
|
}));
|
|
75844
75871
|
//#endregion
|
|
75845
|
-
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-
|
|
75872
|
+
//#region node_modules/@apocaliss92/nodelink-js/dist/chunk-5PJ6XPBF.js
|
|
75846
75873
|
var import_fxp = require_fxp();
|
|
75847
75874
|
var import_lz4 = /* @__PURE__ */ __toESM(require_lz4(), 1);
|
|
75848
75875
|
function encodeHeader(h) {
|
|
@@ -83946,6 +83973,128 @@ function patchMotionSensitivityListXml(currentXml, bands) {
|
|
|
83946
83973
|
const fragment = encodeMotionSensitivityListXml(bands);
|
|
83947
83974
|
return currentXml.replace(/<sensitivityInfoList[\s\S]*?(?:\/>|<\/sensitivityInfoList>)/, fragment);
|
|
83948
83975
|
}
|
|
83976
|
+
var ADTS_SAMPLES_PER_FRAME = 1024;
|
|
83977
|
+
var ADTS_SAMPLE_RATES = [
|
|
83978
|
+
96e3,
|
|
83979
|
+
88200,
|
|
83980
|
+
64e3,
|
|
83981
|
+
48e3,
|
|
83982
|
+
44100,
|
|
83983
|
+
32e3,
|
|
83984
|
+
24e3,
|
|
83985
|
+
22050,
|
|
83986
|
+
16e3,
|
|
83987
|
+
12e3,
|
|
83988
|
+
11025,
|
|
83989
|
+
8e3,
|
|
83990
|
+
7350,
|
|
83991
|
+
null,
|
|
83992
|
+
null,
|
|
83993
|
+
null
|
|
83994
|
+
];
|
|
83995
|
+
var ADTS_PROFILES = [
|
|
83996
|
+
"Main",
|
|
83997
|
+
"LC",
|
|
83998
|
+
"SSR",
|
|
83999
|
+
"LTP"
|
|
84000
|
+
];
|
|
84001
|
+
var parseAdtsHeader = (frame) => {
|
|
84002
|
+
if (frame.length < 7) return null;
|
|
84003
|
+
const b0 = frame[0];
|
|
84004
|
+
const b1 = frame[1];
|
|
84005
|
+
if (b0 !== 255 || (b1 & 240) !== 240) return null;
|
|
84006
|
+
const b2 = frame[2];
|
|
84007
|
+
const b3 = frame[3];
|
|
84008
|
+
const b4 = frame[4];
|
|
84009
|
+
const b5 = frame[5];
|
|
84010
|
+
const profile = b2 >> 6 & 3;
|
|
84011
|
+
const sampleRateIndex = b2 >> 2 & 15;
|
|
84012
|
+
const channelConfig = (b2 & 1) << 2 | b3 >> 6 & 3;
|
|
84013
|
+
const frameLength = (b3 & 3) << 11 | b4 << 3 | b5 >> 5 & 7;
|
|
84014
|
+
if (frameLength < 7) return null;
|
|
84015
|
+
return {
|
|
84016
|
+
profile,
|
|
84017
|
+
profileName: ADTS_PROFILES[profile] ?? "unknown",
|
|
84018
|
+
sampleRate: ADTS_SAMPLE_RATES[sampleRateIndex] ?? null,
|
|
84019
|
+
channels: channelConfig > 0 ? channelConfig : null,
|
|
84020
|
+
frameLength
|
|
84021
|
+
};
|
|
84022
|
+
};
|
|
84023
|
+
var buildRecordingAudioTrack = (params) => {
|
|
84024
|
+
const { codec, frames } = params;
|
|
84025
|
+
if (codec == null || frames.length === 0) return null;
|
|
84026
|
+
const data = frames.length === 1 ? frames[0] : Buffer.concat([...frames]);
|
|
84027
|
+
if (codec === "Adpcm") return {
|
|
84028
|
+
codec,
|
|
84029
|
+
format: "adpcm",
|
|
84030
|
+
data,
|
|
84031
|
+
frames: frames.length,
|
|
84032
|
+
bytes: data.length,
|
|
84033
|
+
sampleRate: null,
|
|
84034
|
+
channels: null,
|
|
84035
|
+
durationSeconds: null
|
|
84036
|
+
};
|
|
84037
|
+
const head = parseAdtsHeader(frames[0]);
|
|
84038
|
+
if (head == null) return {
|
|
84039
|
+
codec,
|
|
84040
|
+
format: "unknown",
|
|
84041
|
+
data,
|
|
84042
|
+
frames: frames.length,
|
|
84043
|
+
bytes: data.length,
|
|
84044
|
+
sampleRate: null,
|
|
84045
|
+
channels: null,
|
|
84046
|
+
durationSeconds: null
|
|
84047
|
+
};
|
|
84048
|
+
const durationSeconds = head.sampleRate != null && head.sampleRate > 0 ? frames.length * ADTS_SAMPLES_PER_FRAME / head.sampleRate : null;
|
|
84049
|
+
return {
|
|
84050
|
+
codec,
|
|
84051
|
+
format: "adts",
|
|
84052
|
+
data,
|
|
84053
|
+
frames: frames.length,
|
|
84054
|
+
bytes: data.length,
|
|
84055
|
+
sampleRate: head.sampleRate,
|
|
84056
|
+
channels: head.channels,
|
|
84057
|
+
durationSeconds
|
|
84058
|
+
};
|
|
84059
|
+
};
|
|
84060
|
+
var roundRecordingFps = (fps) => {
|
|
84061
|
+
if (fps > 14 && fps < 16) return 15;
|
|
84062
|
+
if (fps > 23 && fps < 26) return 25;
|
|
84063
|
+
if (fps > 29 && fps < 31) return 30;
|
|
84064
|
+
return Math.round(fps * 100) / 100;
|
|
84065
|
+
};
|
|
84066
|
+
var estimateVideoTiming = (params) => {
|
|
84067
|
+
const { timestampsUs } = params;
|
|
84068
|
+
const infoFps = params.infoFps != null && params.infoFps > 0 ? params.infoFps : null;
|
|
84069
|
+
const accessUnits = timestampsUs.length;
|
|
84070
|
+
if (accessUnits >= 2) {
|
|
84071
|
+
const spanUs = timestampsUs[accessUnits - 1] - timestampsUs[0];
|
|
84072
|
+
if (spanUs > 0) {
|
|
84073
|
+
const durationSeconds = spanUs / 1e6;
|
|
84074
|
+
return {
|
|
84075
|
+
accessUnits,
|
|
84076
|
+
fps: roundRecordingFps((accessUnits - 1) / durationSeconds),
|
|
84077
|
+
durationSeconds,
|
|
84078
|
+
infoFps,
|
|
84079
|
+
fpsSource: "timestamps"
|
|
84080
|
+
};
|
|
84081
|
+
}
|
|
84082
|
+
}
|
|
84083
|
+
if (infoFps != null) return {
|
|
84084
|
+
accessUnits,
|
|
84085
|
+
fps: infoFps,
|
|
84086
|
+
durationSeconds: accessUnits > 0 ? accessUnits / infoFps : null,
|
|
84087
|
+
infoFps,
|
|
84088
|
+
fpsSource: "infoFps"
|
|
84089
|
+
};
|
|
84090
|
+
return {
|
|
84091
|
+
accessUnits,
|
|
84092
|
+
fps: null,
|
|
84093
|
+
durationSeconds: null,
|
|
84094
|
+
infoFps,
|
|
84095
|
+
fpsSource: "unknown"
|
|
84096
|
+
};
|
|
84097
|
+
};
|
|
83949
84098
|
var buildDetectionClasses = (parsed, recordType) => {
|
|
83950
84099
|
const classes = [];
|
|
83951
84100
|
const flags = parsed?.flags;
|
|
@@ -88860,7 +89009,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
|
|
|
88860
89009
|
return;
|
|
88861
89010
|
}
|
|
88862
89011
|
entry.startInFlight = (async () => {
|
|
88863
|
-
const { BaichuanVideoStream: BaichuanVideoStream2 } = await import("./BaichuanVideoStream-
|
|
89012
|
+
const { BaichuanVideoStream: BaichuanVideoStream2 } = await import("./BaichuanVideoStream-FOE2DBVZ-94yIU75L.mjs");
|
|
88864
89013
|
const sessionKey = `live:object-detections:ch${entry.channel}:${entry.profile}`;
|
|
88865
89014
|
const dedicated = await this.createDedicatedSession(sessionKey);
|
|
88866
89015
|
const stream = new BaichuanVideoStream2({
|
|
@@ -91973,25 +92122,46 @@ ${stderr}`));
|
|
|
91973
92122
|
async downloadRecordingDemuxed(params) {
|
|
91974
92123
|
const raw = await this.downloadRecording(params);
|
|
91975
92124
|
const frames = [];
|
|
92125
|
+
const timestampsUs = [];
|
|
92126
|
+
const audioFrames = [];
|
|
91976
92127
|
const decoder = new BcMediaAnnexBDecoder({
|
|
91977
92128
|
strict: false,
|
|
91978
92129
|
logger: this.logger,
|
|
91979
|
-
onVideoAccessUnit: ({ annexB }) => {
|
|
92130
|
+
onVideoAccessUnit: ({ annexB, microseconds }) => {
|
|
91980
92131
|
frames.push(annexB);
|
|
92132
|
+
timestampsUs.push(microseconds);
|
|
92133
|
+
},
|
|
92134
|
+
onAudioFrame: ({ data }) => {
|
|
92135
|
+
audioFrames.push(data);
|
|
91981
92136
|
}
|
|
91982
92137
|
});
|
|
91983
92138
|
decoder.push(raw);
|
|
91984
92139
|
const stats = decoder.getStats();
|
|
92140
|
+
const audio = buildRecordingAudioTrack({
|
|
92141
|
+
codec: stats.audioType,
|
|
92142
|
+
frames: audioFrames
|
|
92143
|
+
});
|
|
92144
|
+
const timing = estimateVideoTiming({
|
|
92145
|
+
timestampsUs,
|
|
92146
|
+
infoFps: stats.infos[0]?.fps ?? null
|
|
92147
|
+
});
|
|
92148
|
+
if (audio == null) this.logger?.debug?.(`[downloadRecordingDemuxed] no audio packet in ${stats.packets} BcMedia packets (${stats.bytesIn} bytes) for ${params.fileName}`);
|
|
91985
92149
|
return {
|
|
91986
92150
|
annexB: Buffer.concat(frames),
|
|
91987
92151
|
videoType: stats.videoType,
|
|
92152
|
+
audio,
|
|
91988
92153
|
stats: {
|
|
91989
92154
|
bytesIn: stats.bytesIn,
|
|
91990
92155
|
bytesOut: stats.bytesOut,
|
|
91991
92156
|
packets: stats.packets,
|
|
91992
92157
|
videoPackets: stats.videoPackets,
|
|
91993
92158
|
audioPackets: stats.audioPackets,
|
|
91994
|
-
|
|
92159
|
+
audioBytes: stats.audioBytesOut,
|
|
92160
|
+
keyframes: stats.keyframes,
|
|
92161
|
+
fps: timing.fps,
|
|
92162
|
+
durationSeconds: timing.durationSeconds,
|
|
92163
|
+
infoFps: timing.infoFps,
|
|
92164
|
+
fpsSource: timing.fpsSource
|
|
91995
92165
|
}
|
|
91996
92166
|
};
|
|
91997
92167
|
}
|
|
@@ -92037,28 +92207,12 @@ ${stderr}`));
|
|
|
92037
92207
|
decoder.push(raw);
|
|
92038
92208
|
const decoderStats = decoder.getStats();
|
|
92039
92209
|
const videoCodec = decoderStats.videoType ?? "H264";
|
|
92040
|
-
|
|
92041
|
-
|
|
92042
|
-
|
|
92043
|
-
|
|
92044
|
-
|
|
92045
|
-
|
|
92046
|
-
durationSeconds = durationUs / 1e6;
|
|
92047
|
-
fps = (videoFrames.length - 1) / durationSeconds;
|
|
92048
|
-
} else {
|
|
92049
|
-
const infoFps = decoderStats.infos[0]?.fps;
|
|
92050
|
-
fps = infoFps && infoFps > 0 ? infoFps : 15;
|
|
92051
|
-
durationSeconds = videoFrames.length / fps;
|
|
92052
|
-
}
|
|
92053
|
-
} else {
|
|
92054
|
-
const infoFps = decoderStats.infos[0]?.fps;
|
|
92055
|
-
fps = infoFps && infoFps > 0 ? infoFps : 15;
|
|
92056
|
-
durationSeconds = videoFrames.length / fps;
|
|
92057
|
-
}
|
|
92058
|
-
if (fps > 14 && fps < 16) fps = 15;
|
|
92059
|
-
else if (fps > 23 && fps < 26) fps = 25;
|
|
92060
|
-
else if (fps > 29 && fps < 31) fps = 30;
|
|
92061
|
-
else fps = Math.round(fps * 100) / 100;
|
|
92210
|
+
const timing = estimateVideoTiming({
|
|
92211
|
+
timestampsUs: videoFrames.map((f) => f.microseconds),
|
|
92212
|
+
infoFps: decoderStats.infos[0]?.fps ?? null
|
|
92213
|
+
});
|
|
92214
|
+
const fps = timing.fps ?? 15;
|
|
92215
|
+
const durationSeconds = timing.durationSeconds ?? videoFrames.length / fps;
|
|
92062
92216
|
const videoData = Buffer.concat(videoFrames.map((f) => f.annexB));
|
|
92063
92217
|
const audioData = audioFrames.length > 0 ? Buffer.concat(audioFrames) : null;
|
|
92064
92218
|
const hasAudio = audioData != null && audioData.length > 0;
|
|
@@ -95011,7 +95165,7 @@ ${xml}`);
|
|
|
95011
95165
|
* @returns Test results for all stream types and profiles
|
|
95012
95166
|
*/
|
|
95013
95167
|
async testChannelStreams(channel, logger) {
|
|
95014
|
-
const { testChannelStreams } = await import("./DiagnosticsTools-
|
|
95168
|
+
const { testChannelStreams } = await import("./DiagnosticsTools-ZC7I7QM6-9NV95vRN.mjs");
|
|
95015
95169
|
return await testChannelStreams({
|
|
95016
95170
|
api: this,
|
|
95017
95171
|
channel: this.normalizeChannel(channel),
|
|
@@ -95027,7 +95181,7 @@ ${xml}`);
|
|
|
95027
95181
|
* @returns Complete diagnostics for all channels and streams
|
|
95028
95182
|
*/
|
|
95029
95183
|
async collectMultifocalDiagnostics(logger) {
|
|
95030
|
-
const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-
|
|
95184
|
+
const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-ZC7I7QM6-9NV95vRN.mjs");
|
|
95031
95185
|
return await collectMultifocalDiagnostics({
|
|
95032
95186
|
api: this,
|
|
95033
95187
|
logger
|
|
@@ -144089,7 +144243,7 @@ function buildAacAudioSpecificConfigHex(params) {
|
|
|
144089
144243
|
if (!Number.isFinite(audioObjectType) || audioObjectType <= 0 || audioObjectType > 31) return;
|
|
144090
144244
|
return ((audioObjectType & 31) << 11 | (samplingFreqIndex & 15) << 7 | (channels & 15) << 3).toString(16).padStart(4, "0");
|
|
144091
144245
|
}
|
|
144092
|
-
function
|
|
144246
|
+
function parseAdtsHeader2(adtsFrame) {
|
|
144093
144247
|
if (adtsFrame.length < 7) return null;
|
|
144094
144248
|
if (adtsFrame[0] !== 255 || (adtsFrame[1] & 240) !== 240) return null;
|
|
144095
144249
|
const protectionAbsent = (adtsFrame[1] & 1) === 1;
|
|
@@ -144194,7 +144348,7 @@ function packetizeH265(nal, rtp, opts, markerOnLast, isLastNal) {
|
|
|
144194
144348
|
return out;
|
|
144195
144349
|
}
|
|
144196
144350
|
function packetizeAacAdtsFrame(adts, rtp) {
|
|
144197
|
-
const parsed =
|
|
144351
|
+
const parsed = parseAdtsHeader2(adts);
|
|
144198
144352
|
if (!parsed) return { packets: [] };
|
|
144199
144353
|
const raw = adts.subarray(parsed.headerLength);
|
|
144200
144354
|
if (!raw.length) return { packets: [] };
|
|
@@ -145844,7 +145998,7 @@ async function createRfc4571TcpServerInternal(options) {
|
|
|
145844
145998
|
}, isCompositeStream ? Math.min(1e4, keyframeTimeoutMs) : 5e3);
|
|
145845
145999
|
const onAudio = (frame) => {
|
|
145846
146000
|
sawAnyAudio = true;
|
|
145847
|
-
const parsed =
|
|
146001
|
+
const parsed = parseAdtsHeader2(frame);
|
|
145848
146002
|
if (!parsed) {
|
|
145849
146003
|
if (debugLogsLeft-- > 0) {
|
|
145850
146004
|
const head = frame.subarray(0, Math.min(16, frame.length)).toString("hex");
|
|
@@ -147027,6 +147181,23 @@ var reolinkCameraSchema = object({
|
|
|
147027
147181
|
motionEnabled: boolean().optional(),
|
|
147028
147182
|
motionSensitivity: number().int().min(1).max(50).optional(),
|
|
147029
147183
|
/**
|
|
147184
|
+
* Per-device clip-catalog refresh interval, in minutes — the OVERRIDE half
|
|
147185
|
+
* of the cascade whose global default lives in the addon store under the
|
|
147186
|
+
* same key. Absent means "use the global", which is the same thing a reset
|
|
147187
|
+
* means (`resolveClipCatalogRefreshMinutes`, D562). `0` turns the timer off
|
|
147188
|
+
* for this camera. What the timer DOES with it differs by topology and the
|
|
147189
|
+
* field description says both: a free read on a hub child and on mains, and
|
|
147190
|
+
* stale-marking only on a standalone battery camera.
|
|
147191
|
+
*/
|
|
147192
|
+
clipCatalogRefreshMinutes: number().int().min(0).max(1440).optional(),
|
|
147193
|
+
/**
|
|
147194
|
+
* Let the clip timer WAKE this standalone battery camera. Off by default and
|
|
147195
|
+
* per device only — a 15-minute forced timer is 96 wakes/day against a
|
|
147196
|
+
* natural rate of 1–20 clips/day on this fleet (D562). Inert on every other
|
|
147197
|
+
* topology, which is why it is one switch and not a second meaning.
|
|
147198
|
+
*/
|
|
147199
|
+
clipCatalogForcedWake: boolean().optional(),
|
|
147200
|
+
/**
|
|
147030
147201
|
* Per-AI-class sensitivity overrides — pushed via
|
|
147031
147202
|
* `setAiDetection(aiType, sensitivity)` (cmdId=343). Reolink
|
|
147032
147203
|
* range 0..100 — same scale the reolink_aio CGI exposes
|
|
@@ -147571,6 +147742,20 @@ var ClipCatalogCache = class ClipCatalogCache {
|
|
|
147571
147742
|
}
|
|
147572
147743
|
return null;
|
|
147573
147744
|
}
|
|
147745
|
+
/**
|
|
147746
|
+
* Every day key this device holds a listing for, in no particular order.
|
|
147747
|
+
*
|
|
147748
|
+
* It is what lets an availability probe say WHEN a catalog was last
|
|
147749
|
+
* confirmed without listing anything: a camera nobody may wake reports the
|
|
147750
|
+
* age of what is held, and a source with nothing held says so rather than
|
|
147751
|
+
* claiming to be current.
|
|
147752
|
+
*/
|
|
147753
|
+
daysHeld(deviceId) {
|
|
147754
|
+
const out = [];
|
|
147755
|
+
const prefix = `${deviceId}|`;
|
|
147756
|
+
for (const key of this.slots.keys()) if (key.startsWith(prefix)) out.push(key.slice(prefix.length));
|
|
147757
|
+
return out;
|
|
147758
|
+
}
|
|
147574
147759
|
/** Every row held for a device, in no particular order. The prefetch sorts. */
|
|
147575
147760
|
allRows(deviceId) {
|
|
147576
147761
|
const out = [];
|
|
@@ -147624,6 +147809,57 @@ var ClipCatalogCache = class ClipCatalogCache {
|
|
|
147624
147809
|
}
|
|
147625
147810
|
};
|
|
147626
147811
|
//#endregion
|
|
147812
|
+
//#region src/clips/clip-still-refusal.ts
|
|
147813
|
+
/** "You are in the queue." */
|
|
147814
|
+
function deferStill(code, reason, retryAfterMs) {
|
|
147815
|
+
return {
|
|
147816
|
+
ok: false,
|
|
147817
|
+
code,
|
|
147818
|
+
disposition: "deferred",
|
|
147819
|
+
reason,
|
|
147820
|
+
retryAfterMs
|
|
147821
|
+
};
|
|
147822
|
+
}
|
|
147823
|
+
/** "This will not happen." */
|
|
147824
|
+
function refuseStill(code, reason) {
|
|
147825
|
+
return {
|
|
147826
|
+
ok: false,
|
|
147827
|
+
code,
|
|
147828
|
+
disposition: "final",
|
|
147829
|
+
reason
|
|
147830
|
+
};
|
|
147831
|
+
}
|
|
147832
|
+
/**
|
|
147833
|
+
* The queue's own refusal — the only failure that is about the LANE rather
|
|
147834
|
+
* than about the camera, and therefore the only one that is deferred.
|
|
147835
|
+
*
|
|
147836
|
+
* It carries its own `retryAfterMs` because the queue is the only thing that
|
|
147837
|
+
* knows it: the depth it is holding, or the exact instant a host's backoff
|
|
147838
|
+
* closes.
|
|
147839
|
+
*/
|
|
147840
|
+
var ThumbQueueRefusedError = class extends Error {
|
|
147841
|
+
code;
|
|
147842
|
+
retryAfterMs;
|
|
147843
|
+
constructor(code, message, retryAfterMs) {
|
|
147844
|
+
super(message);
|
|
147845
|
+
this.name = "ThumbQueueRefusedError";
|
|
147846
|
+
this.code = code;
|
|
147847
|
+
this.retryAfterMs = retryAfterMs;
|
|
147848
|
+
}
|
|
147849
|
+
};
|
|
147850
|
+
/**
|
|
147851
|
+
* A thrown value from the queue, as a wire answer — or `null` when it was not
|
|
147852
|
+
* the queue at all.
|
|
147853
|
+
*
|
|
147854
|
+
* `null` is not a default: it is what makes the caller name `camera-refused`
|
|
147855
|
+
* explicitly, instead of a socket close quietly inheriting "come back later"
|
|
147856
|
+
* and a tile retrying four times against a camera that is not there.
|
|
147857
|
+
*/
|
|
147858
|
+
function queueRefusal(err) {
|
|
147859
|
+
if (!(err instanceof ThumbQueueRefusedError)) return null;
|
|
147860
|
+
return deferStill(err.code, err.message, err.retryAfterMs);
|
|
147861
|
+
}
|
|
147862
|
+
//#endregion
|
|
147627
147863
|
//#region src/clips/clip-data-plane.ts
|
|
147628
147864
|
/**
|
|
147629
147865
|
* The addon's two data-plane handlers — the still and the bytes.
|
|
@@ -147645,6 +147881,19 @@ var ClipCatalogCache = class ClipCatalogCache {
|
|
|
147645
147881
|
* which. Waiting instead would hold a request open behind a camera that is
|
|
147646
147882
|
* asleep for the night.
|
|
147647
147883
|
*
|
|
147884
|
+
* **And it says whether to COME BACK.** Those reasons are two kinds of answer:
|
|
147885
|
+
* `queue-full` and `camera-backoff` mean the still is still coming, the rest
|
|
147886
|
+
* mean it will not. The branch that refused decides — nothing here maps a code
|
|
147887
|
+
* to a disposition, and no client parses one out of the prose — and the answer
|
|
147888
|
+
* travels as its own headers (`x-camstack-reason-code`,
|
|
147889
|
+
* `x-camstack-disposition`, plus `retry-after` on a deferred one) beside the
|
|
147890
|
+
* prose, which stays verbatim because a `camera-refused` is only ever
|
|
147891
|
+
* explained by the camera's own words.
|
|
147892
|
+
*
|
|
147893
|
+
* A response with no disposition header is read as FINAL by every surface,
|
|
147894
|
+
* which is exactly how this route behaved before: an older addon under a newer
|
|
147895
|
+
* admin UI keeps the old behaviour instead of gaining a retry loop.
|
|
147896
|
+
*
|
|
147648
147897
|
* ## Why Range is hand-rolled here
|
|
147649
147898
|
*
|
|
147650
147899
|
* One file, one path, no root mapping — and `createReadStream().pipe(res)`
|
|
@@ -147687,26 +147936,45 @@ function createClipThumbHandler(deps) {
|
|
|
147687
147936
|
try {
|
|
147688
147937
|
minted = await deps.mint(deviceId, clipKey);
|
|
147689
147938
|
} catch (err) {
|
|
147690
|
-
minted =
|
|
147691
|
-
ok: false,
|
|
147692
|
-
reason: err instanceof Error ? err.message : String(err)
|
|
147693
|
-
};
|
|
147939
|
+
minted = refuseStill("camera-refused", err instanceof Error ? err.message : String(err));
|
|
147694
147940
|
}
|
|
147695
147941
|
if (!minted.ok) {
|
|
147696
|
-
|
|
147697
|
-
tags: { deviceId },
|
|
147698
|
-
meta: {
|
|
147699
|
-
clipKey,
|
|
147700
|
-
reason: minted.reason
|
|
147701
|
-
}
|
|
147702
|
-
});
|
|
147703
|
-
res.writeHead(204, { "x-camstack-reason": minted.reason.slice(0, 500) });
|
|
147704
|
-
res.end();
|
|
147942
|
+
writeRefusal(res, minted, deviceId, deps.logger, clipKey);
|
|
147705
147943
|
return;
|
|
147706
147944
|
}
|
|
147707
147945
|
writeJpeg(res, minted.bytes, req.method === "HEAD");
|
|
147708
147946
|
};
|
|
147709
147947
|
}
|
|
147948
|
+
/**
|
|
147949
|
+
* The 204, with everything the tile needs to decide what to draw next.
|
|
147950
|
+
*
|
|
147951
|
+
* A FINAL refusal is work that was dropped, so it is warned, per camera. A
|
|
147952
|
+
* DEFERRED one dropped nothing — the still is queued behind another — and
|
|
147953
|
+
* warning it would put 43 lines on the log for one 60-tile page against a
|
|
147954
|
+
* 16-deep lane, which is how a real drop stops being visible.
|
|
147955
|
+
*/
|
|
147956
|
+
function writeRefusal(res, refusal, deviceId, logger, clipKey) {
|
|
147957
|
+
const line = "videoclips: thumbnail not minted";
|
|
147958
|
+
const extras = {
|
|
147959
|
+
tags: { deviceId },
|
|
147960
|
+
meta: {
|
|
147961
|
+
clipKey,
|
|
147962
|
+
code: refusal.code,
|
|
147963
|
+
disposition: refusal.disposition,
|
|
147964
|
+
reason: refusal.reason
|
|
147965
|
+
}
|
|
147966
|
+
};
|
|
147967
|
+
if (refusal.disposition === "final") logger.warn(line, extras);
|
|
147968
|
+
else logger.debug(line, extras);
|
|
147969
|
+
const retryAfterMs = refusal.disposition === "deferred" ? refusal.retryAfterMs : void 0;
|
|
147970
|
+
res.writeHead(204, {
|
|
147971
|
+
[CLIP_STILL_REASON_HEADER]: refusal.reason.slice(0, 500),
|
|
147972
|
+
[CLIP_STILL_REASON_CODE_HEADER]: refusal.code,
|
|
147973
|
+
[CLIP_STILL_DISPOSITION_HEADER]: refusal.disposition,
|
|
147974
|
+
...retryAfterMs === void 0 ? {} : { "retry-after": String(Math.max(1, Math.ceil(retryAfterMs / 1e3))) }
|
|
147975
|
+
});
|
|
147976
|
+
res.end();
|
|
147977
|
+
}
|
|
147710
147978
|
function writeJpeg(res, bytes, headOnly) {
|
|
147711
147979
|
res.writeHead(200, {
|
|
147712
147980
|
"content-type": "image/jpeg",
|
|
@@ -147825,21 +148093,73 @@ function createClipPlaybackHandler(deps) {
|
|
|
147825
148093
|
//#region src/clips/clip-mux.ts
|
|
147826
148094
|
/** A clip that has not transferred by here is not going to. */
|
|
147827
148095
|
var CLIP_MUX_TIMEOUT_MS = 6e4;
|
|
148096
|
+
/**
|
|
148097
|
+
* Decide what to do with the track the download returned.
|
|
148098
|
+
*
|
|
148099
|
+
* The gate is `format === 'adts'` and NOT `audio !== null`: an `adpcm` or
|
|
148100
|
+
* `unknown` track stream-copied into MP4 produces a file whose audio track is
|
|
148101
|
+
* garbage, which is worse than a clip that is honestly silent. A track that
|
|
148102
|
+
* arrived with no frames is no track — counting it would promise sound the
|
|
148103
|
+
* file does not have.
|
|
148104
|
+
*/
|
|
148105
|
+
function decideClipAudio(audio) {
|
|
148106
|
+
if (audio === null) return {
|
|
148107
|
+
kind: "silent",
|
|
148108
|
+
reason: "no-audio-track",
|
|
148109
|
+
format: null
|
|
148110
|
+
};
|
|
148111
|
+
if (audio.frames === 0 || audio.bytes === 0) return {
|
|
148112
|
+
kind: "silent",
|
|
148113
|
+
reason: "no-audio-track",
|
|
148114
|
+
format: audio.format
|
|
148115
|
+
};
|
|
148116
|
+
if (audio.format !== "adts") return {
|
|
148117
|
+
kind: "silent",
|
|
148118
|
+
reason: "audio-not-copyable",
|
|
148119
|
+
format: audio.format
|
|
148120
|
+
};
|
|
148121
|
+
return {
|
|
148122
|
+
kind: "copy",
|
|
148123
|
+
format: "adts",
|
|
148124
|
+
data: audio.data
|
|
148125
|
+
};
|
|
148126
|
+
}
|
|
148127
|
+
function usableFps(fps) {
|
|
148128
|
+
if (fps === null || fps === void 0) return null;
|
|
148129
|
+
return Number.isFinite(fps) && fps > 0 ? fps : null;
|
|
148130
|
+
}
|
|
147828
148131
|
function buildClipMuxArgs(input) {
|
|
148132
|
+
const demuxer = input.videoType === "H265" ? "hevc" : "h264";
|
|
148133
|
+
const fps = usableFps(input.fps);
|
|
147829
148134
|
return [
|
|
147830
148135
|
"-hide_banner",
|
|
147831
148136
|
"-loglevel",
|
|
147832
148137
|
"error",
|
|
147833
148138
|
"-nostdin",
|
|
147834
148139
|
"-y",
|
|
148140
|
+
...fps !== null ? ["-r", String(fps)] : [],
|
|
147835
148141
|
"-f",
|
|
147836
|
-
|
|
148142
|
+
demuxer,
|
|
147837
148143
|
"-i",
|
|
147838
148144
|
input.inputPath,
|
|
147839
|
-
|
|
148145
|
+
...input.audio !== void 0 ? [
|
|
148146
|
+
"-f",
|
|
148147
|
+
"aac",
|
|
148148
|
+
"-i",
|
|
148149
|
+
input.audio.path
|
|
148150
|
+
] : [],
|
|
148151
|
+
"-c:v",
|
|
147840
148152
|
"copy",
|
|
148153
|
+
...input.audio !== void 0 ? [
|
|
148154
|
+
"-c:a",
|
|
148155
|
+
"copy",
|
|
148156
|
+
"-bsf:a",
|
|
148157
|
+
"aac_adtstoasc"
|
|
148158
|
+
] : [],
|
|
147841
148159
|
"-movflags",
|
|
147842
148160
|
"+faststart",
|
|
148161
|
+
"-f",
|
|
148162
|
+
"mp4",
|
|
147843
148163
|
input.outputPath
|
|
147844
148164
|
];
|
|
147845
148165
|
}
|
|
@@ -147926,6 +148246,85 @@ function describeMuxFailure(run) {
|
|
|
147926
148246
|
if (run.code !== 0) return `mux-failed (exit ${String(run.code)}): ${run.stderr.slice(0, 300)}`;
|
|
147927
148247
|
return null;
|
|
147928
148248
|
}
|
|
148249
|
+
//#endregion
|
|
148250
|
+
//#region src/clips/clip-truncation.ts
|
|
148251
|
+
/**
|
|
148252
|
+
* The window a TRUNCATED fetch is retried on: the library's own default, the
|
|
148253
|
+
* one this repo shipped for its whole life. A retry exists to answer "was that
|
|
148254
|
+
* the window or the camera?", so it must be the conservative number and not a
|
|
148255
|
+
* second short one.
|
|
148256
|
+
*/
|
|
148257
|
+
var CLIP_IDLE_WINDOW_RETRY_MS = 2e3;
|
|
148258
|
+
/**
|
|
148259
|
+
* Did this pass deliver the clip the row describes?
|
|
148260
|
+
*
|
|
148261
|
+
* Only a SHORTFALL is a defect. A decode that runs longer than the row is
|
|
148262
|
+
* routine — the row's stamps are whole seconds and its twins differ by up to
|
|
148263
|
+
* four — and calling it truncated would refuse a clip that is more complete
|
|
148264
|
+
* than the catalog knew.
|
|
148265
|
+
*/
|
|
148266
|
+
function judgeClipDuration(input) {
|
|
148267
|
+
if (input.inProgress === true) return {
|
|
148268
|
+
kind: "unknown",
|
|
148269
|
+
reason: "clip-open"
|
|
148270
|
+
};
|
|
148271
|
+
if (input.decodedSeconds === null || !Number.isFinite(input.decodedSeconds)) return {
|
|
148272
|
+
kind: "unknown",
|
|
148273
|
+
reason: "duration-unmeasured"
|
|
148274
|
+
};
|
|
148275
|
+
if (!Number.isFinite(input.expectedMs) || input.expectedMs <= 0) return {
|
|
148276
|
+
kind: "unknown",
|
|
148277
|
+
reason: "row-window-unknown"
|
|
148278
|
+
};
|
|
148279
|
+
const expectedSeconds = input.expectedMs / 1e3;
|
|
148280
|
+
const decodedSeconds = input.decodedSeconds;
|
|
148281
|
+
const shortfallSeconds = expectedSeconds - decodedSeconds;
|
|
148282
|
+
if (shortfallSeconds > 1.5) return {
|
|
148283
|
+
kind: "short",
|
|
148284
|
+
expectedSeconds,
|
|
148285
|
+
decodedSeconds,
|
|
148286
|
+
shortfallSeconds
|
|
148287
|
+
};
|
|
148288
|
+
return {
|
|
148289
|
+
kind: "complete",
|
|
148290
|
+
expectedSeconds,
|
|
148291
|
+
decodedSeconds
|
|
148292
|
+
};
|
|
148293
|
+
}
|
|
148294
|
+
//#endregion
|
|
148295
|
+
//#region src/clips/clip-thumb-queue.ts
|
|
148296
|
+
/**
|
|
148297
|
+
* The provider's OWN CoverPreview lane — two queues, one in flight per host.
|
|
148298
|
+
*
|
|
148299
|
+
* The library already serialises CoverPreview: one in flight per api instance,
|
|
148300
|
+
* a 50-deep FIFO that throws past that, and a process-wide host-keyed backoff
|
|
148301
|
+
* that doubles on a 400. What a FIFO cannot express is PRIORITY, and the two
|
|
148302
|
+
* kinds of mint this feature has are not equal — an operator staring at a tile
|
|
148303
|
+
* must not wait behind twenty prefetches for clips nobody opened.
|
|
148304
|
+
*
|
|
148305
|
+
* So the provider keeps two queues of its own, hands the library at most ONE
|
|
148306
|
+
* CoverPreview per host at a time, drains `interactive` before `prefetch`, and
|
|
148307
|
+
* re-checks between every prefetch mint (D549 16).
|
|
148308
|
+
*
|
|
148309
|
+
* ## Two things it refuses to do
|
|
148310
|
+
*
|
|
148311
|
+
* **Wait when the queue is full.** A request past the bound is REFUSED with
|
|
148312
|
+
* `queue-full`, so the route can answer 204 with a reason. A request held open
|
|
148313
|
+
* behind forty-nine others is a spinner the operator cannot cancel, and the
|
|
148314
|
+
* camera will have rolled the clip over before it is served.
|
|
148315
|
+
*
|
|
148316
|
+
* **Let a prefetch probe a sulking host.** While a host's 400 backoff is open
|
|
148317
|
+
* a prefetch is skipped entirely; only an interactive request may probe it,
|
|
148318
|
+
* because only an interactive request has somebody waiting for the answer.
|
|
148319
|
+
*
|
|
148320
|
+
* The backoff mirrors the library's (1 s doubling to 30 s) rather than
|
|
148321
|
+
* replacing it: this one decides whether to ASK at all, the library's decides
|
|
148322
|
+
* how long to wait once asked.
|
|
148323
|
+
*/
|
|
148324
|
+
/** How long one waiting mint is worth, as a retry hint. Coarse on purpose:
|
|
148325
|
+
* the client floors it with its own backoff and caps its own attempts, so a
|
|
148326
|
+
* wrong hint can neither cause a hammer nor a hang. */
|
|
148327
|
+
var THUMB_DRAIN_HINT_PER_WAITER_MS = 1e3;
|
|
147929
148328
|
var THUMB_BACKOFF_INITIAL_MS = 1e3;
|
|
147930
148329
|
var THUMB_BACKOFF_MAX_MS = 3e4;
|
|
147931
148330
|
/**
|
|
@@ -147946,6 +148345,28 @@ var ClipThumbQueue = class {
|
|
|
147946
148345
|
const state = this.hosts.get(host);
|
|
147947
148346
|
return state !== void 0 && state.backoffUntilMs > this.now();
|
|
147948
148347
|
}
|
|
148348
|
+
/**
|
|
148349
|
+
* Is this host busy with, or owed, operator-facing work?
|
|
148350
|
+
*
|
|
148351
|
+
* The periodic catalog refresh asks before it spends the host's one lane:
|
|
148352
|
+
* an operator staring at a tile must never wait behind a timer, and a host
|
|
148353
|
+
* whose 400 backoff is open must not be probed by anything that has nobody
|
|
148354
|
+
* waiting for the answer — the same rule that already keeps a PREFETCH off
|
|
148355
|
+
* a sulking host.
|
|
148356
|
+
*
|
|
148357
|
+
* Either a mint is in flight or the host is sulking. There is deliberately
|
|
148358
|
+
* no third clause for "an interactive request is queued": a queued request
|
|
148359
|
+
* means a mint is running, so `running` already covers it, and a clause
|
|
148360
|
+
* that can never be the reason for the answer is a leftover that reads as
|
|
148361
|
+
* verification. The lane is one per host, so a PREFETCH holding it defers
|
|
148362
|
+
* the refresh too — which is right: the refresh is the lowest-priority
|
|
148363
|
+
* thing on this host.
|
|
148364
|
+
*/
|
|
148365
|
+
busy(host) {
|
|
148366
|
+
const state = this.hosts.get(host);
|
|
148367
|
+
if (state === void 0) return false;
|
|
148368
|
+
return state.backoffUntilMs > this.now() || state.running;
|
|
148369
|
+
}
|
|
147949
148370
|
backoffUntil(host) {
|
|
147950
148371
|
return this.hosts.get(host)?.backoffUntilMs ?? 0;
|
|
147951
148372
|
}
|
|
@@ -147968,8 +148389,8 @@ var ClipThumbQueue = class {
|
|
|
147968
148389
|
*/
|
|
147969
148390
|
async submit(job) {
|
|
147970
148391
|
const state = this.stateFor(job.host);
|
|
147971
|
-
if (job.lane === "prefetch" && state.backoffUntilMs > this.now()) throw new
|
|
147972
|
-
if (state.waiting.length >= 16) throw new
|
|
148392
|
+
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());
|
|
148393
|
+
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);
|
|
147973
148394
|
return new Promise((resolve, reject) => {
|
|
147974
148395
|
state.waiting.push({
|
|
147975
148396
|
lane: job.lane,
|
|
@@ -147989,7 +148410,7 @@ var ClipThumbQueue = class {
|
|
|
147989
148410
|
if (next === void 0) return;
|
|
147990
148411
|
state.waiting.splice(pick, 1);
|
|
147991
148412
|
if (next.lane === "prefetch" && state.backoffUntilMs > this.now()) {
|
|
147992
|
-
next.reject(
|
|
148413
|
+
next.reject(new ThumbQueueRefusedError("camera-backoff", `camera-backoff: host ${host} is backing off; prefetch skipped`, state.backoffUntilMs - this.now()));
|
|
147993
148414
|
this.drain(host);
|
|
147994
148415
|
return;
|
|
147995
148416
|
}
|
|
@@ -148042,12 +148463,13 @@ var ClipThumbQueue = class {
|
|
|
148042
148463
|
* exactly — 29 events, 28 inside the 20 listed files, and all 20 files
|
|
148043
148464
|
* carrying an event. They are two VIEWS of one store.
|
|
148044
148465
|
*
|
|
148045
|
-
* So the operator collapsed them (2026-09-20): one source, which
|
|
148046
|
-
* best API for what the camera IS
|
|
148047
|
-
*
|
|
148048
|
-
*
|
|
148049
|
-
*
|
|
148050
|
-
*
|
|
148466
|
+
* So the operator collapsed them (2026-09-20): one source, which picked the
|
|
148467
|
+
* best API for what the camera IS, and REPORTED the pick on `ClipSource.via`.
|
|
148468
|
+
* Later the same day the pick went too (D565): `FileInfoList` is the catalog
|
|
148469
|
+
* on every topology, so `via` had one value and is gone. What is left is one
|
|
148470
|
+
* source reading one API — and `clipKeyFor` below is still keyed off
|
|
148471
|
+
* `(channel, fileName)` rather than the source, which is now simply true
|
|
148472
|
+
* rather than a concession.
|
|
148051
148473
|
*/
|
|
148052
148474
|
var CLIP_SOURCE_ONBOARD = "native:reolink:onboard";
|
|
148053
148475
|
/** Every source this provider can name. Order is the picker's order. */
|
|
@@ -148098,10 +148520,10 @@ function parseClipId(clipId) {
|
|
|
148098
148520
|
}
|
|
148099
148521
|
/**
|
|
148100
148522
|
* The on-disk key for a clip's derived artifacts (its thumbnail, its muxed
|
|
148101
|
-
* MP4). Derived from `(channel, fileName)` and NOT from the source:
|
|
148102
|
-
*
|
|
148103
|
-
*
|
|
148104
|
-
*
|
|
148523
|
+
* MP4). Derived from `(channel, fileName)` and NOT from the source: a
|
|
148524
|
+
* thumbnail belongs to the FILE, so any surface that names the same file
|
|
148525
|
+
* finds the same picture and the camera is never asked for a second
|
|
148526
|
+
* CoverPreview of it.
|
|
148105
148527
|
*
|
|
148106
148528
|
* A hash rather than the encoded name because a hub `<Id>` is a full path and
|
|
148107
148529
|
* the encoded form is long enough to hit a file-name limit.
|
|
@@ -148250,7 +148672,7 @@ function startOf(file) {
|
|
|
148250
148672
|
return file.startTime?.getTime() ?? 0;
|
|
148251
148673
|
}
|
|
148252
148674
|
var partsCache = /* @__PURE__ */ new Map();
|
|
148253
|
-
function formatterFor(timeZone) {
|
|
148675
|
+
function formatterFor$1(timeZone) {
|
|
148254
148676
|
const cached = partsCache.get(timeZone);
|
|
148255
148677
|
if (cached !== void 0) return cached;
|
|
148256
148678
|
const made = new Intl.DateTimeFormat("en-US", {
|
|
@@ -148267,7 +148689,7 @@ function formatterFor(timeZone) {
|
|
|
148267
148689
|
return made;
|
|
148268
148690
|
}
|
|
148269
148691
|
function partsAt(epochMs, timeZone) {
|
|
148270
|
-
const parts = formatterFor(timeZone).formatToParts(new Date(epochMs));
|
|
148692
|
+
const parts = formatterFor$1(timeZone).formatToParts(new Date(epochMs));
|
|
148271
148693
|
const read = (type) => {
|
|
148272
148694
|
const found = parts.find((p) => p.type === type);
|
|
148273
148695
|
return found === void 0 ? 0 : Number(found.value);
|
|
@@ -148338,36 +148760,6 @@ function enumerateWallClockDays(sinceMs, untilMs, timeZone) {
|
|
|
148338
148760
|
//#endregion
|
|
148339
148761
|
//#region src/clips/clip-catalog.ts
|
|
148340
148762
|
/**
|
|
148341
|
-
* Reading a Reolink camera's own clip catalog: the cheap question, the day
|
|
148342
|
-
* index, then one day's files.
|
|
148343
|
-
*
|
|
148344
|
-
* ## The order is the point
|
|
148345
|
-
*
|
|
148346
|
-
* 1. **Is there a card at all?** `getHddInfoList` (cmd 102). 640 answers
|
|
148347
|
-
* `mount 0, capacity 0` — no SD card — and searching it is 400s and wasted
|
|
148348
|
-
* wake budget. "The cheap question comes first" (D549 4 / D54).
|
|
148349
|
-
* 2. **Which days hold footage?** `getDayRecords` (cmd 142) once per month.
|
|
148350
|
-
* The per-day listing loop of the earlier plan is deleted, not kept as a
|
|
148351
|
-
* fallback (D549 8): 19 days in 50 ms beats 30 searches.
|
|
148352
|
-
* 3. **What is in one day?** `FileInfoList` (cmd 14/15/16), sub AND main, then
|
|
148353
|
-
* twinned (D549 14b). A clip IS a file: its name is its id, its bytes
|
|
148354
|
-
* (cmd 5) and its still (cmd 298) are addressed from it (D549 10).
|
|
148355
|
-
*
|
|
148356
|
-
* This is the API a STANDALONE camera's one source resolves to — a standalone
|
|
148357
|
-
* answers `findEventLog` with an empty body. A hub child resolves to the event
|
|
148358
|
-
* view instead (`clip-event-catalog.ts`), and still uses this reader for the
|
|
148359
|
-
* day's files, which is what an event's window is joined against (D555).
|
|
148360
|
-
*
|
|
148361
|
-
* ## What an empty answer means
|
|
148362
|
-
*
|
|
148363
|
-
* A day the CALENDAR names and the INDEX answers with nothing is a defect on
|
|
148364
|
-
* the camera, measured on 618: the calendar marks 3–20 September, days 6–20
|
|
148365
|
-
* list zero files on both streams, on both filters, on a hand-built window,
|
|
148366
|
-
* with 177 GB free. The reader reports `indexEmpty` for that day so the
|
|
148367
|
-
* provider can say `index-empty` — never "no clips", which would be a lie
|
|
148368
|
-
* about a card that is full of them (D549 18).
|
|
148369
|
-
*/
|
|
148370
|
-
/**
|
|
148371
148763
|
* Locate the disk record structurally.
|
|
148372
148764
|
*
|
|
148373
148765
|
* The library's declared `HddInfoListConfig` and the captured wire disagree —
|
|
@@ -148452,6 +148844,30 @@ function monthsBetween(sinceMs, untilMs, timeZone) {
|
|
|
148452
148844
|
}
|
|
148453
148845
|
return out;
|
|
148454
148846
|
}
|
|
148847
|
+
/**
|
|
148848
|
+
* Is this file still being written?
|
|
148849
|
+
*
|
|
148850
|
+
* A Reolink recording is named `<prefix>_DST<date>_<start>_<end>_<hex>_<hex>`,
|
|
148851
|
+
* and while it is open the camera writes the END stamp as `000000` and the
|
|
148852
|
+
* size as `0` — measured on 592, 2026-09-20:
|
|
148853
|
+
* `RecS03_DST20260920_192927_000000_2B1E808_0.mp4`. Its `<endTime>` block is
|
|
148854
|
+
* FROZEN at whatever the index entry held when it was created (19:29:56, read
|
|
148855
|
+
* three times over fifteen minutes while the file grew), so a duration drawn
|
|
148856
|
+
* from it is a lie about the one row the operator sees first.
|
|
148857
|
+
*
|
|
148858
|
+
* BOTH signals are required. A recording that happens to end at midnight is
|
|
148859
|
+
* named `_000000_` and is closed; its bytes are what separate the two. Nothing
|
|
148860
|
+
* on the wire distinguishes them otherwise, and a false positive here would
|
|
148861
|
+
* replace a correct duration with `Recording…` for ever. Re-measured
|
|
148862
|
+
* read-only the same evening: of 355 sub and 355 main files on that camera's
|
|
148863
|
+
* day, zero carried a `_000000_` segment and zero carried a size of 0.
|
|
148864
|
+
*/
|
|
148865
|
+
function isFileStillBeingWritten(file) {
|
|
148866
|
+
if (file.sizeBytes !== void 0 && file.sizeBytes > 0) return false;
|
|
148867
|
+
return OPEN_END_STAMP.test(file.fileName);
|
|
148868
|
+
}
|
|
148869
|
+
/** The end-stamp field of an open recording, between its own separators. */
|
|
148870
|
+
var OPEN_END_STAMP = /_000000_/;
|
|
148455
148871
|
function handleOf(file) {
|
|
148456
148872
|
const id = file.id;
|
|
148457
148873
|
return id !== void 0 && id !== "" ? id : file.fileName;
|
|
@@ -148492,139 +148908,123 @@ async function listDayRows(api, input) {
|
|
|
148492
148908
|
...pair.sub.sizeBytes !== void 0 ? { subBytes: pair.sub.sizeBytes } : {},
|
|
148493
148909
|
...pair.main !== void 0 ? { mainFileName: handleOf(pair.main) } : {},
|
|
148494
148910
|
...pair.main?.sizeBytes !== void 0 ? { mainBytes: pair.main.sizeBytes } : {},
|
|
148911
|
+
...pair.main?.startTime !== void 0 ? { mainStartMs: pair.main.startTime.getTime() } : {},
|
|
148495
148912
|
supportSub: pair.sub.supportSub !== false,
|
|
148496
|
-
nativeTypes: parseNativeTypes(pair.sub.recordType)
|
|
148913
|
+
nativeTypes: parseNativeTypes(pair.sub.recordType),
|
|
148914
|
+
...isFileStillBeingWritten(pair.sub) ? { inProgress: true } : {}
|
|
148497
148915
|
};
|
|
148498
148916
|
}),
|
|
148499
148917
|
indexEmpty: subs.length === 0,
|
|
148500
148918
|
...mainSearchFailed !== void 0 ? { mainSearchFailed } : {}
|
|
148501
148919
|
};
|
|
148502
148920
|
}
|
|
148503
|
-
|
|
148504
|
-
|
|
148505
|
-
|
|
148506
|
-
* The
|
|
148507
|
-
*
|
|
148508
|
-
*
|
|
148509
|
-
|
|
148510
|
-
|
|
148511
|
-
|
|
148512
|
-
|
|
148513
|
-
|
|
148514
|
-
|
|
148515
|
-
|
|
148516
|
-
|
|
148517
|
-
|
|
148518
|
-
|
|
148519
|
-
* ## An event is not a promise of a clip
|
|
148520
|
-
*
|
|
148521
|
-
* A row whose `hasRecFile` is false has nothing to play, and both of the wrong
|
|
148522
|
-
* things are easy: dropping it under-reports a camera's events, and showing it
|
|
148523
|
-
* as playable fails on click. It is listed with `playable: false` and a
|
|
148524
|
-
* reason, which is what `ClipSchema.unplayableReason` exists for.
|
|
148525
|
-
*
|
|
148526
|
-
* **How often that bites is a per-camera fact.** Measured 2026-09-20 over 20
|
|
148527
|
-
* days: 3628 → 222 rows with 0 orphans, 3629 → 1 059 rows with 0 orphans, and
|
|
148528
|
-
* the orphans D549 19 describes are on 3825 — the child whose uid was never
|
|
148529
|
-
* persisted, which this provider cannot address at all today. So one camera's
|
|
148530
|
-
* events are all playable and another's are all not, and a surface built for
|
|
148531
|
-
* either extreme is wrong on the other.
|
|
148532
|
-
*
|
|
148533
|
-
* ## Where the file comes from
|
|
148534
|
-
*
|
|
148535
|
-
* `findEventLog` rows carry no file name — they name events, not files. So the
|
|
148536
|
-
* file is resolved by WINDOW CONTAINMENT against the day's `FileInfoList`
|
|
148537
|
-
* rows, which the `onboard` reader already fetches: an event whose
|
|
148538
|
-
* `[start, end]` lies inside a file's `[start − 3 s, end + 3 s]`. 28 of 29 on
|
|
148539
|
-
* the measured day; the one that did not is a real orphan (the hub refused its
|
|
148540
|
-
* CoverPreview too), not a join bug.
|
|
148541
|
-
*
|
|
148542
|
-
* Deliberately NOT used here: `searchAlarmVideos` (272/273/274). It does carry
|
|
148543
|
-
* a `fileName`, and its containment is measured at 1271/1271 — but it is
|
|
148544
|
-
* clamped to ONE camera-local day (an 18→19 September window silently answered
|
|
148545
|
-
* 462 windows all dated the 18th) where `searchEventLog` spans the whole
|
|
148546
|
-
* window in one open, and D549 19 chose the event list on the measured timing.
|
|
148547
|
-
* Its place is the per-window label upgrade for a clip's DETAIL (D549 20), and
|
|
148548
|
-
* that is a separate slice.
|
|
148549
|
-
*
|
|
148550
|
-
* ## `devices` is the selector; `chnbits` is inert
|
|
148551
|
-
*
|
|
148552
|
-
* Measured: `chnbits: 3` answered exactly the same 9 rows as the default, and
|
|
148553
|
-
* OMITTING `devices` is refused outright by the library ("no devices to
|
|
148554
|
-
* search") rather than answered emptily — a hub that has pushed nothing yet is
|
|
148555
|
-
* a refusal, not an empty result. This module always passes `devices`, never
|
|
148556
|
-
* sends `chnbits`, and never derives one from the other. With one child named,
|
|
148557
|
-
* foreign rows in the reply were 0; the uid filter below is belt and braces.
|
|
148558
|
-
*/
|
|
148559
|
-
/**
|
|
148560
|
-
* Every event for ONE child in the window, newest first, in ONE call.
|
|
148561
|
-
*
|
|
148562
|
-
* The event list is multi-day by construction — a per-day loop would pay the
|
|
148563
|
-
* open/close for each day and answer the same rows. The bound the caller owes
|
|
148564
|
-
* is on the WINDOW, and it has a number behind it: measured on one hub child,
|
|
148565
|
-
* ONE day is 267 ms and 20 days is 3.6–5.4 s. A day-range default is therefore
|
|
148566
|
-
* a UI decision, not a free one.
|
|
148567
|
-
*/
|
|
148568
|
-
async function listEventRows(api, input) {
|
|
148569
|
-
const entries = await api.searchEventLog({
|
|
148570
|
-
devices: [{ uid: input.uid }],
|
|
148571
|
-
start: new Date(input.since),
|
|
148572
|
-
end: new Date(input.until),
|
|
148573
|
-
timeZone: input.timeZone
|
|
148921
|
+
/**
|
|
148922
|
+
* Every alarm window of ONE camera-local day.
|
|
148923
|
+
*
|
|
148924
|
+
* The one-day clamp is the firmware's, not ours: an 18→19 September ask
|
|
148925
|
+
* answered 462 windows all dated the 18th. The caller owns the iteration, and
|
|
148926
|
+
* this is asked from inside the day loop the listing already runs.
|
|
148927
|
+
*/
|
|
148928
|
+
async function readAlarmWindows(api, input) {
|
|
148929
|
+
const { startMs, endMs } = dayBounds(input.day, input.timeZone);
|
|
148930
|
+
return await api.searchAlarmVideos({
|
|
148931
|
+
channel: input.channel,
|
|
148932
|
+
start: new Date(startMs),
|
|
148933
|
+
end: new Date(endMs),
|
|
148934
|
+
streamType: 1,
|
|
148935
|
+
timeZone: input.timeZone,
|
|
148936
|
+
...input.uid !== void 0 ? { uid: input.uid } : {}
|
|
148574
148937
|
});
|
|
148575
|
-
|
|
148576
|
-
|
|
148577
|
-
|
|
148578
|
-
|
|
148579
|
-
|
|
148580
|
-
|
|
148581
|
-
|
|
148582
|
-
|
|
148583
|
-
|
|
148584
|
-
|
|
148585
|
-
|
|
148586
|
-
|
|
148938
|
+
}
|
|
148939
|
+
/** The camera declined to class this file: `none`, or no `recordType` at all. */
|
|
148940
|
+
function fileCarriesNoClass(nativeTypes) {
|
|
148941
|
+
return nativeTypes.every((type) => type === "none");
|
|
148942
|
+
}
|
|
148943
|
+
/**
|
|
148944
|
+
* Should this day's labels be asked for separately?
|
|
148945
|
+
*
|
|
148946
|
+
* Only when NOTHING in the day carried a class. One file saying `md` is proof
|
|
148947
|
+
* that this camera writes `recordType`, and then the free answer is the
|
|
148948
|
+
* answer — paying 8–9 s to restate it is the cheap-question rule inverted.
|
|
148949
|
+
*/
|
|
148950
|
+
function dayNeedsAlarmLabels(rows) {
|
|
148951
|
+
return rows.length > 0 && rows.every((row) => fileCarriesNoClass(row.nativeTypes));
|
|
148952
|
+
}
|
|
148953
|
+
/**
|
|
148954
|
+
* Attach each window's `alarmType`s to the file whose START it names.
|
|
148955
|
+
*
|
|
148956
|
+
* A row the camera already classed is returned untouched: the file's own
|
|
148957
|
+
* `recordType` is the authority wherever it exists, and a per-window answer
|
|
148958
|
+
* must not narrow it. A window that names no listed file contributes nothing
|
|
148959
|
+
* and is COUNTED — it is never attached to the nearest neighbour, which is how
|
|
148960
|
+
* a surface ends up showing one recording's class on another's row.
|
|
148961
|
+
*/
|
|
148962
|
+
function joinAlarmLabels(rows, windows, timeZone) {
|
|
148963
|
+
const byStamp = /* @__PURE__ */ new Map();
|
|
148964
|
+
for (const window of windows) {
|
|
148965
|
+
const key = window.fileName.slice(-STAMP_LENGTH);
|
|
148966
|
+
const existing = byStamp.get(key);
|
|
148967
|
+
const target = existing ?? [];
|
|
148968
|
+
if (existing === void 0) byStamp.set(key, target);
|
|
148969
|
+
for (const type of window.alarmTypes) if (type !== "" && !target.includes(type)) target.push(type);
|
|
148587
148970
|
}
|
|
148588
|
-
|
|
148971
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
148972
|
+
let matched = 0;
|
|
148973
|
+
return {
|
|
148974
|
+
rows: rows.map((row) => {
|
|
148975
|
+
if (!fileCarriesNoClass(row.nativeTypes)) return row;
|
|
148976
|
+
const subKey = alarmJoinKey(row.startMs, timeZone);
|
|
148977
|
+
const mainKey = row.mainStartMs !== void 0 ? alarmJoinKey(row.mainStartMs, timeZone) : void 0;
|
|
148978
|
+
const key = byStamp.has(subKey) ? subKey : mainKey !== void 0 && byStamp.has(mainKey) ? mainKey : void 0;
|
|
148979
|
+
if (key === void 0) return row;
|
|
148980
|
+
const types = byStamp.get(key);
|
|
148981
|
+
if (types === void 0 || types.length === 0) return row;
|
|
148982
|
+
claimed.add(key);
|
|
148983
|
+
matched += 1;
|
|
148984
|
+
return {
|
|
148985
|
+
...row,
|
|
148986
|
+
nativeTypes: [...types]
|
|
148987
|
+
};
|
|
148988
|
+
}),
|
|
148989
|
+
matched,
|
|
148990
|
+
unmatchedWindows: byStamp.size - claimed.size
|
|
148991
|
+
};
|
|
148589
148992
|
}
|
|
148590
|
-
|
|
148591
|
-
|
|
148592
|
-
|
|
148593
|
-
|
|
148594
|
-
|
|
148595
|
-
|
|
148596
|
-
|
|
148597
|
-
|
|
148598
|
-
|
|
148599
|
-
|
|
148600
|
-
|
|
148601
|
-
|
|
148602
|
-
|
|
148603
|
-
|
|
148604
|
-
|
|
148605
|
-
return {
|
|
148606
|
-
...event,
|
|
148607
|
-
clipKey: file.clipKey,
|
|
148608
|
-
fileName: file.subFileName,
|
|
148609
|
-
playable: true
|
|
148610
|
-
};
|
|
148993
|
+
/** `YYYYMMDDHHMMSS` — 14 digits. */
|
|
148994
|
+
var STAMP_LENGTH = 14;
|
|
148995
|
+
var FORMATTER_BY_ZONE = /* @__PURE__ */ new Map();
|
|
148996
|
+
function formatterFor(timeZone) {
|
|
148997
|
+
const cached = FORMATTER_BY_ZONE.get(timeZone);
|
|
148998
|
+
if (cached !== void 0) return cached;
|
|
148999
|
+
const made = new Intl.DateTimeFormat("en-CA", {
|
|
149000
|
+
timeZone,
|
|
149001
|
+
year: "numeric",
|
|
149002
|
+
month: "2-digit",
|
|
149003
|
+
day: "2-digit",
|
|
149004
|
+
hour: "2-digit",
|
|
149005
|
+
minute: "2-digit",
|
|
149006
|
+
second: "2-digit",
|
|
149007
|
+
hour12: false
|
|
148611
149008
|
});
|
|
149009
|
+
FORMATTER_BY_ZONE.set(timeZone, made);
|
|
149010
|
+
return made;
|
|
148612
149011
|
}
|
|
148613
|
-
/**
|
|
148614
|
-
|
|
148615
|
-
|
|
149012
|
+
/**
|
|
149013
|
+
* The 14-digit tail of the short name a window carries — the file's start in
|
|
149014
|
+
* the CAMERA's wall clock. Never a UTC stamp: 00:30 Rome on the 17th is 22:30
|
|
149015
|
+
* UTC on the 16th, and a UTC key would file it under the wrong day and join it
|
|
149016
|
+
* to nothing.
|
|
149017
|
+
*/
|
|
149018
|
+
function alarmJoinKey(atMs, timeZone) {
|
|
149019
|
+
const parts = new Map(formatterFor(timeZone).formatToParts(new Date(atMs)).map((part) => [part.type, part.value]));
|
|
149020
|
+
return `${parts.get("year") ?? ""}${parts.get("month") ?? ""}${parts.get("day") ?? ""}${normaliseHour(parts.get("hour"))}${parts.get("minute") ?? ""}${parts.get("second") ?? ""}`;
|
|
149021
|
+
}
|
|
149022
|
+
/** `hourCycle: h23` still renders midnight as `24` in some ICU builds. */
|
|
149023
|
+
function normaliseHour(hour) {
|
|
149024
|
+
return hour === "24" ? "00" : hour ?? "";
|
|
148616
149025
|
}
|
|
148617
149026
|
//#endregion
|
|
148618
149027
|
//#region src/clips/reolink-videoclips-provider.ts
|
|
148619
|
-
/**
|
|
148620
|
-
* Which API the ONE source resolved to for a camera — reported on
|
|
148621
|
-
* `ClipSource.via` and on the listing log line.
|
|
148622
|
-
*
|
|
148623
|
-
* The CHOICE is made from what the camera is (`viaFor`); naming it is what
|
|
148624
|
-
* makes "why do these two cameras behave differently" answerable at all.
|
|
148625
|
-
*/
|
|
148626
|
-
var CATALOG_VIA_EVENTS = "events";
|
|
148627
|
-
var CATALOG_VIA_FILES = "files";
|
|
148628
149028
|
function createReolinkVideoclipsProvider(deps) {
|
|
148629
149029
|
/** Last known availability per `deviceId|source`, written by every read. */
|
|
148630
149030
|
const health = /* @__PURE__ */ new Map();
|
|
@@ -148748,7 +149148,7 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
148748
149148
|
error: result.mainSearchFailed
|
|
148749
149149
|
}
|
|
148750
149150
|
});
|
|
148751
|
-
return result.rows;
|
|
149151
|
+
return await labelDay(device, api, day, result.rows);
|
|
148752
149152
|
});
|
|
148753
149153
|
if (entry.clips.length === 0) emptyDays.push(day);
|
|
148754
149154
|
rows.push(...entry.clips);
|
|
@@ -148776,11 +149176,7 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
148776
149176
|
days: emptyDays
|
|
148777
149177
|
}
|
|
148778
149178
|
});
|
|
148779
|
-
noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD,
|
|
148780
|
-
state: "index-empty",
|
|
148781
|
-
reason: `The camera’s calendar marks ${emptyDays.length} day(s) with footage that its own index lists no files for: ${emptyDays.join(", ")}.`,
|
|
148782
|
-
catalogAsOf: deps.now()
|
|
148783
|
-
});
|
|
149179
|
+
noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, indexEmpty(emptyDays));
|
|
148784
149180
|
} else noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, {
|
|
148785
149181
|
state: "ok",
|
|
148786
149182
|
catalogAsOf: deps.now()
|
|
@@ -148788,140 +149184,55 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
148788
149184
|
return rows;
|
|
148789
149185
|
};
|
|
148790
149186
|
/**
|
|
148791
|
-
*
|
|
149187
|
+
* Give a day's rows a class when the FILES carried none.
|
|
148792
149188
|
*
|
|
148793
|
-
*
|
|
148794
|
-
*
|
|
148795
|
-
*
|
|
148796
|
-
*
|
|
148797
|
-
*
|
|
148798
|
-
*
|
|
149189
|
+
* The gate is what the day actually said, never what the camera is: a hub
|
|
149190
|
+
* child's files are `recordType: none` on every row (measured), a
|
|
149191
|
+
* standalone's carry `md,people,dog_cat,sched`, and a camera that changes
|
|
149192
|
+
* firmware changes which of those it is without this code learning a new
|
|
149193
|
+
* topology. The cheap question comes first (D54) — on 592 an alarm search
|
|
149194
|
+
* costs 8.2–9.5 s for 1 271 windows to restate labels already on the row.
|
|
148799
149195
|
*
|
|
148800
|
-
*
|
|
148801
|
-
*
|
|
148802
|
-
*
|
|
148803
|
-
* rows still list, each saying why it cannot play.
|
|
149196
|
+
* A refusal costs the LABELS and nothing else. Losing the day because its
|
|
149197
|
+
* class could not be read would be the upgrade destroying the thing it
|
|
149198
|
+
* upgrades — the same rule the MAIN search already lives by.
|
|
148804
149199
|
*/
|
|
148805
|
-
const
|
|
149200
|
+
const labelDay = async (device, api, day, rows) => {
|
|
148806
149201
|
const tags = { deviceId: device.deviceId };
|
|
148807
|
-
if (!
|
|
148808
|
-
const uid = device.uid;
|
|
148809
|
-
if (uid === void 0) {
|
|
148810
|
-
deps.logger.warn("videoclips: no uid for this camera — the hub cannot be asked about it", {
|
|
148811
|
-
tags,
|
|
148812
|
-
meta: {
|
|
148813
|
-
branch: "uid-missing",
|
|
148814
|
-
via: CATALOG_VIA_EVENTS
|
|
148815
|
-
}
|
|
148816
|
-
});
|
|
148817
|
-
noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, {
|
|
148818
|
-
state: "unreachable",
|
|
148819
|
-
reason: UID_MISSING_REASON
|
|
148820
|
-
});
|
|
148821
|
-
return [];
|
|
148822
|
-
}
|
|
148823
|
-
let api;
|
|
148824
|
-
try {
|
|
148825
|
-
api = await device.getApi();
|
|
148826
|
-
} catch (err) {
|
|
148827
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
148828
|
-
deps.logger.warn("videoclips: could not reach the hub", {
|
|
148829
|
-
tags,
|
|
148830
|
-
meta: {
|
|
148831
|
-
branch: "camera-refused",
|
|
148832
|
-
via: CATALOG_VIA_EVENTS,
|
|
148833
|
-
error: reason
|
|
148834
|
-
}
|
|
148835
|
-
});
|
|
148836
|
-
noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, {
|
|
148837
|
-
state: "unreachable",
|
|
148838
|
-
reason
|
|
148839
|
-
});
|
|
148840
|
-
return [];
|
|
148841
|
-
}
|
|
149202
|
+
if (!dayNeedsAlarmLabels(rows)) return rows;
|
|
148842
149203
|
const startedAt = deps.now();
|
|
148843
|
-
let
|
|
148844
|
-
let joined = [];
|
|
149204
|
+
let windows;
|
|
148845
149205
|
try {
|
|
148846
|
-
|
|
148847
|
-
uid,
|
|
149206
|
+
windows = await readAlarmWindows(api, {
|
|
148848
149207
|
channel: device.channel,
|
|
149208
|
+
uid: device.uid,
|
|
148849
149209
|
timeZone: device.timeZone,
|
|
148850
|
-
|
|
148851
|
-
until
|
|
149210
|
+
day
|
|
148852
149211
|
});
|
|
148853
|
-
events = rows;
|
|
148854
|
-
joined = joinEventsToFiles(rows, await readOnboard(device, since, until).catch(() => []));
|
|
148855
149212
|
} catch (err) {
|
|
148856
|
-
|
|
148857
|
-
deps.logger.warn("videoclips: the hub event log refused", {
|
|
149213
|
+
deps.logger.warn("videoclips: the label search refused — the day lists without classes", {
|
|
148858
149214
|
tags,
|
|
148859
149215
|
meta: {
|
|
148860
149216
|
branch: "camera-refused",
|
|
148861
|
-
|
|
148862
|
-
error:
|
|
149217
|
+
day,
|
|
149218
|
+
error: err instanceof Error ? err.message : String(err)
|
|
148863
149219
|
}
|
|
148864
149220
|
});
|
|
148865
|
-
|
|
148866
|
-
state: "unreachable",
|
|
148867
|
-
reason
|
|
148868
|
-
});
|
|
148869
|
-
return [];
|
|
149221
|
+
return rows;
|
|
148870
149222
|
}
|
|
148871
|
-
const
|
|
148872
|
-
deps.logger.info("videoclips: read the
|
|
149223
|
+
const joined = joinAlarmLabels(rows, windows, device.timeZone);
|
|
149224
|
+
deps.logger.info("videoclips: read the classes a day of files does not carry", {
|
|
148873
149225
|
tags,
|
|
148874
149226
|
meta: {
|
|
148875
|
-
|
|
148876
|
-
|
|
148877
|
-
|
|
148878
|
-
|
|
148879
|
-
|
|
149227
|
+
day,
|
|
149228
|
+
windows: windows.length,
|
|
149229
|
+
rows: rows.length,
|
|
149230
|
+
labelled: joined.matched,
|
|
149231
|
+
unmatchedWindows: joined.unmatchedWindows,
|
|
149232
|
+
elapsedMs: deps.now() - startedAt
|
|
148880
149233
|
}
|
|
148881
149234
|
});
|
|
148882
|
-
|
|
148883
|
-
state: "ok",
|
|
148884
|
-
catalogAsOf: deps.now()
|
|
148885
|
-
});
|
|
148886
|
-
return joined;
|
|
148887
|
-
};
|
|
148888
|
-
/**
|
|
148889
|
-
* An events-view row as a `Clip`.
|
|
148890
|
-
*
|
|
148891
|
-
* The id carries the event WINDOW as well as the file, because a file holds
|
|
148892
|
-
* 1–13 windows and two events of one recording must not collapse into one
|
|
148893
|
-
* row. A row with nothing to play carries `playable: false` and its reason,
|
|
148894
|
-
* and offers no thumbnail mint — the hub refused the CoverPreview for
|
|
148895
|
-
* exactly that orphan event, so an offer here would be a tile that spins and
|
|
148896
|
-
* then fails.
|
|
148897
|
-
*/
|
|
148898
|
-
const toEventClip = async (device, row, catalogAsOf) => {
|
|
148899
|
-
const classification = classifyNativeTypes(row.nativeTypes);
|
|
148900
|
-
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" } };
|
|
148901
|
-
return {
|
|
148902
|
-
id: mintClipId({
|
|
148903
|
-
source: CLIP_SOURCE_ONBOARD,
|
|
148904
|
-
channel: row.channel,
|
|
148905
|
-
fileName: row.fileName ?? `event:${row.startMs}-${row.endMs}`,
|
|
148906
|
-
window: {
|
|
148907
|
-
startMs: row.startMs,
|
|
148908
|
-
endMs: row.endMs
|
|
148909
|
-
}
|
|
148910
|
-
}),
|
|
148911
|
-
source: CLIP_SOURCE_ONBOARD,
|
|
148912
|
-
kind: classification.kind,
|
|
148913
|
-
timeRange: {
|
|
148914
|
-
startMs: row.startMs,
|
|
148915
|
-
endMs: row.endMs
|
|
148916
|
-
},
|
|
148917
|
-
...classification.labels.length > 0 ? { labels: [...classification.labels] } : {},
|
|
148918
|
-
...classification.behaviours.length > 0 ? { behaviours: [...classification.behaviours] } : {},
|
|
148919
|
-
...classification.nativeTypes.length > 0 ? { nativeTypes: [...classification.nativeTypes] } : {},
|
|
148920
|
-
...still,
|
|
148921
|
-
...catalogAsOf !== void 0 ? { catalogAsOf } : {},
|
|
148922
|
-
playable: row.playable,
|
|
148923
|
-
...row.unplayableReason !== void 0 ? { unplayableReason: row.unplayableReason } : {}
|
|
148924
|
-
};
|
|
149235
|
+
return joined.rows;
|
|
148925
149236
|
};
|
|
148926
149237
|
const toClip = async (device, source, row, catalogAsOf) => {
|
|
148927
149238
|
rowsByKey.set(`${device.deviceId}|${row.clipKey}`, row);
|
|
@@ -148940,6 +149251,7 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
148940
149251
|
startMs: row.startMs,
|
|
148941
149252
|
endMs: row.endMs
|
|
148942
149253
|
},
|
|
149254
|
+
...row.inProgress === true ? { inProgress: true } : {},
|
|
148943
149255
|
...classification.labels.length > 0 ? { labels: [...classification.labels] } : {},
|
|
148944
149256
|
...classification.behaviours.length > 0 ? { behaviours: [...classification.behaviours] } : {},
|
|
148945
149257
|
...classification.nativeTypes.length > 0 ? { nativeTypes: [...classification.nativeTypes] } : {},
|
|
@@ -148970,36 +149282,26 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
148970
149282
|
listSources: async ({ deviceId }) => {
|
|
148971
149283
|
const device = await deps.resolveDevice(deviceId);
|
|
148972
149284
|
if (device === null) return [];
|
|
148973
|
-
const via = viaFor(device);
|
|
148974
149285
|
return [{
|
|
148975
149286
|
source: CLIP_SOURCE_ONBOARD,
|
|
148976
149287
|
addonId: REOLINK_ADDON_ID,
|
|
148977
149288
|
label: "Camera storage",
|
|
148978
|
-
|
|
148979
|
-
availability: await resolveAvailability(device, via)
|
|
149289
|
+
availability: await resolveAvailability(device)
|
|
148980
149290
|
}];
|
|
148981
149291
|
},
|
|
148982
149292
|
listClips: async ({ deviceId, since, until, limit }) => {
|
|
148983
149293
|
const device = await deps.resolveDevice(deviceId);
|
|
148984
149294
|
if (device === null) return [];
|
|
148985
149295
|
const tags = { deviceId };
|
|
148986
|
-
const
|
|
148987
|
-
const
|
|
148988
|
-
|
|
148989
|
-
const asOf = health.get(healthKey(deviceId, CLIP_SOURCE_ONBOARD))?.availability.catalogAsOf;
|
|
148990
|
-
return await Promise.all(events.map((row) => toEventClip(device, row, asOf)));
|
|
148991
|
-
})() : await (async () => {
|
|
148992
|
-
const rows = await readOnboard(device, since, until);
|
|
148993
|
-
const asOf = health.get(healthKey(deviceId, CLIP_SOURCE_ONBOARD))?.availability.catalogAsOf;
|
|
148994
|
-
return await Promise.all(rows.toSorted((a, b) => b.startMs - a.startMs).map((row) => toClip(device, CLIP_SOURCE_ONBOARD, row, asOf)));
|
|
148995
|
-
})()).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
149296
|
+
const rows = await readOnboard(device, since, until);
|
|
149297
|
+
const asOf = health.get(healthKey(deviceId, CLIP_SOURCE_ONBOARD))?.availability.catalogAsOf;
|
|
149298
|
+
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);
|
|
148996
149299
|
const capped = limit !== void 0 ? ordered.slice(0, limit) : ordered;
|
|
148997
149300
|
const vouched = capped.filter((c) => c.thumbnail !== void 0).length;
|
|
148998
149301
|
deps.logger.info("videoclips: listed a Reolink catalog", {
|
|
148999
149302
|
tags,
|
|
149000
149303
|
meta: {
|
|
149001
149304
|
source: CLIP_SOURCE_ONBOARD,
|
|
149002
|
-
via,
|
|
149003
149305
|
clips: capped.length,
|
|
149004
149306
|
truncated: limit !== void 0 && ordered.length > limit,
|
|
149005
149307
|
unplayable: capped.filter((c) => c.playable === false).length,
|
|
@@ -149008,6 +149310,18 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
149008
149310
|
availability: health.get(healthKey(deviceId, CLIP_SOURCE_ONBOARD))?.availability.state
|
|
149009
149311
|
}
|
|
149010
149312
|
});
|
|
149313
|
+
const availability = health.get(healthKey(deviceId, CLIP_SOURCE_ONBOARD))?.availability;
|
|
149314
|
+
if (capped.length === 0 && availability !== void 0 && availability.state !== "ok") {
|
|
149315
|
+
deps.logger.warn("videoclips: nothing to list, and the source cannot answer", {
|
|
149316
|
+
tags,
|
|
149317
|
+
meta: {
|
|
149318
|
+
branch: "refused-empty",
|
|
149319
|
+
state: availability.state,
|
|
149320
|
+
reason: availability.reason ?? null
|
|
149321
|
+
}
|
|
149322
|
+
});
|
|
149323
|
+
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}` : "."));
|
|
149324
|
+
}
|
|
149011
149325
|
return capped;
|
|
149012
149326
|
},
|
|
149013
149327
|
getClipPlayback: async ({ deviceId, clipId, profile }) => {
|
|
@@ -149016,7 +149330,6 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
149016
149330
|
const device = await deps.resolveDevice(deviceId);
|
|
149017
149331
|
if (device === null) throw new Error(`videoclips: device ${deviceId} is not a Reolink camera`);
|
|
149018
149332
|
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.`);
|
|
149019
|
-
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.`);
|
|
149020
149333
|
const row = rowsByKey.get(`${deviceId}|${clipKeyFor(parsed.channel, parsed.fileName)}`) ?? null;
|
|
149021
149334
|
const wantsMain = profile === "high" && row?.mainFileName !== void 0;
|
|
149022
149335
|
const fileName = wantsMain ? row?.mainFileName ?? parsed.fileName : parsed.fileName;
|
|
@@ -149035,46 +149348,350 @@ function createReolinkVideoclipsProvider(deps) {
|
|
|
149035
149348
|
}
|
|
149036
149349
|
};
|
|
149037
149350
|
/**
|
|
149038
|
-
*
|
|
149039
|
-
*
|
|
149040
|
-
*
|
|
149041
|
-
*
|
|
149042
|
-
*
|
|
149043
|
-
*
|
|
149044
|
-
*
|
|
149045
|
-
*
|
|
149046
|
-
|
|
149047
|
-
|
|
149048
|
-
|
|
149049
|
-
|
|
149050
|
-
|
|
149351
|
+
* What a source is saying right now — MEASURED, never defaulted.
|
|
149352
|
+
*
|
|
149353
|
+
* A listing that already ran is the better answer and wins: `listClips`
|
|
149354
|
+
* asked the whole window and this probe asks one day, so re-deciding from
|
|
149355
|
+
* the narrower question would throw away 618's other nineteen empty days.
|
|
149356
|
+
* With nothing established, the probe runs — and `ok` is only ever returned
|
|
149357
|
+
* because something was really read.
|
|
149358
|
+
*
|
|
149359
|
+
* ## What it costs, per call, and why
|
|
149360
|
+
*
|
|
149361
|
+
* This used to answer `ok` after at most one storage command, and `ok` is
|
|
149362
|
+
* the one state whose list may be read as complete. On the live hub, on the
|
|
149363
|
+
* cold runners a deploy had just produced, that default made 640 (no SD
|
|
149364
|
+
* card) and 618 (an index that lists nothing for days its own calendar
|
|
149365
|
+
* marks) both answer `ok` and then an empty list — the exact lie this line
|
|
149366
|
+
* of work exists to end. So `listSources` pays for its answer:
|
|
149367
|
+
*
|
|
149368
|
+
* - **a standalone battery camera: NOTHING, ever.** No `getApi`, no command,
|
|
149369
|
+
* not even the cheap one — on UDP the login IS the wake. `sleeping`, with
|
|
149370
|
+
* the age of whatever is cached. The gate is `isBattery`, never the
|
|
149371
|
+
* BELIEF `sleeping`: that belief is `state.battery.sleeping ?? false`, so
|
|
149372
|
+
* a slice that has not restored reads as awake, and a probe on it is a
|
|
149373
|
+
* wake nobody asked for.
|
|
149374
|
+
* - **a hub child: one `getDayRecords` through the parent's existing socket**
|
|
149375
|
+
* (65 ms measured on 3628). The child never hears it; the storage question
|
|
149376
|
+
* is the HUB's and is not asked.
|
|
149377
|
+
* - **a mains standalone: `getHddInfoList` (ms) + `getDayRecords`
|
|
149378
|
+
* (38 ms–1.9 s) + ONE day listing of the NEWEST day the calendar claims**
|
|
149379
|
+
* (0.3–6 s, and cache-backed — `serveAndRevalidate`, so a day already
|
|
149380
|
+
* listed costs nothing and a day listed here is free for the `listClips`
|
|
149381
|
+
* that follows). The day listing is what distinguishes `index-empty` from
|
|
149382
|
+
* `ok`, and nothing cheaper can: `no-storage` is the cheap question and it
|
|
149383
|
+
* comes first precisely so a camera with no card never pays the rest.
|
|
149384
|
+
*
|
|
149385
|
+
* The calendar is read for the CURRENT month only. A picker asks "can this
|
|
149386
|
+
* source answer", not "what does it hold since April", and the measured
|
|
149387
|
+
* defect (618) is in the month the operator is looking at.
|
|
149388
|
+
*/
|
|
149389
|
+
async function resolveAvailability(device) {
|
|
149051
149390
|
const known = health.get(healthKey(device.deviceId, CLIP_SOURCE_ONBOARD))?.availability;
|
|
149052
149391
|
if (known !== void 0) return known;
|
|
149053
|
-
|
|
149054
|
-
|
|
149055
|
-
|
|
149056
|
-
|
|
149057
|
-
|
|
149058
|
-
|
|
149059
|
-
|
|
149392
|
+
const availability = await probeAvailability(device);
|
|
149393
|
+
noteHealth(device.deviceId, CLIP_SOURCE_ONBOARD, availability);
|
|
149394
|
+
return availability;
|
|
149395
|
+
}
|
|
149396
|
+
async function probeAvailability(device) {
|
|
149397
|
+
const tags = { deviceId: device.deviceId };
|
|
149398
|
+
if (device.isBattery && !device.isHubChild) {
|
|
149399
|
+
const asOf = newestCachedAsOf(device.deviceId);
|
|
149400
|
+
deps.logger.warn("videoclips: not probing a standalone battery camera — login is the wake", {
|
|
149401
|
+
tags,
|
|
149402
|
+
meta: {
|
|
149403
|
+
branch: "sleeping",
|
|
149404
|
+
believedSleeping: device.sleeping,
|
|
149405
|
+
confirmedAt: asOf
|
|
149406
|
+
}
|
|
149407
|
+
});
|
|
149408
|
+
return {
|
|
149409
|
+
state: "sleeping",
|
|
149410
|
+
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.",
|
|
149411
|
+
...asOf !== void 0 ? { catalogAsOf: asOf } : {}
|
|
149412
|
+
};
|
|
149413
|
+
}
|
|
149414
|
+
if (uidIsMissing(device)) {
|
|
149415
|
+
deps.logger.warn("videoclips: no uid for this camera — its catalog cannot be addressed", {
|
|
149416
|
+
tags,
|
|
149417
|
+
meta: {
|
|
149418
|
+
branch: "uid-missing",
|
|
149419
|
+
probe: true
|
|
149420
|
+
}
|
|
149421
|
+
});
|
|
149422
|
+
return {
|
|
149423
|
+
state: "unreachable",
|
|
149424
|
+
reason: UID_MISSING_REASON
|
|
149425
|
+
};
|
|
149426
|
+
}
|
|
149427
|
+
let api;
|
|
149428
|
+
try {
|
|
149429
|
+
api = await device.getApi();
|
|
149430
|
+
} catch (err) {
|
|
149431
|
+
return unreachableFrom(device, "camera-refused", err);
|
|
149432
|
+
}
|
|
149433
|
+
if (!device.isHubChild) {
|
|
149434
|
+
const storage = await readStorageState(api);
|
|
149435
|
+
if (storage.state === "absent") {
|
|
149436
|
+
deps.logger.info("videoclips: no SD card mounted — nothing to list", {
|
|
149437
|
+
tags,
|
|
149438
|
+
meta: {
|
|
149439
|
+
branch: "no-storage",
|
|
149440
|
+
probe: true
|
|
149441
|
+
}
|
|
149442
|
+
});
|
|
149443
|
+
return {
|
|
149444
|
+
state: "no-storage",
|
|
149445
|
+
reason: "The camera reports no mounted storage (HddInfo mount 0)."
|
|
149446
|
+
};
|
|
149447
|
+
}
|
|
149448
|
+
if (storage.state === "unknown") {
|
|
149449
|
+
deps.logger.warn("videoclips: the storage question was not answered", {
|
|
149450
|
+
tags,
|
|
149451
|
+
meta: {
|
|
149452
|
+
branch: "camera-refused",
|
|
149453
|
+
probe: true,
|
|
149454
|
+
error: storage.reason ?? null
|
|
149455
|
+
}
|
|
149456
|
+
});
|
|
149457
|
+
return {
|
|
149458
|
+
state: "unreachable",
|
|
149459
|
+
reason: storage.reason ?? "The camera did not answer the storage question (HddInfo unreadable)."
|
|
149460
|
+
};
|
|
149461
|
+
}
|
|
149462
|
+
}
|
|
149463
|
+
const today = wallClockDayKey(deps.now(), device.timeZone);
|
|
149464
|
+
let calendar;
|
|
149465
|
+
try {
|
|
149466
|
+
calendar = await readDayIndex(api, {
|
|
149467
|
+
channel: device.channel,
|
|
149468
|
+
uid: device.uid,
|
|
149469
|
+
months: [{
|
|
149470
|
+
year: Number(today.slice(0, 4)),
|
|
149471
|
+
month: Number(today.slice(5, 7))
|
|
149472
|
+
}]
|
|
149473
|
+
});
|
|
149474
|
+
} catch (err) {
|
|
149475
|
+
return unreachableFrom(device, "camera-refused", err);
|
|
149476
|
+
}
|
|
149477
|
+
const claimed = [...calendar].toSorted().toReversed();
|
|
149478
|
+
if (claimed.length === 0) return {
|
|
149479
|
+
state: "ok",
|
|
149480
|
+
catalogAsOf: deps.now()
|
|
149060
149481
|
};
|
|
149061
|
-
|
|
149482
|
+
const newest = claimed[0];
|
|
149483
|
+
if (newest === void 0) return {
|
|
149484
|
+
state: "ok",
|
|
149485
|
+
catalogAsOf: deps.now()
|
|
149486
|
+
};
|
|
149487
|
+
try {
|
|
149488
|
+
await deps.cache.serveAndRevalidate(device.deviceId, newest, { closed: newest !== today }, async () => (await listDayRows(api, {
|
|
149489
|
+
channel: device.channel,
|
|
149490
|
+
uid: device.uid,
|
|
149491
|
+
timeZone: device.timeZone,
|
|
149492
|
+
day: newest
|
|
149493
|
+
})).rows);
|
|
149494
|
+
} catch (err) {
|
|
149495
|
+
return unreachableFrom(device, "camera-refused", err);
|
|
149496
|
+
}
|
|
149497
|
+
const emptyClaimed = claimed.filter((day) => deps.cache.peek(device.deviceId, day)?.clips.length === 0);
|
|
149498
|
+
if (emptyClaimed.length > 0) {
|
|
149499
|
+
deps.logger.warn("videoclips: the calendar marks days the index will not list", {
|
|
149500
|
+
tags,
|
|
149501
|
+
meta: {
|
|
149502
|
+
branch: "index-empty",
|
|
149503
|
+
probe: true,
|
|
149504
|
+
days: emptyClaimed
|
|
149505
|
+
}
|
|
149506
|
+
});
|
|
149507
|
+
return indexEmpty(emptyClaimed);
|
|
149508
|
+
}
|
|
149509
|
+
return {
|
|
149510
|
+
state: "ok",
|
|
149511
|
+
catalogAsOf: deps.now()
|
|
149512
|
+
};
|
|
149513
|
+
}
|
|
149514
|
+
function unreachableFrom(device, branch, err) {
|
|
149515
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
149516
|
+
deps.logger.warn("videoclips: the source could not be probed", {
|
|
149517
|
+
tags: { deviceId: device.deviceId },
|
|
149518
|
+
meta: {
|
|
149519
|
+
branch,
|
|
149520
|
+
probe: true,
|
|
149521
|
+
error: reason
|
|
149522
|
+
}
|
|
149523
|
+
});
|
|
149524
|
+
return {
|
|
149062
149525
|
state: "unreachable",
|
|
149063
|
-
reason
|
|
149526
|
+
reason
|
|
149064
149527
|
};
|
|
149528
|
+
}
|
|
149529
|
+
/** When any day of this device's catalog was last confirmed, newest first. */
|
|
149530
|
+
function newestCachedAsOf(deviceId) {
|
|
149531
|
+
let newest;
|
|
149532
|
+
for (const day of deps.cache.daysHeld(deviceId)) {
|
|
149533
|
+
const asOf = deps.cache.peek(deviceId, day)?.catalogAsOf;
|
|
149534
|
+
if (asOf !== void 0 && (newest === void 0 || asOf > newest)) newest = asOf;
|
|
149535
|
+
}
|
|
149536
|
+
return newest;
|
|
149537
|
+
}
|
|
149538
|
+
/** The one wording for "the camera's calendar and its own index disagree". */
|
|
149539
|
+
function indexEmpty(days) {
|
|
149540
|
+
return {
|
|
149541
|
+
state: "index-empty",
|
|
149542
|
+
reason: `The camera’s calendar marks ${days.length} day(s) with footage that its own index lists no files for: ${days.join(", ")}.`,
|
|
149543
|
+
catalogAsOf: deps.now()
|
|
149544
|
+
};
|
|
149545
|
+
}
|
|
149546
|
+
}
|
|
149547
|
+
//#endregion
|
|
149548
|
+
//#region src/clips/clip-refresh.ts
|
|
149549
|
+
/** The settings key, global default and per-device override (D562). */
|
|
149550
|
+
var CLIP_CATALOG_REFRESH_MINUTES_KEY = "clipCatalogRefreshMinutes";
|
|
149551
|
+
/** The settings key, per device only, OFF by default (D562). */
|
|
149552
|
+
var CLIP_CATALOG_FORCED_WAKE_KEY = "clipCatalogForcedWake";
|
|
149553
|
+
/** A day. Past this the catalog's own TTLs are the thing doing the work. */
|
|
149554
|
+
var CLIP_CATALOG_REFRESH_MINUTES_MAX = 1440;
|
|
149555
|
+
/**
|
|
149556
|
+
* How much of the catalog one refresh confirms.
|
|
149557
|
+
*
|
|
149558
|
+
* A day. The listing cost is per DAY (0.3–6 s on a standalone, 267 ms for one
|
|
149559
|
+
* child-day on the hub) and a 20-day window measured 3.6–5.4 s per child, so a
|
|
149560
|
+
* timer that re-confirmed everything would spend the whole budget re-reading
|
|
149561
|
+
* days that cannot change. Older days are immutable and the cache holds them
|
|
149562
|
+
* under their own 24 h TTL.
|
|
149563
|
+
*/
|
|
149564
|
+
var CLIP_CATALOG_REFRESH_WINDOW_MS = 1440 * 6e4;
|
|
149565
|
+
/**
|
|
149566
|
+
* The mode for one camera. Pure, and decided from what the camera IS — never
|
|
149567
|
+
* from a belief about whether it is asleep right now: the gate has to hold for
|
|
149568
|
+
* a camera whose sleep slice has not restored, which reads "awake" and is how
|
|
149569
|
+
* a picker draw once logged into 640 (D561).
|
|
149570
|
+
*/
|
|
149571
|
+
function resolveClipRefreshMode(topology) {
|
|
149572
|
+
if (topology.isHubChild) return "via-hub";
|
|
149573
|
+
if (!topology.isBattery) return "direct";
|
|
149574
|
+
return topology.forcedWake ? "forced-wake" : "mark-stale";
|
|
149575
|
+
}
|
|
149576
|
+
/**
|
|
149577
|
+
* The refresh interval for one camera: the per-device override on top of the
|
|
149578
|
+
* global default, with **reset == unset == the default**.
|
|
149579
|
+
*
|
|
149580
|
+
* Never throws on a bad blob. An invalid value at either level resolves to the
|
|
149581
|
+
* level above it, so a store written by an older release cannot stop the timer
|
|
149582
|
+
* — the same cascade `stationary-settings.ts` uses, for the same reason.
|
|
149583
|
+
*/
|
|
149584
|
+
function resolveClipCatalogRefreshMinutes(globalValue, deviceValue) {
|
|
149585
|
+
return asRefreshMinutes(deviceValue) ?? asRefreshMinutes(globalValue) ?? 30;
|
|
149586
|
+
}
|
|
149587
|
+
function asRefreshMinutes(value) {
|
|
149588
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
149589
|
+
const rounded = Math.round(value);
|
|
149590
|
+
if (rounded === 0) return 0;
|
|
149591
|
+
if (rounded < 5) return null;
|
|
149592
|
+
if (rounded > 1440) return null;
|
|
149593
|
+
return rounded;
|
|
149594
|
+
}
|
|
149595
|
+
/** Is the operator's forced-wake switch on for this camera? */
|
|
149596
|
+
function resolveClipCatalogForcedWake(value) {
|
|
149597
|
+
return value === true;
|
|
149598
|
+
}
|
|
149599
|
+
/**
|
|
149600
|
+
* The timer. `tick()` is the whole surface, so a test drives it from COLD and
|
|
149601
|
+
* the scheduler owns no clock of its own — the addon supplies the interval.
|
|
149602
|
+
*/
|
|
149603
|
+
var ClipCatalogRefreshScheduler = class {
|
|
149604
|
+
deps;
|
|
149605
|
+
/** Last time an interval was SPENT on a camera, successful or not. */
|
|
149606
|
+
lastRunAt = /* @__PURE__ */ new Map();
|
|
149607
|
+
/** Cameras whose refresh has not come back yet. */
|
|
149608
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
149609
|
+
constructor(deps) {
|
|
149610
|
+
this.deps = deps;
|
|
149611
|
+
}
|
|
149612
|
+
async tick() {
|
|
149613
|
+
const now = this.deps.now();
|
|
149614
|
+
const due = this.deps.listTargets().filter((target) => this.isDue(target, now));
|
|
149615
|
+
for (const target of due) await this.run(target, now);
|
|
149616
|
+
}
|
|
149617
|
+
isDue(target, now) {
|
|
149618
|
+
if (target.intervalMinutes <= 0) return false;
|
|
149619
|
+
if (this.inFlight.has(target.deviceId)) return false;
|
|
149620
|
+
const last = this.lastRunAt.get(target.deviceId);
|
|
149621
|
+
if (last === void 0) return true;
|
|
149622
|
+
return now - last >= target.intervalMinutes * 6e4;
|
|
149623
|
+
}
|
|
149624
|
+
async run(target, now) {
|
|
149625
|
+
const tags = { deviceId: target.deviceId };
|
|
149626
|
+
const mode = resolveClipRefreshMode(target);
|
|
149627
|
+
if (this.deps.hostBusy(target.hostKey)) {
|
|
149628
|
+
this.deps.logger.warn("videoclips: catalog refresh deferred — the host is serving a read", {
|
|
149629
|
+
tags,
|
|
149630
|
+
meta: {
|
|
149631
|
+
branch: "deferred-interactive",
|
|
149632
|
+
mode,
|
|
149633
|
+
host: target.hostKey
|
|
149634
|
+
}
|
|
149635
|
+
});
|
|
149636
|
+
return;
|
|
149637
|
+
}
|
|
149638
|
+
if (mode === "mark-stale") {
|
|
149639
|
+
this.deps.markStale(target.deviceId);
|
|
149640
|
+
this.lastRunAt.set(target.deviceId, now);
|
|
149641
|
+
this.deps.logger.info("videoclips: catalog marked stale — a timer cannot read a standalone battery camera", {
|
|
149642
|
+
tags,
|
|
149643
|
+
meta: {
|
|
149644
|
+
branch: "mark-stale",
|
|
149645
|
+
mode,
|
|
149646
|
+
refreshesOn: "onWakeTransition",
|
|
149647
|
+
intervalMinutes: target.intervalMinutes
|
|
149648
|
+
}
|
|
149649
|
+
});
|
|
149650
|
+
return;
|
|
149651
|
+
}
|
|
149652
|
+
this.lastRunAt.set(target.deviceId, now);
|
|
149653
|
+
this.inFlight.add(target.deviceId);
|
|
149065
149654
|
try {
|
|
149066
|
-
if (
|
|
149067
|
-
|
|
149068
|
-
|
|
149069
|
-
|
|
149070
|
-
|
|
149655
|
+
if (mode === "forced-wake") try {
|
|
149656
|
+
await this.deps.wake(target.deviceId);
|
|
149657
|
+
} catch (err) {
|
|
149658
|
+
this.deps.markStale(target.deviceId);
|
|
149659
|
+
this.deps.logger.warn("videoclips: the authorised wake did not bring the camera up — catalog left stale", {
|
|
149660
|
+
tags,
|
|
149661
|
+
meta: {
|
|
149662
|
+
branch: "forced-wake-failed",
|
|
149663
|
+
mode,
|
|
149664
|
+
error: errMessage$1(err)
|
|
149665
|
+
}
|
|
149666
|
+
});
|
|
149667
|
+
return;
|
|
149668
|
+
}
|
|
149669
|
+
await this.deps.refresh(target.deviceId);
|
|
149670
|
+
this.deps.logger.info("videoclips: catalog refreshed", {
|
|
149671
|
+
tags,
|
|
149672
|
+
meta: {
|
|
149673
|
+
branch: "refreshed",
|
|
149674
|
+
mode,
|
|
149675
|
+
elapsedMs: this.deps.now() - now
|
|
149676
|
+
}
|
|
149677
|
+
});
|
|
149071
149678
|
} catch (err) {
|
|
149072
|
-
|
|
149073
|
-
|
|
149074
|
-
|
|
149075
|
-
|
|
149679
|
+
this.deps.markStale(target.deviceId);
|
|
149680
|
+
this.deps.logger.warn("videoclips: catalog refresh failed — the catalog stays as it was", {
|
|
149681
|
+
tags,
|
|
149682
|
+
meta: {
|
|
149683
|
+
branch: "refresh-failed",
|
|
149684
|
+
mode,
|
|
149685
|
+
error: errMessage$1(err)
|
|
149686
|
+
}
|
|
149687
|
+
});
|
|
149688
|
+
} finally {
|
|
149689
|
+
this.inFlight.delete(target.deviceId);
|
|
149076
149690
|
}
|
|
149077
149691
|
}
|
|
149692
|
+
};
|
|
149693
|
+
function errMessage$1(err) {
|
|
149694
|
+
return err instanceof Error ? err.message : String(err);
|
|
149078
149695
|
}
|
|
149079
149696
|
function createClipService(deps) {
|
|
149080
149697
|
const now = deps.now ?? (() => Date.now());
|
|
@@ -149105,23 +149722,11 @@ function createClipService(deps) {
|
|
|
149105
149722
|
};
|
|
149106
149723
|
const mintThumbnail = async (deviceId, clipKey, lane) => {
|
|
149107
149724
|
const device = await deps.resolveDevice(deviceId);
|
|
149108
|
-
if (device === null) return {
|
|
149109
|
-
|
|
149110
|
-
|
|
149111
|
-
};
|
|
149112
|
-
if (device.isBattery && device.sleeping && !device.isHubChild) return {
|
|
149113
|
-
ok: false,
|
|
149114
|
-
reason: "sleeping: a still would wake this standalone battery camera"
|
|
149115
|
-
};
|
|
149116
|
-
if (uidIsMissing(device)) return {
|
|
149117
|
-
ok: false,
|
|
149118
|
-
reason: UID_MISSING_REASON
|
|
149119
|
-
};
|
|
149725
|
+
if (device === null) return refuseStill("unknown-device", `unknown-device: ${deviceId} is not a Reolink camera here`);
|
|
149726
|
+
if (device.isBattery && device.sleeping && !device.isHubChild) return refuseStill("sleeping", "sleeping: a still would wake this standalone battery camera");
|
|
149727
|
+
if (uidIsMissing(device)) return refuseStill("uid-missing", UID_MISSING_REASON);
|
|
149120
149728
|
const row = rowFor(deviceId, clipKey);
|
|
149121
|
-
if (row === null) return
|
|
149122
|
-
ok: false,
|
|
149123
|
-
reason: "no-catalog-row: list this day before asking for its stills"
|
|
149124
|
-
};
|
|
149729
|
+
if (row === null) return refuseStill("no-catalog-row", "no-catalog-row: list this day before asking for its stills");
|
|
149125
149730
|
try {
|
|
149126
149731
|
const bytes = await queue.submit({
|
|
149127
149732
|
host: device.hostKey,
|
|
@@ -149138,10 +149743,7 @@ function createClipService(deps) {
|
|
|
149138
149743
|
});
|
|
149139
149744
|
}
|
|
149140
149745
|
});
|
|
149141
|
-
if (bytes.byteLength === 0) return
|
|
149142
|
-
ok: false,
|
|
149143
|
-
reason: "no-keyframe: the camera returned an empty frame"
|
|
149144
|
-
};
|
|
149746
|
+
if (bytes.byteLength === 0) return refuseStill("no-keyframe", "no-keyframe: the camera returned an empty frame");
|
|
149145
149747
|
await thumbs.put(deviceId, clipKey, bytes);
|
|
149146
149748
|
return {
|
|
149147
149749
|
ok: true,
|
|
@@ -149149,6 +149751,19 @@ function createClipService(deps) {
|
|
|
149149
149751
|
};
|
|
149150
149752
|
} catch (err) {
|
|
149151
149753
|
const reason = err instanceof Error ? err.message : String(err);
|
|
149754
|
+
const deferred = queueRefusal(err);
|
|
149755
|
+
if (deferred !== null) {
|
|
149756
|
+
deps.logger.debug("videoclips: a thumbnail mint was deferred", {
|
|
149757
|
+
tags: { deviceId },
|
|
149758
|
+
meta: {
|
|
149759
|
+
clipKey,
|
|
149760
|
+
lane,
|
|
149761
|
+
code: deferred.code,
|
|
149762
|
+
retryAfterMs: deferred.retryAfterMs
|
|
149763
|
+
}
|
|
149764
|
+
});
|
|
149765
|
+
return deferred;
|
|
149766
|
+
}
|
|
149152
149767
|
deps.logger.warn("videoclips: a thumbnail mint refused", {
|
|
149153
149768
|
tags: { deviceId },
|
|
149154
149769
|
meta: {
|
|
@@ -149158,11 +149773,97 @@ function createClipService(deps) {
|
|
|
149158
149773
|
error: reason
|
|
149159
149774
|
}
|
|
149160
149775
|
});
|
|
149161
|
-
return
|
|
149162
|
-
|
|
149163
|
-
|
|
149776
|
+
return refuseStill("camera-refused", reason);
|
|
149777
|
+
}
|
|
149778
|
+
};
|
|
149779
|
+
/**
|
|
149780
|
+
* ONE clip's bytes, on the short window, with the fetch PROVED (D568).
|
|
149781
|
+
*
|
|
149782
|
+
* Completion is an idle window because nothing on the wire ends a cmd 5
|
|
149783
|
+
* transfer, and that window is short. What makes it safe is not the window,
|
|
149784
|
+
* it is the decoded duration judged against the row that asked for it.
|
|
149785
|
+
*/
|
|
149786
|
+
const fetchVerifiedClipBytes = async (device, row, clipKey) => {
|
|
149787
|
+
const deviceId = device.deviceId;
|
|
149788
|
+
const api = await device.getApi();
|
|
149789
|
+
const fetchPass = (idleTimeoutMs) => fetchClipBytes({
|
|
149790
|
+
download: () => api.downloadRecordingDemuxed({
|
|
149791
|
+
channel: device.channel,
|
|
149792
|
+
...device.uid !== void 0 ? { uid: device.uid } : {},
|
|
149793
|
+
fileName: row.subFileName,
|
|
149794
|
+
idleTimeoutMs
|
|
149795
|
+
}),
|
|
149796
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
149797
|
+
});
|
|
149798
|
+
const judge = (pass) => judgeClipDuration({
|
|
149799
|
+
expectedMs: row.endMs - row.startMs,
|
|
149800
|
+
decodedSeconds: pass.stats.durationSeconds,
|
|
149801
|
+
...row.inProgress === true ? { inProgress: true } : {}
|
|
149802
|
+
});
|
|
149803
|
+
const first = await fetchPass(300);
|
|
149804
|
+
const firstVerdict = judge(first);
|
|
149805
|
+
if (firstVerdict.kind === "unknown") {
|
|
149806
|
+
const line = {
|
|
149807
|
+
tags: { deviceId },
|
|
149808
|
+
meta: {
|
|
149809
|
+
clipKey,
|
|
149810
|
+
branch: "completeness-unverified",
|
|
149811
|
+
reason: firstVerdict.reason,
|
|
149812
|
+
idleTimeoutMs: 300,
|
|
149813
|
+
expectedMs: row.endMs - row.startMs,
|
|
149814
|
+
decodedSeconds: first.stats.durationSeconds
|
|
149815
|
+
}
|
|
149164
149816
|
};
|
|
149817
|
+
if (firstVerdict.reason === "clip-open") deps.logger.debug("videoclips: an open clip cannot be checked for truncation", line);
|
|
149818
|
+
else deps.logger.warn("videoclips: a clip fetch could not be checked for truncation", line);
|
|
149819
|
+
return first;
|
|
149820
|
+
}
|
|
149821
|
+
if (firstVerdict.kind === "complete") return first;
|
|
149822
|
+
deps.logger.warn("videoclips: a clip fetch came back truncated", {
|
|
149823
|
+
tags: { deviceId },
|
|
149824
|
+
meta: {
|
|
149825
|
+
clipKey,
|
|
149826
|
+
branch: "truncated-fetch",
|
|
149827
|
+
idleTimeoutMs: 300,
|
|
149828
|
+
expectedSeconds: firstVerdict.expectedSeconds,
|
|
149829
|
+
decodedSeconds: firstVerdict.decodedSeconds,
|
|
149830
|
+
shortfallSeconds: firstVerdict.shortfallSeconds,
|
|
149831
|
+
bytes: first.annexB.byteLength,
|
|
149832
|
+
keyframes: first.stats.keyframes
|
|
149833
|
+
}
|
|
149834
|
+
});
|
|
149835
|
+
const retry = await fetchPass(CLIP_IDLE_WINDOW_RETRY_MS);
|
|
149836
|
+
const keepRetry = (retry.stats.durationSeconds ?? 0) >= (first.stats.durationSeconds ?? 0);
|
|
149837
|
+
const kept = keepRetry ? retry : first;
|
|
149838
|
+
const verdict = keepRetry ? judge(retry) : firstVerdict;
|
|
149839
|
+
const keptWindowMs = keepRetry ? CLIP_IDLE_WINDOW_RETRY_MS : 300;
|
|
149840
|
+
if (verdict.kind === "short") {
|
|
149841
|
+
deps.logger.warn("videoclips: a clip is still short after the long window", {
|
|
149842
|
+
tags: { deviceId },
|
|
149843
|
+
meta: {
|
|
149844
|
+
clipKey,
|
|
149845
|
+
branch: "truncated-after-retry",
|
|
149846
|
+
idleTimeoutMs: keptWindowMs,
|
|
149847
|
+
retriedOnMs: CLIP_IDLE_WINDOW_RETRY_MS,
|
|
149848
|
+
expectedSeconds: verdict.expectedSeconds,
|
|
149849
|
+
decodedSeconds: verdict.decodedSeconds,
|
|
149850
|
+
shortfallSeconds: verdict.shortfallSeconds,
|
|
149851
|
+
bytes: kept.annexB.byteLength
|
|
149852
|
+
}
|
|
149853
|
+
});
|
|
149854
|
+
return kept;
|
|
149165
149855
|
}
|
|
149856
|
+
deps.logger.info("videoclips: a truncated clip fetch landed whole on the long window", {
|
|
149857
|
+
tags: { deviceId },
|
|
149858
|
+
meta: {
|
|
149859
|
+
clipKey,
|
|
149860
|
+
branch: "truncation-recovered",
|
|
149861
|
+
idleTimeoutMs: keptWindowMs,
|
|
149862
|
+
decodedSeconds: verdict.kind === "complete" ? verdict.decodedSeconds : null,
|
|
149863
|
+
bytes: kept.annexB.byteLength
|
|
149864
|
+
}
|
|
149865
|
+
});
|
|
149866
|
+
return kept;
|
|
149166
149867
|
};
|
|
149167
149868
|
const prepareClip = async (deviceId, clipKey) => {
|
|
149168
149869
|
const ready = await media.pathIfPresent(deviceId, clipKey);
|
|
@@ -149200,25 +149901,44 @@ function createClipService(deps) {
|
|
|
149200
149901
|
});
|
|
149201
149902
|
return null;
|
|
149202
149903
|
}
|
|
149203
|
-
const
|
|
149204
|
-
const
|
|
149205
|
-
|
|
149206
|
-
|
|
149207
|
-
|
|
149208
|
-
|
|
149209
|
-
|
|
149210
|
-
|
|
149211
|
-
|
|
149904
|
+
const downloaded = await fetchVerifiedClipBytes(device, row, clipKey);
|
|
149905
|
+
const audio = decideClipAudio(downloaded.audio);
|
|
149906
|
+
if (audio.kind === "silent") deps.logger.warn("videoclips: serving a clip with no sound", {
|
|
149907
|
+
tags: { deviceId },
|
|
149908
|
+
meta: {
|
|
149909
|
+
clipKey,
|
|
149910
|
+
branch: audio.reason,
|
|
149911
|
+
audioFormat: audio.format,
|
|
149912
|
+
audioPackets: downloaded.stats.audioPackets,
|
|
149913
|
+
audioBytes: downloaded.stats.audioBytes
|
|
149914
|
+
}
|
|
149915
|
+
});
|
|
149916
|
+
if (downloaded.stats.fpsSource !== "timestamps") deps.logger.warn("videoclips: no measured frame rate for this clip", {
|
|
149917
|
+
tags: { deviceId },
|
|
149918
|
+
meta: {
|
|
149919
|
+
clipKey,
|
|
149920
|
+
branch: "fps-unmeasured",
|
|
149921
|
+
fpsSource: downloaded.stats.fpsSource,
|
|
149922
|
+
fps: downloaded.stats.fps,
|
|
149923
|
+
infoFps: downloaded.stats.infoFps
|
|
149924
|
+
}
|
|
149212
149925
|
});
|
|
149213
149926
|
const scratch = await mkdtemp(join$1(tmpdir(), "camstack-clip-mux-"));
|
|
149214
149927
|
try {
|
|
149215
149928
|
const rawPath = join$1(scratch, "clip.h264");
|
|
149929
|
+
const audioPath = join$1(scratch, "clip.aac");
|
|
149216
149930
|
const outPath = join$1(scratch, "clip.mp4");
|
|
149217
149931
|
await writeFile(rawPath, downloaded.annexB);
|
|
149932
|
+
if (audio.kind === "copy") await writeFile(audioPath, audio.data);
|
|
149218
149933
|
const failure = describeMuxFailure(await muxRunner(buildClipMuxArgs({
|
|
149219
149934
|
inputPath: rawPath,
|
|
149220
149935
|
outputPath: outPath,
|
|
149221
|
-
videoType: downloaded.videoType
|
|
149936
|
+
videoType: downloaded.videoType,
|
|
149937
|
+
fps: downloaded.stats.fps,
|
|
149938
|
+
...audio.kind === "copy" ? { audio: {
|
|
149939
|
+
path: audioPath,
|
|
149940
|
+
format: audio.format
|
|
149941
|
+
} } : {}
|
|
149222
149942
|
})));
|
|
149223
149943
|
if (failure !== null) {
|
|
149224
149944
|
deps.logger.warn("videoclips: the clip mux failed", {
|
|
@@ -149240,21 +149960,32 @@ function createClipService(deps) {
|
|
|
149240
149960
|
});
|
|
149241
149961
|
}
|
|
149242
149962
|
};
|
|
149243
|
-
|
|
149244
|
-
|
|
149245
|
-
|
|
149246
|
-
|
|
149247
|
-
|
|
149248
|
-
|
|
149249
|
-
|
|
149250
|
-
|
|
149251
|
-
|
|
149252
|
-
|
|
149253
|
-
served: profile === "high" ? "high" : "low"
|
|
149254
|
-
}),
|
|
149255
|
-
logger: deps.logger
|
|
149963
|
+
const provider = createReolinkVideoclipsProvider({
|
|
149964
|
+
now,
|
|
149965
|
+
cache,
|
|
149966
|
+
resolveDevice: deps.resolveDevice,
|
|
149967
|
+
hasVouchedThumbnail: (deviceId, clipKey) => thumbs.has(deviceId, clipKey),
|
|
149968
|
+
thumbnailUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
|
|
149969
|
+
thumbnailMintUrl: (deviceId, clipKey) => `/addon/${REOLINK_ADDON_ID}/${CLIP_THUMB_PREFIX}/${deviceId}/${clipKey}`,
|
|
149970
|
+
preparePlayback: async ({ device, clipKey, profile }) => ({
|
|
149971
|
+
playbackUrl: `/addon/${REOLINK_ADDON_ID}/${CLIP_MEDIA_PREFIX}/${device.deviceId}/${clipKey}.mp4`,
|
|
149972
|
+
served: profile === "high" ? "high" : "low"
|
|
149256
149973
|
}),
|
|
149974
|
+
logger: deps.logger
|
|
149975
|
+
});
|
|
149976
|
+
return {
|
|
149977
|
+
provider,
|
|
149257
149978
|
cache,
|
|
149979
|
+
refreshCatalog: async (deviceId) => {
|
|
149980
|
+
const until = now();
|
|
149981
|
+
await provider.listClips({
|
|
149982
|
+
deviceId,
|
|
149983
|
+
since: until - CLIP_CATALOG_REFRESH_WINDOW_MS,
|
|
149984
|
+
until,
|
|
149985
|
+
provider: REOLINK_ADDON_ID
|
|
149986
|
+
});
|
|
149987
|
+
},
|
|
149988
|
+
hostBusy: (hostKey) => queue.busy(hostKey),
|
|
149258
149989
|
thumbPrefix: CLIP_THUMB_PREFIX,
|
|
149259
149990
|
mediaPrefix: CLIP_MEDIA_PREFIX,
|
|
149260
149991
|
thumbHandler: createClipThumbHandler({
|
|
@@ -149279,6 +150010,79 @@ function createClipService(deps) {
|
|
|
149279
150010
|
};
|
|
149280
150011
|
}
|
|
149281
150012
|
//#endregion
|
|
150013
|
+
//#region src/clips/clip-refresh-settings.ts
|
|
150014
|
+
/** The section id, so the addon's declaration and this schema cannot drift. */
|
|
150015
|
+
var CLIP_REFRESH_SECTION_ID = "clip-catalog-refresh";
|
|
150016
|
+
/**
|
|
150017
|
+
* The one sentence the interval must always carry.
|
|
150018
|
+
*
|
|
150019
|
+
* It does TWO different things depending on what the camera is, and a knob
|
|
150020
|
+
* that silently means two things on two cameras is how this repo has shipped
|
|
150021
|
+
* settings nobody could reason about. So the difference is in the operator's
|
|
150022
|
+
* own text, at both levels, not only in a record.
|
|
150023
|
+
*/
|
|
150024
|
+
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.";
|
|
150025
|
+
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.";
|
|
150026
|
+
/**
|
|
150027
|
+
* The addon-level (global) default. Per-camera overrides live on the device,
|
|
150028
|
+
* under the SAME key, and a device that sets nothing — or resets — resolves to
|
|
150029
|
+
* this value (`resolveClipCatalogRefreshMinutes`).
|
|
150030
|
+
*/
|
|
150031
|
+
function buildClipRefreshSettingsSchema() {
|
|
150032
|
+
return { sections: [{
|
|
150033
|
+
id: CLIP_REFRESH_SECTION_ID,
|
|
150034
|
+
title: "Clip catalog refresh",
|
|
150035
|
+
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.",
|
|
150036
|
+
columns: 1,
|
|
150037
|
+
fields: [{
|
|
150038
|
+
type: "number",
|
|
150039
|
+
key: CLIP_CATALOG_REFRESH_MINUTES_KEY,
|
|
150040
|
+
label: "Refresh interval (min)",
|
|
150041
|
+
description: CLIP_CATALOG_REFRESH_MINUTES_DESCRIPTION,
|
|
150042
|
+
default: 30,
|
|
150043
|
+
min: 0,
|
|
150044
|
+
max: CLIP_CATALOG_REFRESH_MINUTES_MAX,
|
|
150045
|
+
step: 5
|
|
150046
|
+
}]
|
|
150047
|
+
}] };
|
|
150048
|
+
}
|
|
150049
|
+
/**
|
|
150050
|
+
* The per-camera section: the interval override, and — only where it can mean
|
|
150051
|
+
* anything — the forced wake.
|
|
150052
|
+
*
|
|
150053
|
+
* `forcedWake` is a plain persisted switch on purpose. It is NOT a
|
|
150054
|
+
* `device-action-button` and there is no custom action behind it, so nothing
|
|
150055
|
+
* that dispatches device actions (a schedule, an automation, a rule) can spend
|
|
150056
|
+
* a battery camera's wakes; the only way to turn it on is an operator editing
|
|
150057
|
+
* this camera's settings.
|
|
150058
|
+
*/
|
|
150059
|
+
function buildClipRefreshDeviceSection(input) {
|
|
150060
|
+
const forcedWakeApplies = input.isBattery && !input.isHubChild;
|
|
150061
|
+
return [{
|
|
150062
|
+
id: CLIP_REFRESH_SECTION_ID,
|
|
150063
|
+
tab: "recording",
|
|
150064
|
+
title: "Clip catalog refresh",
|
|
150065
|
+
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.",
|
|
150066
|
+
columns: 1,
|
|
150067
|
+
fields: [{
|
|
150068
|
+
type: "number",
|
|
150069
|
+
key: CLIP_CATALOG_REFRESH_MINUTES_KEY,
|
|
150070
|
+
label: "Refresh interval (min)",
|
|
150071
|
+
description: CLIP_CATALOG_REFRESH_MINUTES_DESCRIPTION,
|
|
150072
|
+
min: 0,
|
|
150073
|
+
max: CLIP_CATALOG_REFRESH_MINUTES_MAX,
|
|
150074
|
+
step: 5
|
|
150075
|
+
}, ...forcedWakeApplies ? [{
|
|
150076
|
+
type: "boolean",
|
|
150077
|
+
key: CLIP_CATALOG_FORCED_WAKE_KEY,
|
|
150078
|
+
label: "Wake this camera to refresh",
|
|
150079
|
+
description: CLIP_CATALOG_FORCED_WAKE_DESCRIPTION,
|
|
150080
|
+
default: false,
|
|
150081
|
+
style: "switch"
|
|
150082
|
+
}] : []]
|
|
150083
|
+
}];
|
|
150084
|
+
}
|
|
150085
|
+
//#endregion
|
|
149282
150086
|
//#region src/intercom-failure-report.ts
|
|
149283
150087
|
/**
|
|
149284
150088
|
* Per-camera talk-back counters, published through `failure-contribution`.
|
|
@@ -154356,6 +155160,73 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
154356
155160
|
* provider still treats a missing `uid` as a dropped branch rather than as
|
|
154357
155161
|
* an empty catalog.
|
|
154358
155162
|
*/
|
|
155163
|
+
/**
|
|
155164
|
+
* Re-read this camera's clip catalog. Set by the addon for every camera it
|
|
155165
|
+
* owns; `null` until it is wired (a redeploy re-registers the device, and
|
|
155166
|
+
* the addon re-wires on `DeviceRegistered` AND `DeviceMetaChanged` — the
|
|
155167
|
+
* same fan-out the email-push resolver needed, for the same reason).
|
|
155168
|
+
*/
|
|
155169
|
+
clipCatalogRefresher = null;
|
|
155170
|
+
setClipCatalogRefresher(refresh) {
|
|
155171
|
+
this.clipCatalogRefresher = refresh;
|
|
155172
|
+
}
|
|
155173
|
+
/**
|
|
155174
|
+
* This camera's OVERRIDE of the clip-refresh interval, or `undefined` when
|
|
155175
|
+
* it overrides nothing — which is what a reset leaves behind too, and is
|
|
155176
|
+
* why the resolver takes `unknown` rather than a number (D562).
|
|
155177
|
+
*/
|
|
155178
|
+
getClipCatalogRefreshMinutes() {
|
|
155179
|
+
return this.config.get("clipCatalogRefreshMinutes");
|
|
155180
|
+
}
|
|
155181
|
+
/** Whether the operator authorised the clip timer to wake this camera. */
|
|
155182
|
+
getClipCatalogForcedWake() {
|
|
155183
|
+
return this.config.get("clipCatalogForcedWake");
|
|
155184
|
+
}
|
|
155185
|
+
/**
|
|
155186
|
+
* The clip refresh the TIMER cannot do.
|
|
155187
|
+
*
|
|
155188
|
+
* On a standalone battery camera a catalog read is a login and a login is
|
|
155189
|
+
* the wake, so the periodic refresh only marks the catalog stale (D562).
|
|
155190
|
+
* This is the other half: the moment the camera confirmed itself awake for
|
|
155191
|
+
* its own reasons — a camera event, a stream, an operator action — is a free
|
|
155192
|
+
* read, and it is the same moment that already warms the stream catalog and
|
|
155193
|
+
* every settings slice. Called from `onWakeTransition`, best-effort, last:
|
|
155194
|
+
* a clip listing must never delay the live-stream catalog the operator is
|
|
155195
|
+
* waiting for.
|
|
155196
|
+
*/
|
|
155197
|
+
async refreshClipCatalogOnWake() {
|
|
155198
|
+
const refresh = this.clipCatalogRefresher;
|
|
155199
|
+
if (refresh === null) {
|
|
155200
|
+
this.ctx.logger.debug("onWakeTransition: no clip catalog refresher wired", {
|
|
155201
|
+
tags: { deviceId: this.id },
|
|
155202
|
+
meta: { branch: "clip-refresher-unwired" }
|
|
155203
|
+
});
|
|
155204
|
+
return;
|
|
155205
|
+
}
|
|
155206
|
+
try {
|
|
155207
|
+
await refresh();
|
|
155208
|
+
} catch (err) {
|
|
155209
|
+
this.ctx.logger.warn("onWakeTransition: the clip catalog refresh failed", {
|
|
155210
|
+
tags: { deviceId: this.id },
|
|
155211
|
+
meta: {
|
|
155212
|
+
branch: "clip-refresh-failed",
|
|
155213
|
+
error: err instanceof Error ? err.message : String(err)
|
|
155214
|
+
}
|
|
155215
|
+
});
|
|
155216
|
+
}
|
|
155217
|
+
}
|
|
155218
|
+
/**
|
|
155219
|
+
* Wake this camera because the OPERATOR authorised the clip timer to
|
|
155220
|
+
* (`clipCatalogForcedWake`, off by default — D562).
|
|
155221
|
+
*
|
|
155222
|
+
* The only path from the timer to a battery camera's radio, and it is the
|
|
155223
|
+
* same forced wake `wakeIfSleeping` already drives for a config button.
|
|
155224
|
+
* Throws when the camera did not come up, so the scheduler can say so and
|
|
155225
|
+
* leave the catalog stale rather than stamping it fresh.
|
|
155226
|
+
*/
|
|
155227
|
+
async wakeForClipCatalogRefresh() {
|
|
155228
|
+
if (!await this.wakeIfSleeping()) throw new Error(`ReolinkCamera ${this.id}: the authorised clip-refresh wake did not bring the camera up`);
|
|
155229
|
+
}
|
|
154359
155230
|
getClipDeviceRef() {
|
|
154360
155231
|
const uid = this.config.get("uid");
|
|
154361
155232
|
return {
|
|
@@ -158616,6 +159487,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
158616
159487
|
await this.warmSnapshotOnWake();
|
|
158617
159488
|
await this.alignAuxDevicesState("wake");
|
|
158618
159489
|
await this.refreshParentSettingsSnapshot();
|
|
159490
|
+
await this.refreshClipCatalogOnWake();
|
|
158619
159491
|
}
|
|
158620
159492
|
static PING_INTERVAL_MS = 3e4;
|
|
158621
159493
|
static PING_MAX_FAILURES = 3;
|
|
@@ -159801,7 +160673,11 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
159801
160673
|
}),
|
|
159802
160674
|
...this.buildSessionsTabSections(),
|
|
159803
160675
|
...this.buildEmailPushSection(),
|
|
159804
|
-
...this.buildEmailTabSections()
|
|
160676
|
+
...this.buildEmailTabSections(),
|
|
160677
|
+
...buildClipRefreshDeviceSection({
|
|
160678
|
+
isHubChild: this.isHubChild(),
|
|
160679
|
+
isBattery: this.isBattery
|
|
160680
|
+
})
|
|
159805
160681
|
]
|
|
159806
160682
|
};
|
|
159807
160683
|
const emailSnap = cache?.emailConfigSnapshot;
|
|
@@ -159820,6 +160696,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
|
|
|
159820
160696
|
emailRecipient3: emailSnap?.recipients?.[2] ?? "",
|
|
159821
160697
|
emailSendNickname: emailSnap?.sendNickname ?? this.name,
|
|
159822
160698
|
emailScheduleEnabled: emailSnap?.taskEnabled ?? false,
|
|
160699
|
+
clipCatalogRefreshMinutes: this.config.get("clipCatalogRefreshMinutes"),
|
|
160700
|
+
clipCatalogForcedWake: this.config.get("clipCatalogForcedWake") ?? false,
|
|
159823
160701
|
debugGeneral: this.config.get("debugGeneral") ?? false,
|
|
159824
160702
|
debugSocketLogs: this.config.get("debugSocketLogs") ?? [],
|
|
159825
160703
|
motionEnabled: this.config.get("motionEnabled") ?? motionSnap.enabled ?? true,
|
|
@@ -163093,6 +163971,10 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
163093
163971
|
throw new Error(`Reolink: ${reason}`);
|
|
163094
163972
|
}
|
|
163095
163973
|
async onShutdown() {
|
|
163974
|
+
if (this.clipRefreshTimer) {
|
|
163975
|
+
clearInterval(this.clipRefreshTimer);
|
|
163976
|
+
this.clipRefreshTimer = null;
|
|
163977
|
+
}
|
|
163096
163978
|
this.logChannels?.stop();
|
|
163097
163979
|
await this.emailPushServer?.stop().catch(() => {});
|
|
163098
163980
|
await this.autodetectCache.dispose();
|
|
@@ -163103,6 +163985,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
163103
163985
|
* logged inside the server and leaves it stopped until the next edit.
|
|
163104
163986
|
*/
|
|
163105
163987
|
async onConfigChanged() {
|
|
163988
|
+
await this.refreshClipRefreshGlobalStore();
|
|
163106
163989
|
await this.emailPushServer?.restart().catch((err) => {
|
|
163107
163990
|
this.ctx.logger.warn("email-push: restart after settings change failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
163108
163991
|
});
|
|
@@ -163122,7 +164005,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
163122
164005
|
* for core to select from.
|
|
163123
164006
|
*/
|
|
163124
164007
|
globalSettingsSchema() {
|
|
163125
|
-
return buildEmailPushSettingsSchema(listLanHosts()[0] ?? "127.0.0.1");
|
|
164008
|
+
return { sections: [...buildEmailPushSettingsSchema(listLanHosts()[0] ?? "127.0.0.1").sections, ...buildClipRefreshSettingsSchema().sections] };
|
|
163126
164009
|
}
|
|
163127
164010
|
/**
|
|
163128
164011
|
* Both email-push sections ARE this integration's configuration — one SMTP
|
|
@@ -163169,6 +164052,9 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
163169
164052
|
capability: videoclipsCapability,
|
|
163170
164053
|
provider: this.clipService.provider
|
|
163171
164054
|
});
|
|
164055
|
+
await this.refreshClipRefreshGlobalStore();
|
|
164056
|
+
this.startClipCatalogRefresh(this.clipService);
|
|
164057
|
+
this.wireClipCatalogRefreshers();
|
|
163172
164058
|
await this.serveClipPlanes(this.clipService);
|
|
163173
164059
|
this.ensureEmailPushServer().start().catch((err) => {
|
|
163174
164060
|
this.ctx.logger.warn("email-push: SMTP intake failed to start at init", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
@@ -163181,6 +164067,7 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
163181
164067
|
if (dev instanceof ReolinkCamera) {
|
|
163182
164068
|
const server = this.ensureEmailPushServer();
|
|
163183
164069
|
dev.setEmailPushParamsResolver(() => server.buildCameraSetupParams(dev));
|
|
164070
|
+
this.wireClipCatalogRefreshers();
|
|
163184
164071
|
this.ctx.logger.debug("email-push: resolver wired", {
|
|
163185
164072
|
tags: { deviceId: data.deviceId },
|
|
163186
164073
|
meta: { via }
|
|
@@ -163194,6 +164081,102 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
|
|
|
163194
164081
|
}
|
|
163195
164082
|
/** The clip catalog, its thumbnail cache and its two routes (D549). */
|
|
163196
164083
|
clipService = null;
|
|
164084
|
+
clipRefreshTimer = null;
|
|
164085
|
+
/**
|
|
164086
|
+
* The addon store as the refresh timer last saw it.
|
|
164087
|
+
*
|
|
164088
|
+
* `listTargets` is synchronous on purpose — a tick that awaited a settings
|
|
164089
|
+
* read per camera would be a store round-trip per camera per tick — so the
|
|
164090
|
+
* global half of the cascade is mirrored here and refreshed at init and on
|
|
164091
|
+
* every `onConfigChanged`. An empty mirror resolves to the DEFAULT, never to
|
|
164092
|
+
* "off": a settings read that has not happened yet must not switch the timer
|
|
164093
|
+
* off for the whole fleet.
|
|
164094
|
+
*/
|
|
164095
|
+
clipRefreshGlobalStore = {};
|
|
164096
|
+
/**
|
|
164097
|
+
* Give every camera the wake-side refresh.
|
|
164098
|
+
*
|
|
164099
|
+
* Idempotent, and re-run on both device lifecycle events for the reason the
|
|
164100
|
+
* email-push resolver documents: an addon-only redeploy re-registers a
|
|
164101
|
+
* device the hub already knows, which emits `DeviceMetaChanged` and NOT
|
|
164102
|
+
* `DeviceRegistered`, and subscribing to one left every battery camera
|
|
164103
|
+
* unwired.
|
|
164104
|
+
*/
|
|
164105
|
+
wireClipCatalogRefreshers() {
|
|
164106
|
+
const service = this.clipService;
|
|
164107
|
+
if (service === null) return;
|
|
164108
|
+
for (const camera of this.listReolinkCameras()) {
|
|
164109
|
+
const deviceId = camera.getClipDeviceRef().deviceId;
|
|
164110
|
+
camera.setClipCatalogRefresher(async () => {
|
|
164111
|
+
await service.refreshCatalog(deviceId);
|
|
164112
|
+
});
|
|
164113
|
+
}
|
|
164114
|
+
}
|
|
164115
|
+
/** Re-read the global half of the clip-refresh cascade. */
|
|
164116
|
+
async refreshClipRefreshGlobalStore() {
|
|
164117
|
+
try {
|
|
164118
|
+
this.clipRefreshGlobalStore = await this.resolveGlobalStore();
|
|
164119
|
+
} catch (err) {
|
|
164120
|
+
this.ctx.logger.warn("videoclips: could not read the clip refresh default — keeping the last", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
164121
|
+
}
|
|
164122
|
+
}
|
|
164123
|
+
/**
|
|
164124
|
+
* Arm the clip-catalog refresh.
|
|
164125
|
+
*
|
|
164126
|
+
* The scheduler owns no clock: it is ticked, so its rules are testable from
|
|
164127
|
+
* COLD (`clip-refresh.spec.ts` — the discipline D561 cost us). The tick
|
|
164128
|
+
* cadence is the FLOOR of what any camera can be configured to, not the
|
|
164129
|
+
* interval itself; each camera's own interval is decided inside the tick
|
|
164130
|
+
* from the cascade, so changing a setting takes effect without re-arming.
|
|
164131
|
+
*/
|
|
164132
|
+
startClipCatalogRefresh(service) {
|
|
164133
|
+
const scheduler = new ClipCatalogRefreshScheduler({
|
|
164134
|
+
now: () => Date.now(),
|
|
164135
|
+
listTargets: () => this.clipRefreshTargets(),
|
|
164136
|
+
refresh: async (deviceId) => {
|
|
164137
|
+
await service.refreshCatalog(deviceId);
|
|
164138
|
+
},
|
|
164139
|
+
markStale: (deviceId) => {
|
|
164140
|
+
service.cache.markStale(deviceId);
|
|
164141
|
+
},
|
|
164142
|
+
wake: async (deviceId) => {
|
|
164143
|
+
const device = this.ctx.kernel.deviceRegistry?.getById(deviceId);
|
|
164144
|
+
if (!(device instanceof ReolinkCamera)) throw new Error(`clip refresh: device ${String(deviceId)} is not a Reolink camera here`);
|
|
164145
|
+
await device.wakeForClipCatalogRefresh();
|
|
164146
|
+
},
|
|
164147
|
+
hostBusy: (hostKey) => service.hostBusy(hostKey),
|
|
164148
|
+
logger: this.ctx.logger
|
|
164149
|
+
});
|
|
164150
|
+
this.clipRefreshTimer = setInterval(() => {
|
|
164151
|
+
scheduler.tick().catch((err) => {
|
|
164152
|
+
this.ctx.logger.warn("videoclips: the catalog refresh tick failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
164153
|
+
});
|
|
164154
|
+
}, 5 * 6e4);
|
|
164155
|
+
if (typeof this.clipRefreshTimer.unref === "function") this.clipRefreshTimer.unref();
|
|
164156
|
+
}
|
|
164157
|
+
/**
|
|
164158
|
+
* Every Reolink CAMERA this process owns, with its cascade resolved.
|
|
164159
|
+
*
|
|
164160
|
+
* Cameras only — the Home Hub (639) is `type: hub` and never binds
|
|
164161
|
+
* `videoclips` (`deviceTypes: [Camera]`); its catalog IS its children's and
|
|
164162
|
+
* is refreshed per child, on its one session. Accessories are not cameras.
|
|
164163
|
+
*/
|
|
164164
|
+
clipRefreshTargets() {
|
|
164165
|
+
const globalMinutes = this.clipRefreshGlobalStore[CLIP_CATALOG_REFRESH_MINUTES_KEY];
|
|
164166
|
+
const out = [];
|
|
164167
|
+
for (const device of this.listReolinkCameras()) {
|
|
164168
|
+
const ref = device.getClipDeviceRef();
|
|
164169
|
+
out.push({
|
|
164170
|
+
deviceId: ref.deviceId,
|
|
164171
|
+
hostKey: ref.hostKey,
|
|
164172
|
+
isHubChild: ref.isHubChild,
|
|
164173
|
+
isBattery: ref.isBattery,
|
|
164174
|
+
forcedWake: resolveClipCatalogForcedWake(device.getClipCatalogForcedWake()),
|
|
164175
|
+
intervalMinutes: resolveClipCatalogRefreshMinutes(globalMinutes, device.getClipCatalogRefreshMinutes())
|
|
164176
|
+
});
|
|
164177
|
+
}
|
|
164178
|
+
return out;
|
|
164179
|
+
}
|
|
163197
164180
|
/**
|
|
163198
164181
|
* Host the thumbnail and media planes, and never let a host without the
|
|
163199
164182
|
* facility take the addon down with it — a data plane is an enhancement to
|