@apocaliss92/scrypted-reolink-native 0.1.32 → 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,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, pirSuffix, recordingFileToVideoClip, sirenSuffix, updateDeviceInfo } from "./utils";
23
26
 
24
27
  export type CameraType = 'battery' | 'regular' | 'multi-focal' | 'multi-focal-battery';
25
28
 
@@ -545,6 +548,49 @@ 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
+ 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
+ },
548
594
  diagnosticsRun: {
549
595
  subgroup: 'Diagnostics',
550
596
  title: 'Run Diagnostics',
@@ -586,7 +632,6 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
586
632
  // Abstract init method that subclasses must implement
587
633
  abstract init(): Promise<void>;
588
634
 
589
- protected withBaichuanClient?<T>(fn: (api: ReolinkBaichuanApi) => Promise<T>): Promise<T>;
590
635
  motionTimeout?: NodeJS.Timeout;
591
636
  doorbellBinaryTimeout?: NodeJS.Timeout;
592
637
  initComplete?: boolean;
@@ -598,6 +643,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
598
643
  isBattery: boolean;
599
644
  isMultiFocal: boolean;
600
645
  private streamManagerRestartTimeout: NodeJS.Timeout | undefined;
646
+ private videoClipsAutoLoadInterval: NodeJS.Timeout | undefined;
647
+ private videoClipsAutoLoadInProgress: boolean = false;
601
648
 
602
649
  constructor(
603
650
  nativeId: string,
@@ -605,6 +652,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
605
652
  public options: CommonCameraMixinOptions
606
653
  ) {
607
654
  super(nativeId);
655
+ this.plugin.mixinsMap.set(this.id, this);
608
656
 
609
657
  // Store NVR device reference if provided
610
658
  this.nvrDevice = options.nvrDevice;
@@ -620,25 +668,452 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
620
668
  }, 2000);
621
669
  }
622
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
+
623
758
  /**
624
- * TODO: Implement video clip fetching using Baichuan/NVR recordings API.
759
+ * Get the cache directory for video clips and thumbnails
625
760
  */
626
- async getVideoClips(options?: VideoClipOptions): Promise<VideoClip[]> {
627
- throw new Error("getVideoClips is not implemented yet.");
761
+ private getVideoClipCacheDir(): string {
762
+ const pluginVolume = process.env.SCRYPTED_PLUGIN_VOLUME || '';
763
+ const cameraId = this.id;
764
+ return path.join(pluginVolume, 'videoclips', cameraId);
628
765
  }
629
766
 
630
- getVideoClip(videoId: string): Promise<MediaObject> {
631
- throw new Error("getVideoClip is not implemented yet.");
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
+ }
632
886
  }
633
887
 
634
- getVideoClipThumbnail(thumbnailId: string, options?: VideoClipThumbnailOptions): Promise<MediaObject> {
635
- throw new Error("getVideoClipThumbnail is not implemented yet.");
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
+ }
636
996
  }
637
997
 
638
998
  removeVideoClips(...videoClipIds: string[]): Promise<void> {
639
999
  throw new Error("removeVideoClips is not implemented yet.");
640
1000
  }
641
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
+
642
1117
  async reboot(): Promise<void> {
643
1118
  const api = await this.ensureBaichuanClient();
644
1119
  await api.reboot();
@@ -654,13 +1129,14 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
654
1129
  throw new Error('UID is required for battery cameras (BCUDP)');
655
1130
  }
656
1131
 
1132
+ const logger = this.getBaichuanLogger();
657
1133
  return {
658
1134
  host: ipAddress,
659
1135
  username,
660
1136
  password,
661
1137
  uid: normalizedUid,
662
1138
  transport: this.protocol,
663
- logger: this.console,
1139
+ logger,
664
1140
  debugOptions,
665
1141
  };
666
1142
  }
@@ -700,6 +1176,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
700
1176
  }
701
1177
 
702
1178
  async withBaichuanRetry<T>(fn: () => Promise<T>): Promise<T> {
1179
+ return await fn();
1180
+
703
1181
  if (this.isBattery) {
704
1182
  return await fn();
705
1183
  } else {
@@ -777,13 +1255,19 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
777
1255
  * - For TCP devices (regular + multifocal), this creates a new TCP session with its own client.
778
1256
  * - For UDP/battery devices, this reuses the existing client via ensureClient().
779
1257
  */
780
- async createStreamClient(): Promise<ReolinkBaichuanApi> {
1258
+ async createStreamClient(profile?: StreamProfile): Promise<ReolinkBaichuanApi> {
781
1259
  // Battery / BCUDP path: reuse the main client to avoid extra wake-ups and sockets.
782
1260
  if (this.isBattery) {
783
1261
  return await this.ensureClient();
784
1262
  }
785
1263
 
786
- // TCP path: create a separate session for streaming (RFC4571/composite/NVR-friendly).
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).
787
1271
  const { ipAddress, username, password } = this.storageSettings.values;
788
1272
  const logger = this.getBaichuanLogger();
789
1273
 
@@ -825,7 +1309,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
825
1309
  const { username, password } = this.storageSettings.values;
826
1310
 
827
1311
  const baseOptions: any = {
828
- createStreamClient: () => this.createStreamClient(),
1312
+ createStreamClient: (profile?: StreamProfile) => this.createStreamClient(profile),
829
1313
  getLogger: () => logger,
830
1314
  credentials: {
831
1315
  username,
@@ -876,6 +1360,13 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
876
1360
  restartLogger.log('Restarting StreamManager due to PIP/composite settings change');
877
1361
  this.initStreamManager(restartLogger, true);
878
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
+
879
1370
  // Notify consumers (e.g. prebuffer) that stream configuration changed.
880
1371
  try {
881
1372
  this.onDeviceEvent(ScryptedInterface.VideoCamera, undefined);
@@ -1410,6 +1901,10 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1410
1901
  }
1411
1902
  }
1412
1903
 
1904
+ async release() {
1905
+ this.plugin.mixinsMap.delete(this.id);
1906
+ }
1907
+
1413
1908
  async releaseDevice(id: string, nativeId: string): Promise<void> {
1414
1909
  if (nativeId.endsWith(sirenSuffix)) {
1415
1910
  this.siren = undefined;
@@ -1799,9 +2294,9 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1799
2294
  }
1800
2295
  }
1801
2296
 
1802
- const { username, password } = this.storageSettings.values;
1803
2297
 
1804
- this.storageSettings.settings.uid.hide = !this.isBattery;
2298
+ this.storageSettings.settings.videoclipsRegularChecks.defaultValue = this.isBattery ? 120 : 30;
2299
+
1805
2300
  this.storageSettings.settings.batteryUpdateIntervalMinutes.hide = !this.isBattery;
1806
2301
  this.storageSettings.settings.lowThresholdBatteryRecording.hide = !this.isBattery;
1807
2302
  this.storageSettings.settings.highThresholdBatteryRecording.hide = !this.isBattery;
@@ -1865,7 +2360,6 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1865
2360
  this.storageSettings.settings.username.hide = true;
1866
2361
  this.storageSettings.settings.password.hide = true;
1867
2362
  this.storageSettings.settings.ipAddress.hide = true;
1868
- this.storageSettings.settings.uid.hide = true;
1869
2363
 
1870
2364
  this.storageSettings.settings.username.defaultValue = this.nvrDevice.storageSettings.values.username;
1871
2365
  this.storageSettings.settings.password.defaultValue = this.nvrDevice.storageSettings.values.password;
@@ -1875,6 +2369,9 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1875
2369
  await this.init();
1876
2370
 
1877
2371
  this.initComplete = true;
2372
+
2373
+ // Initialize video clips auto-load if enabled
2374
+ this.updateVideoClipsAutoLoad();
1878
2375
  }
1879
2376
  }
1880
2377
 
@@ -8,6 +8,8 @@ export enum DebugLogOption {
8
8
  General = 'general',
9
9
  /** RTSP proxy/server debug logs */
10
10
  DebugRtsp = 'debugRtsp',
11
+ /** Low-level tracing for recording-related commands */
12
+ TraceRecordings = 'traceRecordings',
11
13
  /** Stream command tracing */
12
14
  TraceStream = 'traceStream',
13
15
  /** Talkback tracing */
@@ -27,6 +29,7 @@ export function mapDebugLogToApiOption(option: DebugLogOption): keyof DebugOptio
27
29
  const mapping: Record<DebugLogOption, keyof DebugOptions | null> = {
28
30
  [DebugLogOption.General]: 'general',
29
31
  [DebugLogOption.DebugRtsp]: 'debugRtsp',
32
+ [DebugLogOption.TraceRecordings]: 'traceRecordings',
30
33
  [DebugLogOption.TraceStream]: 'traceStream',
31
34
  [DebugLogOption.TraceTalk]: 'traceTalk',
32
35
  [DebugLogOption.TraceEvents]: 'traceEvents',
@@ -77,6 +80,7 @@ export function getApiRelevantDebugLogs(debugLogs: string[]): string[] {
77
80
  export const DebugLogDisplayNames: Record<DebugLogOption, string> = {
78
81
  [DebugLogOption.General]: 'General',
79
82
  [DebugLogOption.DebugRtsp]: 'RTSP',
83
+ [DebugLogOption.TraceRecordings]: 'Trace recordings',
80
84
  [DebugLogOption.TraceStream]: 'Trace stream',
81
85
  [DebugLogOption.TraceTalk]: 'Trace talk',
82
86
  [DebugLogOption.TraceEvents]: 'Trace events XML',