@apocaliss92/scrypted-reolink-native 0.5.50 → 0.5.52
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/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +2 -2
- package/src/camera.ts +136 -9
- package/src/nvr.ts +28 -7
- package/src/utils.ts +5 -0
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apocaliss92/scrypted-reolink-native",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.52",
|
|
4
4
|
"description": "Use any reolink camera with Scrypted, even older/unsupported models without HTTP protocol support",
|
|
5
5
|
"author": "@apocaliss92",
|
|
6
6
|
"license": "Apache",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
]
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@apocaliss92/nodelink-js": "^0.6.
|
|
47
|
+
"@apocaliss92/nodelink-js": "^0.6.6",
|
|
48
48
|
"@scrypted/common": "file:../../scrypted/common",
|
|
49
49
|
"@scrypted/rtsp": "file:../../scrypted/plugins/rtsp",
|
|
50
50
|
"@scrypted/sdk": "^0.3.118"
|
package/src/camera.ts
CHANGED
|
@@ -605,6 +605,18 @@ export class ReolinkCamera
|
|
|
605
605
|
this.updateVideoClipsAutoLoad();
|
|
606
606
|
},
|
|
607
607
|
},
|
|
608
|
+
videoclipsCacheStaleMinutes: {
|
|
609
|
+
title: "Video Clips Cache TTL (minutes)",
|
|
610
|
+
group: "Videoclips",
|
|
611
|
+
description:
|
|
612
|
+
"For battery cameras: how long a fetched video-clip list stays fresh. " +
|
|
613
|
+
"While fresh, getVideoClips returns the cached list without waking a " +
|
|
614
|
+
"sleeping camera. Once older than this, a sleeping camera is woken to " +
|
|
615
|
+
"reload. An already-awake camera always reloads. Default: 15 minutes.",
|
|
616
|
+
type: "number",
|
|
617
|
+
defaultValue: 15,
|
|
618
|
+
hide: true,
|
|
619
|
+
},
|
|
608
620
|
videoclipsHighWaterMark: {
|
|
609
621
|
// Internal: epoch ms of the most recent clip fully processed by the
|
|
610
622
|
// auto-load run. Subsequent runs start from this mark (minus a small
|
|
@@ -962,6 +974,12 @@ export class ReolinkCamera
|
|
|
962
974
|
private streamManagerRestartTimeout: NodeJS.Timeout | undefined;
|
|
963
975
|
private videoClipsAutoLoadInterval: NodeJS.Timeout | undefined;
|
|
964
976
|
private videoClipsAutoLoadInProgress: boolean = false;
|
|
977
|
+
// In-memory cache of the most recently fetched video clips, merged by id.
|
|
978
|
+
// Used to serve getVideoClips for sleeping battery cameras without waking
|
|
979
|
+
// them, until the cache is considered stale (videoclipsCacheStaleMinutes).
|
|
980
|
+
private videoClipsCache:
|
|
981
|
+
| { clips: VideoClip[]; fetchedAt: number }
|
|
982
|
+
| undefined;
|
|
965
983
|
|
|
966
984
|
private batteryUpdatePromise: Promise<void> | undefined;
|
|
967
985
|
private sleepCheckTimer: NodeJS.Timeout | undefined;
|
|
@@ -1044,15 +1062,6 @@ export class ReolinkCamera
|
|
|
1044
1062
|
return this.multiFocalDevice.getVideoClips(options);
|
|
1045
1063
|
}
|
|
1046
1064
|
|
|
1047
|
-
const isSleeping = !this.nvrDevice && this.isBattery && this.sleeping;
|
|
1048
|
-
|
|
1049
|
-
// Skip sleeping check during auto-load to allow auto-load to start for battery cameras
|
|
1050
|
-
if (!this.videoClipsAutoLoadInProgress && isSleeping) {
|
|
1051
|
-
const logger = this.getBaichuanLogger();
|
|
1052
|
-
logger.debug("getVideoClips: disabled for battery devices");
|
|
1053
|
-
return [];
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
1065
|
const logger = this.getBaichuanLogger();
|
|
1057
1066
|
|
|
1058
1067
|
// Determine time window
|
|
@@ -1097,6 +1106,30 @@ export class ReolinkCamera
|
|
|
1097
1106
|
end.setTime(endOfDay.getTime());
|
|
1098
1107
|
}
|
|
1099
1108
|
|
|
1109
|
+
// Battery cameras: while sleeping, avoid waking the camera on every poll.
|
|
1110
|
+
// If we have a recently-fetched clip list (within the cache TTL), serve it
|
|
1111
|
+
// from cache filtered to the requested window. If the cache is stale (or
|
|
1112
|
+
// empty), fall through to a live fetch — ensureClient() wakes the camera.
|
|
1113
|
+
// An already-awake camera (isSleeping === false) always reloads. The
|
|
1114
|
+
// auto-load path bypasses this so it can populate the cache while asleep.
|
|
1115
|
+
const isSleeping = !this.nvrDevice && this.isBattery && this.sleeping;
|
|
1116
|
+
if (!this.videoClipsAutoLoadInProgress && isSleeping) {
|
|
1117
|
+
const cached = this.getFreshCachedVideoClips(
|
|
1118
|
+
start.getTime(),
|
|
1119
|
+
end.getTime(),
|
|
1120
|
+
nowMs,
|
|
1121
|
+
);
|
|
1122
|
+
if (cached) {
|
|
1123
|
+
logger.debug(
|
|
1124
|
+
`getVideoClips: battery camera sleeping, returning ${cached.length} cached clips (cache fresh)`,
|
|
1125
|
+
);
|
|
1126
|
+
return count ? cached.slice(0, count) : cached;
|
|
1127
|
+
}
|
|
1128
|
+
logger.debug(
|
|
1129
|
+
"getVideoClips: battery camera sleeping and cache stale/empty, waking to reload",
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1100
1133
|
try {
|
|
1101
1134
|
const api = await this.ensureClient();
|
|
1102
1135
|
const channel = this.storageSettings.values.rtspChannel ?? 0;
|
|
@@ -1146,6 +1179,10 @@ export class ReolinkCamera
|
|
|
1146
1179
|
`[getVideoClips] Converted ${clips.length} video clips (limit: ${count || "none"})`,
|
|
1147
1180
|
);
|
|
1148
1181
|
|
|
1182
|
+
// Refresh the in-memory cache (merged by clip id) so a later poll against
|
|
1183
|
+
// a sleeping battery camera can be served without waking it.
|
|
1184
|
+
this.updateVideoClipsCache(clips, nowMs);
|
|
1185
|
+
|
|
1149
1186
|
return clips;
|
|
1150
1187
|
} catch (e: any) {
|
|
1151
1188
|
const message = e instanceof Error ? e.message : String(e);
|
|
@@ -1162,10 +1199,100 @@ export class ReolinkCamera
|
|
|
1162
1199
|
error: message,
|
|
1163
1200
|
});
|
|
1164
1201
|
}
|
|
1202
|
+
|
|
1203
|
+
// Resilience: if a live fetch fails, fall back to any cached clips for the
|
|
1204
|
+
// requested window rather than reporting an empty list. This keeps already
|
|
1205
|
+
// downloaded thumbnails/clips visible instead of "disappearing" on a
|
|
1206
|
+
// transient failure (common on battery cameras mid sleep/wake).
|
|
1207
|
+
const stale = this.getCachedVideoClipsInWindow(
|
|
1208
|
+
start.getTime(),
|
|
1209
|
+
end.getTime(),
|
|
1210
|
+
);
|
|
1211
|
+
if (stale.length) {
|
|
1212
|
+
logger.warn(
|
|
1213
|
+
`getVideoClips: live fetch failed, returning ${stale.length} stale cached clips`,
|
|
1214
|
+
);
|
|
1215
|
+
return count ? stale.slice(0, count) : stale;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1165
1218
|
return [];
|
|
1166
1219
|
}
|
|
1167
1220
|
}
|
|
1168
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* Return cached clips for the [startMs, endMs] window (inclusive), sorted by
|
|
1224
|
+
* most recent first. Does not consider freshness — see
|
|
1225
|
+
* {@link getFreshCachedVideoClips} for the TTL-gated variant.
|
|
1226
|
+
*/
|
|
1227
|
+
private getCachedVideoClipsInWindow(
|
|
1228
|
+
startMs: number,
|
|
1229
|
+
endMs: number,
|
|
1230
|
+
): VideoClip[] {
|
|
1231
|
+
const cache = this.videoClipsCache;
|
|
1232
|
+
if (!cache) {
|
|
1233
|
+
return [];
|
|
1234
|
+
}
|
|
1235
|
+
return cache.clips
|
|
1236
|
+
.filter(
|
|
1237
|
+
(c) =>
|
|
1238
|
+
typeof c.startTime === "number" &&
|
|
1239
|
+
c.startTime >= startMs &&
|
|
1240
|
+
c.startTime <= endMs,
|
|
1241
|
+
)
|
|
1242
|
+
.sort((a, b) => (b.startTime ?? 0) - (a.startTime ?? 0));
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Return cached clips for the requested window only if the cache is still
|
|
1247
|
+
* fresh (younger than the configured TTL). Returns undefined when there is no
|
|
1248
|
+
* cache or it is stale, signalling the caller to perform a live fetch.
|
|
1249
|
+
*/
|
|
1250
|
+
private getFreshCachedVideoClips(
|
|
1251
|
+
startMs: number,
|
|
1252
|
+
endMs: number,
|
|
1253
|
+
nowMs: number,
|
|
1254
|
+
): VideoClip[] | undefined {
|
|
1255
|
+
const cache = this.videoClipsCache;
|
|
1256
|
+
if (!cache) {
|
|
1257
|
+
return undefined;
|
|
1258
|
+
}
|
|
1259
|
+
const ttlMinutes =
|
|
1260
|
+
this.storageSettings.values.videoclipsCacheStaleMinutes ?? 15;
|
|
1261
|
+
const ttlMs = Math.max(0, ttlMinutes) * 60 * 1000;
|
|
1262
|
+
if (nowMs - cache.fetchedAt > ttlMs) {
|
|
1263
|
+
return undefined;
|
|
1264
|
+
}
|
|
1265
|
+
return this.getCachedVideoClipsInWindow(startMs, endMs);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Merge freshly fetched clips into the in-memory cache (keyed by clip id) and
|
|
1270
|
+
* stamp it fresh. Old clips outside the preload window are pruned to bound
|
|
1271
|
+
* memory. Merging (rather than replacing) preserves the broad coverage built
|
|
1272
|
+
* by the auto-load pass when a narrower viewer request comes in.
|
|
1273
|
+
*/
|
|
1274
|
+
private updateVideoClipsCache(clips: VideoClip[], fetchedAt: number): void {
|
|
1275
|
+
const byId = new Map<string, VideoClip>();
|
|
1276
|
+
for (const c of this.videoClipsCache?.clips ?? []) {
|
|
1277
|
+
byId.set(c.id, c);
|
|
1278
|
+
}
|
|
1279
|
+
for (const c of clips) {
|
|
1280
|
+
byId.set(c.id, c);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const daysToPreload =
|
|
1284
|
+
this.storageSettings.values.videoclipsDaysToPreload ?? 1;
|
|
1285
|
+
// Keep a small grace margin so clips straddling the window edge are retained.
|
|
1286
|
+
const cutoffMs =
|
|
1287
|
+
fetchedAt - (daysToPreload + 1) * 24 * 60 * 60 * 1000;
|
|
1288
|
+
|
|
1289
|
+
const merged = Array.from(byId.values()).filter((c) =>
|
|
1290
|
+
typeof c.startTime === "number" ? c.startTime >= cutoffMs : true,
|
|
1291
|
+
);
|
|
1292
|
+
|
|
1293
|
+
this.videoClipsCache = { clips: merged, fetchedAt };
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1169
1296
|
/**
|
|
1170
1297
|
* Get the cache directory for video clips and thumbnails
|
|
1171
1298
|
*/
|
package/src/nvr.ts
CHANGED
|
@@ -453,22 +453,43 @@ export class ReolinkNativeNvrDevice
|
|
|
453
453
|
];
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
-
async syncEntitiesFromRemote() {
|
|
456
|
+
async syncEntitiesFromRemote(attempt = 0) {
|
|
457
457
|
const logger = this.getBaichuanLogger();
|
|
458
458
|
// const { ipAddress } = this.storageSettings.values;
|
|
459
459
|
|
|
460
460
|
const api = await this.ensureBaichuanClient();
|
|
461
|
-
//
|
|
462
|
-
// without depending on the async cmd_id 145 Baichuan push.
|
|
463
|
-
//
|
|
464
|
-
|
|
461
|
+
// Prefer CGI (HTTP GetChannelstatus): it returns the channel list
|
|
462
|
+
// immediately, without depending on the async cmd_id 145 Baichuan push.
|
|
463
|
+
// But some Home Hub models expose no HTTP API at all (issue #15): CGI
|
|
464
|
+
// then fails fast (connection refused), so we fall back to HTTP-free
|
|
465
|
+
// Baichuan discovery, which probes the Support-advertised channel slots.
|
|
466
|
+
let result: Awaited<ReturnType<typeof api.getNvrChannelsSummary>>;
|
|
467
|
+
try {
|
|
468
|
+
result = await api.getNvrChannelsSummary({ source: "cgi" });
|
|
469
|
+
if (!result.channels.length) {
|
|
470
|
+
throw new Error("CGI returned no channels");
|
|
471
|
+
}
|
|
472
|
+
} catch (e) {
|
|
473
|
+
logger.debug(
|
|
474
|
+
`CGI channel discovery unavailable (${(e as Error)?.message}); falling back to Baichuan`,
|
|
475
|
+
);
|
|
476
|
+
result = await api.getNvrChannelsSummary({ source: "baichuan" });
|
|
477
|
+
}
|
|
478
|
+
const { devices, channels } = result;
|
|
465
479
|
|
|
466
480
|
if (!channels.length) {
|
|
481
|
+
const maxAttempts = 5;
|
|
482
|
+
if (attempt >= maxAttempts) {
|
|
483
|
+
logger.warn(
|
|
484
|
+
`No channels found after ${attempt + 1} attempts; giving up for now`,
|
|
485
|
+
);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
467
488
|
logger.debug(
|
|
468
|
-
`No channels found, retrying in 1s. ${JSON.stringify({ channels, devices })}`,
|
|
489
|
+
`No channels found, retrying in 1s (attempt ${attempt + 1}/${maxAttempts}). ${JSON.stringify({ channels, devices })}`,
|
|
469
490
|
);
|
|
470
491
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
471
|
-
await this.syncEntitiesFromRemote();
|
|
492
|
+
await this.syncEntitiesFromRemote(attempt + 1);
|
|
472
493
|
return;
|
|
473
494
|
}
|
|
474
495
|
|
package/src/utils.ts
CHANGED
|
@@ -594,6 +594,11 @@ export async function handleVideoClipRequest(props: {
|
|
|
594
594
|
isNvr: device.isOnNvr,
|
|
595
595
|
logger,
|
|
596
596
|
deviceId: sessionId,
|
|
597
|
+
// Browsers (Chrome/Firefox) can't decode H.265/HEVC in MSE, so a raw
|
|
598
|
+
// passthrough of an HEVC recording loads but never plays. The library
|
|
599
|
+
// only transcodes when the source is actually H.265 (H.264 is copied
|
|
600
|
+
// untouched), so this is safe to always request. Mirrors the HLS path.
|
|
601
|
+
transcodeH265ToH264: true,
|
|
597
602
|
});
|
|
598
603
|
|
|
599
604
|
let totalSize = 0;
|