@apocaliss92/scrypted-reolink-native 0.1.33 → 0.1.35

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.35",
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
@@ -22,7 +22,7 @@ import {
22
22
  selectStreamOption,
23
23
  StreamManager
24
24
  } from "./stream-utils";
25
- import { floodlightSuffix, getDeviceInterfaces, pirSuffix, recordingFileToVideoClip, sirenSuffix, updateDeviceInfo } from "./utils";
25
+ import { floodlightSuffix, getDeviceInterfaces, getVideoClipWebhookUrls, pirSuffix, recordingFileToVideoClip, sirenSuffix, updateDeviceInfo, vodSearchResultsToVideoClips } from "./utils";
26
26
 
27
27
  export type CameraType = 'battery' | 'regular' | 'multi-focal' | 'multi-focal-battery';
28
28
 
@@ -559,6 +559,14 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
559
559
  this.updateVideoClipsAutoLoad();
560
560
  },
561
561
  },
562
+ clipsSource: {
563
+ title: "Clips Source",
564
+ subgroup: 'Videoclips',
565
+ description: "Source for fetching video clips: NVR (fetch from NVR device) or Device (fetch directly from camera).",
566
+ type: "string",
567
+ choices: ["NVR", "Device"],
568
+ immediate: true,
569
+ },
562
570
  loadVideoclips: {
563
571
  title: "Auto-load Video Clips",
564
572
  subgroup: 'Videoclips',
@@ -709,36 +717,126 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
709
717
 
710
718
  const start = new Date(startMs);
711
719
  const end = new Date(endMs);
712
- start.setHours(0, 0, 0, 0);
720
+ // Use UTC to match API's dateToReolinkTime conversion
721
+ start.setUTCHours(0, 0, 0, 0);
713
722
 
714
723
  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
- });
724
+ const { clipsSource } = this.storageSettings.values;
725
+ const useNvr = clipsSource === "NVR" && this.nvrDevice;
726
+
727
+ if (useNvr) {
728
+ // Fetch from NVR using listEnrichedVodFiles (library handles parsing correctly)
729
+ const channel = this.storageSettings.values.rtspChannel ?? 0;
730
+ logger.debug(`Fetching video clips from NVR for channel ${channel}`);
731
+
732
+ // Use listEnrichedVodFiles which properly parses filenames and extracts detection info
733
+ logger.log(`[NVR VOD] Searching for video clips: channel=${channel}, start=${start.toISOString()}, end=${end.toISOString()}`);
734
+ // Filter to only include recordings within the requested time window
735
+ const enrichedRecordings = await this.nvrDevice.listEnrichedVodFiles({
736
+ channel,
737
+ start,
738
+ end,
739
+ streamType: "main",
740
+ autoSearchByDay: false, // Disable autoSearchByDay to avoid searching past days
741
+ bypassCache: false,
742
+ });
724
743
 
725
- const clips: VideoClip[] = [];
744
+ logger.log(`[NVR VOD] Found ${enrichedRecordings.length} enriched recordings from NVR`);
745
+
746
+ // Log sample of enriched recordings to see what the library returned
747
+ if (enrichedRecordings.length > 0) {
748
+ const sampleSize = Math.min(3, enrichedRecordings.length);
749
+ for (let i = 0; i < sampleSize; i++) {
750
+ const rec = enrichedRecordings[i];
751
+ logger.log(`[NVR VOD] Sample enriched recording ${i + 1}/${enrichedRecordings.length}:`, {
752
+ fileName: rec.fileName,
753
+ startTimeMs: rec.startTimeMs,
754
+ endTimeMs: rec.endTimeMs,
755
+ durationMs: rec.durationMs,
756
+ hasPerson: rec.hasPerson,
757
+ hasVehicle: rec.hasVehicle,
758
+ hasAnimal: rec.hasAnimal,
759
+ hasFace: rec.hasFace,
760
+ hasMotion: rec.hasMotion,
761
+ hasDoorbell: rec.hasDoorbell,
762
+ hasPackage: rec.hasPackage,
763
+ recordType: rec.recordType,
764
+ parsedFileName: rec.parsedFileName ? {
765
+ start: rec.parsedFileName.start?.toISOString(),
766
+ end: rec.parsedFileName.end?.toISOString(),
767
+ flags: rec.parsedFileName.flags,
768
+ } : null,
769
+ });
770
+ }
771
+ }
726
772
 
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,
773
+ // Convert enriched recordings to VideoClip array
774
+ const clips: VideoClip[] = [];
775
+
776
+ for (const rec of enrichedRecordings) {
777
+ // Log detection flags before conversion
778
+ const flags = {
779
+ hasPerson: 'hasPerson' in rec ? rec.hasPerson : false,
780
+ hasVehicle: 'hasVehicle' in rec ? rec.hasVehicle : false,
781
+ hasAnimal: 'hasAnimal' in rec ? rec.hasAnimal : false,
782
+ hasFace: 'hasFace' in rec ? rec.hasFace : false,
783
+ hasMotion: 'hasMotion' in rec ? rec.hasMotion : false,
784
+ hasDoorbell: 'hasDoorbell' in rec ? rec.hasDoorbell : false,
785
+ hasPackage: 'hasPackage' in rec ? rec.hasPackage : false,
786
+ recordType: rec.recordType || 'none',
787
+ };
788
+ logger.debug(`[NVR VOD] Processing recording: fileName=${rec.fileName}, flags=${JSON.stringify(flags)}`);
789
+
790
+ const clip = await recordingFileToVideoClip(rec, {
791
+ fallbackStart: start,
792
+ logger,
793
+ plugin: this,
794
+ deviceId: this.id,
795
+ useWebhook: true,
796
+ });
797
+
798
+ // Log detection classes in the final clip
799
+ logger.debug(`[NVR VOD] Generated clip: id=${clip.id}, detectionClasses=${clip.detectionClasses?.join(',') || 'none'}`);
800
+ clips.push(clip);
801
+ }
802
+
803
+ // Apply count limit if specified
804
+ const finalClips = count ? clips.slice(0, count) : clips;
805
+ logger.log(`[NVR VOD] Converted ${finalClips.length} video clips (limit: ${count || 'none'})`);
806
+
807
+ return finalClips;
808
+ } else {
809
+ // Fetch directly from device using Baichuan API
810
+ const api = await this.ensureClient();
811
+
812
+ const recordings = await api.listEnrichedRecordingsByTime({
813
+ start,
814
+ end,
815
+ count,
816
+ channel: this.storageSettings.values.rtspChannel,
817
+ streamType: 'mainStream',
818
+ httpFallback: false,
819
+ fetchRtmpUrls: true
735
820
  });
736
- clips.push(clip);
737
- }
738
821
 
739
- logger.debug(`Videoclips found: ${JSON.stringify(clips)}`);
822
+ const clips: VideoClip[] = [];
823
+
824
+ for (const rec of recordings) {
825
+ const clip = await recordingFileToVideoClip(rec, {
826
+ fallbackStart: start,
827
+ api,
828
+ logger,
829
+ plugin: this,
830
+ deviceId: this.id,
831
+ useWebhook: true,
832
+ });
833
+ clips.push(clip);
834
+ }
835
+
836
+ logger.debug(`Videoclips found: ${clips.length}`);
740
837
 
741
- return clips;
838
+ return clips;
839
+ }
742
840
  } catch (e: any) {
743
841
  const message = e instanceof Error ? e.message : String(e);
744
842
 
@@ -948,26 +1046,9 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
948
1046
  logger: this.getBaichuanLogger(),
949
1047
  });
950
1048
  } 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
- }
1049
+ // Get RTMP URL using the appropriate API (NVR or Baichuan)
1050
+ // Use forThumbnail=true to prefer Download over Playback (better for ffmpeg)
1051
+ const rtmpVodUrl = await this.getVideoClipRtmpUrl(thumbnailId, true);
971
1052
 
972
1053
  // Use the plugin's thumbnail generation queue with RTMP URL
973
1054
  thumbnail = await this.plugin.generateThumbnail({
@@ -995,6 +1076,56 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
995
1076
  }
996
1077
  }
997
1078
 
1079
+ /**
1080
+ * Get RTMP URL for a video clip file
1081
+ * Handles both NVR source (full path) and Device source (filename only)
1082
+ * @param fileId - The file ID or full path
1083
+ * @param forThumbnail - If true, prefer Download over Playback (better for ffmpeg thumbnail extraction)
1084
+ */
1085
+ async getVideoClipRtmpUrl(fileId: string, forThumbnail: boolean = false): Promise<string> {
1086
+ const logger = this.getBaichuanLogger();
1087
+ const { clipsSource } = this.storageSettings.values;
1088
+ const useNvr = clipsSource === "NVR" && this.nvrDevice && fileId.includes('/');
1089
+
1090
+ if (useNvr) {
1091
+ logger.log(`[getVideoClipRtmpUrl] Using NVR API for fileId="${fileId}", forThumbnail=${forThumbnail}`);
1092
+ const nvrApi = await this.nvrDevice.ensureClient();
1093
+ const channel = this.storageSettings.values.rtspChannel ?? 0;
1094
+
1095
+ // For both thumbnails and video streaming, try Download first
1096
+ // Download might return MP4 format which is better supported than FLV from Playback
1097
+ const requestTypes = ["Download", "Playback"];
1098
+
1099
+ for (const requestType of requestTypes) {
1100
+ try {
1101
+ logger.log(`[getVideoClipRtmpUrl] Trying getVodUrl with ${requestType} requestType...`);
1102
+ const url = await nvrApi.getVodUrl(fileId, channel, {
1103
+ requestType: requestType as "Playback" | "Download",
1104
+ streamType: "main",
1105
+ });
1106
+ logger.log(`[getVideoClipRtmpUrl] NVR getVodUrl ${requestType} URL received: url="${url || 'none'}"`);
1107
+ if (url) return url;
1108
+ } catch (e: any) {
1109
+ logger.debug(`[getVideoClipRtmpUrl] getVodUrl ${requestType} failed: ${e.message}`);
1110
+ }
1111
+ }
1112
+
1113
+ throw new Error(`No streaming URL found from NVR for file ${fileId} after trying Playback and Download methods`);
1114
+ } else {
1115
+ // Camera standalone: DEVE usare RTMP da Baichuan API
1116
+ logger.log(`[getVideoClipRtmpUrl] Getting RTMP URL from Baichuan API for fileId="${fileId}" (camera standalone)`);
1117
+ const api = await this.ensureClient();
1118
+ const result = await api.getRecordingPlaybackUrls({
1119
+ fileName: fileId,
1120
+ });
1121
+ logger.log(`[getVideoClipRtmpUrl] Baichuan RTMP URL received: rtmpVodUrl="${result.rtmpVodUrl || 'none'}"`);
1122
+ if (!result.rtmpVodUrl) {
1123
+ throw new Error(`No RTMP URL found from Baichuan API for file ${fileId}`);
1124
+ }
1125
+ return result.rtmpVodUrl;
1126
+ }
1127
+ }
1128
+
998
1129
  removeVideoClips(...videoClipIds: string[]): Promise<void> {
999
1130
  throw new Error("removeVideoClips is not implemented yet.");
1000
1131
  }
@@ -1055,8 +1186,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1055
1186
  // Get today's date range (start of today to now)
1056
1187
  const now = new Date();
1057
1188
  const startOfToday = new Date(now);
1058
- startOfToday.setHours(0, 0, 0, 0);
1059
- startOfToday.setMinutes(0, 0, 0);
1189
+ startOfToday.setUTCHours(0, 0, 0, 0);
1190
+ startOfToday.setUTCMinutes(0, 0, 0);
1060
1191
 
1061
1192
  // Fetch today's video clips
1062
1193
  const clips = await this.getVideoClips({
@@ -2293,7 +2424,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2293
2424
  logger.warn('Failed to connect/refresh during init', e);
2294
2425
  }
2295
2426
  }
2296
-
2427
+ this.storageSettings.settings.clipsSource.hide = !this.nvrDevice;
2428
+ this.storageSettings.settings.clipsSource.defaultValue = this.nvrDevice ? "NVR" : "Device";
2297
2429
 
2298
2430
  this.storageSettings.settings.videoclipsRegularChecks.defaultValue = this.isBattery ? 120 : 30;
2299
2431
 
package/src/main.ts CHANGED
@@ -347,8 +347,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
347
347
  */
348
348
  async generateThumbnail(request: ThumbnailRequestInput): Promise<MediaObject> {
349
349
  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}`);
350
+ request.logger.log(`[Thumbnail] Download start: fileId=${request.fileId}, queuePosition=${queueLength + 1}`);
352
351
 
353
352
  return new Promise((resolve, reject) => {
354
353
  this.thumbnailQueue.push({
@@ -376,6 +375,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
376
375
 
377
376
  try {
378
377
  const thumbnail = await extractThumbnailFromVideo(request);
378
+ logger.log(`[Thumbnail] OK: fileId=${request.fileId}`);
379
379
  request.resolve(thumbnail);
380
380
  } catch (error) {
381
381
  logger.error(`[Thumbnail] Error: fileId=${request.fileId}`, error);
package/src/nvr.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { DeviceInfoResponse, DeviceInputData, EventsResponse, ReolinkBaichuanApi, ReolinkCgiApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
- import sdk, { AdoptDevice, Device, DeviceDiscovery, DeviceProvider, DiscoveredDevice, Reboot, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue } from "@scrypted/sdk";
1
+ import type { DeviceInfoResponse, EnrichedRecordingFile, EventsResponse, ReolinkBaichuanApi, ReolinkCgiApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
+ import sdk, { AdoptDevice, Device, DeviceDiscovery, DeviceProvider, DiscoveredDevice, Reboot, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue } from "@scrypted/sdk";
3
3
  import { StorageSettings } from "@scrypted/sdk/storage-settings";
4
- import { BaseBaichuanClass, type BaichuanConnectionConfig, type BaichuanConnectionCallbacks } from "./baichuan-base";
4
+ import { BaseBaichuanClass, type BaichuanConnectionCallbacks, type BaichuanConnectionConfig } from "./baichuan-base";
5
5
  import { ReolinkNativeCamera } from "./camera";
6
6
  import { ReolinkNativeBatteryCamera } from "./camera-battery";
7
7
  import { normalizeUid } from "./connect";
@@ -80,8 +80,8 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
80
80
  }
81
81
 
82
82
  async reboot(): Promise<void> {
83
- const api = await this.ensureClient();
84
- await api.Reboot();
83
+ const api = await this.ensureBaichuanClient();
84
+ await api.reboot();
85
85
  }
86
86
 
87
87
  // BaseBaichuanClass abstract methods implementation
@@ -175,16 +175,41 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
175
175
  }
176
176
 
177
177
  const { ReolinkCgiApi } = await import("@apocaliss92/reolink-baichuan-js");
178
+ const logger = this.getBaichuanLogger();
178
179
  this.nvrApi = new ReolinkCgiApi({
179
180
  host: ipAddress,
180
181
  username,
181
182
  password,
183
+ logger,
182
184
  });
183
185
 
184
186
  await this.nvrApi.login();
185
187
  return this.nvrApi;
186
188
  }
187
189
 
190
+ /**
191
+ * List enriched VOD files (with proper parsing and detection info)
192
+ * This uses the library's enrichVodFile which handles all parsing correctly
193
+ */
194
+ async listEnrichedVodFiles(params: {
195
+ channel: number;
196
+ start: Date;
197
+ end: Date;
198
+ streamType?: "main" | "sub";
199
+ autoSearchByDay?: boolean;
200
+ bypassCache?: boolean;
201
+ }): Promise<Array<EnrichedRecordingFile>> {
202
+ const api = await this.ensureClient();
203
+ return await api.listEnrichedVodFiles({
204
+ channel: params.channel,
205
+ start: params.start,
206
+ end: params.end,
207
+ streamType: params.streamType,
208
+ autoSearchByDay: params.autoSearchByDay,
209
+ bypassCache: params.bypassCache,
210
+ });
211
+ }
212
+
188
213
  private forwardNativeEvent(ev: ReolinkSimpleEvent): void {
189
214
  const logger = this.getBaichuanLogger();
190
215
 
@@ -467,33 +492,15 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
467
492
  async syncEntitiesFromRemote() {
468
493
  const logger = this.getBaichuanLogger();
469
494
 
470
- // Ensure both APIs are ready before syncing
471
- const api = await this.ensureClient();
472
- const baichuanApi = await this.ensureBaichuanClient();
473
-
474
- // Wait for Baichuan connection to be fully established
475
- if (baichuanApi?.client) {
476
- // Check if already connected
477
- if (!baichuanApi.client.isSocketConnected()) {
478
- logger.debug('Waiting for Baichuan connection to be established...');
479
- // Wait up to 5 seconds for connection
480
- let attempts = 0;
481
- while (!baichuanApi.client.isSocketConnected() && attempts < 50) {
482
- await new Promise(resolve => setTimeout(resolve, 100));
483
- attempts++;
484
- }
485
- if (!baichuanApi.client.isSocketConnected()) {
486
- logger.warn('Baichuan connection not established after waiting, proceeding anyway');
487
- } else {
488
- logger.debug('Baichuan connection established');
489
- }
490
- }
491
- }
495
+ const cgiApi = await this.ensureClient();
496
+ const { devicesData, channels } = await cgiApi.getDevicesInfo();
492
497
 
493
- const { devicesData, channels } = await api.getDevicesInfo();
498
+ // const api = await this.ensureBaichuanClient();
499
+ // const devicesMap = api.getDevicesInfo();
500
+ // const deviceEntries = Object.entries(devicesMap);
494
501
 
495
502
  if (!channels.length) {
496
- logger.debug(`No channels found, ${JSON.stringify({ devicesData, channels })}`);
503
+ logger.debug(`No channels found, ${JSON.stringify({ channels, devicesData })}`);
497
504
  return;
498
505
  }
499
506