@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/src/utils.ts CHANGED
@@ -1,10 +1,32 @@
1
- import type { DeviceCapabilities, EnrichedRecordingFile, RecordingFile, ReolinkBaichuanApi, ReolinkDeviceInfo } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
1
+ import type { DeviceCapabilities, EnrichedRecordingFile, ParsedRecordingFileName, RecordingFile, ReolinkBaichuanApi, ReolinkDeviceInfo, VodFile, VodSearchResponse } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
2
  import sdk, { DeviceBase, HttpRequest, HttpResponse, MediaObject, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, VideoClip, VideoClips } from "@scrypted/sdk";
3
3
  import { spawn } from "node:child_process";
4
+ import { Readable } from "stream";
5
+ import http from "http";
6
+ import https from "https";
4
7
  import fs from "fs";
5
8
  import path from "path";
6
9
  import crypto from "crypto";
7
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
+
8
30
 
9
31
  /**
10
32
  * Enumeration of operation types that may require specific channel assignments
@@ -173,58 +195,92 @@ export async function recordingFileToVideoClip(
173
195
  let videoHref: string | undefined = providedVideoHref;
174
196
  let thumbnailHref: string | undefined;
175
197
 
198
+ logger?.debug(`[recordingFileToVideoClip] URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
199
+
176
200
  // If webhook is enabled, generate webhook URLs
177
201
  if (useWebhook && plugin && deviceId) {
202
+ logger?.debug(`[recordingFileToVideoClip] Generating webhook URLs for fileId=${id}`);
178
203
  try {
179
204
  const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
180
205
  deviceId,
181
206
  fileId: id,
182
207
  plugin,
208
+ logger,
183
209
  });
184
210
  videoHref = videoUrl;
185
211
  thumbnailHref = thumbnailUrl;
212
+ logger?.debug(`[recordingFileToVideoClip] Webhook URLs generated successfully: videoHref="${videoHref}", thumbnailHref="${thumbnailHref}"`);
186
213
  } catch (e) {
187
- logger?.error('recordingFileToVideoClip: failed to generate webhook URLs', e);
214
+ logger?.error(`[recordingFileToVideoClip] Failed to generate webhook URLs for fileId=${id}:`, e);
188
215
  }
189
216
  } else if (!videoHref && api) {
190
217
  // Fallback to direct RTMP URL if webhook is not used
218
+ logger?.debug(`[recordingFileToVideoClip] Fetching RTMP playback URL for fileName=${rec.fileName}`);
191
219
  try {
192
220
  const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
193
221
  fileName: rec.fileName,
194
222
  });
195
223
  videoHref = rtmpVodUrl;
224
+ logger?.debug(`[recordingFileToVideoClip] RTMP URL fetched successfully: videoHref="${videoHref}"`);
196
225
  } catch (e) {
197
- logger?.debug('recordingFileToVideoClip: failed to build playback URL for recording', rec.fileName, e);
226
+ logger?.debug(`[recordingFileToVideoClip] Failed to build playback URL for recording fileName=${rec.fileName}:`, e);
198
227
  }
228
+ } else {
229
+ logger?.debug(`[recordingFileToVideoClip] No URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
199
230
  }
200
231
 
201
232
  const description = ('name' in rec && typeof rec.name === 'string' && rec.name) ? rec.name : (rec.fileName ?? rec.id ?? '');
202
233
 
203
234
  // Build detectionClasses from flags or recordType
204
- const detectionClasses: string[] = ['Motion'];
235
+ const detectionClasses: string[] = [];
205
236
 
206
- // Check for EnrichedRecordingFile flags
237
+ // Check for EnrichedRecordingFile flags first (most accurate)
238
+ let hasAnyDetection = false;
207
239
  if ('hasPerson' in rec && rec.hasPerson) {
208
240
  detectionClasses.push('Person');
241
+ hasAnyDetection = true;
209
242
  }
210
243
  if ('hasVehicle' in rec && rec.hasVehicle) {
211
244
  detectionClasses.push('Vehicle');
245
+ hasAnyDetection = true;
212
246
  }
213
247
  if ('hasAnimal' in rec && rec.hasAnimal) {
214
248
  detectionClasses.push('Animal');
249
+ hasAnyDetection = true;
215
250
  }
216
251
  if ('hasFace' in rec && rec.hasFace) {
217
252
  detectionClasses.push('Face');
253
+ hasAnyDetection = true;
254
+ }
255
+ if ('hasMotion' in rec && rec.hasMotion) {
256
+ detectionClasses.push('Motion');
257
+ hasAnyDetection = true;
218
258
  }
219
259
  if ('hasDoorbell' in rec && rec.hasDoorbell) {
220
260
  detectionClasses.push('Doorbell');
261
+ hasAnyDetection = true;
221
262
  }
222
263
  if ('hasPackage' in rec && rec.hasPackage) {
223
264
  detectionClasses.push('Package');
265
+ hasAnyDetection = true;
266
+ }
267
+
268
+ // Log detection flags for debugging
269
+ if (logger) {
270
+ const flags = {
271
+ hasPerson: 'hasPerson' in rec ? rec.hasPerson : undefined,
272
+ hasVehicle: 'hasVehicle' in rec ? rec.hasVehicle : undefined,
273
+ hasAnimal: 'hasAnimal' in rec ? rec.hasAnimal : undefined,
274
+ hasFace: 'hasFace' in rec ? rec.hasFace : undefined,
275
+ hasMotion: 'hasMotion' in rec ? rec.hasMotion : undefined,
276
+ hasDoorbell: 'hasDoorbell' in rec ? rec.hasDoorbell : undefined,
277
+ hasPackage: 'hasPackage' in rec ? rec.hasPackage : undefined,
278
+ recordType: rec.recordType || 'none',
279
+ };
224
280
  }
225
281
 
226
282
  // Fallback: parse recordType string if flags are not available
227
- if (detectionClasses.length === 0 && rec.recordType) {
283
+ if (!hasAnyDetection && rec.recordType) {
228
284
  const recordTypeLower = rec.recordType.toLowerCase();
229
285
  if (recordTypeLower.includes('people') || recordTypeLower.includes('person')) {
230
286
  detectionClasses.push('Person');
@@ -249,6 +305,19 @@ export async function recordingFileToVideoClip(
249
305
  }
250
306
  }
251
307
 
308
+ // Always include Motion if no other detections found
309
+ if (detectionClasses.length === 0) {
310
+ detectionClasses.push('Motion');
311
+ }
312
+
313
+ const resources = videoHref || thumbnailHref
314
+ ? {
315
+ ...(videoHref ? { video: { href: videoHref } } : {}),
316
+ ...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
317
+ }
318
+ : undefined;
319
+
320
+
252
321
  return {
253
322
  id,
254
323
  startTime: recStartMs,
@@ -256,12 +325,7 @@ export async function recordingFileToVideoClip(
256
325
  event: rec.recordType,
257
326
  description,
258
327
  detectionClasses: detectionClasses.length > 0 ? detectionClasses : undefined,
259
- resources: videoHref || thumbnailHref
260
- ? {
261
- ...(videoHref ? { video: { href: videoHref } } : {}),
262
- ...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
263
- }
264
- : undefined,
328
+ resources,
265
329
  };
266
330
  }
267
331
 
@@ -272,17 +336,26 @@ export async function getVideoClipWebhookUrls(props: {
272
336
  deviceId: string;
273
337
  fileId: string;
274
338
  plugin: ScryptedDeviceBase;
339
+ logger?: Console;
275
340
  }): Promise<{ videoUrl: string; thumbnailUrl: string }> {
276
- const { deviceId, fileId, plugin } = props;
341
+ const { deviceId, fileId, plugin, logger } = props;
342
+ const log = logger || plugin.console;
343
+
344
+ // log.debug?.(`[getVideoClipWebhookUrls] Starting URL generation: deviceId=${deviceId}, fileId=${fileId}`);
277
345
 
278
346
  try {
279
347
  let endpoint: string;
348
+ let endpointSource: 'cloud' | 'local';
280
349
  try {
281
350
  endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, { public: true });
351
+ endpointSource = 'cloud';
352
+ // log.debug?.(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
282
353
  } catch (e) {
283
354
  // Fallback to local endpoint if cloud is not available (e.g., not logged in)
284
- // plugin.console.debug('Cloud endpoint not available, using local endpoint', e);
355
+ log.debug?.(`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e instanceof Error ? e.message : String(e)}`);
285
356
  endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
357
+ endpointSource = 'local';
358
+ // log.debug?.(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
286
359
  }
287
360
 
288
361
  const encodedDeviceId = encodeURIComponent(deviceId);
@@ -296,7 +369,7 @@ export async function getVideoClipWebhookUrls(props: {
296
369
  const queryParams = endpointUrl.search;
297
370
  // Remove query parameters from the base endpoint URL
298
371
  endpointUrl.search = '';
299
-
372
+
300
373
  // Ensure endpoint has trailing slash
301
374
  const normalizedEndpoint = endpointUrl.toString().endsWith('/') ? endpointUrl.toString() : `${endpointUrl.toString()}/`;
302
375
 
@@ -306,7 +379,10 @@ export async function getVideoClipWebhookUrls(props: {
306
379
 
307
380
  return { videoUrl, thumbnailUrl };
308
381
  } catch (e) {
309
- plugin.console.error('Failed to generate webhook URLs', e);
382
+ log.error?.(
383
+ `[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`,
384
+ e
385
+ );
310
386
  throw e;
311
387
  }
312
388
  }
@@ -319,10 +395,13 @@ export async function extractThumbnailFromVideo(props: {
319
395
  filePath?: string;
320
396
  fileId: string;
321
397
  deviceId: string;
322
- logger: Console;
398
+ logger?: Console;
399
+ device?: CommonCameraMixin;
323
400
  }): Promise<MediaObject> {
324
- const { rtmpUrl, filePath, fileId, deviceId, logger } = props;
325
-
401
+ const { rtmpUrl, filePath, fileId, deviceId, device } = props;
402
+ // Use device logger if available, otherwise fallback to provided logger
403
+ const logger = device?.getBaichuanLogger?.() || props.logger || console;
404
+
326
405
  // Use file path if available, otherwise use RTMP URL
327
406
  const inputSource = filePath || rtmpUrl;
328
407
  if (!inputSource) {
@@ -330,89 +409,17 @@ export async function extractThumbnailFromVideo(props: {
330
409
  }
331
410
 
332
411
  try {
333
- // Get ffmpeg path
334
- const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
335
-
336
- // Build ffmpeg args to extract a frame at 2 seconds
337
- const ffmpegArgs = [
338
- '-ss', '2', // Seek to 2 seconds
339
- '-i', inputSource,
340
- '-vframes', '1', // Extract only 1 frame
341
- '-q:v', '2', // High quality JPEG
342
- '-f', 'image2', // Output format
343
- 'pipe:1', // Output to stdout
344
- ];
345
-
346
- return new Promise<MediaObject>((resolve, reject) => {
347
- const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
348
- stdio: ['ignore', 'pipe', 'pipe'],
349
- });
350
-
351
- const chunks: Buffer[] = [];
352
- let errorOutput = '';
353
-
354
- ffmpeg.stdout.on('data', (chunk: Buffer) => {
355
- chunks.push(chunk);
356
- });
357
-
358
- ffmpeg.stderr.on('data', (chunk: Buffer) => {
359
- errorOutput += chunk.toString();
360
- });
361
-
362
- let resolved = false;
363
-
364
- ffmpeg.on('close', async (code) => {
365
- if (resolved) return;
366
- resolved = true;
367
-
368
- if (code !== 0) {
369
- logger.error(`[Thumbnail] Error: fileId=${fileId}`, new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
370
- reject(new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
371
- return;
372
- }
373
-
374
- try {
375
- const imageBuffer = Buffer.concat(chunks);
376
- if (imageBuffer.length === 0) {
377
- logger.error(`[Thumbnail] Error: fileId=${fileId}`, new Error('No image data received from ffmpeg'));
378
- reject(new Error('No image data received from ffmpeg'));
379
- return;
380
- }
381
-
382
- const mo = await sdk.mediaManager.createMediaObject(imageBuffer, 'image/jpeg');
383
- logger.log(`[Thumbnail] Completed: fileId=${fileId}, size=${imageBuffer.length} bytes`);
384
- resolve(mo);
385
- } catch (e) {
386
- logger.error(`[Thumbnail] Error: fileId=${fileId}`, e);
387
- reject(e);
388
- }
389
- });
390
-
391
- ffmpeg.on('error', (error) => {
392
- if (resolved) return;
393
- resolved = true;
394
- logger.error(`[Thumbnail] Error: fileId=${fileId}`, error);
395
- reject(error);
396
- });
397
-
398
- // Timeout after 30 seconds
399
- const timeout = setTimeout(() => {
400
- if (resolved) return;
401
- resolved = true;
402
- try {
403
- ffmpeg.kill('SIGKILL');
404
- } catch (e) {
405
- // Ignore
406
- }
407
- reject(new Error('Thumbnail extraction timeout'));
408
- }, 30000);
409
-
410
- ffmpeg.on('close', () => {
411
- clearTimeout(timeout);
412
- });
412
+ // Use createFFmpegMediaObject which handles codec detection better
413
+ // For Download URLs from NVR, they might return only a short segment, so use 1 second instead of 5
414
+ const mo = await sdk.mediaManager.createFFmpegMediaObject({
415
+ inputArguments: [
416
+ '-ss', '00:00:01', // Seek to 1 second (safer for short segments from NVR Download URLs)
417
+ '-i', inputSource,
418
+ ],
413
419
  });
420
+ return mo;
414
421
  } catch (e) {
415
- logger.error(`[Thumbnail] Error: fileId=${fileId}`, e);
422
+ // Error already logged in main.ts
416
423
  throw e;
417
424
  }
418
425
  }
@@ -426,7 +433,7 @@ function getVideoClipCachePath(deviceId: string, fileId: string): string {
426
433
  const hash = crypto.createHash('md5').update(fileId).digest('hex');
427
434
  // Keep original extension if present, otherwise use .mp4
428
435
  const ext = fileId.includes('.') ? path.extname(fileId) : '.mp4';
429
- const cacheDir = path.join(pluginVolume, 'snapshots', deviceId);
436
+ const cacheDir = path.join(pluginVolume, 'videoclips', deviceId);
430
437
  return path.join(cacheDir, `${hash}${ext}`);
431
438
  }
432
439
 
@@ -440,12 +447,14 @@ export async function handleVideoClipRequest(props: {
440
447
  fileId: string;
441
448
  request: HttpRequest;
442
449
  response: HttpResponse;
443
- logger: Console;
450
+ logger?: Console;
444
451
  }): Promise<void> {
445
- const { device, deviceId, fileId, request, response, logger } = props;
452
+ const { device, deviceId, fileId, request, response } = props;
453
+ const logger = device.getBaichuanLogger?.() || props.logger || console;
446
454
 
447
455
  // Check if file is cached
448
456
  const cachePath = getVideoClipCachePath(deviceId, fileId);
457
+ const MIN_VIDEO_CACHE_BYTES = 16 * 1024; // 16KB, evita file quasi vuoti/corrotti
449
458
 
450
459
  try {
451
460
  // Check if cached file exists
@@ -453,6 +462,17 @@ export async function handleVideoClipRequest(props: {
453
462
  const fileSize = stat.size;
454
463
  const range = request.headers.range;
455
464
 
465
+ if (fileSize < MIN_VIDEO_CACHE_BYTES) {
466
+ logger.warn(`Cached video clip too small, deleting and reloading: fileId=${fileId}, size=${fileSize} bytes`);
467
+ try {
468
+ await fs.promises.unlink(cachePath);
469
+ } catch (unlinkErr) {
470
+ logger.warn(`Failed to delete small cached video clip: fileId=${fileId}`, unlinkErr);
471
+ }
472
+ // Force cache miss path below
473
+ throw new Error('Cached video too small, deleted');
474
+ }
475
+
456
476
  logger.log(`Serving cached video clip: fileId=${fileId}, size=${fileSize}, range=${range}`);
457
477
 
458
478
  if (range) {
@@ -493,32 +513,231 @@ export async function handleVideoClipRequest(props: {
493
513
  }
494
514
  } catch (e) {
495
515
  // File not cached, need to proxy RTMP stream
496
- logger.log(`Cache miss, proxying RTMP stream: fileId=${fileId}`);
516
+ logger.log(`[VideoClip] Stream start: fileId=${fileId}`);
497
517
 
498
- // Get RTMP URL directly from API using fileId
499
- // Cast device to CommonCameraMixin to access API
518
+ // Get RTMP URL using the appropriate API (NVR or Baichuan)
500
519
  let rtmpVodUrl: string | undefined;
501
520
  try {
502
- const api = await device.ensureClient();
503
- const result = await api.getRecordingPlaybackUrls({
504
- fileName: fileId,
505
- });
506
- rtmpVodUrl = result.rtmpVodUrl;
521
+ rtmpVodUrl = await device.getVideoClipRtmpUrl(fileId);
507
522
  } catch (e2) {
508
- logger.error(`Failed to get RTMP URL from API: fileId=${fileId}`, e2);
523
+ logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e2);
509
524
  response.send('Failed to get RTMP playback URL', { code: 500 });
510
525
  return;
511
526
  }
512
527
 
513
528
  if (!rtmpVodUrl) {
514
- logger.error(`No RTMP URL found for video: fileId=${fileId}`);
529
+ logger.error(`[VideoClip] Stream error: fileId=${fileId} - No URL found`);
515
530
  response.send('No RTMP playback URL found for video', { code: 404 });
516
531
  return;
517
532
  }
518
533
 
519
- // logger.log(`Got RTMP URL for proxy: fileId=${fileId}`);
534
+ // Check if URL is HTTP (Playback/Download) or RTMP
535
+ const isHttpUrl = rtmpVodUrl.startsWith('http://') || rtmpVodUrl.startsWith('https://');
536
+
537
+ if (isHttpUrl) {
538
+ // For HTTP URLs (Playback/Download from NVR), do direct HTTP proxy with ranged headers support
539
+ logger.debug(`Proxying HTTP URL directly: fileId=${fileId}, url=${rtmpVodUrl}`);
540
+
541
+ const sendVideo = async () => {
542
+ // Pre-fetch ffmpeg path in case we need it for FLV conversion
543
+ const ffmpegPathPromise = sdk.mediaManager.getFFmpegPath();
544
+
545
+ return new Promise<void>(async (resolve, reject) => {
546
+ const urlObj = new URL(rtmpVodUrl);
547
+ const httpModule = urlObj.protocol === 'https:' ? https : http;
548
+
549
+ // Filter and prepare headers (remove host, connection, etc. that shouldn't be forwarded)
550
+ const requestHeaders: Record<string, string> = {};
551
+ if (request.headers.range) {
552
+ requestHeaders['Range'] = request.headers.range;
553
+ }
554
+ // Add other headers that might be needed
555
+ if (request.headers['user-agent']) {
556
+ requestHeaders['User-Agent'] = request.headers['user-agent'];
557
+ }
558
+
559
+ const options = {
560
+ hostname: urlObj.hostname,
561
+ port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
562
+ path: urlObj.pathname + urlObj.search,
563
+ method: 'GET',
564
+ headers: requestHeaders,
565
+ };
566
+
567
+ logger.debug(`Starting HTTP request: ${rtmpVodUrl}, headers: ${JSON.stringify(requestHeaders)}`);
568
+
569
+ httpModule.get(options, async (httpResponse) => {
570
+ if (httpResponse.statusCode && httpResponse.statusCode >= 400) {
571
+ logger.error(`HTTP error: status=${httpResponse.statusCode}, message=${httpResponse.statusMessage}`);
572
+ reject(new Error(`Error loading the video: ${httpResponse.statusCode} - ${httpResponse.statusMessage}`));
573
+ return;
574
+ }
575
+
576
+ let contentType = httpResponse.headers['content-type'] || 'video/mp4';
577
+ const contentLength = httpResponse.headers['content-length'];
578
+ const contentRange = httpResponse.headers['content-range'];
579
+ const acceptRanges = httpResponse.headers['accept-ranges'] || 'bytes';
580
+
581
+ // Check if we need to convert FLV to MP4
582
+ const isFlv = typeof contentType === 'string' && (contentType === 'video/x-flv' || contentType === 'video/flv');
583
+
584
+ if (isFlv) {
585
+ logger.debug(`Content-Type is FLV (${contentType}), will convert to MP4 using ffmpeg`);
586
+ }
587
+
588
+ const responseHeaders: Record<string, string> = {
589
+ 'Content-Type': typeof contentType === 'string' ? contentType : 'video/mp4',
590
+ 'Accept-Ranges': typeof acceptRanges === 'string' ? acceptRanges : 'bytes',
591
+ 'Cache-Control': 'no-cache',
592
+ };
593
+
594
+ if (contentLength) {
595
+ responseHeaders['Content-Length'] = typeof contentLength === 'string' ? contentLength : String(contentLength);
596
+ }
597
+
598
+ if (contentRange) {
599
+ responseHeaders['Content-Range'] = typeof contentRange === 'string' ? contentRange : String(contentRange);
600
+ }
601
+
602
+ const statusCode = httpResponse.statusCode || 200;
603
+
604
+ logger.debug(`HTTP response received: status=${statusCode}, contentType=${contentType}, contentLength=${contentLength || 'unknown'}`);
605
+
606
+ try {
607
+ if (isFlv) {
608
+ // Convert FLV to MP4 using ffmpeg
609
+ const ffmpegPath = await ffmpegPathPromise;
610
+ // Re-encode instead of copy because FLV codec might not be supported
611
+ const ffmpegArgs: string[] = [
612
+ '-i', 'pipe:0', // Read from stdin (httpResponse)
613
+ '-c:v', 'libx264', // Re-encode video to H.264
614
+ '-preset', 'ultrafast', // Fast encoding for streaming
615
+ '-tune', 'zerolatency', // Low latency
616
+ '-c:a', 'aac', // Re-encode audio to AAC
617
+ '-f', 'mp4',
618
+ '-movflags', 'frag_keyframe+empty_moov', // Enable streaming
619
+ 'pipe:1', // Output to stdout
620
+ ];
621
+
622
+ const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
623
+ stdio: ['pipe', 'pipe', 'pipe'],
624
+ });
625
+
626
+ let ffmpegError = '';
627
+ ffmpeg.stderr.on('data', (chunk: Buffer) => {
628
+ ffmpegError += chunk.toString();
629
+ });
630
+
631
+ // Pipe httpResponse to ffmpeg stdin
632
+ httpResponse.pipe(ffmpeg.stdin);
633
+
634
+ ffmpeg.stdin.on('error', (err) => {
635
+ // Ignore EPIPE errors when ffmpeg closes
636
+ if ((err as any).code !== 'EPIPE') {
637
+ logger.error(`FFmpeg stdin error: fileId=${fileId}`, err);
638
+ }
639
+ });
640
+
641
+ httpResponse.on('error', (err) => {
642
+ logger.error(`HTTP response error before ffmpeg: fileId=${fileId}`, err);
643
+ try {
644
+ ffmpeg.kill('SIGKILL');
645
+ } catch (e) {
646
+ // Ignore
647
+ }
648
+ });
649
+
650
+ let streamStarted = false;
651
+
652
+ // Stream ffmpeg output
653
+ response.sendStream((async function* () {
654
+ try {
655
+ for await (const chunk of ffmpeg.stdout) {
656
+ if (!streamStarted) {
657
+ streamStarted = true;
658
+ }
659
+ yield chunk;
660
+ }
661
+ } catch (e) {
662
+ logger.error(`Error streaming ffmpeg output: fileId=${fileId}`, e);
663
+ throw e;
664
+ } finally {
665
+ // Clean up ffmpeg process
666
+ try {
667
+ ffmpeg.kill('SIGKILL');
668
+ } catch (e) {
669
+ // Ignore
670
+ }
671
+ }
672
+ })(), {
673
+ code: 200,
674
+ headers: {
675
+ 'Content-Type': 'video/mp4',
676
+ 'Accept-Ranges': 'bytes',
677
+ 'Cache-Control': 'no-cache',
678
+ },
679
+ });
680
+
681
+ // Handle ffmpeg errors
682
+ ffmpeg.on('close', (code) => {
683
+ if (code !== 0 && code !== null && !streamStarted) {
684
+ const sanitized = sanitizeFfmpegOutput(ffmpegError);
685
+ logger.error(`FFmpeg conversion failed: fileId=${fileId}, code=${code}, error=${sanitized}`);
686
+ reject(new Error(`FFmpeg conversion failed: ${code}`));
687
+ } else {
688
+ logger.debug(`FFmpeg conversion completed: fileId=${fileId}, code=${code}`);
689
+ resolve();
690
+ }
691
+ });
692
+
693
+ ffmpeg.on('error', (error) => {
694
+ logger.error(`FFmpeg spawn error: fileId=${fileId}`, error);
695
+ reject(error);
696
+ });
697
+
698
+ logger.debug(`FFmpeg conversion started: fileId=${fileId}`);
699
+ } else {
700
+ // Direct proxy for non-FLV content (should be MP4 already)
701
+ // Stream directly without buffering - yield chunks as they arrive
702
+ response.sendStream((async function* () {
703
+ try {
704
+ for await (const chunk of Readable.from(httpResponse)) {
705
+ yield chunk;
706
+ }
707
+ logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
708
+ } catch (streamErr) {
709
+ logger.error(`[VideoClip] Stream error: fileId=${fileId}`, streamErr);
710
+ throw streamErr;
711
+ }
712
+ })(), {
713
+ code: statusCode,
714
+ headers: responseHeaders,
715
+ });
716
+
717
+ resolve();
718
+ }
719
+ } catch (err) {
720
+ logger.error(`Error sending stream: fileId=${fileId}`, err);
721
+ reject(err);
722
+ }
723
+ }).on('error', (e) => {
724
+ logger.error(`Error fetching videoclip: fileId=${fileId}`, e);
725
+ reject(e);
726
+ });
727
+ });
728
+ };
729
+
730
+ try {
731
+ await sendVideo();
732
+ return;
733
+ } catch (e) {
734
+ logger.error(`HTTP proxy error: fileId=${fileId}`, e);
735
+ response.send('Failed to proxy HTTP stream', { code: 500 });
736
+ return;
737
+ }
738
+ }
520
739
 
521
- // Use ffmpeg to proxy the RTMP stream
740
+ // For RTMP URLs (camera standalone), use ffmpeg
522
741
  const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
523
742
  const ffmpegArgs: string[] = [
524
743
  '-i', rtmpVodUrl,
@@ -548,8 +767,9 @@ export async function handleVideoClipRequest(props: {
548
767
  }
549
768
  yield chunk;
550
769
  }
770
+ logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
551
771
  } catch (e) {
552
- logger.error(`Error streaming video: fileId=${fileId}`, e);
772
+ logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e);
553
773
  throw e;
554
774
  } finally {
555
775
  // Clean up ffmpeg process
@@ -571,7 +791,8 @@ export async function handleVideoClipRequest(props: {
571
791
  // Handle ffmpeg errors
572
792
  ffmpeg.on('close', (code) => {
573
793
  if (code !== 0 && code !== null && !streamStarted) {
574
- logger.error(`FFmpeg proxy failed for video: fileId=${fileId}, code=${code}, error=${ffmpegError}`);
794
+ const sanitized = sanitizeFfmpegOutput(ffmpegError);
795
+ logger.error(`FFmpeg proxy failed for video: fileId=${fileId}, code=${code}, error=${sanitized}`);
575
796
  }
576
797
  });
577
798
 
@@ -581,4 +802,218 @@ export async function handleVideoClipRequest(props: {
581
802
 
582
803
  return;
583
804
  }
805
+ }
806
+
807
+ /**
808
+ * Parse recordType string to extract detection classes
809
+ * Based on the same logic as ReolinkCgiApi.parseRecordTypeFlags
810
+ */
811
+ function parseRecordTypeToDetectionClasses(recordType?: string): string[] {
812
+ const detectionClasses: string[] = [];
813
+
814
+ if (!recordType) {
815
+ return ['Motion']; // Default to Motion if no type specified
816
+ }
817
+
818
+ // Split by comma or whitespace and process each type
819
+ const types = recordType.toLowerCase().split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
820
+
821
+ let hasMotion = false;
822
+
823
+ for (const t of types) {
824
+ if (t === "people" || t === "person") {
825
+ detectionClasses.push('Person');
826
+ } else if (t === "vehicle" || t === "car") {
827
+ detectionClasses.push('Vehicle');
828
+ } else if (t === "dog_cat" || t === "animal" || t === "pet") {
829
+ detectionClasses.push('Animal');
830
+ } else if (t === "face") {
831
+ detectionClasses.push('Face');
832
+ } else if (t === "md" || t === "motion") {
833
+ hasMotion = true;
834
+ } else if (t === "visitor" || t === "doorbell") {
835
+ detectionClasses.push('Doorbell');
836
+ } else if (t === "package") {
837
+ detectionClasses.push('Package');
838
+ }
839
+ }
840
+
841
+ // Always include Motion as base (if not already added or if no other detections)
842
+ if (hasMotion || detectionClasses.length === 0) {
843
+ detectionClasses.unshift('Motion');
844
+ }
845
+
846
+ return detectionClasses;
847
+ }
848
+
849
+ /**
850
+ * Extract detection classes from parsed filename flags (hex decoding)
851
+ */
852
+ function parseFilenameFlagsToDetectionClasses(parsed?: ParsedRecordingFileName): string[] {
853
+ const detectionClasses: string[] = [];
854
+ const flags = parsed?.flags;
855
+
856
+ if (!flags) {
857
+ return [];
858
+ }
859
+
860
+ // Extract detection types from hex flags (same logic as enrichVodFile)
861
+ if (flags.aiPerson) {
862
+ detectionClasses.push('Person');
863
+ }
864
+ if (flags.aiVehicle) {
865
+ detectionClasses.push('Vehicle');
866
+ }
867
+ if (flags.aiAnimal) {
868
+ detectionClasses.push('Animal');
869
+ }
870
+ if (flags.aiFace) {
871
+ detectionClasses.push('Face');
872
+ }
873
+ if (flags.motion) {
874
+ detectionClasses.push('Motion');
875
+ }
876
+ if (flags.doorbell) {
877
+ detectionClasses.push('Doorbell');
878
+ }
879
+ if (flags.package) {
880
+ detectionClasses.push('Package');
881
+ }
882
+
883
+ return detectionClasses;
884
+ }
885
+
886
+ /**
887
+ * Convert Reolink time format to Date
888
+ * Uses UTC to match the API's dateToReolinkTime conversion
889
+ */
890
+ function reolinkTimeToDate(time: { year: number; mon: number; day: number; hour: number; min: number; sec: number }): Date {
891
+ return new Date(Date.UTC(
892
+ time.year,
893
+ time.mon - 1,
894
+ time.day,
895
+ time.hour,
896
+ time.min,
897
+ time.sec
898
+ ));
899
+ }
900
+
901
+ /**
902
+ * Convert VOD search results to VideoClip array
903
+ */
904
+ export async function vodSearchResultsToVideoClips(
905
+ vodResults: Array<VodSearchResponse>,
906
+ options: {
907
+ deviceId: string;
908
+ plugin: ScryptedDeviceBase;
909
+ logger?: Console;
910
+ }
911
+ ): Promise<VideoClip[]> {
912
+ const { deviceId, plugin, logger } = options;
913
+ const clips: VideoClip[] = [];
914
+
915
+ // Import parseRecordingFileName once (it's exported from the package)
916
+ const reolinkModule = await import("@apocaliss92/reolink-baichuan-js");
917
+ const parseRecordingFileName = reolinkModule.parseRecordingFileName;
918
+
919
+ // Process VOD search results
920
+ for (const result of vodResults) {
921
+ if (result.code !== 0) {
922
+ logger?.debug(`VOD search result code: ${result.code}`, result.error);
923
+ continue;
924
+ }
925
+
926
+ const searchResult = result.value?.SearchResult;
927
+ if (!searchResult?.File || !Array.isArray(searchResult.File)) {
928
+ continue;
929
+ }
930
+
931
+ // Convert each VOD file to VideoClip
932
+ for (const file of searchResult.File) {
933
+ try {
934
+ // Parse filename to extract flags (like enrichVodFile does)
935
+ const parsed = parseRecordingFileName(file.name);
936
+
937
+ // Get times from parsed filename or from StartTime/EndTime
938
+ // Camera times in StartTime/EndTime are in local timezone (not UTC)
939
+ // Use local time constructor to preserve the timezone
940
+ const fileStart = parsed?.start ?? new Date(
941
+ file.StartTime.year,
942
+ file.StartTime.mon - 1,
943
+ file.StartTime.day,
944
+ file.StartTime.hour,
945
+ file.StartTime.min,
946
+ file.StartTime.sec
947
+ );
948
+ const fileEnd = parsed?.end ?? new Date(
949
+ file.EndTime.year,
950
+ file.EndTime.mon - 1,
951
+ file.EndTime.day,
952
+ file.EndTime.hour,
953
+ file.EndTime.min,
954
+ file.EndTime.sec
955
+ );
956
+
957
+ const duration = fileEnd.getTime() - fileStart.getTime();
958
+ const fileName = file.name || '';
959
+
960
+ // Extract detection classes from both filename flags (hex) and file.type
961
+ const filenameFlags = parseFilenameFlagsToDetectionClasses(parsed);
962
+ const typeFlags = parseRecordTypeToDetectionClasses(file.type);
963
+
964
+ // Debug: log file.type to see what we're parsing
965
+ if (logger && file.type) {
966
+ logger.debug(`[VOD] Parsing file.type="${file.type}" for file=${fileName}, filenameFlags=${filenameFlags.join(',')}, typeFlags=${typeFlags.join(',')}`);
967
+ }
968
+
969
+ // Merge both sources (OR them together, like enrichVodFile does)
970
+ // Remove duplicates and ensure Motion is included if any detection is found
971
+ const allDetections = [...filenameFlags, ...typeFlags];
972
+ const detectionClasses = [...new Set(allDetections)];
973
+
974
+ // If we have detections from filename flags, use those (they're more accurate)
975
+ // Otherwise use type flags, or default to Motion
976
+ if (detectionClasses.length === 0) {
977
+ detectionClasses.push('Motion');
978
+ } else if (!detectionClasses.includes('Motion') && (filenameFlags.length > 0 || typeFlags.some(t => t.toLowerCase().includes('motion') || t.toLowerCase().includes('md')))) {
979
+ // Add Motion if we have other detections but Motion is not explicitly included
980
+ detectionClasses.push('Motion');
981
+ }
982
+
983
+ // Generate webhook URLs
984
+ let videoHref: string | undefined;
985
+ let thumbnailHref: string | undefined;
986
+ try {
987
+ const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
988
+ deviceId,
989
+ fileId: fileName,
990
+ plugin,
991
+ logger,
992
+ });
993
+ videoHref = videoUrl;
994
+ thumbnailHref = thumbnailUrl;
995
+ } catch (e) {
996
+ logger?.debug('Failed to generate webhook URLs for VOD file', fileName, e);
997
+ }
998
+
999
+ const clip: VideoClip = {
1000
+ id: fileName,
1001
+ description: fileName,
1002
+ startTime: fileStart.getTime(),
1003
+ duration,
1004
+ resources: {
1005
+ video: videoHref ? { href: videoHref } : undefined,
1006
+ thumbnail: thumbnailHref ? { href: thumbnailHref } : undefined,
1007
+ },
1008
+ detectionClasses,
1009
+ };
1010
+
1011
+ clips.push(clip);
1012
+ } catch (e) {
1013
+ logger?.warn(`Failed to convert VOD file to clip: ${file.name}`, e);
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ return clips;
584
1019
  }