@apocaliss92/scrypted-reolink-native 0.1.32 → 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/src/common.ts CHANGED
@@ -1,7 +1,10 @@
1
- import type { DeviceCapabilities, PtzCommand, PtzPreset, ReolinkBaichuanApi, ReolinkSimpleEvent, ReolinkSupportedStream, StreamSamplingSelection } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
1
+ import type { DeviceCapabilities, PtzCommand, PtzPreset, ReolinkBaichuanApi, ReolinkSimpleEvent, ReolinkSupportedStream, StreamProfile, StreamSamplingSelection } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
2
  import sdk, { BinarySensor, Brightness, Camera, Device, DeviceProvider, Intercom, MediaObject, MediaStreamUrl, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, Reboot, RequestMediaStreamOptions, RequestPictureOptions, ResponsePictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera, VideoClip, VideoClipOptions, VideoClips, VideoClipThumbnailOptions, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk";
3
3
  import { StorageSettings } from "@scrypted/sdk/storage-settings";
4
4
  import path from 'path';
5
+ import fs from 'fs';
6
+ import crypto from 'crypto';
7
+ import { spawn } from 'node:child_process';
5
8
  import type { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
6
9
  import { BaseBaichuanClass, type BaichuanConnectionCallbacks, type BaichuanConnectionConfig } from "./baichuan-base";
7
10
  import { createBaichuanApi, normalizeUid, type BaichuanTransport } from "./connect";
@@ -19,7 +22,7 @@ import {
19
22
  selectStreamOption,
20
23
  StreamManager
21
24
  } from "./stream-utils";
22
- import { floodlightSuffix, getDeviceInterfaces, pirSuffix, sirenSuffix, updateDeviceInfo } from "./utils";
25
+ import { floodlightSuffix, getDeviceInterfaces, getVideoClipWebhookUrls, pirSuffix, recordingFileToVideoClip, sirenSuffix, updateDeviceInfo, vodSearchResultsToVideoClips } from "./utils";
23
26
 
24
27
  export type CameraType = 'battery' | 'regular' | 'multi-focal' | 'multi-focal-battery';
25
28
 
@@ -545,6 +548,57 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
545
548
  type: "string",
546
549
  defaultValue: path.join(process.env.SCRYPTED_PLUGIN_VOLUME, 'diagnostics', this.name),
547
550
  },
551
+ enableVideoclips: {
552
+ title: "Enable Video Clips",
553
+ subgroup: 'Videoclips',
554
+ description: "Enable video clips functionality. If disabled, getVideoClips will return empty and all other videoclip settings are ignored.",
555
+ type: "boolean",
556
+ defaultValue: false,
557
+ immediate: true,
558
+ onPut: async () => {
559
+ this.updateVideoClipsAutoLoad();
560
+ },
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
+ },
570
+ loadVideoclips: {
571
+ title: "Auto-load Video Clips",
572
+ subgroup: 'Videoclips',
573
+ description: "Automatically fetch today's video clips and download missing thumbnails at regular intervals.",
574
+ type: "boolean",
575
+ defaultValue: false,
576
+ immediate: true,
577
+ onPut: async () => {
578
+ this.updateVideoClipsAutoLoad();
579
+ },
580
+ },
581
+ videoclipsRegularChecks: {
582
+ title: "Video Clips Check Interval (minutes)",
583
+ subgroup: 'Videoclips',
584
+ description: "How often to check for new video clips and download thumbnails (default: 30 minutes).",
585
+ type: "number",
586
+ defaultValue: 30,
587
+ onPut: async () => {
588
+ this.updateVideoClipsAutoLoad();
589
+ },
590
+ },
591
+ downloadVideoclipsLocally: {
592
+ title: "Download Video Clips Locally",
593
+ subgroup: 'Videoclips',
594
+ description: "Automatically download and cache video clips to local filesystem during auto-load.",
595
+ type: "boolean",
596
+ defaultValue: false,
597
+ immediate: true,
598
+ onPut: async () => {
599
+ this.updateVideoClipsAutoLoad();
600
+ },
601
+ },
548
602
  diagnosticsRun: {
549
603
  subgroup: 'Diagnostics',
550
604
  title: 'Run Diagnostics',
@@ -586,7 +640,6 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
586
640
  // Abstract init method that subclasses must implement
587
641
  abstract init(): Promise<void>;
588
642
 
589
- protected withBaichuanClient?<T>(fn: (api: ReolinkBaichuanApi) => Promise<T>): Promise<T>;
590
643
  motionTimeout?: NodeJS.Timeout;
591
644
  doorbellBinaryTimeout?: NodeJS.Timeout;
592
645
  initComplete?: boolean;
@@ -598,6 +651,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
598
651
  isBattery: boolean;
599
652
  isMultiFocal: boolean;
600
653
  private streamManagerRestartTimeout: NodeJS.Timeout | undefined;
654
+ private videoClipsAutoLoadInterval: NodeJS.Timeout | undefined;
655
+ private videoClipsAutoLoadInProgress: boolean = false;
601
656
 
602
657
  constructor(
603
658
  nativeId: string,
@@ -605,6 +660,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
605
660
  public options: CommonCameraMixinOptions
606
661
  ) {
607
662
  super(nativeId);
663
+ this.plugin.mixinsMap.set(this.id, this);
608
664
 
609
665
  // Store NVR device reference if provided
610
666
  this.nvrDevice = options.nvrDevice;
@@ -620,25 +676,575 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
620
676
  }, 2000);
621
677
  }
622
678
 
679
+ protected async withBaichuanClient<T>(fn: (api: ReolinkBaichuanApi) => Promise<T>): Promise<T> {
680
+ const client = await this.ensureClient();
681
+ return fn(client);
682
+ }
683
+
684
+ async getVideoClips(options?: VideoClipOptions): Promise<VideoClip[]> {
685
+ // Check if videoclips are enabled
686
+ if (!this.storageSettings.values.enableVideoclips) {
687
+ return [];
688
+ }
689
+
690
+ if (this.isBattery && this.sleeping) {
691
+ const logger = this.getBaichuanLogger();
692
+ logger.debug('getVideoClips: disabled for battery devices');
693
+ return [];
694
+ }
695
+
696
+ const logger = this.getBaichuanLogger();
697
+
698
+ // Determine time window
699
+ const nowMs = Date.now();
700
+ const defaultWindowMs = 60 * 60 * 1000; // last 60 minutes
701
+
702
+ const startMs = options?.startTime ?? (nowMs - defaultWindowMs);
703
+ let endMs = options?.endTime ?? nowMs;
704
+ const count = options?.count;
705
+
706
+ if (endMs > nowMs) {
707
+ endMs = nowMs;
708
+ }
709
+
710
+ if (endMs <= startMs) {
711
+ logger.warn('getVideoClips: invalid time window, endTime <= startTime', {
712
+ startTime: startMs,
713
+ endTime: endMs,
714
+ });
715
+ return [];
716
+ }
717
+
718
+ const start = new Date(startMs);
719
+ const end = new Date(endMs);
720
+ // Use UTC to match API's dateToReolinkTime conversion
721
+ start.setUTCHours(0, 0, 0, 0);
722
+
723
+ try {
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
+ });
743
+
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
+ }
772
+
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
820
+ });
821
+
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}`);
837
+
838
+ return clips;
839
+ }
840
+ } catch (e: any) {
841
+ const message = e instanceof Error ? e.message : String(e);
842
+
843
+ if (message?.includes('UID is required to access recordings')) {
844
+ logger.log('getVideoClips: recordings not available or UID not resolvable for this device', {
845
+ error: message,
846
+ });
847
+ } else {
848
+ logger.warn('getVideoClips: failed to list recordings', {
849
+ error: message,
850
+ });
851
+ }
852
+ return [];
853
+ }
854
+ }
855
+
623
856
  /**
624
- * TODO: Implement video clip fetching using Baichuan/NVR recordings API.
857
+ * Get the cache directory for video clips and thumbnails
625
858
  */
626
- async getVideoClips(options?: VideoClipOptions): Promise<VideoClip[]> {
627
- throw new Error("getVideoClips is not implemented yet.");
859
+ private getVideoClipCacheDir(): string {
860
+ const pluginVolume = process.env.SCRYPTED_PLUGIN_VOLUME || '';
861
+ const cameraId = this.id;
862
+ return path.join(pluginVolume, 'videoclips', cameraId);
863
+ }
864
+
865
+ /**
866
+ * Get cache file path for a video clip
867
+ */
868
+ getVideoClipCachePath(videoId: string): string {
869
+ // Create a safe filename from videoId using hash
870
+ const hash = crypto.createHash('md5').update(videoId).digest('hex');
871
+ // Keep original extension if present, otherwise use .mp4
872
+ const ext = videoId.includes('.') ? path.extname(videoId) : '.mp4';
873
+ const cacheDir = this.getVideoClipCacheDir();
874
+ return path.join(cacheDir, `${hash}${ext}`);
875
+ }
876
+
877
+ async getVideoClip(videoId: string): Promise<MediaObject> {
878
+ const logger = this.getBaichuanLogger();
879
+ try {
880
+ const cacheEnabled = this.storageSettings.values.downloadVideoclipsLocally
881
+
882
+ // Always check cache first, even if caching is disabled (in case user enabled it before)
883
+ const cachePath = this.getVideoClipCachePath(videoId);
884
+ const cacheDir = this.getVideoClipCacheDir();
885
+
886
+ // Check if cached file exists
887
+ try {
888
+ await fs.promises.access(cachePath, fs.constants.F_OK);
889
+ const stats = await fs.promises.stat(cachePath);
890
+ logger.debug(`[VideoClip] Using cached file: fileId=${videoId}, size=${stats.size} bytes`);
891
+ // Return cached file as MediaObject
892
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
893
+ return mo;
894
+ } catch (e) {
895
+ // File doesn't exist or error accessing it
896
+ logger.debug(`[VideoClip] Cache miss: fileId=${videoId}, error=${e instanceof Error ? e.message : String(e)}`);
897
+ if (cacheEnabled) {
898
+ logger.debug(`[VideoClip] Will download and cache: fileId=${videoId}`);
899
+ }
900
+ }
901
+
902
+ // If caching is enabled, ensure cache directory exists
903
+ if (cacheEnabled) {
904
+ await fs.promises.mkdir(cacheDir, { recursive: true });
905
+ }
906
+
907
+ const api = await this.ensureClient();
908
+
909
+ // videoId is the fileId (fileName or id from the recording)
910
+ const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
911
+ fileName: videoId,
912
+ });
913
+
914
+ if (!rtmpVodUrl) {
915
+ throw new Error(`No playback URL found for video ${videoId}`);
916
+ }
917
+
918
+ // If caching is enabled, download and cache the video
919
+ if (cacheEnabled) {
920
+ const cachePath = this.getVideoClipCachePath(videoId);
921
+
922
+ // Download and convert RTMP to MP4 using ffmpeg
923
+ const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
924
+ const ffmpegArgs = [
925
+ '-i', rtmpVodUrl,
926
+ '-c', 'copy', // Copy codecs without re-encoding
927
+ '-f', 'mp4',
928
+ '-movflags', 'frag_keyframe+empty_moov', // Enable streaming
929
+ cachePath,
930
+ ];
931
+
932
+ logger.log(`Downloading video clip to cache: ${cachePath}`);
933
+
934
+ await new Promise<void>((resolve, reject) => {
935
+ const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
936
+ stdio: ['ignore', 'pipe', 'pipe'],
937
+ });
938
+
939
+ let errorOutput = '';
940
+
941
+ ffmpeg.stderr.on('data', (chunk: Buffer) => {
942
+ errorOutput += chunk.toString();
943
+ });
944
+
945
+ ffmpeg.on('close', (code) => {
946
+ if (code !== 0) {
947
+ logger.error(`ffmpeg failed to download video clip: ${errorOutput}`);
948
+ reject(new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
949
+ return;
950
+ }
951
+
952
+ logger.log(`Video clip cached successfully: ${cachePath}`);
953
+ resolve();
954
+ });
955
+
956
+ ffmpeg.on('error', (error) => {
957
+ logger.error(`ffmpeg spawn error for video clip ${videoId}`, error);
958
+ reject(error);
959
+ });
960
+
961
+ // Timeout after 5 minutes
962
+ const timeout = setTimeout(() => {
963
+ ffmpeg.kill('SIGKILL');
964
+ reject(new Error('Video clip download timeout'));
965
+ }, 5 * 60 * 1000);
966
+
967
+ ffmpeg.on('close', () => {
968
+ clearTimeout(timeout);
969
+ });
970
+ });
971
+
972
+ // Return cached file as MediaObject
973
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
974
+ return mo;
975
+ } else {
976
+ // Caching disabled, return RTMP URL directly
977
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(rtmpVodUrl);
978
+ return mo;
979
+ }
980
+ } catch (e) {
981
+ logger.error(`getVideoClip: failed to get video clip ${videoId}`, e);
982
+ throw e;
983
+ }
984
+ }
985
+
986
+ /**
987
+ * Get the cache directory for thumbnails (same as video clips)
988
+ */
989
+ private getThumbnailCacheDir(): string {
990
+ // Use the same directory as video clips
991
+ return this.getVideoClipCacheDir();
628
992
  }
629
993
 
630
- getVideoClip(videoId: string): Promise<MediaObject> {
631
- throw new Error("getVideoClip is not implemented yet.");
994
+ /**
995
+ * Get cache file path for a thumbnail
996
+ */
997
+ private getThumbnailCachePath(fileId: string): string {
998
+ // Use the same hash and base name as video clips, but with .jpg extension
999
+ const hash = crypto.createHash('md5').update(fileId).digest('hex');
1000
+ const cacheDir = this.getThumbnailCacheDir();
1001
+ return path.join(cacheDir, `${hash}.jpg`);
1002
+ }
1003
+
1004
+ async getVideoClipThumbnail(thumbnailId: string, options?: VideoClipThumbnailOptions): Promise<MediaObject> {
1005
+ const logger = this.getBaichuanLogger();
1006
+
1007
+ try {
1008
+ // Check cache first
1009
+ const cachePath = this.getThumbnailCachePath(thumbnailId);
1010
+ const cacheDir = this.getThumbnailCacheDir();
1011
+
1012
+ try {
1013
+ await fs.promises.access(cachePath, fs.constants.F_OK);
1014
+ const stats = await fs.promises.stat(cachePath);
1015
+ logger.debug(`[Thumbnail] Using cached: fileId=${thumbnailId}, size=${stats.size} bytes`);
1016
+ // Return cached thumbnail as MediaObject
1017
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
1018
+ return mo;
1019
+ } catch {
1020
+ // File doesn't exist, need to generate it
1021
+ logger.debug(`[Thumbnail] Cache miss: fileId=${thumbnailId}`);
1022
+ }
1023
+
1024
+ // Ensure cache directory exists
1025
+ await fs.promises.mkdir(cacheDir, { recursive: true });
1026
+
1027
+ // Check if video clip is already cached locally - use it instead of calling camera
1028
+ const videoCachePath = this.getVideoClipCachePath(thumbnailId);
1029
+ let useLocalVideo = false;
1030
+ try {
1031
+ await fs.promises.access(videoCachePath, fs.constants.F_OK);
1032
+ useLocalVideo = true;
1033
+ logger.debug(`[Thumbnail] Using local video file for thumbnail extraction: fileId=${thumbnailId}`);
1034
+ } catch {
1035
+ // Video not cached locally, will use RTMP URL
1036
+ }
1037
+
1038
+ let thumbnail: MediaObject;
1039
+
1040
+ if (useLocalVideo) {
1041
+ // Extract thumbnail from local video file
1042
+ thumbnail = await this.plugin.generateThumbnail({
1043
+ deviceId: this.id,
1044
+ fileId: thumbnailId,
1045
+ filePath: videoCachePath,
1046
+ logger: this.getBaichuanLogger(),
1047
+ });
1048
+ } else {
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);
1052
+
1053
+ // Use the plugin's thumbnail generation queue with RTMP URL
1054
+ thumbnail = await this.plugin.generateThumbnail({
1055
+ deviceId: this.id,
1056
+ fileId: thumbnailId,
1057
+ rtmpUrl: rtmpVodUrl,
1058
+ logger: this.getBaichuanLogger(),
1059
+ });
1060
+ }
1061
+
1062
+ // Cache the thumbnail
1063
+ try {
1064
+ const buffer = await sdk.mediaManager.convertMediaObjectToBuffer(thumbnail, 'image/jpeg');
1065
+ await fs.promises.writeFile(cachePath, buffer);
1066
+ logger.debug(`[Thumbnail] Cached: fileId=${thumbnailId}, size=${buffer.length} bytes`);
1067
+ } catch (e) {
1068
+ logger.warn(`[Thumbnail] Failed to cache: fileId=${thumbnailId}`, e);
1069
+ // Continue even if caching fails
1070
+ }
1071
+
1072
+ return thumbnail;
1073
+ } catch (e) {
1074
+ logger.error(`[Thumbnail] Error: fileId=${thumbnailId}`, e);
1075
+ throw e;
1076
+ }
632
1077
  }
633
1078
 
634
- getVideoClipThumbnail(thumbnailId: string, options?: VideoClipThumbnailOptions): Promise<MediaObject> {
635
- throw new Error("getVideoClipThumbnail is not implemented yet.");
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
+ }
636
1127
  }
637
1128
 
638
1129
  removeVideoClips(...videoClipIds: string[]): Promise<void> {
639
1130
  throw new Error("removeVideoClips is not implemented yet.");
640
1131
  }
641
1132
 
1133
+ /**
1134
+ * Update video clips auto-load timer based on settings
1135
+ */
1136
+ private updateVideoClipsAutoLoad(): void {
1137
+ // Clear existing interval if any
1138
+ if (this.videoClipsAutoLoadInterval) {
1139
+ clearInterval(this.videoClipsAutoLoadInterval);
1140
+ this.videoClipsAutoLoadInterval = undefined;
1141
+ }
1142
+
1143
+ // Check if videoclips are enabled at all
1144
+ const { enableVideoclips, loadVideoclips, videoclipsRegularChecks } = this.storageSettings.values;
1145
+ if (!enableVideoclips) {
1146
+ return;
1147
+ }
1148
+
1149
+
1150
+ if (!loadVideoclips) {
1151
+ return;
1152
+ }
1153
+
1154
+ const logger = this.getBaichuanLogger();
1155
+ const intervalMs = videoclipsRegularChecks * 60 * 1000;
1156
+
1157
+ logger.log(`Starting video clips auto-load: checking every ${videoclipsRegularChecks} minutes`);
1158
+
1159
+ // Run immediately on start
1160
+ this.loadTodayVideoClipsAndThumbnails();
1161
+
1162
+ // Then run at regular intervals
1163
+ this.videoClipsAutoLoadInterval = setInterval(() => {
1164
+ this.loadTodayVideoClipsAndThumbnails();
1165
+ }, intervalMs);
1166
+ }
1167
+
1168
+ /**
1169
+ * Load today's video clips and download missing thumbnails
1170
+ */
1171
+ private async loadTodayVideoClipsAndThumbnails(): Promise<void> {
1172
+ // Prevent concurrent executions
1173
+ if (this.videoClipsAutoLoadInProgress) {
1174
+ const logger = this.getBaichuanLogger();
1175
+ logger.debug('Video clips auto-load already in progress, skipping...');
1176
+ return;
1177
+ }
1178
+
1179
+ const logger = this.getBaichuanLogger();
1180
+
1181
+ this.videoClipsAutoLoadInProgress = true;
1182
+
1183
+ try {
1184
+ logger.log('Auto-loading today\'s video clips and thumbnails...');
1185
+
1186
+ // Get today's date range (start of today to now)
1187
+ const now = new Date();
1188
+ const startOfToday = new Date(now);
1189
+ startOfToday.setUTCHours(0, 0, 0, 0);
1190
+ startOfToday.setUTCMinutes(0, 0, 0);
1191
+
1192
+ // Fetch today's video clips
1193
+ const clips = await this.getVideoClips({
1194
+ startTime: startOfToday.getTime(),
1195
+ endTime: now.getTime(),
1196
+ });
1197
+
1198
+ logger.log(`Found ${clips.length} video clips for today`);
1199
+
1200
+ const downloadVideoclipsLocally = this.storageSettings.values.downloadVideoclipsLocally ?? false;
1201
+
1202
+ // Track processed clips to avoid duplicate calls to the camera
1203
+ const processedClips = new Set<string>();
1204
+
1205
+ // Download videos first (if enabled), then thumbnails for each clip
1206
+ for (const clip of clips) {
1207
+ // Skip if already processed (avoid duplicate calls)
1208
+ if (processedClips.has(clip.id)) {
1209
+ logger.debug(`Skipping already processed clip: ${clip.id}`);
1210
+ continue;
1211
+ }
1212
+ processedClips.add(clip.id);
1213
+
1214
+ try {
1215
+ // If downloadVideoclipsLocally is enabled, download the video clip first
1216
+ // This allows the thumbnail to use the local file instead of calling the camera
1217
+ if (downloadVideoclipsLocally) {
1218
+ try {
1219
+ // Call getVideoClip to trigger download and caching
1220
+ await this.getVideoClip(clip.id);
1221
+ logger.debug(`Downloaded video clip: ${clip.id}`);
1222
+ } catch (e) {
1223
+ logger.warn(`Failed to download video clip ${clip.id}:`, e instanceof Error ? e.message : String(e));
1224
+ }
1225
+ }
1226
+
1227
+ // Then get the thumbnail - this will use the local video file if available
1228
+ // or call the camera if the video wasn't downloaded
1229
+ try {
1230
+ await this.getVideoClipThumbnail(clip.id);
1231
+ logger.debug(`Downloaded thumbnail for clip: ${clip.id}`);
1232
+ } catch (e) {
1233
+ logger.warn(`Failed to load thumbnail for clip ${clip.id}:`, e instanceof Error ? e.message : String(e));
1234
+ }
1235
+ } catch (e) {
1236
+ logger.warn(`Error processing clip ${clip.id}:`, e instanceof Error ? e.message : String(e));
1237
+ }
1238
+ }
1239
+
1240
+ logger.log(`Completed auto-loading video clips and thumbnails`);
1241
+ } catch (e) {
1242
+ logger.error('Error during auto-loading video clips:', e);
1243
+ } finally {
1244
+ this.videoClipsAutoLoadInProgress = false;
1245
+ }
1246
+ }
1247
+
642
1248
  async reboot(): Promise<void> {
643
1249
  const api = await this.ensureBaichuanClient();
644
1250
  await api.reboot();
@@ -654,13 +1260,14 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
654
1260
  throw new Error('UID is required for battery cameras (BCUDP)');
655
1261
  }
656
1262
 
1263
+ const logger = this.getBaichuanLogger();
657
1264
  return {
658
1265
  host: ipAddress,
659
1266
  username,
660
1267
  password,
661
1268
  uid: normalizedUid,
662
1269
  transport: this.protocol,
663
- logger: this.console,
1270
+ logger,
664
1271
  debugOptions,
665
1272
  };
666
1273
  }
@@ -700,6 +1307,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
700
1307
  }
701
1308
 
702
1309
  async withBaichuanRetry<T>(fn: () => Promise<T>): Promise<T> {
1310
+ return await fn();
1311
+
703
1312
  if (this.isBattery) {
704
1313
  return await fn();
705
1314
  } else {
@@ -777,13 +1386,19 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
777
1386
  * - For TCP devices (regular + multifocal), this creates a new TCP session with its own client.
778
1387
  * - For UDP/battery devices, this reuses the existing client via ensureClient().
779
1388
  */
780
- async createStreamClient(): Promise<ReolinkBaichuanApi> {
1389
+ async createStreamClient(profile?: StreamProfile): Promise<ReolinkBaichuanApi> {
781
1390
  // Battery / BCUDP path: reuse the main client to avoid extra wake-ups and sockets.
782
1391
  if (this.isBattery) {
783
1392
  return await this.ensureClient();
784
1393
  }
785
1394
 
786
- // TCP path: create a separate session for streaming (RFC4571/composite/NVR-friendly).
1395
+ // For TCP path: create a new client ONLY for "ext" profile
1396
+ // For other profiles (main, sub), reuse the main client
1397
+ if (profile !== 'ext') {
1398
+ return await this.ensureClient();
1399
+ }
1400
+
1401
+ // TCP path with ext profile: create a separate session for streaming (RFC4571/composite/NVR-friendly).
787
1402
  const { ipAddress, username, password } = this.storageSettings.values;
788
1403
  const logger = this.getBaichuanLogger();
789
1404
 
@@ -825,7 +1440,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
825
1440
  const { username, password } = this.storageSettings.values;
826
1441
 
827
1442
  const baseOptions: any = {
828
- createStreamClient: () => this.createStreamClient(),
1443
+ createStreamClient: (profile?: StreamProfile) => this.createStreamClient(profile),
829
1444
  getLogger: () => logger,
830
1445
  credentials: {
831
1446
  username,
@@ -876,6 +1491,13 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
876
1491
  restartLogger.log('Restarting StreamManager due to PIP/composite settings change');
877
1492
  this.initStreamManager(restartLogger, true);
878
1493
 
1494
+ // Invalidate snapshot cache for battery/multifocal-battery so that
1495
+ // the next snapshot reflects the new PIP/composite configuration.
1496
+ if (this.isBattery) {
1497
+ this.forceNewSnapshot = true;
1498
+ this.lastPicture = undefined;
1499
+ }
1500
+
879
1501
  // Notify consumers (e.g. prebuffer) that stream configuration changed.
880
1502
  try {
881
1503
  this.onDeviceEvent(ScryptedInterface.VideoCamera, undefined);
@@ -1410,6 +2032,10 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1410
2032
  }
1411
2033
  }
1412
2034
 
2035
+ async release() {
2036
+ this.plugin.mixinsMap.delete(this.id);
2037
+ }
2038
+
1413
2039
  async releaseDevice(id: string, nativeId: string): Promise<void> {
1414
2040
  if (nativeId.endsWith(sirenSuffix)) {
1415
2041
  this.siren = undefined;
@@ -1798,10 +2424,11 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1798
2424
  logger.warn('Failed to connect/refresh during init', e);
1799
2425
  }
1800
2426
  }
2427
+ this.storageSettings.settings.clipsSource.hide = !this.nvrDevice;
2428
+ this.storageSettings.settings.clipsSource.defaultValue = this.nvrDevice ? "NVR" : "Device";
1801
2429
 
1802
- const { username, password } = this.storageSettings.values;
2430
+ this.storageSettings.settings.videoclipsRegularChecks.defaultValue = this.isBattery ? 120 : 30;
1803
2431
 
1804
- this.storageSettings.settings.uid.hide = !this.isBattery;
1805
2432
  this.storageSettings.settings.batteryUpdateIntervalMinutes.hide = !this.isBattery;
1806
2433
  this.storageSettings.settings.lowThresholdBatteryRecording.hide = !this.isBattery;
1807
2434
  this.storageSettings.settings.highThresholdBatteryRecording.hide = !this.isBattery;
@@ -1865,7 +2492,6 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1865
2492
  this.storageSettings.settings.username.hide = true;
1866
2493
  this.storageSettings.settings.password.hide = true;
1867
2494
  this.storageSettings.settings.ipAddress.hide = true;
1868
- this.storageSettings.settings.uid.hide = true;
1869
2495
 
1870
2496
  this.storageSettings.settings.username.defaultValue = this.nvrDevice.storageSettings.values.username;
1871
2497
  this.storageSettings.settings.password.defaultValue = this.nvrDevice.storageSettings.values.password;
@@ -1875,6 +2501,9 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1875
2501
  await this.init();
1876
2502
 
1877
2503
  this.initComplete = true;
2504
+
2505
+ // Initialize video clips auto-load if enabled
2506
+ this.updateVideoClipsAutoLoad();
1878
2507
  }
1879
2508
  }
1880
2509