@apocaliss92/scrypted-reolink-native 0.2.12 → 0.2.15

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.2.12",
3
+ "version": "0.2.15",
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
@@ -787,22 +787,21 @@ export class ReolinkCamera extends BaseBaichuanClass implements VideoCamera, Cam
787
787
  const api = await this.ensureClient();
788
788
 
789
789
  if (useNvr) {
790
- // Fetch from NVR using listEnrichedVodFiles (library handles parsing correctly)
790
+ // Fetch from NVR using VOD listing so clip.id/fileId is a real file path (e.g. /mnt/sda/...)
791
791
  const channel = this.storageSettings.values.rtspChannel ?? 0;
792
792
 
793
- // Prefer Hub-like event listing (alarm events) instead of full VOD.
794
- logger.debug(`[NVR EVENTS] Searching for alarm events: channel=${channel}, start=${start.toISOString()}, end=${end.toISOString()}`);
795
- const enrichedRecordings = await api.listNvrAlarmEventsEnrichedViaBaichuan({
793
+ logger.debug(`[NVR VOD] Listing recordings: channel=${channel}, start=${start.toISOString()}, end=${end.toISOString()}`);
794
+ const recordings = await api.listNvrRecordings({
795
+ channel,
796
796
  start,
797
797
  end,
798
- channels: [channel],
799
- streamType: "mainStream",
798
+ streamType: "main",
799
+ autoSearchByDay: true,
800
+ fetchStreamUrls: false,
800
801
  });
801
802
 
802
- logger.debug(`[NVR EVENTS] Found ${enrichedRecordings.length} enriched alarm events from NVR`);
803
-
804
- // Convert enriched recordings to VideoClip array using the shared parser
805
- const clips = await recordingsToVideoClips(enrichedRecordings, {
803
+ // Convert VOD recordings to VideoClip array using the shared parser
804
+ const clips = await recordingsToVideoClips(recordings, {
806
805
  fallbackStart: start,
807
806
  logger,
808
807
  plugin: this,
@@ -811,7 +810,7 @@ export class ReolinkCamera extends BaseBaichuanClass implements VideoCamera, Cam
811
810
  count,
812
811
  });
813
812
 
814
- logger.debug(`[NVR EVENTS] Converted ${clips.length} video clips (limit: ${count || 'none'})`);
813
+ logger.debug(`[NVR VOD] Converted ${clips.length} video clips (limit: ${count || 'none'})`);
815
814
 
816
815
  return clips;
817
816
  } else {
@@ -918,60 +917,10 @@ export class ReolinkCamera extends BaseBaichuanClass implements VideoCamera, Cam
918
917
  }
919
918
 
920
919
  const { clipsSource } = this.storageSettings.values;
921
- const useNvr = clipsSource === "NVR" && this.nvrDevice && videoId.includes('/');
922
-
923
- // NVR/HUB case: prefer Download endpoint (HTTP) instead of RTMP
924
- if (useNvr && this.nvrDevice) {
925
- // Reuse centralized logic for NVR VOD URL (Download)
926
- const downloadUrl = await this.getVideoClipRtmpUrl(videoId);
927
-
928
- // If caching is enabled, download via HTTP and cache as file
929
- if (cacheEnabled) {
930
- const cachePath = this.getVideoClipCachePath(videoId);
931
- logger.log(`Downloading video clip from NVR to cache: fileId=${videoId}, path=${cachePath}`);
932
-
933
- await new Promise<void>((resolve, reject) => {
934
- const urlObj = new URL(downloadUrl);
935
- const httpModule = urlObj.protocol === 'https:' ? https : http;
936
-
937
- const fileStream = fs.createWriteStream(cachePath);
938
-
939
- const req = httpModule.get(downloadUrl, (res) => {
940
- if (!res.statusCode || res.statusCode >= 400) {
941
- reject(new Error(`NVR download failed: ${res.statusCode} ${res.statusMessage}`));
942
- return;
943
- }
944
-
945
- res.pipe(fileStream);
946
-
947
- res.on('error', (err) => {
948
- reject(err);
949
- });
950
-
951
- fileStream.on('finish', () => {
952
- resolve();
953
- });
954
-
955
- fileStream.on('error', (err) => {
956
- reject(err);
957
- });
958
- });
959
-
960
- req.on('error', (err) => {
961
- reject(err);
962
- });
963
- });
964
-
965
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
966
- return mo;
967
- } else {
968
- // Caching disabled: return HTTP Download URL directly
969
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(downloadUrl);
970
- return mo;
971
- }
972
- }
920
+ const useNvr = clipsSource === "NVR" && this.nvrDevice;
973
921
 
974
- // Standalone camera (or fallback): reuse getVideoClipRtmpUrl (Baichuan RTMP)
922
+ // Both standalone and NVR now use a URL-based playback path.
923
+ // In NVR mode, `videoId` is expected to be a full recording path (e.g. /mnt/sda/...).
975
924
  const playbackUrl = await this.getVideoClipRtmpUrl(videoId);
976
925
 
977
926
  // If caching is enabled, download and cache the video via ffmpeg
@@ -1098,6 +1047,12 @@ export class ReolinkCamera extends BaseBaichuanClass implements VideoCamera, Cam
1098
1047
  // Ensure cache directory exists
1099
1048
  await fs.promises.mkdir(cacheDir, { recursive: true });
1100
1049
 
1050
+ const { clipsSource } = this.storageSettings.values;
1051
+ const useNvr = clipsSource === "NVR" && this.nvrDevice;
1052
+
1053
+ // NVR mode: `thumbnailId` is expected to be a full recording path (e.g. /mnt/sda/...).
1054
+ // Use the same ffmpeg-based thumbnail extraction flow as other sources.
1055
+
1101
1056
  // Check if video clip is already cached locally - use it instead of calling camera
1102
1057
  const videoCachePath = this.getVideoClipCachePath(thumbnailId);
1103
1058
  let useLocalVideo = false;
@@ -1159,20 +1114,19 @@ export class ReolinkCamera extends BaseBaichuanClass implements VideoCamera, Cam
1159
1114
  async getVideoClipRtmpUrl(fileId: string, forThumbnail: boolean = false): Promise<string> {
1160
1115
  const logger = this.getBaichuanLogger();
1161
1116
  const { clipsSource } = this.storageSettings.values;
1162
- const useNvr = clipsSource === "NVR" && this.nvrDevice && fileId.includes('/');
1117
+ const useNvr = clipsSource === "NVR" && this.nvrDevice;
1163
1118
 
1164
1119
  if (useNvr) {
1165
- // logger.debug(`[getVideoClipRtmpUrl] Using NVR API for fileId="${fileId}", forThumbnail=${forThumbnail}`);
1120
+ // NVR mode: `fileId` is expected to be a full recording path (e.g. /mnt/sda/...).
1121
+ logger.debug(`[getVideoClipRtmpUrl] Using NVR VOD API for fileId="${fileId}"`);
1166
1122
  const api = await this.ensureClient();
1167
- const channel = this.storageSettings.values.rtspChannel ?? 0;
1168
1123
 
1124
+ const channel = this.storageSettings.values.rtspChannel ?? 0;
1169
1125
  try {
1170
- // logger.debug(`[getVideoClipRtmpUrl] Trying getVodUrl with Download requestType...`);
1171
1126
  const url = await api.getVodUrl(fileId, channel, {
1172
1127
  requestType: "Download",
1173
1128
  streamType: "main",
1174
1129
  });
1175
- // logger.debug(`[getVideoClipRtmpUrl] NVR getVodUrl Download URL received: url="${url || 'none'}"`);
1176
1130
  if (url) return url;
1177
1131
  } catch (e: any) {
1178
1132
  logger.error(`[getVideoClipRtmpUrl] getVodUrl Download failed: ${e?.message || String(e)}`);
@@ -1181,7 +1135,7 @@ export class ReolinkCamera extends BaseBaichuanClass implements VideoCamera, Cam
1181
1135
  throw new Error(`No streaming URL found from NVR for file ${fileId} after trying Playback and Download methods`);
1182
1136
  } else {
1183
1137
  // Camera standalone: DEVE usare RTMP da Baichuan API
1184
- // logger.debug(`[getVideoClipRtmpUrl] Getting RTMP URL from Baichuan API for fileId="${fileId}" (camera standalone)`);
1138
+ logger.debug(`[getVideoClipRtmpUrl] Getting RTMP URL from Baichuan API for fileId="${fileId}" (camera standalone)`);
1185
1139
  const api = await this.ensureClient();
1186
1140
  const result = await api.getRecordingPlaybackUrls({
1187
1141
  fileName: fileId,
package/src/main.ts CHANGED
@@ -32,6 +32,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
32
32
  nvrDeviceId: string;
33
33
  private thumbnailQueue: ThumbnailRequest[] = [];
34
34
  private thumbnailProcessing = false;
35
+ private thumbnailPendingRequests = new Map<string, Promise<MediaObject>>();
35
36
 
36
37
  constructor(nativeId: string) {
37
38
  super(nativeId);
@@ -340,12 +341,23 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
340
341
  * Add a thumbnail generation request to the queue
341
342
  */
342
343
  async generateThumbnail(request: ThumbnailRequestInput): Promise<MediaObject> {
344
+ // Create a unique key for this request (deviceId:fileId)
345
+ const requestKey = `${request.deviceId}:${request.fileId}`;
346
+
347
+ // Check if this thumbnail is already in queue or being processed
348
+ const existingRequest = this.thumbnailPendingRequests.get(requestKey);
349
+ if (existingRequest) {
350
+ const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
351
+ logger.debug(`[Thumbnail] Request already in queue: fileId=${request.fileId}, reusing existing promise`);
352
+ return existingRequest;
353
+ }
354
+
343
355
  const queueLength = this.thumbnailQueue.length;
344
356
  // Use device logger if available, otherwise fallback to provided logger
345
357
  const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
346
358
  logger.log(`[Thumbnail] Download start: fileId=${request.fileId}, queuePosition=${queueLength + 1}`);
347
359
 
348
- return new Promise((resolve, reject) => {
360
+ const promise = new Promise<MediaObject>((resolve, reject) => {
349
361
  this.thumbnailQueue.push({
350
362
  ...request,
351
363
  resolve,
@@ -353,6 +365,16 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
353
365
  });
354
366
  this.processThumbnailQueue();
355
367
  });
368
+
369
+ // Track this request
370
+ this.thumbnailPendingRequests.set(requestKey, promise);
371
+
372
+ // Remove from tracking when the promise resolves or rejects
373
+ promise.finally(() => {
374
+ this.thumbnailPendingRequests.delete(requestKey);
375
+ });
376
+
377
+ return promise;
356
378
  }
357
379
 
358
380
  /**
package/src/utils.ts CHANGED
@@ -192,7 +192,12 @@ export async function recordingFileToVideoClip(
192
192
  const recEndMs = Math.max(recEnd.getTime(), recStartMs);
193
193
  const duration = recEndMs - recStartMs;
194
194
 
195
- const id = rec.id || rec.fileName;
195
+ // IMPORTANT: For NVR/Hub, ensure the clip id (fileId) is the actual recording path (/mnt/...) when available.
196
+ // Some sources may provide an alternate id (e.g. eventId/Baichuan id); we prefer the filesystem path because
197
+ // downstream VOD download/playback endpoints expect it.
198
+ const id = typeof rec.fileName === 'string' && rec.fileName.startsWith('/mnt/')
199
+ ? rec.fileName
200
+ : (rec.id || rec.fileName);
196
201
 
197
202
  // Get video URL if not provided
198
203
  let videoHref: string | undefined = providedVideoHref;