@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/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +3 -2
- package/src/baichuan-base.ts +35 -7
- package/src/camera-battery.ts +0 -6
- package/src/camera.ts +1 -36
- package/src/common.ts +514 -17
- package/src/debug-options.ts +4 -0
- package/src/main.ts +158 -4
- package/src/multiFocal.ts +1 -29
- package/src/nvr.ts +1 -3
- package/src/stream-utils.ts +24 -7
- package/src/utils.ts +471 -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, 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
|
-
*
|
|
759
|
+
* Get the cache directory for video clips and thumbnails
|
|
625
760
|
*/
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
package/src/debug-options.ts
CHANGED
|
@@ -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',
|