@apocaliss92/scrypted-reolink-native 0.1.33 → 0.1.36

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.1.33",
3
+ "version": "0.1.36",
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",
@@ -219,9 +219,9 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
219
219
 
220
220
  // Only reuse if both conditions are true
221
221
  if (isConnected && isLoggedIn) {
222
- return this.baichuanApi;
223
- }
224
-
222
+ return this.baichuanApi;
223
+ }
224
+
225
225
  // If socket is not connected or not logged in, cleanup the stale client
226
226
  // This prevents leaking connections when the socket appears connected but isn't
227
227
  const logger = this.getBaichuanLogger();
@@ -358,7 +358,7 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
358
358
  // Only cleanup if this is still the current API instance
359
359
  // This prevents cleanup of a new connection that was created
360
360
  // while the old one was closing
361
- await this.cleanupBaichuanApi();
361
+ await this.cleanupBaichuanApi();
362
362
  }
363
363
 
364
364
  // Call custom close handler if provided
package/src/common.ts CHANGED
@@ -5,6 +5,8 @@ import path from 'path';
5
5
  import fs from 'fs';
6
6
  import crypto from 'crypto';
7
7
  import { spawn } from 'node:child_process';
8
+ import http from 'http';
9
+ import https from 'https';
8
10
  import type { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
9
11
  import { BaseBaichuanClass, type BaichuanConnectionCallbacks, type BaichuanConnectionConfig } from "./baichuan-base";
10
12
  import { createBaichuanApi, normalizeUid, type BaichuanTransport } from "./connect";
@@ -22,7 +24,7 @@ import {
22
24
  selectStreamOption,
23
25
  StreamManager
24
26
  } from "./stream-utils";
25
- import { floodlightSuffix, getDeviceInterfaces, pirSuffix, recordingFileToVideoClip, sirenSuffix, updateDeviceInfo } from "./utils";
27
+ import { floodlightSuffix, getDeviceInterfaces, getVideoClipWebhookUrls, pirSuffix, recordingFileToVideoClip, sanitizeFfmpegOutput, sirenSuffix, updateDeviceInfo, vodSearchResultsToVideoClips } from "./utils";
26
28
 
27
29
  export type CameraType = 'battery' | 'regular' | 'multi-focal' | 'multi-focal-battery';
28
30
 
@@ -550,7 +552,6 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
550
552
  },
551
553
  enableVideoclips: {
552
554
  title: "Enable Video Clips",
553
- subgroup: 'Videoclips',
554
555
  description: "Enable video clips functionality. If disabled, getVideoClips will return empty and all other videoclip settings are ignored.",
555
556
  type: "boolean",
556
557
  defaultValue: false,
@@ -559,6 +560,14 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
559
560
  this.updateVideoClipsAutoLoad();
560
561
  },
561
562
  },
563
+ clipsSource: {
564
+ title: "Clips Source",
565
+ subgroup: 'Videoclips',
566
+ description: "Source for fetching video clips: NVR (fetch from NVR device) or Device (fetch directly from camera).",
567
+ type: "string",
568
+ choices: ["NVR", "Device"],
569
+ immediate: true,
570
+ },
562
571
  loadVideoclips: {
563
572
  title: "Auto-load Video Clips",
564
573
  subgroup: 'Videoclips',
@@ -591,6 +600,16 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
591
600
  this.updateVideoClipsAutoLoad();
592
601
  },
593
602
  },
603
+ videoclipsDaysToPreload: {
604
+ title: "Days to Preload",
605
+ subgroup: 'Videoclips',
606
+ description: "Number of days to preload video clips and thumbnails (default: 1, only today).",
607
+ type: "number",
608
+ defaultValue: 3,
609
+ onPut: async () => {
610
+ this.updateVideoClipsAutoLoad();
611
+ },
612
+ },
594
613
  diagnosticsRun: {
595
614
  subgroup: 'Diagnostics',
596
615
  title: 'Run Diagnostics',
@@ -709,36 +728,125 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
709
728
 
710
729
  const start = new Date(startMs);
711
730
  const end = new Date(endMs);
712
- start.setHours(0, 0, 0, 0);
731
+ // Use UTC to match API's dateToReolinkTime conversion
732
+ start.setUTCHours(0, 0, 0, 0);
713
733
 
714
734
  try {
715
- const api = await this.ensureClient();
716
- const recordings = await api.listEnrichedRecordingsByTime({
717
- start,
718
- end,
719
- count,
720
- streamType: 'mainStream',
721
- httpFallback: false,
722
- fetchRtmpUrls: true
723
- });
735
+ const { clipsSource } = this.storageSettings.values;
736
+ const useNvr = clipsSource === "NVR" && this.nvrDevice;
737
+
738
+ if (useNvr) {
739
+ // Fetch from NVR using listEnrichedVodFiles (library handles parsing correctly)
740
+ const channel = this.storageSettings.values.rtspChannel ?? 0;
741
+
742
+ // Use listEnrichedVodFiles which properly parses filenames and extracts detection info
743
+ logger.debug(`[NVR VOD] Searching for video clips: channel=${channel}, start=${start.toISOString()}, end=${end.toISOString()}`);
744
+ // Filter to only include recordings within the requested time window
745
+ const enrichedRecordings = await this.nvrDevice.listEnrichedVodFiles({
746
+ channel,
747
+ start,
748
+ end,
749
+ streamType: "main",
750
+ autoSearchByDay: false, // Disable autoSearchByDay to avoid searching past days
751
+ bypassCache: false,
752
+ });
724
753
 
725
- const clips: VideoClip[] = [];
754
+ logger.debug(`[NVR VOD] Found ${enrichedRecordings.length} enriched recordings from NVR`);
755
+
756
+ // Log sample of enriched recordings to see what the library returned
757
+ if (enrichedRecordings.length > 0) {
758
+ const sampleSize = Math.min(3, enrichedRecordings.length);
759
+ for (let i = 0; i < sampleSize; i++) {
760
+ const rec = enrichedRecordings[i];
761
+ logger.debug(`[NVR VOD] Sample enriched recording ${i + 1}/${enrichedRecordings.length}:`, {
762
+ fileName: rec.fileName,
763
+ startTimeMs: rec.startTimeMs,
764
+ endTimeMs: rec.endTimeMs,
765
+ durationMs: rec.durationMs,
766
+ hasPerson: rec.hasPerson,
767
+ hasVehicle: rec.hasVehicle,
768
+ hasAnimal: rec.hasAnimal,
769
+ hasFace: rec.hasFace,
770
+ hasMotion: rec.hasMotion,
771
+ hasDoorbell: rec.hasDoorbell,
772
+ hasPackage: rec.hasPackage,
773
+ recordType: rec.recordType,
774
+ parsedFileName: rec.parsedFileName ? {
775
+ start: rec.parsedFileName.start?.toISOString(),
776
+ end: rec.parsedFileName.end?.toISOString(),
777
+ flags: rec.parsedFileName.flags,
778
+ } : null,
779
+ });
780
+ }
781
+ }
726
782
 
727
- for (const rec of recordings) {
728
- const clip = await recordingFileToVideoClip(rec, {
729
- fallbackStart: start,
730
- api,
731
- logger,
732
- plugin: this,
733
- deviceId: this.id,
734
- useWebhook: true,
783
+ // Convert enriched recordings to VideoClip array
784
+ const clips: VideoClip[] = [];
785
+
786
+ for (const rec of enrichedRecordings) {
787
+ // Log detection flags before conversion
788
+ const flags = {
789
+ hasPerson: 'hasPerson' in rec ? rec.hasPerson : false,
790
+ hasVehicle: 'hasVehicle' in rec ? rec.hasVehicle : false,
791
+ hasAnimal: 'hasAnimal' in rec ? rec.hasAnimal : false,
792
+ hasFace: 'hasFace' in rec ? rec.hasFace : false,
793
+ hasMotion: 'hasMotion' in rec ? rec.hasMotion : false,
794
+ hasDoorbell: 'hasDoorbell' in rec ? rec.hasDoorbell : false,
795
+ hasPackage: 'hasPackage' in rec ? rec.hasPackage : false,
796
+ recordType: rec.recordType || 'none',
797
+ };
798
+ logger.debug(`[NVR VOD] Processing recording: fileName=${rec.fileName}, flags=${JSON.stringify(flags)}`);
799
+
800
+ const clip = await recordingFileToVideoClip(rec, {
801
+ fallbackStart: start,
802
+ logger,
803
+ plugin: this,
804
+ deviceId: this.id,
805
+ useWebhook: true,
806
+ });
807
+
808
+ // Log detection classes in the final clip
809
+ logger.debug(`[NVR VOD] Generated clip: id=${clip.id}, detectionClasses=${clip.detectionClasses?.join(',') || 'none'}`);
810
+ clips.push(clip);
811
+ }
812
+
813
+ // Apply count limit if specified
814
+ const finalClips = count ? clips.slice(0, count) : clips;
815
+ logger.debug(`[NVR VOD] Converted ${finalClips.length} video clips (limit: ${count || 'none'})`);
816
+
817
+ return finalClips;
818
+ } else {
819
+ // Fetch directly from device using Baichuan API
820
+ const api = await this.ensureClient();
821
+
822
+ const recordings = await api.listEnrichedRecordingsByTime({
823
+ start,
824
+ end,
825
+ count,
826
+ channel: this.storageSettings.values.rtspChannel,
827
+ streamType: 'mainStream',
828
+ httpFallback: false,
829
+ fetchRtmpUrls: true
735
830
  });
736
- clips.push(clip);
737
- }
738
831
 
739
- logger.debug(`Videoclips found: ${JSON.stringify(clips)}`);
832
+ const clips: VideoClip[] = [];
833
+
834
+ for (const rec of recordings) {
835
+ const clip = await recordingFileToVideoClip(rec, {
836
+ fallbackStart: start,
837
+ api,
838
+ logger,
839
+ plugin: this,
840
+ deviceId: this.id,
841
+ useWebhook: true,
842
+ });
843
+ clips.push(clip);
844
+ }
845
+
846
+ logger.debug(`Videoclips found: ${clips.length}`);
740
847
 
741
- return clips;
848
+ return clips;
849
+ }
742
850
  } catch (e: any) {
743
851
  const message = e instanceof Error ? e.message : String(e);
744
852
 
@@ -779,7 +887,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
779
887
  async getVideoClip(videoId: string): Promise<MediaObject> {
780
888
  const logger = this.getBaichuanLogger();
781
889
  try {
782
- const cacheEnabled = this.storageSettings.values.downloadVideoclipsLocally
890
+ const cacheEnabled = this.storageSettings.values.downloadVideoclipsLocally;
891
+ const MIN_VIDEO_CACHE_BYTES = 16 * 1024;
783
892
 
784
893
  // Always check cache first, even if caching is disabled (in case user enabled it before)
785
894
  const cachePath = this.getVideoClipCachePath(videoId);
@@ -789,10 +898,19 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
789
898
  try {
790
899
  await fs.promises.access(cachePath, fs.constants.F_OK);
791
900
  const stats = await fs.promises.stat(cachePath);
792
- logger.debug(`[VideoClip] Using cached file: fileId=${videoId}, size=${stats.size} bytes`);
793
- // Return cached file as MediaObject
794
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
795
- return mo;
901
+ if (stats.size < MIN_VIDEO_CACHE_BYTES) {
902
+ logger.warn(`[VideoClip] Cached file too small, deleting and re-downloading: fileId=${videoId}, size=${stats.size} bytes`);
903
+ try {
904
+ await fs.promises.unlink(cachePath);
905
+ } catch (unlinkErr) {
906
+ logger.warn(`[VideoClip] Failed to delete small cached file: fileId=${videoId}`, unlinkErr);
907
+ }
908
+ } else {
909
+ logger.debug(`[VideoClip] Using cached file: fileId=${videoId}, size=${stats.size} bytes`);
910
+ // Return cached file as MediaObject
911
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
912
+ return mo;
913
+ }
796
914
  } catch (e) {
797
915
  // File doesn't exist or error accessing it
798
916
  logger.debug(`[VideoClip] Cache miss: fileId=${videoId}, error=${e instanceof Error ? e.message : String(e)}`);
@@ -806,25 +924,71 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
806
924
  await fs.promises.mkdir(cacheDir, { recursive: true });
807
925
  }
808
926
 
809
- const api = await this.ensureClient();
927
+ const { clipsSource } = this.storageSettings.values;
928
+ const useNvr = clipsSource === "NVR" && this.nvrDevice && videoId.includes('/');
810
929
 
811
- // videoId is the fileId (fileName or id from the recording)
812
- const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
813
- fileName: videoId,
814
- });
930
+ // NVR/HUB case: prefer Download endpoint (HTTP) instead of RTMP
931
+ if (useNvr && this.nvrDevice) {
932
+ // Reuse centralized logic for NVR VOD URL (Download)
933
+ const downloadUrl = await this.getVideoClipRtmpUrl(videoId);
934
+
935
+ // If caching is enabled, download via HTTP and cache as file
936
+ if (cacheEnabled) {
937
+ const cachePath = this.getVideoClipCachePath(videoId);
938
+ logger.log(`Downloading video clip from NVR to cache: fileId=${videoId}, path=${cachePath}`);
939
+
940
+ await new Promise<void>((resolve, reject) => {
941
+ const urlObj = new URL(downloadUrl);
942
+ const httpModule = urlObj.protocol === 'https:' ? https : http;
943
+
944
+ const fileStream = fs.createWriteStream(cachePath);
815
945
 
816
- if (!rtmpVodUrl) {
817
- throw new Error(`No playback URL found for video ${videoId}`);
946
+ const req = httpModule.get(downloadUrl, (res) => {
947
+ if (!res.statusCode || res.statusCode >= 400) {
948
+ reject(new Error(`NVR download failed: ${res.statusCode} ${res.statusMessage}`));
949
+ return;
950
+ }
951
+
952
+ res.pipe(fileStream);
953
+
954
+ res.on('error', (err) => {
955
+ reject(err);
956
+ });
957
+
958
+ fileStream.on('finish', () => {
959
+ resolve();
960
+ });
961
+
962
+ fileStream.on('error', (err) => {
963
+ reject(err);
964
+ });
965
+ });
966
+
967
+ req.on('error', (err) => {
968
+ reject(err);
969
+ });
970
+ });
971
+
972
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
973
+ return mo;
974
+ } else {
975
+ // Caching disabled: return HTTP Download URL directly
976
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(downloadUrl);
977
+ return mo;
978
+ }
818
979
  }
819
980
 
820
- // If caching is enabled, download and cache the video
981
+ // Standalone camera (or fallback): reuse getVideoClipRtmpUrl (Baichuan RTMP)
982
+ const playbackUrl = await this.getVideoClipRtmpUrl(videoId);
983
+
984
+ // If caching is enabled, download and cache the video via ffmpeg
821
985
  if (cacheEnabled) {
822
986
  const cachePath = this.getVideoClipCachePath(videoId);
823
987
 
824
988
  // Download and convert RTMP to MP4 using ffmpeg
825
989
  const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
826
990
  const ffmpegArgs = [
827
- '-i', rtmpVodUrl,
991
+ '-i', playbackUrl,
828
992
  '-c', 'copy', // Copy codecs without re-encoding
829
993
  '-f', 'mp4',
830
994
  '-movflags', 'frag_keyframe+empty_moov', // Enable streaming
@@ -846,8 +1010,9 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
846
1010
 
847
1011
  ffmpeg.on('close', (code) => {
848
1012
  if (code !== 0) {
849
- logger.error(`ffmpeg failed to download video clip: ${errorOutput}`);
850
- reject(new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
1013
+ const sanitized = sanitizeFfmpegOutput(errorOutput);
1014
+ logger.error(`ffmpeg failed to download video clip: ${sanitized}`);
1015
+ reject(new Error(`ffmpeg failed with code ${code}: ${sanitized}`));
851
1016
  return;
852
1017
  }
853
1018
 
@@ -875,8 +1040,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
875
1040
  const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
876
1041
  return mo;
877
1042
  } else {
878
- // Caching disabled, return RTMP URL directly
879
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(rtmpVodUrl);
1043
+ // Caching disabled, return playback URL directly (RTMP for standalone camera)
1044
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(playbackUrl);
880
1045
  return mo;
881
1046
  }
882
1047
  } catch (e) {
@@ -910,14 +1075,24 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
910
1075
  // Check cache first
911
1076
  const cachePath = this.getThumbnailCachePath(thumbnailId);
912
1077
  const cacheDir = this.getThumbnailCacheDir();
1078
+ const MIN_THUMB_CACHE_BYTES = 512; // 0.5KB, evita file vuoti o quasi
913
1079
 
914
1080
  try {
915
1081
  await fs.promises.access(cachePath, fs.constants.F_OK);
916
1082
  const stats = await fs.promises.stat(cachePath);
917
- logger.debug(`[Thumbnail] Using cached: fileId=${thumbnailId}, size=${stats.size} bytes`);
918
- // Return cached thumbnail as MediaObject
919
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
920
- return mo;
1083
+ if (stats.size < MIN_THUMB_CACHE_BYTES) {
1084
+ logger.warn(`[Thumbnail] Cached thumbnail too small, deleting and regenerating: fileId=${thumbnailId}, size=${stats.size} bytes`);
1085
+ try {
1086
+ await fs.promises.unlink(cachePath);
1087
+ } catch (unlinkErr) {
1088
+ logger.warn(`[Thumbnail] Failed to delete small cached thumbnail: fileId=${thumbnailId}`, unlinkErr);
1089
+ }
1090
+ } else {
1091
+ logger.debug(`[Thumbnail] Using cached: fileId=${thumbnailId}, size=${stats.size} bytes`);
1092
+ // Return cached thumbnail as MediaObject
1093
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
1094
+ return mo;
1095
+ }
921
1096
  } catch {
922
1097
  // File doesn't exist, need to generate it
923
1098
  logger.debug(`[Thumbnail] Cache miss: fileId=${thumbnailId}`);
@@ -945,36 +1120,19 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
945
1120
  deviceId: this.id,
946
1121
  fileId: thumbnailId,
947
1122
  filePath: videoCachePath,
948
- logger: this.getBaichuanLogger(),
1123
+ device: this,
949
1124
  });
950
1125
  } else {
951
- // Ensure client is connected and logged in (reuses existing connection if available)
952
- // This ensures no new sessions are created during thumbnail operations
953
- const api = await this.ensureClient();
954
-
955
- if (!api.client.isSocketConnected() || !api.client.loggedIn) {
956
- logger.warn(`[Thumbnail] Client not ready, waiting for connection: fileId=${thumbnailId}`);
957
- // ensureClient should have already handled connection, but wait a bit if needed
958
- await new Promise(resolve => setTimeout(resolve, 500));
959
- }
960
-
961
- // Get RTMP URL from fileId
962
- // Note: getRecordingPlaybackUrls internally calls login(), but it should be idempotent
963
- // if ensureClient() already established the connection
964
- const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
965
- fileName: thumbnailId,
966
- });
967
-
968
- if (!rtmpVodUrl) {
969
- throw new Error(`No playback URL found for video ${thumbnailId}`);
970
- }
1126
+ // Get RTMP URL using the appropriate API (NVR or Baichuan)
1127
+ // Use forThumbnail=true to prefer Download over Playback (better for ffmpeg)
1128
+ const rtmpVodUrl = await this.getVideoClipRtmpUrl(thumbnailId, true);
971
1129
 
972
1130
  // Use the plugin's thumbnail generation queue with RTMP URL
973
1131
  thumbnail = await this.plugin.generateThumbnail({
974
1132
  deviceId: this.id,
975
1133
  fileId: thumbnailId,
976
1134
  rtmpUrl: rtmpVodUrl,
977
- logger: this.getBaichuanLogger(),
1135
+ device: this,
978
1136
  });
979
1137
  }
980
1138
 
@@ -995,6 +1153,50 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
995
1153
  }
996
1154
  }
997
1155
 
1156
+ /**
1157
+ * Get RTMP URL for a video clip file
1158
+ * Handles both NVR source (full path) and Device source (filename only)
1159
+ * @param fileId - The file ID or full path
1160
+ * @param forThumbnail - If true, prefer Download over Playback (better for ffmpeg thumbnail extraction)
1161
+ */
1162
+ async getVideoClipRtmpUrl(fileId: string, forThumbnail: boolean = false): Promise<string> {
1163
+ const logger = this.getBaichuanLogger();
1164
+ const { clipsSource } = this.storageSettings.values;
1165
+ const useNvr = clipsSource === "NVR" && this.nvrDevice && fileId.includes('/');
1166
+
1167
+ if (useNvr) {
1168
+ logger.debug(`[getVideoClipRtmpUrl] Using NVR API for fileId="${fileId}", forThumbnail=${forThumbnail}`);
1169
+ const nvrApi = await this.nvrDevice.ensureClient();
1170
+ const channel = this.storageSettings.values.rtspChannel ?? 0;
1171
+
1172
+ try {
1173
+ logger.debug(`[getVideoClipRtmpUrl] Trying getVodUrl with Download requestType...`);
1174
+ const url = await nvrApi.getVodUrl(fileId, channel, {
1175
+ requestType: "Download",
1176
+ streamType: "main",
1177
+ });
1178
+ logger.debug(`[getVideoClipRtmpUrl] NVR getVodUrl Download URL received: url="${url || 'none'}"`);
1179
+ if (url) return url;
1180
+ } catch (e: any) {
1181
+ logger.error(`[getVideoClipRtmpUrl] getVodUrl Download failed: ${e.message}`);
1182
+ }
1183
+
1184
+ throw new Error(`No streaming URL found from NVR for file ${fileId} after trying Playback and Download methods`);
1185
+ } else {
1186
+ // Camera standalone: DEVE usare RTMP da Baichuan API
1187
+ logger.debug(`[getVideoClipRtmpUrl] Getting RTMP URL from Baichuan API for fileId="${fileId}" (camera standalone)`);
1188
+ const api = await this.ensureClient();
1189
+ const result = await api.getRecordingPlaybackUrls({
1190
+ fileName: fileId,
1191
+ });
1192
+ logger.debug(`[getVideoClipRtmpUrl] Baichuan RTMP URL received: rtmpVodUrl="${result.rtmpVodUrl || 'none'}"`);
1193
+ if (!result.rtmpVodUrl) {
1194
+ throw new Error(`No RTMP URL found from Baichuan API for file ${fileId}`);
1195
+ }
1196
+ return result.rtmpVodUrl;
1197
+ }
1198
+ }
1199
+
998
1200
  removeVideoClips(...videoClipIds: string[]): Promise<void> {
999
1201
  throw new Error("removeVideoClips is not implemented yet.");
1000
1202
  }
@@ -1050,21 +1252,23 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1050
1252
  this.videoClipsAutoLoadInProgress = true;
1051
1253
 
1052
1254
  try {
1053
- logger.log('Auto-loading today\'s video clips and thumbnails...');
1255
+ const daysToPreload = this.storageSettings.values.videoclipsDaysToPreload ?? 1;
1256
+ logger.log(`Auto-loading video clips and thumbnails for the last ${daysToPreload} day(s)...`);
1054
1257
 
1055
- // Get today's date range (start of today to now)
1258
+ // Get date range (start of N days ago to now)
1056
1259
  const now = new Date();
1057
- const startOfToday = new Date(now);
1058
- startOfToday.setHours(0, 0, 0, 0);
1059
- startOfToday.setMinutes(0, 0, 0);
1260
+ const startDate = new Date(now);
1261
+ startDate.setUTCDate(startDate.getUTCDate() - (daysToPreload - 1));
1262
+ startDate.setUTCHours(0, 0, 0, 0);
1263
+ startDate.setUTCMinutes(0, 0, 0);
1060
1264
 
1061
- // Fetch today's video clips
1265
+ // Fetch video clips for the specified number of days
1062
1266
  const clips = await this.getVideoClips({
1063
- startTime: startOfToday.getTime(),
1267
+ startTime: startDate.getTime(),
1064
1268
  endTime: now.getTime(),
1065
1269
  });
1066
1270
 
1067
- logger.log(`Found ${clips.length} video clips for today`);
1271
+ logger.log(`Found ${clips.length} video clips for the last ${daysToPreload} day(s)`);
1068
1272
 
1069
1273
  const downloadVideoclipsLocally = this.storageSettings.values.downloadVideoclipsLocally ?? false;
1070
1274
 
@@ -2082,11 +2286,11 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2082
2286
 
2083
2287
  let supportedStreams: ReolinkSupportedStream[] = [];
2084
2288
  // Homehub RTMP is not efficient, crashes, offers native streams to not overload the hub
2085
- if (this.nvrDevice && this.nvrDevice.info.model === 'HOMEHUB') {
2086
- supportedStreams = [...nativeStreams, ...rtspStreams, ...rtmpStreams];
2087
- } else {
2088
- supportedStreams = [...rtspStreams, ...rtmpStreams, ...nativeStreams];
2089
- }
2289
+ // if (this.nvrDevice && this.nvrDevice.info.model === 'HOMEHUB') {
2290
+ supportedStreams = [...nativeStreams, ...rtspStreams, ...rtmpStreams];
2291
+ // } else {
2292
+ // supportedStreams = [...rtspStreams, ...rtmpStreams, ...nativeStreams];
2293
+ // }
2090
2294
 
2091
2295
  for (const supportedStream of supportedStreams) {
2092
2296
  const { id, metadata, url, name, container } = supportedStream;
@@ -2293,7 +2497,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2293
2497
  logger.warn('Failed to connect/refresh during init', e);
2294
2498
  }
2295
2499
  }
2296
-
2500
+ this.storageSettings.settings.clipsSource.hide = !this.nvrDevice;
2501
+ this.storageSettings.settings.clipsSource.defaultValue = this.nvrDevice ? "NVR" : "Device";
2297
2502
 
2298
2503
  this.storageSettings.settings.videoclipsRegularChecks.defaultValue = this.isBattery ? 120 : 30;
2299
2504
 
package/src/main.ts CHANGED
@@ -12,7 +12,8 @@ interface ThumbnailRequest {
12
12
  fileId: string;
13
13
  rtmpUrl?: string;
14
14
  filePath?: string;
15
- logger: Console;
15
+ logger?: Console;
16
+ device?: CommonCameraMixin;
16
17
  resolve: (mo: MediaObject) => void;
17
18
  reject: (error: Error) => void;
18
19
  }
@@ -22,7 +23,8 @@ interface ThumbnailRequestInput {
22
23
  fileId: string;
23
24
  rtmpUrl?: string;
24
25
  filePath?: string;
25
- logger: Console;
26
+ logger?: Console;
27
+ device?: CommonCameraMixin;
26
28
  }
27
29
 
28
30
  class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
@@ -347,8 +349,9 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
347
349
  */
348
350
  async generateThumbnail(request: ThumbnailRequestInput): Promise<MediaObject> {
349
351
  const queueLength = this.thumbnailQueue.length;
350
- const isProcessing = this.thumbnailProcessing;
351
- request.logger.log(`[Thumbnail] Adding to queue: fileId=${request.fileId}, queueLength=${queueLength}, processing=${isProcessing}`);
352
+ // Use device logger if available, otherwise fallback to provided logger
353
+ const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
354
+ logger.log(`[Thumbnail] Download start: fileId=${request.fileId}, queuePosition=${queueLength + 1}`);
352
355
 
353
356
  return new Promise((resolve, reject) => {
354
357
  this.thumbnailQueue.push({
@@ -372,10 +375,11 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
372
375
 
373
376
  while (this.thumbnailQueue.length > 0) {
374
377
  const request = this.thumbnailQueue.shift()!;
375
- const logger = request.logger;
378
+ const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
376
379
 
377
380
  try {
378
381
  const thumbnail = await extractThumbnailFromVideo(request);
382
+ logger.log(`[Thumbnail] Download completed: fileId=${request.fileId}`);
379
383
  request.resolve(thumbnail);
380
384
  } catch (error) {
381
385
  logger.error(`[Thumbnail] Error: fileId=${request.fileId}`, error);