@apocaliss92/scrypted-reolink-native 0.5.51 → 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/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.51",
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",
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/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;