@apocaliss92/scrypted-reolink-native 0.1.35 → 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/dist/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apocaliss92/scrypted-reolink-native",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "Use any reolink camera with Scrypted, even older/unsupported models without HTTP protocol support",
5
5
  "author": "@apocaliss92",
6
6
  "license": "Apache",
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, getVideoClipWebhookUrls, pirSuffix, recordingFileToVideoClip, sirenSuffix, updateDeviceInfo, vodSearchResultsToVideoClips } 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,
@@ -599,6 +600,16 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
599
600
  this.updateVideoClipsAutoLoad();
600
601
  },
601
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
+ },
602
613
  diagnosticsRun: {
603
614
  subgroup: 'Diagnostics',
604
615
  title: 'Run Diagnostics',
@@ -727,10 +738,9 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
727
738
  if (useNvr) {
728
739
  // Fetch from NVR using listEnrichedVodFiles (library handles parsing correctly)
729
740
  const channel = this.storageSettings.values.rtspChannel ?? 0;
730
- logger.debug(`Fetching video clips from NVR for channel ${channel}`);
731
741
 
732
742
  // 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()}`);
743
+ logger.debug(`[NVR VOD] Searching for video clips: channel=${channel}, start=${start.toISOString()}, end=${end.toISOString()}`);
734
744
  // Filter to only include recordings within the requested time window
735
745
  const enrichedRecordings = await this.nvrDevice.listEnrichedVodFiles({
736
746
  channel,
@@ -741,14 +751,14 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
741
751
  bypassCache: false,
742
752
  });
743
753
 
744
- logger.log(`[NVR VOD] Found ${enrichedRecordings.length} enriched recordings from NVR`);
754
+ logger.debug(`[NVR VOD] Found ${enrichedRecordings.length} enriched recordings from NVR`);
745
755
 
746
756
  // Log sample of enriched recordings to see what the library returned
747
757
  if (enrichedRecordings.length > 0) {
748
758
  const sampleSize = Math.min(3, enrichedRecordings.length);
749
759
  for (let i = 0; i < sampleSize; i++) {
750
760
  const rec = enrichedRecordings[i];
751
- logger.log(`[NVR VOD] Sample enriched recording ${i + 1}/${enrichedRecordings.length}:`, {
761
+ logger.debug(`[NVR VOD] Sample enriched recording ${i + 1}/${enrichedRecordings.length}:`, {
752
762
  fileName: rec.fileName,
753
763
  startTimeMs: rec.startTimeMs,
754
764
  endTimeMs: rec.endTimeMs,
@@ -794,7 +804,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
794
804
  deviceId: this.id,
795
805
  useWebhook: true,
796
806
  });
797
-
807
+
798
808
  // Log detection classes in the final clip
799
809
  logger.debug(`[NVR VOD] Generated clip: id=${clip.id}, detectionClasses=${clip.detectionClasses?.join(',') || 'none'}`);
800
810
  clips.push(clip);
@@ -802,7 +812,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
802
812
 
803
813
  // Apply count limit if specified
804
814
  const finalClips = count ? clips.slice(0, count) : clips;
805
- logger.log(`[NVR VOD] Converted ${finalClips.length} video clips (limit: ${count || 'none'})`);
815
+ logger.debug(`[NVR VOD] Converted ${finalClips.length} video clips (limit: ${count || 'none'})`);
806
816
 
807
817
  return finalClips;
808
818
  } else {
@@ -877,7 +887,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
877
887
  async getVideoClip(videoId: string): Promise<MediaObject> {
878
888
  const logger = this.getBaichuanLogger();
879
889
  try {
880
- const cacheEnabled = this.storageSettings.values.downloadVideoclipsLocally
890
+ const cacheEnabled = this.storageSettings.values.downloadVideoclipsLocally;
891
+ const MIN_VIDEO_CACHE_BYTES = 16 * 1024;
881
892
 
882
893
  // Always check cache first, even if caching is disabled (in case user enabled it before)
883
894
  const cachePath = this.getVideoClipCachePath(videoId);
@@ -887,10 +898,19 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
887
898
  try {
888
899
  await fs.promises.access(cachePath, fs.constants.F_OK);
889
900
  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;
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
+ }
894
914
  } catch (e) {
895
915
  // File doesn't exist or error accessing it
896
916
  logger.debug(`[VideoClip] Cache miss: fileId=${videoId}, error=${e instanceof Error ? e.message : String(e)}`);
@@ -904,25 +924,71 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
904
924
  await fs.promises.mkdir(cacheDir, { recursive: true });
905
925
  }
906
926
 
907
- const api = await this.ensureClient();
927
+ const { clipsSource } = this.storageSettings.values;
928
+ const useNvr = clipsSource === "NVR" && this.nvrDevice && videoId.includes('/');
908
929
 
909
- // videoId is the fileId (fileName or id from the recording)
910
- const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
911
- fileName: videoId,
912
- });
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);
945
+
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
+ });
913
961
 
914
- if (!rtmpVodUrl) {
915
- throw new Error(`No playback URL found for video ${videoId}`);
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
+ }
916
979
  }
917
980
 
918
- // If caching is enabled, download and cache the video
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
919
985
  if (cacheEnabled) {
920
986
  const cachePath = this.getVideoClipCachePath(videoId);
921
987
 
922
988
  // Download and convert RTMP to MP4 using ffmpeg
923
989
  const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
924
990
  const ffmpegArgs = [
925
- '-i', rtmpVodUrl,
991
+ '-i', playbackUrl,
926
992
  '-c', 'copy', // Copy codecs without re-encoding
927
993
  '-f', 'mp4',
928
994
  '-movflags', 'frag_keyframe+empty_moov', // Enable streaming
@@ -944,8 +1010,9 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
944
1010
 
945
1011
  ffmpeg.on('close', (code) => {
946
1012
  if (code !== 0) {
947
- logger.error(`ffmpeg failed to download video clip: ${errorOutput}`);
948
- reject(new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
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}`));
949
1016
  return;
950
1017
  }
951
1018
 
@@ -973,8 +1040,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
973
1040
  const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
974
1041
  return mo;
975
1042
  } else {
976
- // Caching disabled, return RTMP URL directly
977
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(rtmpVodUrl);
1043
+ // Caching disabled, return playback URL directly (RTMP for standalone camera)
1044
+ const mo = await sdk.mediaManager.createMediaObjectFromUrl(playbackUrl);
978
1045
  return mo;
979
1046
  }
980
1047
  } catch (e) {
@@ -1008,14 +1075,24 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1008
1075
  // Check cache first
1009
1076
  const cachePath = this.getThumbnailCachePath(thumbnailId);
1010
1077
  const cacheDir = this.getThumbnailCacheDir();
1078
+ const MIN_THUMB_CACHE_BYTES = 512; // 0.5KB, evita file vuoti o quasi
1011
1079
 
1012
1080
  try {
1013
1081
  await fs.promises.access(cachePath, fs.constants.F_OK);
1014
1082
  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;
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
+ }
1019
1096
  } catch {
1020
1097
  // File doesn't exist, need to generate it
1021
1098
  logger.debug(`[Thumbnail] Cache miss: fileId=${thumbnailId}`);
@@ -1043,7 +1120,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1043
1120
  deviceId: this.id,
1044
1121
  fileId: thumbnailId,
1045
1122
  filePath: videoCachePath,
1046
- logger: this.getBaichuanLogger(),
1123
+ device: this,
1047
1124
  });
1048
1125
  } else {
1049
1126
  // Get RTMP URL using the appropriate API (NVR or Baichuan)
@@ -1055,7 +1132,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1055
1132
  deviceId: this.id,
1056
1133
  fileId: thumbnailId,
1057
1134
  rtmpUrl: rtmpVodUrl,
1058
- logger: this.getBaichuanLogger(),
1135
+ device: this,
1059
1136
  });
1060
1137
  }
1061
1138
 
@@ -1088,37 +1165,31 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1088
1165
  const useNvr = clipsSource === "NVR" && this.nvrDevice && fileId.includes('/');
1089
1166
 
1090
1167
  if (useNvr) {
1091
- logger.log(`[getVideoClipRtmpUrl] Using NVR API for fileId="${fileId}", forThumbnail=${forThumbnail}`);
1168
+ logger.debug(`[getVideoClipRtmpUrl] Using NVR API for fileId="${fileId}", forThumbnail=${forThumbnail}`);
1092
1169
  const nvrApi = await this.nvrDevice.ensureClient();
1093
1170
  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
- }
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}`);
1111
1182
  }
1112
-
1183
+
1113
1184
  throw new Error(`No streaming URL found from NVR for file ${fileId} after trying Playback and Download methods`);
1114
1185
  } else {
1115
1186
  // Camera standalone: DEVE usare RTMP da Baichuan API
1116
- logger.log(`[getVideoClipRtmpUrl] Getting RTMP URL from Baichuan API for fileId="${fileId}" (camera standalone)`);
1187
+ logger.debug(`[getVideoClipRtmpUrl] Getting RTMP URL from Baichuan API for fileId="${fileId}" (camera standalone)`);
1117
1188
  const api = await this.ensureClient();
1118
1189
  const result = await api.getRecordingPlaybackUrls({
1119
1190
  fileName: fileId,
1120
1191
  });
1121
- logger.log(`[getVideoClipRtmpUrl] Baichuan RTMP URL received: rtmpVodUrl="${result.rtmpVodUrl || 'none'}"`);
1192
+ logger.debug(`[getVideoClipRtmpUrl] Baichuan RTMP URL received: rtmpVodUrl="${result.rtmpVodUrl || 'none'}"`);
1122
1193
  if (!result.rtmpVodUrl) {
1123
1194
  throw new Error(`No RTMP URL found from Baichuan API for file ${fileId}`);
1124
1195
  }
@@ -1181,21 +1252,23 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1181
1252
  this.videoClipsAutoLoadInProgress = true;
1182
1253
 
1183
1254
  try {
1184
- logger.log('Auto-loading today\'s video clips and thumbnails...');
1255
+ const daysToPreload = this.storageSettings.values.videoclipsDaysToPreload ?? 1;
1256
+ logger.log(`Auto-loading video clips and thumbnails for the last ${daysToPreload} day(s)...`);
1185
1257
 
1186
- // Get today's date range (start of today to now)
1258
+ // Get date range (start of N days ago to now)
1187
1259
  const now = new Date();
1188
- const startOfToday = new Date(now);
1189
- startOfToday.setUTCHours(0, 0, 0, 0);
1190
- startOfToday.setUTCMinutes(0, 0, 0);
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);
1191
1264
 
1192
- // Fetch today's video clips
1265
+ // Fetch video clips for the specified number of days
1193
1266
  const clips = await this.getVideoClips({
1194
- startTime: startOfToday.getTime(),
1267
+ startTime: startDate.getTime(),
1195
1268
  endTime: now.getTime(),
1196
1269
  });
1197
1270
 
1198
- logger.log(`Found ${clips.length} video clips for today`);
1271
+ logger.log(`Found ${clips.length} video clips for the last ${daysToPreload} day(s)`);
1199
1272
 
1200
1273
  const downloadVideoclipsLocally = this.storageSettings.values.downloadVideoclipsLocally ?? false;
1201
1274
 
@@ -2213,11 +2286,11 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2213
2286
 
2214
2287
  let supportedStreams: ReolinkSupportedStream[] = [];
2215
2288
  // Homehub RTMP is not efficient, crashes, offers native streams to not overload the hub
2216
- if (this.nvrDevice && this.nvrDevice.info.model === 'HOMEHUB') {
2217
- supportedStreams = [...nativeStreams, ...rtspStreams, ...rtmpStreams];
2218
- } else {
2219
- supportedStreams = [...rtspStreams, ...rtmpStreams, ...nativeStreams];
2220
- }
2289
+ // if (this.nvrDevice && this.nvrDevice.info.model === 'HOMEHUB') {
2290
+ supportedStreams = [...nativeStreams, ...rtspStreams, ...rtmpStreams];
2291
+ // } else {
2292
+ // supportedStreams = [...rtspStreams, ...rtmpStreams, ...nativeStreams];
2293
+ // }
2221
2294
 
2222
2295
  for (const supportedStream of supportedStreams) {
2223
2296
  const { id, metadata, url, name, container } = supportedStream;
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: Console;
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: Console;
26
+ logger?: Console;
27
+ device?: CommonCameraMixin;
26
28
  }
27
29
 
28
30
  class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
@@ -347,7 +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
- request.logger.log(`[Thumbnail] Download start: fileId=${request.fileId}, queuePosition=${queueLength + 1}`);
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}`);
351
355
 
352
356
  return new Promise((resolve, reject) => {
353
357
  this.thumbnailQueue.push({
@@ -371,11 +375,11 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
371
375
 
372
376
  while (this.thumbnailQueue.length > 0) {
373
377
  const request = this.thumbnailQueue.shift()!;
374
- const logger = request.logger;
378
+ const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
375
379
 
376
380
  try {
377
381
  const thumbnail = await extractThumbnailFromVideo(request);
378
- logger.log(`[Thumbnail] OK: fileId=${request.fileId}`);
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);