@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/README.md +6 -4
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +3 -2
- package/src/baichuan-base.ts +32 -4
- package/src/camera-battery.ts +0 -6
- package/src/camera.ts +1 -36
- package/src/common.ts +646 -17
- package/src/debug-options.ts +4 -0
- package/src/main.ts +158 -4
- package/src/multiFocal.ts +1 -29
- package/src/nvr.ts +37 -32
- package/src/stream-utils.ts +24 -7
- package/src/utils.ts +870 -2
- package/logs/composite-stream.txt +0 -16390
- package/logs/lense.txt +0 -44
- package/logs/multifocal.txt +0 -136
- package/logs/multifocal2.txt +0 -3585
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
|
-
*
|
|
857
|
+
* Get the cache directory for video clips and thumbnails
|
|
625
858
|
*/
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|