@apocaliss92/scrypted-reolink-native 0.1.35 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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 {
@@ -198,6 +200,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
198
200
  device.storageSettings.values.ipAddress = ipAddress;
199
201
  device.storageSettings.values.capabilities = capabilities;
200
202
  device.storageSettings.values.uid = uid;
203
+ device.storageSettings.values.discoveryMethod = detection.udpDiscoveryMethod;
201
204
 
202
205
  return nativeId;
203
206
  }
@@ -347,7 +350,9 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
347
350
  */
348
351
  async generateThumbnail(request: ThumbnailRequestInput): Promise<MediaObject> {
349
352
  const queueLength = this.thumbnailQueue.length;
350
- request.logger.log(`[Thumbnail] Download start: fileId=${request.fileId}, queuePosition=${queueLength + 1}`);
353
+ // Use device logger if available, otherwise fallback to provided logger
354
+ const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
355
+ logger.log(`[Thumbnail] Download start: fileId=${request.fileId}, queuePosition=${queueLength + 1}`);
351
356
 
352
357
  return new Promise((resolve, reject) => {
353
358
  this.thumbnailQueue.push({
@@ -371,11 +376,11 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
371
376
 
372
377
  while (this.thumbnailQueue.length > 0) {
373
378
  const request = this.thumbnailQueue.shift()!;
374
- const logger = request.logger;
379
+ const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
375
380
 
376
381
  try {
377
382
  const thumbnail = await extractThumbnailFromVideo(request);
378
- logger.log(`[Thumbnail] OK: fileId=${request.fileId}`);
383
+ logger.log(`[Thumbnail] Download completed: fileId=${request.fileId}`);
379
384
  request.resolve(thumbnail);
380
385
  } catch (error) {
381
386
  logger.error(`[Thumbnail] Error: fileId=${request.fileId}`, error);
package/src/nvr.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { DeviceInfoResponse, EnrichedRecordingFile, EventsResponse, ReolinkBaichuanApi, ReolinkCgiApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
1
+ import type { DeviceInfoResponse, EnrichedRecordingFile, EventsResponse, ListNvrRecordingsParams, ReolinkBaichuanApi, ReolinkCgiApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
2
  import sdk, { AdoptDevice, Device, DeviceDiscovery, DeviceProvider, DiscoveredDevice, Reboot, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue } from "@scrypted/sdk";
3
3
  import { StorageSettings } from "@scrypted/sdk/storage-settings";
4
4
  import { BaseBaichuanClass, type BaichuanConnectionCallbacks, type BaichuanConnectionConfig } from "./baichuan-base";
5
5
  import { ReolinkNativeCamera } from "./camera";
6
6
  import { ReolinkNativeBatteryCamera } from "./camera-battery";
7
+ import { convertDebugLogsToApiOptions, getApiRelevantDebugLogs, getDebugLogChoices } from "./debug-options";
7
8
  import { normalizeUid } from "./connect";
8
9
  import ReolinkNativePlugin from "./main";
9
10
  import { getDeviceInterfaces, updateDeviceInfo } from "./utils";
@@ -44,8 +45,8 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
44
45
  onPut: async () => await this.reinit()
45
46
  },
46
47
  diagnosticsRun: {
47
- subgroup: 'Diagnostics',
48
- title: 'Run NVR Diagnostics',
48
+ subgroup: 'Advanced',
49
+ title: 'Run Diagnostics',
49
50
  description: 'Collect NVR diagnostics and display results in logs.',
50
51
  type: 'button',
51
52
  immediate: true,
@@ -53,6 +54,47 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
53
54
  await this.runNvrDiagnostics();
54
55
  },
55
56
  },
57
+ socketApiDebugLogs: {
58
+ subgroup: 'Advanced',
59
+ title: 'Socket API Debug Logs',
60
+ description: 'Enable specific debug logs.',
61
+ multiple: true,
62
+ combobox: true,
63
+ immediate: true,
64
+ defaultValue: [],
65
+ choices: getDebugLogChoices(),
66
+ onPut: async (ov, value) => {
67
+ const logger = this.getBaichuanLogger();
68
+ const oldApiOptions = getApiRelevantDebugLogs(ov || []);
69
+ const newApiOptions = getApiRelevantDebugLogs(value || []);
70
+
71
+ const oldSel = new Set(oldApiOptions);
72
+ const newSel = new Set(newApiOptions);
73
+
74
+ const changed = oldSel.size !== newSel.size || Array.from(oldSel).some((k) => !newSel.has(k));
75
+ if (changed) {
76
+ // Clear any existing timeout
77
+ if (this.debugLogsResetTimeout) {
78
+ clearTimeout(this.debugLogsResetTimeout);
79
+ this.debugLogsResetTimeout = undefined;
80
+ }
81
+
82
+ // Defer reset by 2 seconds to allow settings to settle
83
+ this.debugLogsResetTimeout = setTimeout(async () => {
84
+ this.debugLogsResetTimeout = undefined;
85
+ try {
86
+ // Force reconnection with new debug options
87
+ this.baichuanApi = undefined;
88
+ this.ensureClientPromise = undefined;
89
+ // Trigger reconnection
90
+ await this.ensureBaichuanClient();
91
+ } catch (e) {
92
+ logger.warn('Failed to reset client after debug logs change', e);
93
+ }
94
+ }, 2000);
95
+ }
96
+ },
97
+ },
56
98
  });
57
99
  plugin: ReolinkNativePlugin;
58
100
  nvrApi: ReolinkCgiApi | undefined;
@@ -71,6 +113,7 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
71
113
  private discoverDevicesPromise: Promise<DiscoveredDevice[]> | undefined;
72
114
  processing = false;
73
115
  private initReinitTimeout: NodeJS.Timeout | undefined;
116
+ private debugLogsResetTimeout: NodeJS.Timeout | undefined;
74
117
 
75
118
  constructor(nativeId: string, plugin: ReolinkNativePlugin) {
76
119
  super(nativeId);
@@ -84,22 +127,28 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
84
127
  await api.reboot();
85
128
  }
86
129
 
87
- // BaseBaichuanClass abstract methods implementation
88
130
  protected getConnectionConfig(): BaichuanConnectionConfig {
89
131
  const { ipAddress, username, password } = this.storageSettings.values;
90
132
  if (!ipAddress || !username || !password) {
91
133
  throw new Error('Missing NVR credentials');
92
134
  }
93
135
 
136
+ const debugOptions = this.getBaichuanDebugOptions();
137
+
94
138
  return {
95
139
  host: ipAddress,
96
140
  username,
97
141
  password,
98
142
  transport: 'tcp',
99
- logger: this.console,
143
+ debugOptions,
100
144
  };
101
145
  }
102
146
 
147
+ getBaichuanDebugOptions(): any | undefined {
148
+ const socketDebugLogs = this.storageSettings.values.socketApiDebugLogs || [];
149
+ return convertDebugLogsToApiOptions(socketDebugLogs);
150
+ }
151
+
103
152
  protected getConnectionCallbacks(): BaichuanConnectionCallbacks {
104
153
  return {
105
154
  onError: undefined, // Use default error handling
@@ -187,29 +236,6 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
187
236
  return this.nvrApi;
188
237
  }
189
238
 
190
- /**
191
- * List enriched VOD files (with proper parsing and detection info)
192
- * This uses the library's enrichVodFile which handles all parsing correctly
193
- */
194
- async listEnrichedVodFiles(params: {
195
- channel: number;
196
- start: Date;
197
- end: Date;
198
- streamType?: "main" | "sub";
199
- autoSearchByDay?: boolean;
200
- bypassCache?: boolean;
201
- }): Promise<Array<EnrichedRecordingFile>> {
202
- const api = await this.ensureClient();
203
- return await api.listEnrichedVodFiles({
204
- channel: params.channel,
205
- start: params.start,
206
- end: params.end,
207
- streamType: params.streamType,
208
- autoSearchByDay: params.autoSearchByDay,
209
- bypassCache: params.bypassCache,
210
- });
211
- }
212
-
213
239
  private forwardNativeEvent(ev: ReolinkSimpleEvent): void {
214
240
  const logger = this.getBaichuanLogger();
215
241
 
@@ -298,15 +324,13 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
298
324
  logger.log(`Starting NVR diagnostics...`);
299
325
 
300
326
  try {
301
- const cgiApi = await this.ensureClient();
327
+ const cgiApi = await this.ensureBaichuanClient();
302
328
 
303
329
  const diagnostics = await cgiApi.collectNvrDiagnostics({
304
330
  logger: this.console,
305
331
  });
306
332
 
307
333
  logger.log(`NVR diagnostics completed successfully.`);
308
-
309
- cgiApi.printNvrDiagnostics(diagnostics, this.console);
310
334
  } catch (e) {
311
335
  logger.error('Failed to run NVR diagnostics', e);
312
336
  throw e;
@@ -427,7 +451,7 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
427
451
 
428
452
  const { ipAddress } = this.storageSettings.values;
429
453
  try {
430
- const api = await this.ensureClient();
454
+ const api = await this.ensureBaichuanClient();
431
455
  const deviceData = await api.getInfo();
432
456
 
433
457
  await updateDeviceInfo({
package/src/utils.ts CHANGED
@@ -8,6 +8,25 @@ import fs from "fs";
8
8
  import path from "path";
9
9
  import crypto from "crypto";
10
10
  import { CommonCameraMixin } from "./common";
11
+ /**
12
+ * Sanitize FFmpeg output or URLs to avoid leaking credentials
13
+ */
14
+ export function sanitizeFfmpegOutput(text: string): string {
15
+ if (!text)
16
+ return text;
17
+
18
+ let sanitized = text;
19
+
20
+ // Remove user/password query parameters from URLs: ?user=xxx&password=yyy
21
+ sanitized = sanitized.replace(/(\buser=)[^&\s]*/gi, '$1***');
22
+ sanitized = sanitized.replace(/(\bpassword=)[^&\s]*/gi, '$1***');
23
+
24
+ // Remove credentials from URLs like rtmp://user:pass@host/...
25
+ sanitized = sanitized.replace(/(rtmp:\/\/)([^:@\/\s]+):([^@\/\s]+)@/gi, '$1$2:***@');
26
+
27
+ return sanitized;
28
+ }
29
+
11
30
 
12
31
  /**
13
32
  * Enumeration of operation types that may require specific channel assignments
@@ -186,6 +205,7 @@ export async function recordingFileToVideoClip(
186
205
  deviceId,
187
206
  fileId: id,
188
207
  plugin,
208
+ logger,
189
209
  });
190
210
  videoHref = videoUrl;
191
211
  thumbnailHref = thumbnailUrl;
@@ -309,6 +329,53 @@ export async function recordingFileToVideoClip(
309
329
  };
310
330
  }
311
331
 
332
+ /**
333
+ * Convert an array of RecordingFile or EnrichedRecordingFile to VideoClip array
334
+ * Uses recordingFileToVideoClip for each recording
335
+ * Handles both NVR (EnrichedRecordingFile) and device standalone (RecordingFile) cases
336
+ */
337
+ export async function recordingsToVideoClips(
338
+ recordings: (RecordingFile | EnrichedRecordingFile)[],
339
+ options: {
340
+ /** Fallback start date if recording doesn't have one */
341
+ fallbackStart: Date;
342
+ /** API instance to get playback URLs (optional, for device standalone recordings) */
343
+ api?: ReolinkBaichuanApi;
344
+ /** Logger for debug messages */
345
+ logger?: Console;
346
+ /** Plugin instance for generating webhook URLs */
347
+ plugin?: ScryptedDeviceBase;
348
+ /** Device ID for webhook URLs */
349
+ deviceId?: string;
350
+ /** Use webhook URLs instead of direct RTMP URLs */
351
+ useWebhook?: boolean;
352
+ /** Maximum number of clips to return (optional) */
353
+ count?: number;
354
+ }
355
+ ): Promise<VideoClip[]> {
356
+ const { fallbackStart, api, logger, plugin, deviceId, useWebhook, count } = options;
357
+ const clips: VideoClip[] = [];
358
+
359
+ for (const rec of recordings) {
360
+ try {
361
+ const clip = await recordingFileToVideoClip(rec, {
362
+ fallbackStart,
363
+ api,
364
+ logger,
365
+ plugin,
366
+ deviceId,
367
+ useWebhook,
368
+ });
369
+ clips.push(clip);
370
+ } catch (e) {
371
+ logger?.warn(`Failed to convert recording to video clip: fileName=${rec.fileName}`, e);
372
+ }
373
+ }
374
+
375
+ // Apply count limit if specified
376
+ return count ? clips.slice(0, count) : clips;
377
+ }
378
+
312
379
  /**
313
380
  * Generate webhook URLs for video clips
314
381
  */
@@ -316,10 +383,12 @@ export async function getVideoClipWebhookUrls(props: {
316
383
  deviceId: string;
317
384
  fileId: string;
318
385
  plugin: ScryptedDeviceBase;
386
+ logger?: Console;
319
387
  }): Promise<{ videoUrl: string; thumbnailUrl: string }> {
320
- const { deviceId, fileId, plugin } = props;
388
+ const { deviceId, fileId, plugin, logger } = props;
389
+ const log = logger || plugin.console;
321
390
 
322
- plugin.console.debug(`[getVideoClipWebhookUrls] Starting URL generation: deviceId=${deviceId}, fileId=${fileId}`);
391
+ // log.debug?.(`[getVideoClipWebhookUrls] Starting URL generation: deviceId=${deviceId}, fileId=${fileId}`);
323
392
 
324
393
  try {
325
394
  let endpoint: string;
@@ -327,13 +396,13 @@ export async function getVideoClipWebhookUrls(props: {
327
396
  try {
328
397
  endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, { public: true });
329
398
  endpointSource = 'cloud';
330
- plugin.console.debug(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
399
+ // log.debug?.(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
331
400
  } catch (e) {
332
401
  // Fallback to local endpoint if cloud is not available (e.g., not logged in)
333
- plugin.console.debug(`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e instanceof Error ? e.message : String(e)}`);
402
+ log.debug?.(`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e instanceof Error ? e.message : String(e)}`);
334
403
  endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
335
404
  endpointSource = 'local';
336
- plugin.console.debug(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
405
+ // log.debug?.(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
337
406
  }
338
407
 
339
408
  const encodedDeviceId = encodeURIComponent(deviceId);
@@ -341,8 +410,6 @@ export async function getVideoClipWebhookUrls(props: {
341
410
  const cleanFileId = fileId.startsWith('/') ? fileId.substring(1) : fileId;
342
411
  const encodedFileId = encodeURIComponent(cleanFileId);
343
412
 
344
- plugin.console.debug(`[getVideoClipWebhookUrls] Encoding: deviceId="${deviceId}" -> "${encodedDeviceId}", fileId="${fileId}" -> cleanFileId="${cleanFileId}" -> encodedFileId="${encodedFileId}"`);
345
-
346
413
  // Parse endpoint URL to extract query parameters (for authentication)
347
414
  const endpointUrl = new URL(endpoint);
348
415
  // Preserve query parameters (e.g., user_token for authentication)
@@ -350,22 +417,19 @@ export async function getVideoClipWebhookUrls(props: {
350
417
  // Remove query parameters from the base endpoint URL
351
418
  endpointUrl.search = '';
352
419
 
353
- plugin.console.debug(`[getVideoClipWebhookUrls] Parsed endpoint URL: base="${endpointUrl.toString()}", queryParams="${queryParams}"`);
354
-
355
420
  // Ensure endpoint has trailing slash
356
421
  const normalizedEndpoint = endpointUrl.toString().endsWith('/') ? endpointUrl.toString() : `${endpointUrl.toString()}/`;
357
422
 
358
- plugin.console.debug(`[getVideoClipWebhookUrls] Normalized endpoint: "${normalizedEndpoint}"`);
359
-
360
423
  // Build webhook URLs and append query parameters at the end
361
424
  const videoUrl = `${normalizedEndpoint}webhook/video/${encodedDeviceId}/${encodedFileId}${queryParams}`;
362
425
  const thumbnailUrl = `${normalizedEndpoint}webhook/thumbnail/${encodedDeviceId}/${encodedFileId}${queryParams}`;
363
426
 
364
- plugin.console.debug(`[getVideoClipWebhookUrls] Generated URLs: videoUrl="${videoUrl}", thumbnailUrl="${thumbnailUrl}"`);
365
-
366
427
  return { videoUrl, thumbnailUrl };
367
428
  } catch (e) {
368
- plugin.console.error(`[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`, e);
429
+ log.error?.(
430
+ `[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`,
431
+ e
432
+ );
369
433
  throw e;
370
434
  }
371
435
  }
@@ -378,9 +442,12 @@ export async function extractThumbnailFromVideo(props: {
378
442
  filePath?: string;
379
443
  fileId: string;
380
444
  deviceId: string;
381
- logger: Console;
445
+ logger?: Console;
446
+ device?: CommonCameraMixin;
382
447
  }): Promise<MediaObject> {
383
- const { rtmpUrl, filePath, fileId, deviceId, logger } = props;
448
+ const { rtmpUrl, filePath, fileId, deviceId, device } = props;
449
+ // Use device logger if available, otherwise fallback to provided logger
450
+ const logger = device?.getBaichuanLogger?.() || props.logger || console;
384
451
 
385
452
  // Use file path if available, otherwise use RTMP URL
386
453
  const inputSource = filePath || rtmpUrl;
@@ -427,12 +494,14 @@ export async function handleVideoClipRequest(props: {
427
494
  fileId: string;
428
495
  request: HttpRequest;
429
496
  response: HttpResponse;
430
- logger: Console;
497
+ logger?: Console;
431
498
  }): Promise<void> {
432
- const { device, deviceId, fileId, request, response, logger } = props;
499
+ const { device, deviceId, fileId, request, response } = props;
500
+ const logger = device.getBaichuanLogger?.() || props.logger || console;
433
501
 
434
502
  // Check if file is cached
435
503
  const cachePath = getVideoClipCachePath(deviceId, fileId);
504
+ const MIN_VIDEO_CACHE_BYTES = 16 * 1024; // 16KB, evita file quasi vuoti/corrotti
436
505
 
437
506
  try {
438
507
  // Check if cached file exists
@@ -440,6 +509,17 @@ export async function handleVideoClipRequest(props: {
440
509
  const fileSize = stat.size;
441
510
  const range = request.headers.range;
442
511
 
512
+ if (fileSize < MIN_VIDEO_CACHE_BYTES) {
513
+ logger.warn(`Cached video clip too small, deleting and reloading: fileId=${fileId}, size=${fileSize} bytes`);
514
+ try {
515
+ await fs.promises.unlink(cachePath);
516
+ } catch (unlinkErr) {
517
+ logger.warn(`Failed to delete small cached video clip: fileId=${fileId}`, unlinkErr);
518
+ }
519
+ // Force cache miss path below
520
+ throw new Error('Cached video too small, deleted');
521
+ }
522
+
443
523
  logger.log(`Serving cached video clip: fileId=${fileId}, size=${fileSize}, range=${range}`);
444
524
 
445
525
  if (range) {
@@ -503,12 +583,12 @@ export async function handleVideoClipRequest(props: {
503
583
 
504
584
  if (isHttpUrl) {
505
585
  // For HTTP URLs (Playback/Download from NVR), do direct HTTP proxy with ranged headers support
506
- logger.log(`Proxying HTTP URL directly: fileId=${fileId}, url=${rtmpVodUrl}`);
586
+ logger.debug(`Proxying HTTP URL directly: fileId=${fileId}, url=${rtmpVodUrl}`);
507
587
 
508
588
  const sendVideo = async () => {
509
589
  // Pre-fetch ffmpeg path in case we need it for FLV conversion
510
590
  const ffmpegPathPromise = sdk.mediaManager.getFFmpegPath();
511
-
591
+
512
592
  return new Promise<void>(async (resolve, reject) => {
513
593
  const urlObj = new URL(rtmpVodUrl);
514
594
  const httpModule = urlObj.protocol === 'https:' ? https : http;
@@ -531,7 +611,7 @@ export async function handleVideoClipRequest(props: {
531
611
  headers: requestHeaders,
532
612
  };
533
613
 
534
- logger.log(`Starting HTTP request: ${rtmpVodUrl}, headers: ${JSON.stringify(requestHeaders)}`);
614
+ logger.debug(`Starting HTTP request: ${rtmpVodUrl}, headers: ${JSON.stringify(requestHeaders)}`);
535
615
 
536
616
  httpModule.get(options, async (httpResponse) => {
537
617
  if (httpResponse.statusCode && httpResponse.statusCode >= 400) {
@@ -547,9 +627,9 @@ export async function handleVideoClipRequest(props: {
547
627
 
548
628
  // Check if we need to convert FLV to MP4
549
629
  const isFlv = typeof contentType === 'string' && (contentType === 'video/x-flv' || contentType === 'video/flv');
550
-
630
+
551
631
  if (isFlv) {
552
- logger.log(`Content-Type is FLV (${contentType}), will convert to MP4 using ffmpeg`);
632
+ logger.debug(`Content-Type is FLV (${contentType}), will convert to MP4 using ffmpeg`);
553
633
  }
554
634
 
555
635
  const responseHeaders: Record<string, string> = {
@@ -568,11 +648,11 @@ export async function handleVideoClipRequest(props: {
568
648
 
569
649
  const statusCode = httpResponse.statusCode || 200;
570
650
 
571
- logger.log(`HTTP response received: status=${statusCode}, contentType=${contentType}, contentLength=${contentLength || 'unknown'}`);
651
+ logger.debug(`HTTP response received: status=${statusCode}, contentType=${contentType}, contentLength=${contentLength || 'unknown'}`);
572
652
 
573
653
  try {
574
- if (isFlv) {
575
- // Convert FLV to MP4 using ffmpeg
654
+ if (isFlv) {
655
+ // Convert FLV to MP4 using ffmpeg
576
656
  const ffmpegPath = await ffmpegPathPromise;
577
657
  // Re-encode instead of copy because FLV codec might not be supported
578
658
  const ffmpegArgs: string[] = [
@@ -648,10 +728,11 @@ export async function handleVideoClipRequest(props: {
648
728
  // Handle ffmpeg errors
649
729
  ffmpeg.on('close', (code) => {
650
730
  if (code !== 0 && code !== null && !streamStarted) {
651
- logger.error(`FFmpeg conversion failed: fileId=${fileId}, code=${code}, error=${ffmpegError}`);
731
+ const sanitized = sanitizeFfmpegOutput(ffmpegError);
732
+ logger.error(`FFmpeg conversion failed: fileId=${fileId}, code=${code}, error=${sanitized}`);
652
733
  reject(new Error(`FFmpeg conversion failed: ${code}`));
653
734
  } else {
654
- logger.log(`FFmpeg conversion completed: fileId=${fileId}, code=${code}`);
735
+ logger.debug(`FFmpeg conversion completed: fileId=${fileId}, code=${code}`);
655
736
  resolve();
656
737
  }
657
738
  });
@@ -661,7 +742,7 @@ export async function handleVideoClipRequest(props: {
661
742
  reject(error);
662
743
  });
663
744
 
664
- logger.log(`FFmpeg conversion started: fileId=${fileId}`);
745
+ logger.debug(`FFmpeg conversion started: fileId=${fileId}`);
665
746
  } else {
666
747
  // Direct proxy for non-FLV content (should be MP4 already)
667
748
  // Stream directly without buffering - yield chunks as they arrive
@@ -757,7 +838,8 @@ export async function handleVideoClipRequest(props: {
757
838
  // Handle ffmpeg errors
758
839
  ffmpeg.on('close', (code) => {
759
840
  if (code !== 0 && code !== null && !streamStarted) {
760
- logger.error(`FFmpeg proxy failed for video: fileId=${fileId}, code=${code}, error=${ffmpegError}`);
841
+ const sanitized = sanitizeFfmpegOutput(ffmpegError);
842
+ logger.error(`FFmpeg proxy failed for video: fileId=${fileId}, code=${code}, error=${sanitized}`);
761
843
  }
762
844
  });
763
845
 
@@ -953,6 +1035,7 @@ export async function vodSearchResultsToVideoClips(
953
1035
  deviceId,
954
1036
  fileId: fileName,
955
1037
  plugin,
1038
+ logger,
956
1039
  });
957
1040
  videoHref = videoUrl;
958
1041
  thumbnailHref = thumbnailUrl;