@apocaliss92/scrypted-reolink-native 0.1.33 → 0.1.36
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/.vscode/settings.json +1 -1
- package/README.md +6 -4
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/baichuan-base.ts +4 -4
- package/src/common.ts +288 -83
- package/src/main.ts +9 -5
- package/src/nvr.ts +36 -29
- package/src/utils.ts +552 -117
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/baichuan-base.ts
CHANGED
|
@@ -219,9 +219,9 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
219
219
|
|
|
220
220
|
// Only reuse if both conditions are true
|
|
221
221
|
if (isConnected && isLoggedIn) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
return this.baichuanApi;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
225
|
// If socket is not connected or not logged in, cleanup the stale client
|
|
226
226
|
// This prevents leaking connections when the socket appears connected but isn't
|
|
227
227
|
const logger = this.getBaichuanLogger();
|
|
@@ -358,7 +358,7 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
358
358
|
// Only cleanup if this is still the current API instance
|
|
359
359
|
// This prevents cleanup of a new connection that was created
|
|
360
360
|
// while the old one was closing
|
|
361
|
-
|
|
361
|
+
await this.cleanupBaichuanApi();
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
// Call custom close handler if provided
|
package/src/common.ts
CHANGED
|
@@ -5,6 +5,8 @@ import path from 'path';
|
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
|
+
import http from 'http';
|
|
9
|
+
import https from 'https';
|
|
8
10
|
import type { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
|
|
9
11
|
import { BaseBaichuanClass, type BaichuanConnectionCallbacks, type BaichuanConnectionConfig } from "./baichuan-base";
|
|
10
12
|
import { createBaichuanApi, normalizeUid, type BaichuanTransport } from "./connect";
|
|
@@ -22,7 +24,7 @@ import {
|
|
|
22
24
|
selectStreamOption,
|
|
23
25
|
StreamManager
|
|
24
26
|
} from "./stream-utils";
|
|
25
|
-
import { floodlightSuffix, getDeviceInterfaces, pirSuffix, recordingFileToVideoClip, sirenSuffix, updateDeviceInfo } from "./utils";
|
|
27
|
+
import { floodlightSuffix, getDeviceInterfaces, getVideoClipWebhookUrls, pirSuffix, recordingFileToVideoClip, sanitizeFfmpegOutput, sirenSuffix, updateDeviceInfo, vodSearchResultsToVideoClips } from "./utils";
|
|
26
28
|
|
|
27
29
|
export type CameraType = 'battery' | 'regular' | 'multi-focal' | 'multi-focal-battery';
|
|
28
30
|
|
|
@@ -550,7 +552,6 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
550
552
|
},
|
|
551
553
|
enableVideoclips: {
|
|
552
554
|
title: "Enable Video Clips",
|
|
553
|
-
subgroup: 'Videoclips',
|
|
554
555
|
description: "Enable video clips functionality. If disabled, getVideoClips will return empty and all other videoclip settings are ignored.",
|
|
555
556
|
type: "boolean",
|
|
556
557
|
defaultValue: false,
|
|
@@ -559,6 +560,14 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
559
560
|
this.updateVideoClipsAutoLoad();
|
|
560
561
|
},
|
|
561
562
|
},
|
|
563
|
+
clipsSource: {
|
|
564
|
+
title: "Clips Source",
|
|
565
|
+
subgroup: 'Videoclips',
|
|
566
|
+
description: "Source for fetching video clips: NVR (fetch from NVR device) or Device (fetch directly from camera).",
|
|
567
|
+
type: "string",
|
|
568
|
+
choices: ["NVR", "Device"],
|
|
569
|
+
immediate: true,
|
|
570
|
+
},
|
|
562
571
|
loadVideoclips: {
|
|
563
572
|
title: "Auto-load Video Clips",
|
|
564
573
|
subgroup: 'Videoclips',
|
|
@@ -591,6 +600,16 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
591
600
|
this.updateVideoClipsAutoLoad();
|
|
592
601
|
},
|
|
593
602
|
},
|
|
603
|
+
videoclipsDaysToPreload: {
|
|
604
|
+
title: "Days to Preload",
|
|
605
|
+
subgroup: 'Videoclips',
|
|
606
|
+
description: "Number of days to preload video clips and thumbnails (default: 1, only today).",
|
|
607
|
+
type: "number",
|
|
608
|
+
defaultValue: 3,
|
|
609
|
+
onPut: async () => {
|
|
610
|
+
this.updateVideoClipsAutoLoad();
|
|
611
|
+
},
|
|
612
|
+
},
|
|
594
613
|
diagnosticsRun: {
|
|
595
614
|
subgroup: 'Diagnostics',
|
|
596
615
|
title: 'Run Diagnostics',
|
|
@@ -709,36 +728,125 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
709
728
|
|
|
710
729
|
const start = new Date(startMs);
|
|
711
730
|
const end = new Date(endMs);
|
|
712
|
-
|
|
731
|
+
// Use UTC to match API's dateToReolinkTime conversion
|
|
732
|
+
start.setUTCHours(0, 0, 0, 0);
|
|
713
733
|
|
|
714
734
|
try {
|
|
715
|
-
const
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
735
|
+
const { clipsSource } = this.storageSettings.values;
|
|
736
|
+
const useNvr = clipsSource === "NVR" && this.nvrDevice;
|
|
737
|
+
|
|
738
|
+
if (useNvr) {
|
|
739
|
+
// Fetch from NVR using listEnrichedVodFiles (library handles parsing correctly)
|
|
740
|
+
const channel = this.storageSettings.values.rtspChannel ?? 0;
|
|
741
|
+
|
|
742
|
+
// Use listEnrichedVodFiles which properly parses filenames and extracts detection info
|
|
743
|
+
logger.debug(`[NVR VOD] Searching for video clips: channel=${channel}, start=${start.toISOString()}, end=${end.toISOString()}`);
|
|
744
|
+
// Filter to only include recordings within the requested time window
|
|
745
|
+
const enrichedRecordings = await this.nvrDevice.listEnrichedVodFiles({
|
|
746
|
+
channel,
|
|
747
|
+
start,
|
|
748
|
+
end,
|
|
749
|
+
streamType: "main",
|
|
750
|
+
autoSearchByDay: false, // Disable autoSearchByDay to avoid searching past days
|
|
751
|
+
bypassCache: false,
|
|
752
|
+
});
|
|
724
753
|
|
|
725
|
-
|
|
754
|
+
logger.debug(`[NVR VOD] Found ${enrichedRecordings.length} enriched recordings from NVR`);
|
|
755
|
+
|
|
756
|
+
// Log sample of enriched recordings to see what the library returned
|
|
757
|
+
if (enrichedRecordings.length > 0) {
|
|
758
|
+
const sampleSize = Math.min(3, enrichedRecordings.length);
|
|
759
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
760
|
+
const rec = enrichedRecordings[i];
|
|
761
|
+
logger.debug(`[NVR VOD] Sample enriched recording ${i + 1}/${enrichedRecordings.length}:`, {
|
|
762
|
+
fileName: rec.fileName,
|
|
763
|
+
startTimeMs: rec.startTimeMs,
|
|
764
|
+
endTimeMs: rec.endTimeMs,
|
|
765
|
+
durationMs: rec.durationMs,
|
|
766
|
+
hasPerson: rec.hasPerson,
|
|
767
|
+
hasVehicle: rec.hasVehicle,
|
|
768
|
+
hasAnimal: rec.hasAnimal,
|
|
769
|
+
hasFace: rec.hasFace,
|
|
770
|
+
hasMotion: rec.hasMotion,
|
|
771
|
+
hasDoorbell: rec.hasDoorbell,
|
|
772
|
+
hasPackage: rec.hasPackage,
|
|
773
|
+
recordType: rec.recordType,
|
|
774
|
+
parsedFileName: rec.parsedFileName ? {
|
|
775
|
+
start: rec.parsedFileName.start?.toISOString(),
|
|
776
|
+
end: rec.parsedFileName.end?.toISOString(),
|
|
777
|
+
flags: rec.parsedFileName.flags,
|
|
778
|
+
} : null,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
726
782
|
|
|
727
|
-
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
783
|
+
// Convert enriched recordings to VideoClip array
|
|
784
|
+
const clips: VideoClip[] = [];
|
|
785
|
+
|
|
786
|
+
for (const rec of enrichedRecordings) {
|
|
787
|
+
// Log detection flags before conversion
|
|
788
|
+
const flags = {
|
|
789
|
+
hasPerson: 'hasPerson' in rec ? rec.hasPerson : false,
|
|
790
|
+
hasVehicle: 'hasVehicle' in rec ? rec.hasVehicle : false,
|
|
791
|
+
hasAnimal: 'hasAnimal' in rec ? rec.hasAnimal : false,
|
|
792
|
+
hasFace: 'hasFace' in rec ? rec.hasFace : false,
|
|
793
|
+
hasMotion: 'hasMotion' in rec ? rec.hasMotion : false,
|
|
794
|
+
hasDoorbell: 'hasDoorbell' in rec ? rec.hasDoorbell : false,
|
|
795
|
+
hasPackage: 'hasPackage' in rec ? rec.hasPackage : false,
|
|
796
|
+
recordType: rec.recordType || 'none',
|
|
797
|
+
};
|
|
798
|
+
logger.debug(`[NVR VOD] Processing recording: fileName=${rec.fileName}, flags=${JSON.stringify(flags)}`);
|
|
799
|
+
|
|
800
|
+
const clip = await recordingFileToVideoClip(rec, {
|
|
801
|
+
fallbackStart: start,
|
|
802
|
+
logger,
|
|
803
|
+
plugin: this,
|
|
804
|
+
deviceId: this.id,
|
|
805
|
+
useWebhook: true,
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// Log detection classes in the final clip
|
|
809
|
+
logger.debug(`[NVR VOD] Generated clip: id=${clip.id}, detectionClasses=${clip.detectionClasses?.join(',') || 'none'}`);
|
|
810
|
+
clips.push(clip);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Apply count limit if specified
|
|
814
|
+
const finalClips = count ? clips.slice(0, count) : clips;
|
|
815
|
+
logger.debug(`[NVR VOD] Converted ${finalClips.length} video clips (limit: ${count || 'none'})`);
|
|
816
|
+
|
|
817
|
+
return finalClips;
|
|
818
|
+
} else {
|
|
819
|
+
// Fetch directly from device using Baichuan API
|
|
820
|
+
const api = await this.ensureClient();
|
|
821
|
+
|
|
822
|
+
const recordings = await api.listEnrichedRecordingsByTime({
|
|
823
|
+
start,
|
|
824
|
+
end,
|
|
825
|
+
count,
|
|
826
|
+
channel: this.storageSettings.values.rtspChannel,
|
|
827
|
+
streamType: 'mainStream',
|
|
828
|
+
httpFallback: false,
|
|
829
|
+
fetchRtmpUrls: true
|
|
735
830
|
});
|
|
736
|
-
clips.push(clip);
|
|
737
|
-
}
|
|
738
831
|
|
|
739
|
-
|
|
832
|
+
const clips: VideoClip[] = [];
|
|
833
|
+
|
|
834
|
+
for (const rec of recordings) {
|
|
835
|
+
const clip = await recordingFileToVideoClip(rec, {
|
|
836
|
+
fallbackStart: start,
|
|
837
|
+
api,
|
|
838
|
+
logger,
|
|
839
|
+
plugin: this,
|
|
840
|
+
deviceId: this.id,
|
|
841
|
+
useWebhook: true,
|
|
842
|
+
});
|
|
843
|
+
clips.push(clip);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
logger.debug(`Videoclips found: ${clips.length}`);
|
|
740
847
|
|
|
741
|
-
|
|
848
|
+
return clips;
|
|
849
|
+
}
|
|
742
850
|
} catch (e: any) {
|
|
743
851
|
const message = e instanceof Error ? e.message : String(e);
|
|
744
852
|
|
|
@@ -779,7 +887,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
779
887
|
async getVideoClip(videoId: string): Promise<MediaObject> {
|
|
780
888
|
const logger = this.getBaichuanLogger();
|
|
781
889
|
try {
|
|
782
|
-
const cacheEnabled = this.storageSettings.values.downloadVideoclipsLocally
|
|
890
|
+
const cacheEnabled = this.storageSettings.values.downloadVideoclipsLocally;
|
|
891
|
+
const MIN_VIDEO_CACHE_BYTES = 16 * 1024;
|
|
783
892
|
|
|
784
893
|
// Always check cache first, even if caching is disabled (in case user enabled it before)
|
|
785
894
|
const cachePath = this.getVideoClipCachePath(videoId);
|
|
@@ -789,10 +898,19 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
789
898
|
try {
|
|
790
899
|
await fs.promises.access(cachePath, fs.constants.F_OK);
|
|
791
900
|
const stats = await fs.promises.stat(cachePath);
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
901
|
+
if (stats.size < MIN_VIDEO_CACHE_BYTES) {
|
|
902
|
+
logger.warn(`[VideoClip] Cached file too small, deleting and re-downloading: fileId=${videoId}, size=${stats.size} bytes`);
|
|
903
|
+
try {
|
|
904
|
+
await fs.promises.unlink(cachePath);
|
|
905
|
+
} catch (unlinkErr) {
|
|
906
|
+
logger.warn(`[VideoClip] Failed to delete small cached file: fileId=${videoId}`, unlinkErr);
|
|
907
|
+
}
|
|
908
|
+
} else {
|
|
909
|
+
logger.debug(`[VideoClip] Using cached file: fileId=${videoId}, size=${stats.size} bytes`);
|
|
910
|
+
// Return cached file as MediaObject
|
|
911
|
+
const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
|
|
912
|
+
return mo;
|
|
913
|
+
}
|
|
796
914
|
} catch (e) {
|
|
797
915
|
// File doesn't exist or error accessing it
|
|
798
916
|
logger.debug(`[VideoClip] Cache miss: fileId=${videoId}, error=${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -806,25 +924,71 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
806
924
|
await fs.promises.mkdir(cacheDir, { recursive: true });
|
|
807
925
|
}
|
|
808
926
|
|
|
809
|
-
const
|
|
927
|
+
const { clipsSource } = this.storageSettings.values;
|
|
928
|
+
const useNvr = clipsSource === "NVR" && this.nvrDevice && videoId.includes('/');
|
|
810
929
|
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
930
|
+
// NVR/HUB case: prefer Download endpoint (HTTP) instead of RTMP
|
|
931
|
+
if (useNvr && this.nvrDevice) {
|
|
932
|
+
// Reuse centralized logic for NVR VOD URL (Download)
|
|
933
|
+
const downloadUrl = await this.getVideoClipRtmpUrl(videoId);
|
|
934
|
+
|
|
935
|
+
// If caching is enabled, download via HTTP and cache as file
|
|
936
|
+
if (cacheEnabled) {
|
|
937
|
+
const cachePath = this.getVideoClipCachePath(videoId);
|
|
938
|
+
logger.log(`Downloading video clip from NVR to cache: fileId=${videoId}, path=${cachePath}`);
|
|
939
|
+
|
|
940
|
+
await new Promise<void>((resolve, reject) => {
|
|
941
|
+
const urlObj = new URL(downloadUrl);
|
|
942
|
+
const httpModule = urlObj.protocol === 'https:' ? https : http;
|
|
943
|
+
|
|
944
|
+
const fileStream = fs.createWriteStream(cachePath);
|
|
815
945
|
|
|
816
|
-
|
|
817
|
-
|
|
946
|
+
const req = httpModule.get(downloadUrl, (res) => {
|
|
947
|
+
if (!res.statusCode || res.statusCode >= 400) {
|
|
948
|
+
reject(new Error(`NVR download failed: ${res.statusCode} ${res.statusMessage}`));
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
res.pipe(fileStream);
|
|
953
|
+
|
|
954
|
+
res.on('error', (err) => {
|
|
955
|
+
reject(err);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
fileStream.on('finish', () => {
|
|
959
|
+
resolve();
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
fileStream.on('error', (err) => {
|
|
963
|
+
reject(err);
|
|
964
|
+
});
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
req.on('error', (err) => {
|
|
968
|
+
reject(err);
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
|
|
973
|
+
return mo;
|
|
974
|
+
} else {
|
|
975
|
+
// Caching disabled: return HTTP Download URL directly
|
|
976
|
+
const mo = await sdk.mediaManager.createMediaObjectFromUrl(downloadUrl);
|
|
977
|
+
return mo;
|
|
978
|
+
}
|
|
818
979
|
}
|
|
819
980
|
|
|
820
|
-
//
|
|
981
|
+
// Standalone camera (or fallback): reuse getVideoClipRtmpUrl (Baichuan RTMP)
|
|
982
|
+
const playbackUrl = await this.getVideoClipRtmpUrl(videoId);
|
|
983
|
+
|
|
984
|
+
// If caching is enabled, download and cache the video via ffmpeg
|
|
821
985
|
if (cacheEnabled) {
|
|
822
986
|
const cachePath = this.getVideoClipCachePath(videoId);
|
|
823
987
|
|
|
824
988
|
// Download and convert RTMP to MP4 using ffmpeg
|
|
825
989
|
const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
|
|
826
990
|
const ffmpegArgs = [
|
|
827
|
-
'-i',
|
|
991
|
+
'-i', playbackUrl,
|
|
828
992
|
'-c', 'copy', // Copy codecs without re-encoding
|
|
829
993
|
'-f', 'mp4',
|
|
830
994
|
'-movflags', 'frag_keyframe+empty_moov', // Enable streaming
|
|
@@ -846,8 +1010,9 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
846
1010
|
|
|
847
1011
|
ffmpeg.on('close', (code) => {
|
|
848
1012
|
if (code !== 0) {
|
|
849
|
-
|
|
850
|
-
|
|
1013
|
+
const sanitized = sanitizeFfmpegOutput(errorOutput);
|
|
1014
|
+
logger.error(`ffmpeg failed to download video clip: ${sanitized}`);
|
|
1015
|
+
reject(new Error(`ffmpeg failed with code ${code}: ${sanitized}`));
|
|
851
1016
|
return;
|
|
852
1017
|
}
|
|
853
1018
|
|
|
@@ -875,8 +1040,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
875
1040
|
const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
|
|
876
1041
|
return mo;
|
|
877
1042
|
} else {
|
|
878
|
-
// Caching disabled, return
|
|
879
|
-
const mo = await sdk.mediaManager.createMediaObjectFromUrl(
|
|
1043
|
+
// Caching disabled, return playback URL directly (RTMP for standalone camera)
|
|
1044
|
+
const mo = await sdk.mediaManager.createMediaObjectFromUrl(playbackUrl);
|
|
880
1045
|
return mo;
|
|
881
1046
|
}
|
|
882
1047
|
} catch (e) {
|
|
@@ -910,14 +1075,24 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
910
1075
|
// Check cache first
|
|
911
1076
|
const cachePath = this.getThumbnailCachePath(thumbnailId);
|
|
912
1077
|
const cacheDir = this.getThumbnailCacheDir();
|
|
1078
|
+
const MIN_THUMB_CACHE_BYTES = 512; // 0.5KB, evita file vuoti o quasi
|
|
913
1079
|
|
|
914
1080
|
try {
|
|
915
1081
|
await fs.promises.access(cachePath, fs.constants.F_OK);
|
|
916
1082
|
const stats = await fs.promises.stat(cachePath);
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1083
|
+
if (stats.size < MIN_THUMB_CACHE_BYTES) {
|
|
1084
|
+
logger.warn(`[Thumbnail] Cached thumbnail too small, deleting and regenerating: fileId=${thumbnailId}, size=${stats.size} bytes`);
|
|
1085
|
+
try {
|
|
1086
|
+
await fs.promises.unlink(cachePath);
|
|
1087
|
+
} catch (unlinkErr) {
|
|
1088
|
+
logger.warn(`[Thumbnail] Failed to delete small cached thumbnail: fileId=${thumbnailId}`, unlinkErr);
|
|
1089
|
+
}
|
|
1090
|
+
} else {
|
|
1091
|
+
logger.debug(`[Thumbnail] Using cached: fileId=${thumbnailId}, size=${stats.size} bytes`);
|
|
1092
|
+
// Return cached thumbnail as MediaObject
|
|
1093
|
+
const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
|
|
1094
|
+
return mo;
|
|
1095
|
+
}
|
|
921
1096
|
} catch {
|
|
922
1097
|
// File doesn't exist, need to generate it
|
|
923
1098
|
logger.debug(`[Thumbnail] Cache miss: fileId=${thumbnailId}`);
|
|
@@ -945,36 +1120,19 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
945
1120
|
deviceId: this.id,
|
|
946
1121
|
fileId: thumbnailId,
|
|
947
1122
|
filePath: videoCachePath,
|
|
948
|
-
|
|
1123
|
+
device: this,
|
|
949
1124
|
});
|
|
950
1125
|
} else {
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
const
|
|
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
|
-
}
|
|
1126
|
+
// Get RTMP URL using the appropriate API (NVR or Baichuan)
|
|
1127
|
+
// Use forThumbnail=true to prefer Download over Playback (better for ffmpeg)
|
|
1128
|
+
const rtmpVodUrl = await this.getVideoClipRtmpUrl(thumbnailId, true);
|
|
971
1129
|
|
|
972
1130
|
// Use the plugin's thumbnail generation queue with RTMP URL
|
|
973
1131
|
thumbnail = await this.plugin.generateThumbnail({
|
|
974
1132
|
deviceId: this.id,
|
|
975
1133
|
fileId: thumbnailId,
|
|
976
1134
|
rtmpUrl: rtmpVodUrl,
|
|
977
|
-
|
|
1135
|
+
device: this,
|
|
978
1136
|
});
|
|
979
1137
|
}
|
|
980
1138
|
|
|
@@ -995,6 +1153,50 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
995
1153
|
}
|
|
996
1154
|
}
|
|
997
1155
|
|
|
1156
|
+
/**
|
|
1157
|
+
* Get RTMP URL for a video clip file
|
|
1158
|
+
* Handles both NVR source (full path) and Device source (filename only)
|
|
1159
|
+
* @param fileId - The file ID or full path
|
|
1160
|
+
* @param forThumbnail - If true, prefer Download over Playback (better for ffmpeg thumbnail extraction)
|
|
1161
|
+
*/
|
|
1162
|
+
async getVideoClipRtmpUrl(fileId: string, forThumbnail: boolean = false): Promise<string> {
|
|
1163
|
+
const logger = this.getBaichuanLogger();
|
|
1164
|
+
const { clipsSource } = this.storageSettings.values;
|
|
1165
|
+
const useNvr = clipsSource === "NVR" && this.nvrDevice && fileId.includes('/');
|
|
1166
|
+
|
|
1167
|
+
if (useNvr) {
|
|
1168
|
+
logger.debug(`[getVideoClipRtmpUrl] Using NVR API for fileId="${fileId}", forThumbnail=${forThumbnail}`);
|
|
1169
|
+
const nvrApi = await this.nvrDevice.ensureClient();
|
|
1170
|
+
const channel = this.storageSettings.values.rtspChannel ?? 0;
|
|
1171
|
+
|
|
1172
|
+
try {
|
|
1173
|
+
logger.debug(`[getVideoClipRtmpUrl] Trying getVodUrl with Download requestType...`);
|
|
1174
|
+
const url = await nvrApi.getVodUrl(fileId, channel, {
|
|
1175
|
+
requestType: "Download",
|
|
1176
|
+
streamType: "main",
|
|
1177
|
+
});
|
|
1178
|
+
logger.debug(`[getVideoClipRtmpUrl] NVR getVodUrl Download URL received: url="${url || 'none'}"`);
|
|
1179
|
+
if (url) return url;
|
|
1180
|
+
} catch (e: any) {
|
|
1181
|
+
logger.error(`[getVideoClipRtmpUrl] getVodUrl Download failed: ${e.message}`);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
throw new Error(`No streaming URL found from NVR for file ${fileId} after trying Playback and Download methods`);
|
|
1185
|
+
} else {
|
|
1186
|
+
// Camera standalone: DEVE usare RTMP da Baichuan API
|
|
1187
|
+
logger.debug(`[getVideoClipRtmpUrl] Getting RTMP URL from Baichuan API for fileId="${fileId}" (camera standalone)`);
|
|
1188
|
+
const api = await this.ensureClient();
|
|
1189
|
+
const result = await api.getRecordingPlaybackUrls({
|
|
1190
|
+
fileName: fileId,
|
|
1191
|
+
});
|
|
1192
|
+
logger.debug(`[getVideoClipRtmpUrl] Baichuan RTMP URL received: rtmpVodUrl="${result.rtmpVodUrl || 'none'}"`);
|
|
1193
|
+
if (!result.rtmpVodUrl) {
|
|
1194
|
+
throw new Error(`No RTMP URL found from Baichuan API for file ${fileId}`);
|
|
1195
|
+
}
|
|
1196
|
+
return result.rtmpVodUrl;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
998
1200
|
removeVideoClips(...videoClipIds: string[]): Promise<void> {
|
|
999
1201
|
throw new Error("removeVideoClips is not implemented yet.");
|
|
1000
1202
|
}
|
|
@@ -1050,21 +1252,23 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
1050
1252
|
this.videoClipsAutoLoadInProgress = true;
|
|
1051
1253
|
|
|
1052
1254
|
try {
|
|
1053
|
-
|
|
1255
|
+
const daysToPreload = this.storageSettings.values.videoclipsDaysToPreload ?? 1;
|
|
1256
|
+
logger.log(`Auto-loading video clips and thumbnails for the last ${daysToPreload} day(s)...`);
|
|
1054
1257
|
|
|
1055
|
-
// Get
|
|
1258
|
+
// Get date range (start of N days ago to now)
|
|
1056
1259
|
const now = new Date();
|
|
1057
|
-
const
|
|
1058
|
-
|
|
1059
|
-
|
|
1260
|
+
const startDate = new Date(now);
|
|
1261
|
+
startDate.setUTCDate(startDate.getUTCDate() - (daysToPreload - 1));
|
|
1262
|
+
startDate.setUTCHours(0, 0, 0, 0);
|
|
1263
|
+
startDate.setUTCMinutes(0, 0, 0);
|
|
1060
1264
|
|
|
1061
|
-
// Fetch
|
|
1265
|
+
// Fetch video clips for the specified number of days
|
|
1062
1266
|
const clips = await this.getVideoClips({
|
|
1063
|
-
startTime:
|
|
1267
|
+
startTime: startDate.getTime(),
|
|
1064
1268
|
endTime: now.getTime(),
|
|
1065
1269
|
});
|
|
1066
1270
|
|
|
1067
|
-
logger.log(`Found ${clips.length} video clips for
|
|
1271
|
+
logger.log(`Found ${clips.length} video clips for the last ${daysToPreload} day(s)`);
|
|
1068
1272
|
|
|
1069
1273
|
const downloadVideoclipsLocally = this.storageSettings.values.downloadVideoclipsLocally ?? false;
|
|
1070
1274
|
|
|
@@ -2082,11 +2286,11 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
2082
2286
|
|
|
2083
2287
|
let supportedStreams: ReolinkSupportedStream[] = [];
|
|
2084
2288
|
// Homehub RTMP is not efficient, crashes, offers native streams to not overload the hub
|
|
2085
|
-
if (this.nvrDevice && this.nvrDevice.info.model === 'HOMEHUB') {
|
|
2086
|
-
|
|
2087
|
-
} else {
|
|
2088
|
-
|
|
2089
|
-
}
|
|
2289
|
+
// if (this.nvrDevice && this.nvrDevice.info.model === 'HOMEHUB') {
|
|
2290
|
+
supportedStreams = [...nativeStreams, ...rtspStreams, ...rtmpStreams];
|
|
2291
|
+
// } else {
|
|
2292
|
+
// supportedStreams = [...rtspStreams, ...rtmpStreams, ...nativeStreams];
|
|
2293
|
+
// }
|
|
2090
2294
|
|
|
2091
2295
|
for (const supportedStream of supportedStreams) {
|
|
2092
2296
|
const { id, metadata, url, name, container } = supportedStream;
|
|
@@ -2293,7 +2497,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
|
|
|
2293
2497
|
logger.warn('Failed to connect/refresh during init', e);
|
|
2294
2498
|
}
|
|
2295
2499
|
}
|
|
2296
|
-
|
|
2500
|
+
this.storageSettings.settings.clipsSource.hide = !this.nvrDevice;
|
|
2501
|
+
this.storageSettings.settings.clipsSource.defaultValue = this.nvrDevice ? "NVR" : "Device";
|
|
2297
2502
|
|
|
2298
2503
|
this.storageSettings.settings.videoclipsRegularChecks.defaultValue = this.isBattery ? 120 : 30;
|
|
2299
2504
|
|
package/src/main.ts
CHANGED
|
@@ -12,7 +12,8 @@ interface ThumbnailRequest {
|
|
|
12
12
|
fileId: string;
|
|
13
13
|
rtmpUrl?: string;
|
|
14
14
|
filePath?: string;
|
|
15
|
-
logger
|
|
15
|
+
logger?: Console;
|
|
16
|
+
device?: CommonCameraMixin;
|
|
16
17
|
resolve: (mo: MediaObject) => void;
|
|
17
18
|
reject: (error: Error) => void;
|
|
18
19
|
}
|
|
@@ -22,7 +23,8 @@ interface ThumbnailRequestInput {
|
|
|
22
23
|
fileId: string;
|
|
23
24
|
rtmpUrl?: string;
|
|
24
25
|
filePath?: string;
|
|
25
|
-
logger
|
|
26
|
+
logger?: Console;
|
|
27
|
+
device?: CommonCameraMixin;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
|
|
@@ -347,8 +349,9 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
|
|
|
347
349
|
*/
|
|
348
350
|
async generateThumbnail(request: ThumbnailRequestInput): Promise<MediaObject> {
|
|
349
351
|
const queueLength = this.thumbnailQueue.length;
|
|
350
|
-
|
|
351
|
-
request.
|
|
352
|
+
// Use device logger if available, otherwise fallback to provided logger
|
|
353
|
+
const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
|
|
354
|
+
logger.log(`[Thumbnail] Download start: fileId=${request.fileId}, queuePosition=${queueLength + 1}`);
|
|
352
355
|
|
|
353
356
|
return new Promise((resolve, reject) => {
|
|
354
357
|
this.thumbnailQueue.push({
|
|
@@ -372,10 +375,11 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
|
|
|
372
375
|
|
|
373
376
|
while (this.thumbnailQueue.length > 0) {
|
|
374
377
|
const request = this.thumbnailQueue.shift()!;
|
|
375
|
-
const logger = request.logger;
|
|
378
|
+
const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
|
|
376
379
|
|
|
377
380
|
try {
|
|
378
381
|
const thumbnail = await extractThumbnailFromVideo(request);
|
|
382
|
+
logger.log(`[Thumbnail] Download completed: fileId=${request.fileId}`);
|
|
379
383
|
request.resolve(thumbnail);
|
|
380
384
|
} catch (error) {
|
|
381
385
|
logger.error(`[Thumbnail] Error: fileId=${request.fileId}`, error);
|