@apocaliss92/scrypted-reolink-native 0.1.31 → 0.1.33

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,10 +1,13 @@
1
- import type { DeviceCapabilities, PtzCommand, PtzPreset, ReolinkBaichuanApi, ReolinkSimpleEvent, ReolinkSupportedStream, StreamSamplingSelection } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
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, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk";
1
+ import type { DeviceCapabilities, PtzCommand, PtzPreset, ReolinkBaichuanApi, ReolinkSimpleEvent, ReolinkSupportedStream, StreamProfile, StreamSamplingSelection } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
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
- import { normalizeUid, type BaichuanTransport } from "./connect";
10
+ import { createBaichuanApi, normalizeUid, type BaichuanTransport } from "./connect";
8
11
  import { convertDebugLogsToApiOptions, getApiRelevantDebugLogs, getDebugLogChoices } from "./debug-options";
9
12
  import { ReolinkBaichuanIntercom } from "./intercom";
10
13
  import ReolinkNativePlugin from "./main";
@@ -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, pirSuffix, recordingFileToVideoClip, sirenSuffix, updateDeviceInfo } from "./utils";
23
26
 
24
27
  export type CameraType = 'battery' | 'regular' | 'multi-focal' | 'multi-focal-battery';
25
28
 
@@ -189,7 +192,7 @@ class ReolinkCameraPirSensor extends ScryptedDeviceBase implements OnOff, Settin
189
192
  }
190
193
  }
191
194
 
192
- export abstract class CommonCameraMixin extends BaseBaichuanClass implements VideoCamera, Camera, Settings, DeviceProvider, ObjectDetector, PanTiltZoom, VideoTextOverlays, BinarySensor, Intercom, Reboot {
195
+ export abstract class CommonCameraMixin extends BaseBaichuanClass implements VideoCamera, Camera, Settings, DeviceProvider, ObjectDetector, PanTiltZoom, VideoTextOverlays, BinarySensor, Intercom, Reboot, VideoClips {
193
196
  storageSettings = new StorageSettings(this, {
194
197
  // Basic connection settings
195
198
  ipAddress: {
@@ -250,28 +253,40 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
250
253
  description: 'Relative size of the PIP overlay (0.1 = 10%, 0.3 = 30%, etc.)',
251
254
  type: 'number',
252
255
  defaultValue: 0.25,
253
- hide: true, // Only show for multifocal devices via getAdditionalSettings
256
+ hide: true,
257
+ onPut: async () => {
258
+ this.scheduleStreamManagerRestart('pipSize changed');
259
+ },
254
260
  },
255
261
  pipMargin: {
256
262
  title: 'PIP Margin',
257
263
  description: 'Margin from edge in pixels',
258
264
  type: 'number',
259
265
  defaultValue: 10,
260
- hide: true, // Only show for multifocal devices via getAdditionalSettings
266
+ hide: true,
267
+ onPut: async () => {
268
+ this.scheduleStreamManagerRestart('pipMargin changed');
269
+ },
261
270
  },
262
271
  widerChannel: {
263
272
  title: 'Wider Channel',
264
273
  description: 'Channel number for wider lens (typically 0)',
265
274
  type: 'number',
266
275
  defaultValue: 0,
267
- hide: true, // Only show for multifocal devices via getAdditionalSettings
276
+ hide: true,
277
+ onPut: async () => {
278
+ this.scheduleStreamManagerRestart('widerChannel changed');
279
+ },
268
280
  },
269
281
  teleChannel: {
270
282
  title: 'Tele Channel',
271
283
  description: 'Channel number for tele lens (typically 1)',
272
284
  type: 'number',
273
285
  defaultValue: 1,
274
- hide: true, // Only show for multifocal devices via getAdditionalSettings
286
+ hide: true,
287
+ onPut: async () => {
288
+ this.scheduleStreamManagerRestart('teleChannel changed');
289
+ },
275
290
  },
276
291
  // Battery camera specific
277
292
  uid: {
@@ -533,6 +548,49 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
533
548
  type: "string",
534
549
  defaultValue: path.join(process.env.SCRYPTED_PLUGIN_VOLUME, 'diagnostics', this.name),
535
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
+ loadVideoclips: {
563
+ title: "Auto-load Video Clips",
564
+ subgroup: 'Videoclips',
565
+ description: "Automatically fetch today's video clips and download missing thumbnails at regular intervals.",
566
+ type: "boolean",
567
+ defaultValue: false,
568
+ immediate: true,
569
+ onPut: async () => {
570
+ this.updateVideoClipsAutoLoad();
571
+ },
572
+ },
573
+ videoclipsRegularChecks: {
574
+ title: "Video Clips Check Interval (minutes)",
575
+ subgroup: 'Videoclips',
576
+ description: "How often to check for new video clips and download thumbnails (default: 30 minutes).",
577
+ type: "number",
578
+ defaultValue: 30,
579
+ onPut: async () => {
580
+ this.updateVideoClipsAutoLoad();
581
+ },
582
+ },
583
+ downloadVideoclipsLocally: {
584
+ title: "Download Video Clips Locally",
585
+ subgroup: 'Videoclips',
586
+ description: "Automatically download and cache video clips to local filesystem during auto-load.",
587
+ type: "boolean",
588
+ defaultValue: false,
589
+ immediate: true,
590
+ onPut: async () => {
591
+ this.updateVideoClipsAutoLoad();
592
+ },
593
+ },
536
594
  diagnosticsRun: {
537
595
  subgroup: 'Diagnostics',
538
596
  title: 'Run Diagnostics',
@@ -574,7 +632,6 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
574
632
  // Abstract init method that subclasses must implement
575
633
  abstract init(): Promise<void>;
576
634
 
577
- protected withBaichuanClient?<T>(fn: (api: ReolinkBaichuanApi) => Promise<T>): Promise<T>;
578
635
  motionTimeout?: NodeJS.Timeout;
579
636
  doorbellBinaryTimeout?: NodeJS.Timeout;
580
637
  initComplete?: boolean;
@@ -582,7 +639,12 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
582
639
 
583
640
  protected nvrDevice?: ReolinkNativeNvrDevice;
584
641
  protected multiFocalDevice?: ReolinkNativeMultiFocalDevice;
585
- thisDevice: Settings
642
+ thisDevice: Settings;
643
+ isBattery: boolean;
644
+ isMultiFocal: boolean;
645
+ private streamManagerRestartTimeout: NodeJS.Timeout | undefined;
646
+ private videoClipsAutoLoadInterval: NodeJS.Timeout | undefined;
647
+ private videoClipsAutoLoadInProgress: boolean = false;
586
648
 
587
649
  constructor(
588
650
  nativeId: string,
@@ -590,20 +652,468 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
590
652
  public options: CommonCameraMixinOptions
591
653
  ) {
592
654
  super(nativeId);
655
+ this.plugin.mixinsMap.set(this.id, this);
593
656
 
594
657
  // Store NVR device reference if provided
595
658
  this.nvrDevice = options.nvrDevice;
596
659
  this.multiFocalDevice = options.multiFocalDevice;
597
660
  this.thisDevice = sdk.systemManager.getDeviceById<Settings>(this.id);
598
661
 
599
- const isBattery = options.type === 'battery' || options.type === 'multi-focal-battery';
600
- this.protocol = isBattery ? 'udp' : 'tcp';
662
+ this.isBattery = options.type === 'battery' || options.type === 'multi-focal-battery';
663
+ this.isMultiFocal = options.type === 'multi-focal' || options.type === 'multi-focal-battery';
664
+ this.protocol = this.isBattery ? 'udp' : 'tcp';
601
665
 
602
666
  setTimeout(async () => {
603
667
  await this.parentInit();
604
668
  }, 2000);
605
669
  }
606
670
 
671
+ protected async withBaichuanClient<T>(fn: (api: ReolinkBaichuanApi) => Promise<T>): Promise<T> {
672
+ const client = await this.ensureClient();
673
+ return fn(client);
674
+ }
675
+
676
+ async getVideoClips(options?: VideoClipOptions): Promise<VideoClip[]> {
677
+ // Check if videoclips are enabled
678
+ if (!this.storageSettings.values.enableVideoclips) {
679
+ return [];
680
+ }
681
+
682
+ if (this.isBattery && this.sleeping) {
683
+ const logger = this.getBaichuanLogger();
684
+ logger.debug('getVideoClips: disabled for battery devices');
685
+ return [];
686
+ }
687
+
688
+ const logger = this.getBaichuanLogger();
689
+
690
+ // Determine time window
691
+ const nowMs = Date.now();
692
+ const defaultWindowMs = 60 * 60 * 1000; // last 60 minutes
693
+
694
+ const startMs = options?.startTime ?? (nowMs - defaultWindowMs);
695
+ let endMs = options?.endTime ?? nowMs;
696
+ const count = options?.count;
697
+
698
+ if (endMs > nowMs) {
699
+ endMs = nowMs;
700
+ }
701
+
702
+ if (endMs <= startMs) {
703
+ logger.warn('getVideoClips: invalid time window, endTime <= startTime', {
704
+ startTime: startMs,
705
+ endTime: endMs,
706
+ });
707
+ return [];
708
+ }
709
+
710
+ const start = new Date(startMs);
711
+ const end = new Date(endMs);
712
+ start.setHours(0, 0, 0, 0);
713
+
714
+ 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
+
725
+ const clips: VideoClip[] = [];
726
+
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,
735
+ });
736
+ clips.push(clip);
737
+ }
738
+
739
+ logger.debug(`Videoclips found: ${JSON.stringify(clips)}`);
740
+
741
+ return clips;
742
+ } catch (e: any) {
743
+ const message = e instanceof Error ? e.message : String(e);
744
+
745
+ if (message?.includes('UID is required to access recordings')) {
746
+ logger.log('getVideoClips: recordings not available or UID not resolvable for this device', {
747
+ error: message,
748
+ });
749
+ } else {
750
+ logger.warn('getVideoClips: failed to list recordings', {
751
+ error: message,
752
+ });
753
+ }
754
+ return [];
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Get the cache directory for video clips and thumbnails
760
+ */
761
+ private getVideoClipCacheDir(): string {
762
+ const pluginVolume = process.env.SCRYPTED_PLUGIN_VOLUME || '';
763
+ const cameraId = this.id;
764
+ return path.join(pluginVolume, 'videoclips', cameraId);
765
+ }
766
+
767
+ /**
768
+ * Get cache file path for a video clip
769
+ */
770
+ getVideoClipCachePath(videoId: string): string {
771
+ // Create a safe filename from videoId using hash
772
+ const hash = crypto.createHash('md5').update(videoId).digest('hex');
773
+ // Keep original extension if present, otherwise use .mp4
774
+ const ext = videoId.includes('.') ? path.extname(videoId) : '.mp4';
775
+ const cacheDir = this.getVideoClipCacheDir();
776
+ return path.join(cacheDir, `${hash}${ext}`);
777
+ }
778
+
779
+ async getVideoClip(videoId: string): Promise<MediaObject> {
780
+ const logger = this.getBaichuanLogger();
781
+ try {
782
+ const cacheEnabled = this.storageSettings.values.downloadVideoclipsLocally
783
+
784
+ // Always check cache first, even if caching is disabled (in case user enabled it before)
785
+ const cachePath = this.getVideoClipCachePath(videoId);
786
+ const cacheDir = this.getVideoClipCacheDir();
787
+
788
+ // Check if cached file exists
789
+ try {
790
+ await fs.promises.access(cachePath, fs.constants.F_OK);
791
+ 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;
796
+ } catch (e) {
797
+ // File doesn't exist or error accessing it
798
+ logger.debug(`[VideoClip] Cache miss: fileId=${videoId}, error=${e instanceof Error ? e.message : String(e)}`);
799
+ if (cacheEnabled) {
800
+ logger.debug(`[VideoClip] Will download and cache: fileId=${videoId}`);
801
+ }
802
+ }
803
+
804
+ // If caching is enabled, ensure cache directory exists
805
+ if (cacheEnabled) {
806
+ await fs.promises.mkdir(cacheDir, { recursive: true });
807
+ }
808
+
809
+ const api = await this.ensureClient();
810
+
811
+ // videoId is the fileId (fileName or id from the recording)
812
+ const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
813
+ fileName: videoId,
814
+ });
815
+
816
+ if (!rtmpVodUrl) {
817
+ throw new Error(`No playback URL found for video ${videoId}`);
818
+ }
819
+
820
+ // If caching is enabled, download and cache the video
821
+ if (cacheEnabled) {
822
+ const cachePath = this.getVideoClipCachePath(videoId);
823
+
824
+ // Download and convert RTMP to MP4 using ffmpeg
825
+ const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
826
+ const ffmpegArgs = [
827
+ '-i', rtmpVodUrl,
828
+ '-c', 'copy', // Copy codecs without re-encoding
829
+ '-f', 'mp4',
830
+ '-movflags', 'frag_keyframe+empty_moov', // Enable streaming
831
+ cachePath,
832
+ ];
833
+
834
+ logger.log(`Downloading video clip to cache: ${cachePath}`);
835
+
836
+ await new Promise<void>((resolve, reject) => {
837
+ const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
838
+ stdio: ['ignore', 'pipe', 'pipe'],
839
+ });
840
+
841
+ let errorOutput = '';
842
+
843
+ ffmpeg.stderr.on('data', (chunk: Buffer) => {
844
+ errorOutput += chunk.toString();
845
+ });
846
+
847
+ ffmpeg.on('close', (code) => {
848
+ if (code !== 0) {
849
+ logger.error(`ffmpeg failed to download video clip: ${errorOutput}`);
850
+ reject(new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
851
+ return;
852
+ }
853
+
854
+ logger.log(`Video clip cached successfully: ${cachePath}`);
855
+ resolve();
856
+ });
857
+
858
+ ffmpeg.on('error', (error) => {
859
+ logger.error(`ffmpeg spawn error for video clip ${videoId}`, error);
860
+ reject(error);
861
+ });
862
+
863
+ // Timeout after 5 minutes
864
+ const timeout = setTimeout(() => {
865
+ ffmpeg.kill('SIGKILL');
866
+ reject(new Error('Video clip download timeout'));
867
+ }, 5 * 60 * 1000);
868
+
869
+ ffmpeg.on('close', () => {
870
+ clearTimeout(timeout);
871
+ });
872
+ });
873
+
874
+ // Return cached file as MediaObject
875
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
876
+ return mo;
877
+ } else {
878
+ // Caching disabled, return RTMP URL directly
879
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(rtmpVodUrl);
880
+ return mo;
881
+ }
882
+ } catch (e) {
883
+ logger.error(`getVideoClip: failed to get video clip ${videoId}`, e);
884
+ throw e;
885
+ }
886
+ }
887
+
888
+ /**
889
+ * Get the cache directory for thumbnails (same as video clips)
890
+ */
891
+ private getThumbnailCacheDir(): string {
892
+ // Use the same directory as video clips
893
+ return this.getVideoClipCacheDir();
894
+ }
895
+
896
+ /**
897
+ * Get cache file path for a thumbnail
898
+ */
899
+ private getThumbnailCachePath(fileId: string): string {
900
+ // Use the same hash and base name as video clips, but with .jpg extension
901
+ const hash = crypto.createHash('md5').update(fileId).digest('hex');
902
+ const cacheDir = this.getThumbnailCacheDir();
903
+ return path.join(cacheDir, `${hash}.jpg`);
904
+ }
905
+
906
+ async getVideoClipThumbnail(thumbnailId: string, options?: VideoClipThumbnailOptions): Promise<MediaObject> {
907
+ const logger = this.getBaichuanLogger();
908
+
909
+ try {
910
+ // Check cache first
911
+ const cachePath = this.getThumbnailCachePath(thumbnailId);
912
+ const cacheDir = this.getThumbnailCacheDir();
913
+
914
+ try {
915
+ await fs.promises.access(cachePath, fs.constants.F_OK);
916
+ 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;
921
+ } catch {
922
+ // File doesn't exist, need to generate it
923
+ logger.debug(`[Thumbnail] Cache miss: fileId=${thumbnailId}`);
924
+ }
925
+
926
+ // Ensure cache directory exists
927
+ await fs.promises.mkdir(cacheDir, { recursive: true });
928
+
929
+ // Check if video clip is already cached locally - use it instead of calling camera
930
+ const videoCachePath = this.getVideoClipCachePath(thumbnailId);
931
+ let useLocalVideo = false;
932
+ try {
933
+ await fs.promises.access(videoCachePath, fs.constants.F_OK);
934
+ useLocalVideo = true;
935
+ logger.debug(`[Thumbnail] Using local video file for thumbnail extraction: fileId=${thumbnailId}`);
936
+ } catch {
937
+ // Video not cached locally, will use RTMP URL
938
+ }
939
+
940
+ let thumbnail: MediaObject;
941
+
942
+ if (useLocalVideo) {
943
+ // Extract thumbnail from local video file
944
+ thumbnail = await this.plugin.generateThumbnail({
945
+ deviceId: this.id,
946
+ fileId: thumbnailId,
947
+ filePath: videoCachePath,
948
+ logger: this.getBaichuanLogger(),
949
+ });
950
+ } 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
+ }
971
+
972
+ // Use the plugin's thumbnail generation queue with RTMP URL
973
+ thumbnail = await this.plugin.generateThumbnail({
974
+ deviceId: this.id,
975
+ fileId: thumbnailId,
976
+ rtmpUrl: rtmpVodUrl,
977
+ logger: this.getBaichuanLogger(),
978
+ });
979
+ }
980
+
981
+ // Cache the thumbnail
982
+ try {
983
+ const buffer = await sdk.mediaManager.convertMediaObjectToBuffer(thumbnail, 'image/jpeg');
984
+ await fs.promises.writeFile(cachePath, buffer);
985
+ logger.debug(`[Thumbnail] Cached: fileId=${thumbnailId}, size=${buffer.length} bytes`);
986
+ } catch (e) {
987
+ logger.warn(`[Thumbnail] Failed to cache: fileId=${thumbnailId}`, e);
988
+ // Continue even if caching fails
989
+ }
990
+
991
+ return thumbnail;
992
+ } catch (e) {
993
+ logger.error(`[Thumbnail] Error: fileId=${thumbnailId}`, e);
994
+ throw e;
995
+ }
996
+ }
997
+
998
+ removeVideoClips(...videoClipIds: string[]): Promise<void> {
999
+ throw new Error("removeVideoClips is not implemented yet.");
1000
+ }
1001
+
1002
+ /**
1003
+ * Update video clips auto-load timer based on settings
1004
+ */
1005
+ private updateVideoClipsAutoLoad(): void {
1006
+ // Clear existing interval if any
1007
+ if (this.videoClipsAutoLoadInterval) {
1008
+ clearInterval(this.videoClipsAutoLoadInterval);
1009
+ this.videoClipsAutoLoadInterval = undefined;
1010
+ }
1011
+
1012
+ // Check if videoclips are enabled at all
1013
+ const { enableVideoclips, loadVideoclips, videoclipsRegularChecks } = this.storageSettings.values;
1014
+ if (!enableVideoclips) {
1015
+ return;
1016
+ }
1017
+
1018
+
1019
+ if (!loadVideoclips) {
1020
+ return;
1021
+ }
1022
+
1023
+ const logger = this.getBaichuanLogger();
1024
+ const intervalMs = videoclipsRegularChecks * 60 * 1000;
1025
+
1026
+ logger.log(`Starting video clips auto-load: checking every ${videoclipsRegularChecks} minutes`);
1027
+
1028
+ // Run immediately on start
1029
+ this.loadTodayVideoClipsAndThumbnails();
1030
+
1031
+ // Then run at regular intervals
1032
+ this.videoClipsAutoLoadInterval = setInterval(() => {
1033
+ this.loadTodayVideoClipsAndThumbnails();
1034
+ }, intervalMs);
1035
+ }
1036
+
1037
+ /**
1038
+ * Load today's video clips and download missing thumbnails
1039
+ */
1040
+ private async loadTodayVideoClipsAndThumbnails(): Promise<void> {
1041
+ // Prevent concurrent executions
1042
+ if (this.videoClipsAutoLoadInProgress) {
1043
+ const logger = this.getBaichuanLogger();
1044
+ logger.debug('Video clips auto-load already in progress, skipping...');
1045
+ return;
1046
+ }
1047
+
1048
+ const logger = this.getBaichuanLogger();
1049
+
1050
+ this.videoClipsAutoLoadInProgress = true;
1051
+
1052
+ try {
1053
+ logger.log('Auto-loading today\'s video clips and thumbnails...');
1054
+
1055
+ // Get today's date range (start of today to now)
1056
+ const now = new Date();
1057
+ const startOfToday = new Date(now);
1058
+ startOfToday.setHours(0, 0, 0, 0);
1059
+ startOfToday.setMinutes(0, 0, 0);
1060
+
1061
+ // Fetch today's video clips
1062
+ const clips = await this.getVideoClips({
1063
+ startTime: startOfToday.getTime(),
1064
+ endTime: now.getTime(),
1065
+ });
1066
+
1067
+ logger.log(`Found ${clips.length} video clips for today`);
1068
+
1069
+ const downloadVideoclipsLocally = this.storageSettings.values.downloadVideoclipsLocally ?? false;
1070
+
1071
+ // Track processed clips to avoid duplicate calls to the camera
1072
+ const processedClips = new Set<string>();
1073
+
1074
+ // Download videos first (if enabled), then thumbnails for each clip
1075
+ for (const clip of clips) {
1076
+ // Skip if already processed (avoid duplicate calls)
1077
+ if (processedClips.has(clip.id)) {
1078
+ logger.debug(`Skipping already processed clip: ${clip.id}`);
1079
+ continue;
1080
+ }
1081
+ processedClips.add(clip.id);
1082
+
1083
+ try {
1084
+ // If downloadVideoclipsLocally is enabled, download the video clip first
1085
+ // This allows the thumbnail to use the local file instead of calling the camera
1086
+ if (downloadVideoclipsLocally) {
1087
+ try {
1088
+ // Call getVideoClip to trigger download and caching
1089
+ await this.getVideoClip(clip.id);
1090
+ logger.debug(`Downloaded video clip: ${clip.id}`);
1091
+ } catch (e) {
1092
+ logger.warn(`Failed to download video clip ${clip.id}:`, e instanceof Error ? e.message : String(e));
1093
+ }
1094
+ }
1095
+
1096
+ // Then get the thumbnail - this will use the local video file if available
1097
+ // or call the camera if the video wasn't downloaded
1098
+ try {
1099
+ await this.getVideoClipThumbnail(clip.id);
1100
+ logger.debug(`Downloaded thumbnail for clip: ${clip.id}`);
1101
+ } catch (e) {
1102
+ logger.warn(`Failed to load thumbnail for clip ${clip.id}:`, e instanceof Error ? e.message : String(e));
1103
+ }
1104
+ } catch (e) {
1105
+ logger.warn(`Error processing clip ${clip.id}:`, e instanceof Error ? e.message : String(e));
1106
+ }
1107
+ }
1108
+
1109
+ logger.log(`Completed auto-loading video clips and thumbnails`);
1110
+ } catch (e) {
1111
+ logger.error('Error during auto-loading video clips:', e);
1112
+ } finally {
1113
+ this.videoClipsAutoLoadInProgress = false;
1114
+ }
1115
+ }
1116
+
607
1117
  async reboot(): Promise<void> {
608
1118
  const api = await this.ensureBaichuanClient();
609
1119
  await api.reboot();
@@ -613,19 +1123,20 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
613
1123
  protected getConnectionConfig(): BaichuanConnectionConfig {
614
1124
  const { ipAddress, username, password, uid } = this.storageSettings.values;
615
1125
  const debugOptions = this.getBaichuanDebugOptions();
616
- const normalizedUid = this.protocol === 'udp' ? normalizeUid(uid) : undefined;
1126
+ const normalizedUid = this.isBattery ? normalizeUid(uid) : undefined;
617
1127
 
618
- if (this.protocol === 'udp' && !normalizedUid) {
1128
+ if (this.isBattery && !normalizedUid) {
619
1129
  throw new Error('UID is required for battery cameras (BCUDP)');
620
1130
  }
621
1131
 
1132
+ const logger = this.getBaichuanLogger();
622
1133
  return {
623
1134
  host: ipAddress,
624
1135
  username,
625
1136
  password,
626
1137
  uid: normalizedUid,
627
1138
  transport: this.protocol,
628
- logger: this.console,
1139
+ logger,
629
1140
  debugOptions,
630
1141
  };
631
1142
  }
@@ -639,8 +1150,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
639
1150
  // For battery cameras, don't auto-resubscribe after idle disconnects
640
1151
  // (idle disconnects are normal for battery cameras to save power)
641
1152
  // Events will be resubscribed when ensureClient() is called for actual operations
642
- const isBattery = this.options.type === 'battery';
643
- if (!isBattery) {
1153
+ if (!this.isBattery) {
644
1154
  // For non-battery cameras, resubscribe to events after reconnection
645
1155
  setTimeout(async () => {
646
1156
  try {
@@ -666,7 +1176,9 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
666
1176
  }
667
1177
 
668
1178
  async withBaichuanRetry<T>(fn: () => Promise<T>): Promise<T> {
669
- if (this.protocol === 'udp') {
1179
+ return await fn();
1180
+
1181
+ if (this.isBattery) {
670
1182
  return await fn();
671
1183
  } else {
672
1184
  try {
@@ -737,8 +1249,44 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
737
1249
  }
738
1250
  }
739
1251
 
740
- createStreamClient(): Promise<ReolinkBaichuanApi> {
741
- throw new Error("Method not implemented.");
1252
+ /**
1253
+ * Create a dedicated Baichuan API session for streaming (used by StreamManager).
1254
+ *
1255
+ * - For TCP devices (regular + multifocal), this creates a new TCP session with its own client.
1256
+ * - For UDP/battery devices, this reuses the existing client via ensureClient().
1257
+ */
1258
+ async createStreamClient(profile?: StreamProfile): Promise<ReolinkBaichuanApi> {
1259
+ // Battery / BCUDP path: reuse the main client to avoid extra wake-ups and sockets.
1260
+ if (this.isBattery) {
1261
+ return await this.ensureClient();
1262
+ }
1263
+
1264
+ // For TCP path: create a new client ONLY for "ext" profile
1265
+ // For other profiles (main, sub), reuse the main client
1266
+ if (profile !== 'ext') {
1267
+ return await this.ensureClient();
1268
+ }
1269
+
1270
+ // TCP path with ext profile: create a separate session for streaming (RFC4571/composite/NVR-friendly).
1271
+ const { ipAddress, username, password } = this.storageSettings.values;
1272
+ const logger = this.getBaichuanLogger();
1273
+
1274
+ const debugOptions = this.getBaichuanDebugOptions();
1275
+ const api = await createBaichuanApi(
1276
+ {
1277
+ inputs: {
1278
+ host: ipAddress,
1279
+ username,
1280
+ password,
1281
+ logger,
1282
+ debugOptions,
1283
+ },
1284
+ transport: 'tcp',
1285
+ },
1286
+ );
1287
+
1288
+ await api.login();
1289
+ return api;
742
1290
  }
743
1291
 
744
1292
  public getAbilities(): DeviceCapabilities {
@@ -750,8 +1298,85 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
750
1298
  }
751
1299
 
752
1300
  getBaichuanDebugOptions(): any | undefined {
753
- const debugLogs = this.storageSettings.values.debugLogs || [];
754
- return convertDebugLogsToApiOptions(debugLogs);
1301
+ const socketDebugLogs = this.storageSettings.values.socketApiDebugLogs || [];
1302
+ return convertDebugLogsToApiOptions(socketDebugLogs);
1303
+ }
1304
+
1305
+ /**
1306
+ * Initialize or recreate the StreamManager, taking into account multifocal composite options.
1307
+ */
1308
+ protected initStreamManager(logger: Console, forceRecreate: boolean = false): void {
1309
+ const { username, password } = this.storageSettings.values;
1310
+
1311
+ const baseOptions: any = {
1312
+ createStreamClient: (profile?: StreamProfile) => this.createStreamClient(profile),
1313
+ getLogger: () => logger,
1314
+ credentials: {
1315
+ username,
1316
+ password,
1317
+ },
1318
+ sharedConnection: this.isBattery,
1319
+ };
1320
+
1321
+ if (this.isMultiFocal) {
1322
+ const values: any = this.storageSettings.values;
1323
+ const pipPosition = values.pipPosition || 'bottom-right';
1324
+ const pipSize = values.pipSize ?? 0.25;
1325
+ const pipMargin = values.pipMargin ?? 10;
1326
+ const widerChannel = values.widerChannel ?? 0;
1327
+ const teleChannel = values.teleChannel ?? 1;
1328
+
1329
+ baseOptions.compositeOptions = {
1330
+ widerChannel,
1331
+ teleChannel,
1332
+ pipPosition,
1333
+ pipSize,
1334
+ pipMargin,
1335
+ };
1336
+ }
1337
+
1338
+ if (!this.streamManager || forceRecreate) {
1339
+ this.streamManager = new StreamManager(baseOptions);
1340
+ }
1341
+ }
1342
+
1343
+ /**
1344
+ * Debounced restart of StreamManager when PIP/composite settings change.
1345
+ * Also notifies listeners so that active streams (prebuffer, etc.) restart cleanly.
1346
+ */
1347
+ protected scheduleStreamManagerRestart(reason: string): void {
1348
+ const logger = this.getBaichuanLogger();
1349
+ logger.log(`Scheduling StreamManager restart (${reason})`);
1350
+
1351
+ if (this.streamManagerRestartTimeout) {
1352
+ clearTimeout(this.streamManagerRestartTimeout);
1353
+ this.streamManagerRestartTimeout = undefined;
1354
+ }
1355
+
1356
+ this.streamManagerRestartTimeout = setTimeout(async () => {
1357
+ this.streamManagerRestartTimeout = undefined;
1358
+ const restartLogger = this.getBaichuanLogger();
1359
+ try {
1360
+ restartLogger.log('Restarting StreamManager due to PIP/composite settings change');
1361
+ this.initStreamManager(restartLogger, true);
1362
+
1363
+ // Invalidate snapshot cache for battery/multifocal-battery so that
1364
+ // the next snapshot reflects the new PIP/composite configuration.
1365
+ if (this.isBattery) {
1366
+ this.forceNewSnapshot = true;
1367
+ this.lastPicture = undefined;
1368
+ }
1369
+
1370
+ // Notify consumers (e.g. prebuffer) that stream configuration changed.
1371
+ try {
1372
+ this.onDeviceEvent(ScryptedInterface.VideoCamera, undefined);
1373
+ } catch {
1374
+ // best-effort
1375
+ }
1376
+ } catch (e) {
1377
+ restartLogger.warn('Failed to restart StreamManager after settings change', e);
1378
+ }
1379
+ }, 500);
755
1380
  }
756
1381
 
757
1382
  isRecoverableBaichuanError(e: any): boolean {
@@ -1167,7 +1792,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1167
1792
  }
1168
1793
 
1169
1794
  async takePicture(options?: RequestPictureOptions) {
1170
- if (this.protocol === 'tcp') {
1795
+ if (!this.isBattery) {
1171
1796
  try {
1172
1797
  return this.withBaichuanRetry(async () => {
1173
1798
  const client = await this.ensureClient();
@@ -1276,6 +1901,10 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1276
1901
  }
1277
1902
  }
1278
1903
 
1904
+ async release() {
1905
+ this.plugin.mixinsMap.delete(this.id);
1906
+ }
1907
+
1279
1908
  async releaseDevice(id: string, nativeId: string): Promise<void> {
1280
1909
  if (nativeId.endsWith(sirenSuffix)) {
1281
1910
  this.siren = undefined;
@@ -1665,23 +2294,21 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1665
2294
  }
1666
2295
  }
1667
2296
 
1668
- const { username, password } = this.storageSettings.values;
1669
- const isBattery = ['multi-focal-battery', 'battery'].includes(this.options.type);
1670
- const isMultiFocal = ['multi-focal', 'multi-focal'].includes(this.options.type);
1671
2297
 
1672
- this.storageSettings.settings.uid.hide = !isBattery;
1673
- this.storageSettings.settings.batteryUpdateIntervalMinutes.hide = !isBattery;
1674
- this.storageSettings.settings.lowThresholdBatteryRecording.hide = !isBattery;
1675
- this.storageSettings.settings.highThresholdBatteryRecording.hide = !isBattery;
2298
+ this.storageSettings.settings.videoclipsRegularChecks.defaultValue = this.isBattery ? 120 : 30;
2299
+
2300
+ this.storageSettings.settings.batteryUpdateIntervalMinutes.hide = !this.isBattery;
2301
+ this.storageSettings.settings.lowThresholdBatteryRecording.hide = !this.isBattery;
2302
+ this.storageSettings.settings.highThresholdBatteryRecording.hide = !this.isBattery;
1676
2303
 
1677
2304
  // Show PIP settings only for multifocal devices
1678
- this.storageSettings.settings.pipPosition.hide = !isMultiFocal;
1679
- this.storageSettings.settings.pipSize.hide = !isMultiFocal;
1680
- this.storageSettings.settings.pipMargin.hide = !isMultiFocal;
1681
- this.storageSettings.settings.widerChannel.hide = !isMultiFocal;
1682
- this.storageSettings.settings.teleChannel.hide = !isMultiFocal;
2305
+ this.storageSettings.settings.pipPosition.hide = !this.isMultiFocal;
2306
+ this.storageSettings.settings.pipSize.hide = !this.isMultiFocal;
2307
+ this.storageSettings.settings.pipMargin.hide = !this.isMultiFocal;
2308
+ this.storageSettings.settings.widerChannel.hide = !this.isMultiFocal;
2309
+ this.storageSettings.settings.teleChannel.hide = !this.isMultiFocal;
1683
2310
 
1684
- if (isBattery && !this.storageSettings.values.mixinsSetup) {
2311
+ if (this.isBattery && !this.storageSettings.values.mixinsSetup) {
1685
2312
  try {
1686
2313
  const device = sdk.systemManager.getDeviceById<Settings>(this.id);
1687
2314
  if (device) {
@@ -1703,15 +2330,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1703
2330
  logger.warn('Failed to subscribe to Baichuan events', e);
1704
2331
  }
1705
2332
 
1706
- this.streamManager = new StreamManager({
1707
- createStreamClient: () => this.createStreamClient(),
1708
- getLogger: () => logger,
1709
- credentials: {
1710
- username,
1711
- password
1712
- },
1713
- sharedConnection: isBattery,
1714
- });
2333
+ // Initialize StreamManager (with composite options for multifocal devices)
2334
+ this.initStreamManager(logger);
1715
2335
 
1716
2336
  const { hasIntercom, hasPtz } = this.getAbilities();
1717
2337
 
@@ -1740,7 +2360,6 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1740
2360
  this.storageSettings.settings.username.hide = true;
1741
2361
  this.storageSettings.settings.password.hide = true;
1742
2362
  this.storageSettings.settings.ipAddress.hide = true;
1743
- this.storageSettings.settings.uid.hide = true;
1744
2363
 
1745
2364
  this.storageSettings.settings.username.defaultValue = this.nvrDevice.storageSettings.values.username;
1746
2365
  this.storageSettings.settings.password.defaultValue = this.nvrDevice.storageSettings.values.password;
@@ -1750,6 +2369,9 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1750
2369
  await this.init();
1751
2370
 
1752
2371
  this.initComplete = true;
2372
+
2373
+ // Initialize video clips auto-load if enabled
2374
+ this.updateVideoClipsAutoLoad();
1753
2375
  }
1754
2376
  }
1755
2377