@apocaliss92/scrypted-reolink-native 0.1.33 → 0.1.35

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