@apocaliss92/scrypted-reolink-native 0.1.32 → 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,5 +1,13 @@
1
- import type { DeviceCapabilities, ReolinkDeviceInfo } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
- import sdk, { Device, DeviceBase, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface } from "@scrypted/sdk";
1
+ import type { DeviceCapabilities, EnrichedRecordingFile, ParsedRecordingFileName, RecordingFile, ReolinkBaichuanApi, ReolinkDeviceInfo, VodFile, VodSearchResponse } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
+ import sdk, { DeviceBase, HttpRequest, HttpResponse, MediaObject, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, VideoClip, VideoClips } from "@scrypted/sdk";
3
+ import { spawn } from "node:child_process";
4
+ import { Readable } from "stream";
5
+ import http from "http";
6
+ import https from "https";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import crypto from "crypto";
10
+ import { CommonCameraMixin } from "./common";
3
11
 
4
12
  /**
5
13
  * Enumeration of operation types that may require specific channel assignments
@@ -112,4 +120,864 @@ export const updateDeviceInfo = async (props: {
112
120
  logger.log(`Device info updated`);
113
121
  logger.debug(`${JSON.stringify({ newInfo: device.info, deviceData })}`);
114
122
  }
123
+ }
124
+
125
+ /**
126
+ * Convert a Reolink RecordingFile or EnrichedRecordingFile to a Scrypted VideoClip
127
+ */
128
+ export async function recordingFileToVideoClip(
129
+ rec: RecordingFile | EnrichedRecordingFile,
130
+ options: {
131
+ /** Fallback start date if recording doesn't have one */
132
+ fallbackStart: Date;
133
+ /** API instance to get playback URLs (optional, can provide videoHref directly) */
134
+ api?: ReolinkBaichuanApi;
135
+ /** Pre-fetched video URL (optional, will fetch if not provided and api is available) */
136
+ videoHref?: string;
137
+ /** Logger for debug messages */
138
+ logger?: Console;
139
+ /** Plugin instance for generating webhook URLs */
140
+ plugin?: ScryptedDeviceBase;
141
+ /** Device ID for webhook URLs */
142
+ deviceId?: string;
143
+ /** Use webhook URLs instead of direct RTMP URLs */
144
+ useWebhook?: boolean;
145
+ }
146
+ ): Promise<VideoClip> {
147
+ const { fallbackStart, api, videoHref: providedVideoHref, logger, plugin, deviceId, useWebhook } = options;
148
+
149
+ // Handle both RecordingFile (has startTime/endTime as Date) and EnrichedRecordingFile (has startTimeMs/endTimeMs as number)
150
+ let recStart: Date;
151
+ let recEnd: Date;
152
+
153
+ if ('startTime' in rec && rec.startTime instanceof Date) {
154
+ recStart = rec.startTime;
155
+ } else if ('startTimeMs' in rec && typeof rec.startTimeMs === 'number') {
156
+ recStart = new Date(rec.startTimeMs);
157
+ } else {
158
+ recStart = rec.parsedFileName?.start ?? fallbackStart;
159
+ }
160
+
161
+ if ('endTime' in rec && rec.endTime instanceof Date) {
162
+ recEnd = rec.endTime;
163
+ } else if ('endTimeMs' in rec && typeof rec.endTimeMs === 'number') {
164
+ recEnd = new Date(rec.endTimeMs);
165
+ } else {
166
+ recEnd = rec.parsedFileName?.end ?? recStart;
167
+ }
168
+
169
+ const recStartMs = recStart.getTime();
170
+ const recEndMs = Math.max(recEnd.getTime(), recStartMs);
171
+ const duration = recEndMs - recStartMs;
172
+
173
+ const id = rec.id || rec.fileName;
174
+
175
+ // Get video URL if not provided
176
+ let videoHref: string | undefined = providedVideoHref;
177
+ let thumbnailHref: string | undefined;
178
+
179
+ logger?.debug(`[recordingFileToVideoClip] URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
180
+
181
+ // If webhook is enabled, generate webhook URLs
182
+ if (useWebhook && plugin && deviceId) {
183
+ logger?.debug(`[recordingFileToVideoClip] Generating webhook URLs for fileId=${id}`);
184
+ try {
185
+ const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
186
+ deviceId,
187
+ fileId: id,
188
+ plugin,
189
+ });
190
+ videoHref = videoUrl;
191
+ thumbnailHref = thumbnailUrl;
192
+ logger?.debug(`[recordingFileToVideoClip] Webhook URLs generated successfully: videoHref="${videoHref}", thumbnailHref="${thumbnailHref}"`);
193
+ } catch (e) {
194
+ logger?.error(`[recordingFileToVideoClip] Failed to generate webhook URLs for fileId=${id}:`, e);
195
+ }
196
+ } else if (!videoHref && api) {
197
+ // Fallback to direct RTMP URL if webhook is not used
198
+ logger?.debug(`[recordingFileToVideoClip] Fetching RTMP playback URL for fileName=${rec.fileName}`);
199
+ try {
200
+ const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
201
+ fileName: rec.fileName,
202
+ });
203
+ videoHref = rtmpVodUrl;
204
+ logger?.debug(`[recordingFileToVideoClip] RTMP URL fetched successfully: videoHref="${videoHref}"`);
205
+ } catch (e) {
206
+ logger?.debug(`[recordingFileToVideoClip] Failed to build playback URL for recording fileName=${rec.fileName}:`, e);
207
+ }
208
+ } else {
209
+ logger?.debug(`[recordingFileToVideoClip] No URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
210
+ }
211
+
212
+ const description = ('name' in rec && typeof rec.name === 'string' && rec.name) ? rec.name : (rec.fileName ?? rec.id ?? '');
213
+
214
+ // Build detectionClasses from flags or recordType
215
+ const detectionClasses: string[] = [];
216
+
217
+ // Check for EnrichedRecordingFile flags first (most accurate)
218
+ let hasAnyDetection = false;
219
+ if ('hasPerson' in rec && rec.hasPerson) {
220
+ detectionClasses.push('Person');
221
+ hasAnyDetection = true;
222
+ }
223
+ if ('hasVehicle' in rec && rec.hasVehicle) {
224
+ detectionClasses.push('Vehicle');
225
+ hasAnyDetection = true;
226
+ }
227
+ if ('hasAnimal' in rec && rec.hasAnimal) {
228
+ detectionClasses.push('Animal');
229
+ hasAnyDetection = true;
230
+ }
231
+ if ('hasFace' in rec && rec.hasFace) {
232
+ detectionClasses.push('Face');
233
+ hasAnyDetection = true;
234
+ }
235
+ if ('hasMotion' in rec && rec.hasMotion) {
236
+ detectionClasses.push('Motion');
237
+ hasAnyDetection = true;
238
+ }
239
+ if ('hasDoorbell' in rec && rec.hasDoorbell) {
240
+ detectionClasses.push('Doorbell');
241
+ hasAnyDetection = true;
242
+ }
243
+ if ('hasPackage' in rec && rec.hasPackage) {
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
+ };
260
+ }
261
+
262
+ // Fallback: parse recordType string if flags are not available
263
+ if (!hasAnyDetection && rec.recordType) {
264
+ const recordTypeLower = rec.recordType.toLowerCase();
265
+ if (recordTypeLower.includes('people') || recordTypeLower.includes('person')) {
266
+ detectionClasses.push('Person');
267
+ }
268
+ if (recordTypeLower.includes('vehicle')) {
269
+ detectionClasses.push('Vehicle');
270
+ }
271
+ if (recordTypeLower.includes('dog_cat') || recordTypeLower.includes('animal')) {
272
+ detectionClasses.push('Animal');
273
+ }
274
+ if (recordTypeLower.includes('face')) {
275
+ detectionClasses.push('Face');
276
+ }
277
+ if (recordTypeLower.includes('md') || recordTypeLower.includes('motion')) {
278
+ detectionClasses.push('Motion');
279
+ }
280
+ if (recordTypeLower.includes('visitor') || recordTypeLower.includes('doorbell')) {
281
+ detectionClasses.push('Doorbell');
282
+ }
283
+ if (recordTypeLower.includes('package')) {
284
+ detectionClasses.push('Package');
285
+ }
286
+ }
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
+
301
+ return {
302
+ id,
303
+ startTime: recStartMs,
304
+ duration,
305
+ event: rec.recordType,
306
+ description,
307
+ detectionClasses: detectionClasses.length > 0 ? detectionClasses : undefined,
308
+ resources,
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Generate webhook URLs for video clips
314
+ */
315
+ export async function getVideoClipWebhookUrls(props: {
316
+ deviceId: string;
317
+ fileId: string;
318
+ plugin: ScryptedDeviceBase;
319
+ }): Promise<{ videoUrl: string; thumbnailUrl: string }> {
320
+ const { deviceId, fileId, plugin } = props;
321
+
322
+ plugin.console.debug(`[getVideoClipWebhookUrls] Starting URL generation: deviceId=${deviceId}, fileId=${fileId}`);
323
+
324
+ try {
325
+ let endpoint: string;
326
+ let endpointSource: 'cloud' | 'local';
327
+ try {
328
+ endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, { public: true });
329
+ endpointSource = 'cloud';
330
+ plugin.console.debug(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
331
+ } catch (e) {
332
+ // 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)}`);
334
+ endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
335
+ endpointSource = 'local';
336
+ plugin.console.debug(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
337
+ }
338
+
339
+ const encodedDeviceId = encodeURIComponent(deviceId);
340
+ // Remove leading slash from fileId if present, as it causes invalid paths when encoded
341
+ const cleanFileId = fileId.startsWith('/') ? fileId.substring(1) : fileId;
342
+ const encodedFileId = encodeURIComponent(cleanFileId);
343
+
344
+ plugin.console.debug(`[getVideoClipWebhookUrls] Encoding: deviceId="${deviceId}" -> "${encodedDeviceId}", fileId="${fileId}" -> cleanFileId="${cleanFileId}" -> encodedFileId="${encodedFileId}"`);
345
+
346
+ // Parse endpoint URL to extract query parameters (for authentication)
347
+ const endpointUrl = new URL(endpoint);
348
+ // Preserve query parameters (e.g., user_token for authentication)
349
+ const queryParams = endpointUrl.search;
350
+ // Remove query parameters from the base endpoint URL
351
+ endpointUrl.search = '';
352
+
353
+ plugin.console.debug(`[getVideoClipWebhookUrls] Parsed endpoint URL: base="${endpointUrl.toString()}", queryParams="${queryParams}"`);
354
+
355
+ // Ensure endpoint has trailing slash
356
+ const normalizedEndpoint = endpointUrl.toString().endsWith('/') ? endpointUrl.toString() : `${endpointUrl.toString()}/`;
357
+
358
+ plugin.console.debug(`[getVideoClipWebhookUrls] Normalized endpoint: "${normalizedEndpoint}"`);
359
+
360
+ // Build webhook URLs and append query parameters at the end
361
+ const videoUrl = `${normalizedEndpoint}webhook/video/${encodedDeviceId}/${encodedFileId}${queryParams}`;
362
+ const thumbnailUrl = `${normalizedEndpoint}webhook/thumbnail/${encodedDeviceId}/${encodedFileId}${queryParams}`;
363
+
364
+ plugin.console.debug(`[getVideoClipWebhookUrls] Generated URLs: videoUrl="${videoUrl}", thumbnailUrl="${thumbnailUrl}"`);
365
+
366
+ return { videoUrl, thumbnailUrl };
367
+ } catch (e) {
368
+ plugin.console.error(`[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`, e);
369
+ throw e;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Extract a thumbnail frame from video using ffmpeg
375
+ */
376
+ export async function extractThumbnailFromVideo(props: {
377
+ rtmpUrl?: string;
378
+ filePath?: string;
379
+ fileId: string;
380
+ deviceId: string;
381
+ logger: Console;
382
+ }): Promise<MediaObject> {
383
+ const { rtmpUrl, filePath, fileId, deviceId, logger } = props;
384
+
385
+ // Use file path if available, otherwise use RTMP URL
386
+ const inputSource = filePath || rtmpUrl;
387
+ if (!inputSource) {
388
+ throw new Error('Either rtmpUrl or filePath must be provided');
389
+ }
390
+
391
+ try {
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
+ ],
399
+ });
400
+ return mo;
401
+ } catch (e) {
402
+ // Error already logged in main.ts
403
+ throw e;
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Get cache file path for a video clip
409
+ */
410
+ function getVideoClipCachePath(deviceId: string, fileId: string): string {
411
+ const pluginVolume = process.env.SCRYPTED_PLUGIN_VOLUME || '';
412
+ // Create a safe filename from fileId using hash
413
+ const hash = crypto.createHash('md5').update(fileId).digest('hex');
414
+ // Keep original extension if present, otherwise use .mp4
415
+ const ext = fileId.includes('.') ? path.extname(fileId) : '.mp4';
416
+ const cacheDir = path.join(pluginVolume, 'videoclips', deviceId);
417
+ return path.join(cacheDir, `${hash}${ext}`);
418
+ }
419
+
420
+ /**
421
+ * Handle video clip webhook request
422
+ * Checks cache first, then proxies RTMP stream if not cached
423
+ */
424
+ export async function handleVideoClipRequest(props: {
425
+ device: CommonCameraMixin;
426
+ deviceId: string;
427
+ fileId: string;
428
+ request: HttpRequest;
429
+ response: HttpResponse;
430
+ logger: Console;
431
+ }): Promise<void> {
432
+ const { device, deviceId, fileId, request, response, logger } = props;
433
+
434
+ // Check if file is cached
435
+ const cachePath = getVideoClipCachePath(deviceId, fileId);
436
+
437
+ try {
438
+ // Check if cached file exists
439
+ const stat = await fs.promises.stat(cachePath);
440
+ const fileSize = stat.size;
441
+ const range = request.headers.range;
442
+
443
+ logger.log(`Serving cached video clip: fileId=${fileId}, size=${fileSize}, range=${range}`);
444
+
445
+ if (range) {
446
+ // Parse range header
447
+ const parts = range.replace(/bytes=/, "").split("-");
448
+ const start = parseInt(parts[0], 10);
449
+ const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
450
+
451
+ const chunksize = (end - start) + 1;
452
+ const file = fs.createReadStream(cachePath, { start, end });
453
+
454
+ // Send stream with range support
455
+ response.sendStream((async function* () {
456
+ for await (const chunk of file) {
457
+ yield chunk;
458
+ }
459
+ })(), {
460
+ code: 206,
461
+ headers: {
462
+ 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
463
+ 'Accept-Ranges': 'bytes',
464
+ 'Content-Length': chunksize.toString(),
465
+ 'Content-Type': 'video/mp4',
466
+ }
467
+ });
468
+ return;
469
+ } else {
470
+ // No range header, send full file
471
+ response.sendFile(cachePath, {
472
+ code: 200,
473
+ headers: {
474
+ 'Content-Length': fileSize.toString(),
475
+ 'Content-Type': 'video/mp4',
476
+ 'Accept-Ranges': 'bytes',
477
+ }
478
+ });
479
+ return;
480
+ }
481
+ } catch (e) {
482
+ // File not cached, need to proxy RTMP stream
483
+ logger.log(`[VideoClip] Stream start: fileId=${fileId}`);
484
+
485
+ // Get RTMP URL using the appropriate API (NVR or Baichuan)
486
+ let rtmpVodUrl: string | undefined;
487
+ try {
488
+ rtmpVodUrl = await device.getVideoClipRtmpUrl(fileId);
489
+ } catch (e2) {
490
+ logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e2);
491
+ response.send('Failed to get RTMP playback URL', { code: 500 });
492
+ return;
493
+ }
494
+
495
+ if (!rtmpVodUrl) {
496
+ logger.error(`[VideoClip] Stream error: fileId=${fileId} - No URL found`);
497
+ response.send('No RTMP playback URL found for video', { code: 404 });
498
+ return;
499
+ }
500
+
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
+ }
705
+
706
+ // For RTMP URLs (camera standalone), use ffmpeg
707
+ const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
708
+ const ffmpegArgs: string[] = [
709
+ '-i', rtmpVodUrl,
710
+ '-c', 'copy', // Copy codecs without re-encoding
711
+ '-f', 'mp4',
712
+ '-movflags', 'frag_keyframe+empty_moov', // Enable streaming
713
+ 'pipe:1', // Output to stdout
714
+ ];
715
+
716
+ const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
717
+ stdio: ['ignore', 'pipe', 'pipe'],
718
+ });
719
+
720
+ let ffmpegError = '';
721
+ ffmpeg.stderr.on('data', (chunk: Buffer) => {
722
+ ffmpegError += chunk.toString();
723
+ });
724
+
725
+ let streamStarted = false;
726
+
727
+ // Stream the output
728
+ response.sendStream((async function* () {
729
+ try {
730
+ for await (const chunk of ffmpeg.stdout) {
731
+ if (!streamStarted) {
732
+ streamStarted = true;
733
+ }
734
+ yield chunk;
735
+ }
736
+ logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
737
+ } catch (e) {
738
+ logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e);
739
+ throw e;
740
+ } finally {
741
+ // Clean up ffmpeg process
742
+ try {
743
+ ffmpeg.kill('SIGKILL');
744
+ } catch (e) {
745
+ // Ignore
746
+ }
747
+ }
748
+ })(), {
749
+ code: 200,
750
+ headers: {
751
+ 'Content-Type': 'video/mp4',
752
+ 'Accept-Ranges': 'bytes',
753
+ 'Cache-Control': 'no-cache',
754
+ },
755
+ });
756
+
757
+ // Handle ffmpeg errors
758
+ ffmpeg.on('close', (code) => {
759
+ if (code !== 0 && code !== null && !streamStarted) {
760
+ logger.error(`FFmpeg proxy failed for video: fileId=${fileId}, code=${code}, error=${ffmpegError}`);
761
+ }
762
+ });
763
+
764
+ ffmpeg.on('error', (error) => {
765
+ logger.error(`FFmpeg spawn error for video proxy: fileId=${fileId}`, error);
766
+ });
767
+
768
+ return;
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;
115
983
  }