@apocaliss92/scrypted-reolink-native 0.1.32 → 0.1.33

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,10 @@
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, RecordingFile, ReolinkBaichuanApi, ReolinkDeviceInfo } 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 fs from "fs";
5
+ import path from "path";
6
+ import crypto from "crypto";
7
+ import { CommonCameraMixin } from "./common";
3
8
 
4
9
  /**
5
10
  * Enumeration of operation types that may require specific channel assignments
@@ -112,4 +117,468 @@ export const updateDeviceInfo = async (props: {
112
117
  logger.log(`Device info updated`);
113
118
  logger.debug(`${JSON.stringify({ newInfo: device.info, deviceData })}`);
114
119
  }
120
+ }
121
+
122
+ /**
123
+ * Convert a Reolink RecordingFile or EnrichedRecordingFile to a Scrypted VideoClip
124
+ */
125
+ export async function recordingFileToVideoClip(
126
+ rec: RecordingFile | EnrichedRecordingFile,
127
+ options: {
128
+ /** Fallback start date if recording doesn't have one */
129
+ fallbackStart: Date;
130
+ /** API instance to get playback URLs (optional, can provide videoHref directly) */
131
+ api?: ReolinkBaichuanApi;
132
+ /** Pre-fetched video URL (optional, will fetch if not provided and api is available) */
133
+ videoHref?: string;
134
+ /** Logger for debug messages */
135
+ logger?: Console;
136
+ /** Plugin instance for generating webhook URLs */
137
+ plugin?: ScryptedDeviceBase;
138
+ /** Device ID for webhook URLs */
139
+ deviceId?: string;
140
+ /** Use webhook URLs instead of direct RTMP URLs */
141
+ useWebhook?: boolean;
142
+ }
143
+ ): Promise<VideoClip> {
144
+ const { fallbackStart, api, videoHref: providedVideoHref, logger, plugin, deviceId, useWebhook } = options;
145
+
146
+ // Handle both RecordingFile (has startTime/endTime as Date) and EnrichedRecordingFile (has startTimeMs/endTimeMs as number)
147
+ let recStart: Date;
148
+ let recEnd: Date;
149
+
150
+ if ('startTime' in rec && rec.startTime instanceof Date) {
151
+ recStart = rec.startTime;
152
+ } else if ('startTimeMs' in rec && typeof rec.startTimeMs === 'number') {
153
+ recStart = new Date(rec.startTimeMs);
154
+ } else {
155
+ recStart = rec.parsedFileName?.start ?? fallbackStart;
156
+ }
157
+
158
+ if ('endTime' in rec && rec.endTime instanceof Date) {
159
+ recEnd = rec.endTime;
160
+ } else if ('endTimeMs' in rec && typeof rec.endTimeMs === 'number') {
161
+ recEnd = new Date(rec.endTimeMs);
162
+ } else {
163
+ recEnd = rec.parsedFileName?.end ?? recStart;
164
+ }
165
+
166
+ const recStartMs = recStart.getTime();
167
+ const recEndMs = Math.max(recEnd.getTime(), recStartMs);
168
+ const duration = recEndMs - recStartMs;
169
+
170
+ const id = rec.id || rec.fileName;
171
+
172
+ // Get video URL if not provided
173
+ let videoHref: string | undefined = providedVideoHref;
174
+ let thumbnailHref: string | undefined;
175
+
176
+ // If webhook is enabled, generate webhook URLs
177
+ if (useWebhook && plugin && deviceId) {
178
+ try {
179
+ const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
180
+ deviceId,
181
+ fileId: id,
182
+ plugin,
183
+ });
184
+ videoHref = videoUrl;
185
+ thumbnailHref = thumbnailUrl;
186
+ } catch (e) {
187
+ logger?.error('recordingFileToVideoClip: failed to generate webhook URLs', e);
188
+ }
189
+ } else if (!videoHref && api) {
190
+ // Fallback to direct RTMP URL if webhook is not used
191
+ try {
192
+ const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
193
+ fileName: rec.fileName,
194
+ });
195
+ videoHref = rtmpVodUrl;
196
+ } catch (e) {
197
+ logger?.debug('recordingFileToVideoClip: failed to build playback URL for recording', rec.fileName, e);
198
+ }
199
+ }
200
+
201
+ const description = ('name' in rec && typeof rec.name === 'string' && rec.name) ? rec.name : (rec.fileName ?? rec.id ?? '');
202
+
203
+ // Build detectionClasses from flags or recordType
204
+ const detectionClasses: string[] = ['Motion'];
205
+
206
+ // Check for EnrichedRecordingFile flags
207
+ if ('hasPerson' in rec && rec.hasPerson) {
208
+ detectionClasses.push('Person');
209
+ }
210
+ if ('hasVehicle' in rec && rec.hasVehicle) {
211
+ detectionClasses.push('Vehicle');
212
+ }
213
+ if ('hasAnimal' in rec && rec.hasAnimal) {
214
+ detectionClasses.push('Animal');
215
+ }
216
+ if ('hasFace' in rec && rec.hasFace) {
217
+ detectionClasses.push('Face');
218
+ }
219
+ if ('hasDoorbell' in rec && rec.hasDoorbell) {
220
+ detectionClasses.push('Doorbell');
221
+ }
222
+ if ('hasPackage' in rec && rec.hasPackage) {
223
+ detectionClasses.push('Package');
224
+ }
225
+
226
+ // Fallback: parse recordType string if flags are not available
227
+ if (detectionClasses.length === 0 && rec.recordType) {
228
+ const recordTypeLower = rec.recordType.toLowerCase();
229
+ if (recordTypeLower.includes('people') || recordTypeLower.includes('person')) {
230
+ detectionClasses.push('Person');
231
+ }
232
+ if (recordTypeLower.includes('vehicle')) {
233
+ detectionClasses.push('Vehicle');
234
+ }
235
+ if (recordTypeLower.includes('dog_cat') || recordTypeLower.includes('animal')) {
236
+ detectionClasses.push('Animal');
237
+ }
238
+ if (recordTypeLower.includes('face')) {
239
+ detectionClasses.push('Face');
240
+ }
241
+ if (recordTypeLower.includes('md') || recordTypeLower.includes('motion')) {
242
+ detectionClasses.push('Motion');
243
+ }
244
+ if (recordTypeLower.includes('visitor') || recordTypeLower.includes('doorbell')) {
245
+ detectionClasses.push('Doorbell');
246
+ }
247
+ if (recordTypeLower.includes('package')) {
248
+ detectionClasses.push('Package');
249
+ }
250
+ }
251
+
252
+ return {
253
+ id,
254
+ startTime: recStartMs,
255
+ duration,
256
+ event: rec.recordType,
257
+ description,
258
+ detectionClasses: detectionClasses.length > 0 ? detectionClasses : undefined,
259
+ resources: videoHref || thumbnailHref
260
+ ? {
261
+ ...(videoHref ? { video: { href: videoHref } } : {}),
262
+ ...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
263
+ }
264
+ : undefined,
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Generate webhook URLs for video clips
270
+ */
271
+ export async function getVideoClipWebhookUrls(props: {
272
+ deviceId: string;
273
+ fileId: string;
274
+ plugin: ScryptedDeviceBase;
275
+ }): Promise<{ videoUrl: string; thumbnailUrl: string }> {
276
+ const { deviceId, fileId, plugin } = props;
277
+
278
+ try {
279
+ let endpoint: string;
280
+ try {
281
+ endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, { public: true });
282
+ } catch (e) {
283
+ // 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);
285
+ endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
286
+ }
287
+
288
+ const encodedDeviceId = encodeURIComponent(deviceId);
289
+ // Remove leading slash from fileId if present, as it causes invalid paths when encoded
290
+ const cleanFileId = fileId.startsWith('/') ? fileId.substring(1) : fileId;
291
+ const encodedFileId = encodeURIComponent(cleanFileId);
292
+
293
+ // Parse endpoint URL to extract query parameters (for authentication)
294
+ const endpointUrl = new URL(endpoint);
295
+ // Preserve query parameters (e.g., user_token for authentication)
296
+ const queryParams = endpointUrl.search;
297
+ // Remove query parameters from the base endpoint URL
298
+ endpointUrl.search = '';
299
+
300
+ // Ensure endpoint has trailing slash
301
+ const normalizedEndpoint = endpointUrl.toString().endsWith('/') ? endpointUrl.toString() : `${endpointUrl.toString()}/`;
302
+
303
+ // Build webhook URLs and append query parameters at the end
304
+ const videoUrl = `${normalizedEndpoint}webhook/video/${encodedDeviceId}/${encodedFileId}${queryParams}`;
305
+ const thumbnailUrl = `${normalizedEndpoint}webhook/thumbnail/${encodedDeviceId}/${encodedFileId}${queryParams}`;
306
+
307
+ return { videoUrl, thumbnailUrl };
308
+ } catch (e) {
309
+ plugin.console.error('Failed to generate webhook URLs', e);
310
+ throw e;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Extract a thumbnail frame from video using ffmpeg
316
+ */
317
+ export async function extractThumbnailFromVideo(props: {
318
+ rtmpUrl?: string;
319
+ filePath?: string;
320
+ fileId: string;
321
+ deviceId: string;
322
+ logger: Console;
323
+ }): Promise<MediaObject> {
324
+ const { rtmpUrl, filePath, fileId, deviceId, logger } = props;
325
+
326
+ // Use file path if available, otherwise use RTMP URL
327
+ const inputSource = filePath || rtmpUrl;
328
+ if (!inputSource) {
329
+ throw new Error('Either rtmpUrl or filePath must be provided');
330
+ }
331
+
332
+ 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
+ });
413
+ });
414
+ } catch (e) {
415
+ logger.error(`[Thumbnail] Error: fileId=${fileId}`, e);
416
+ throw e;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Get cache file path for a video clip
422
+ */
423
+ function getVideoClipCachePath(deviceId: string, fileId: string): string {
424
+ const pluginVolume = process.env.SCRYPTED_PLUGIN_VOLUME || '';
425
+ // Create a safe filename from fileId using hash
426
+ const hash = crypto.createHash('md5').update(fileId).digest('hex');
427
+ // Keep original extension if present, otherwise use .mp4
428
+ const ext = fileId.includes('.') ? path.extname(fileId) : '.mp4';
429
+ const cacheDir = path.join(pluginVolume, 'snapshots', deviceId);
430
+ return path.join(cacheDir, `${hash}${ext}`);
431
+ }
432
+
433
+ /**
434
+ * Handle video clip webhook request
435
+ * Checks cache first, then proxies RTMP stream if not cached
436
+ */
437
+ export async function handleVideoClipRequest(props: {
438
+ device: CommonCameraMixin;
439
+ deviceId: string;
440
+ fileId: string;
441
+ request: HttpRequest;
442
+ response: HttpResponse;
443
+ logger: Console;
444
+ }): Promise<void> {
445
+ const { device, deviceId, fileId, request, response, logger } = props;
446
+
447
+ // Check if file is cached
448
+ const cachePath = getVideoClipCachePath(deviceId, fileId);
449
+
450
+ try {
451
+ // Check if cached file exists
452
+ const stat = await fs.promises.stat(cachePath);
453
+ const fileSize = stat.size;
454
+ const range = request.headers.range;
455
+
456
+ logger.log(`Serving cached video clip: fileId=${fileId}, size=${fileSize}, range=${range}`);
457
+
458
+ if (range) {
459
+ // Parse range header
460
+ const parts = range.replace(/bytes=/, "").split("-");
461
+ const start = parseInt(parts[0], 10);
462
+ const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
463
+
464
+ const chunksize = (end - start) + 1;
465
+ const file = fs.createReadStream(cachePath, { start, end });
466
+
467
+ // Send stream with range support
468
+ response.sendStream((async function* () {
469
+ for await (const chunk of file) {
470
+ yield chunk;
471
+ }
472
+ })(), {
473
+ code: 206,
474
+ headers: {
475
+ 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
476
+ 'Accept-Ranges': 'bytes',
477
+ 'Content-Length': chunksize.toString(),
478
+ 'Content-Type': 'video/mp4',
479
+ }
480
+ });
481
+ return;
482
+ } else {
483
+ // No range header, send full file
484
+ response.sendFile(cachePath, {
485
+ code: 200,
486
+ headers: {
487
+ 'Content-Length': fileSize.toString(),
488
+ 'Content-Type': 'video/mp4',
489
+ 'Accept-Ranges': 'bytes',
490
+ }
491
+ });
492
+ return;
493
+ }
494
+ } catch (e) {
495
+ // File not cached, need to proxy RTMP stream
496
+ logger.log(`Cache miss, proxying RTMP stream: fileId=${fileId}`);
497
+
498
+ // Get RTMP URL directly from API using fileId
499
+ // Cast device to CommonCameraMixin to access API
500
+ let rtmpVodUrl: string | undefined;
501
+ try {
502
+ const api = await device.ensureClient();
503
+ const result = await api.getRecordingPlaybackUrls({
504
+ fileName: fileId,
505
+ });
506
+ rtmpVodUrl = result.rtmpVodUrl;
507
+ } catch (e2) {
508
+ logger.error(`Failed to get RTMP URL from API: fileId=${fileId}`, e2);
509
+ response.send('Failed to get RTMP playback URL', { code: 500 });
510
+ return;
511
+ }
512
+
513
+ if (!rtmpVodUrl) {
514
+ logger.error(`No RTMP URL found for video: fileId=${fileId}`);
515
+ response.send('No RTMP playback URL found for video', { code: 404 });
516
+ return;
517
+ }
518
+
519
+ // logger.log(`Got RTMP URL for proxy: fileId=${fileId}`);
520
+
521
+ // Use ffmpeg to proxy the RTMP stream
522
+ const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
523
+ const ffmpegArgs: string[] = [
524
+ '-i', rtmpVodUrl,
525
+ '-c', 'copy', // Copy codecs without re-encoding
526
+ '-f', 'mp4',
527
+ '-movflags', 'frag_keyframe+empty_moov', // Enable streaming
528
+ 'pipe:1', // Output to stdout
529
+ ];
530
+
531
+ const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
532
+ stdio: ['ignore', 'pipe', 'pipe'],
533
+ });
534
+
535
+ let ffmpegError = '';
536
+ ffmpeg.stderr.on('data', (chunk: Buffer) => {
537
+ ffmpegError += chunk.toString();
538
+ });
539
+
540
+ let streamStarted = false;
541
+
542
+ // Stream the output
543
+ response.sendStream((async function* () {
544
+ try {
545
+ for await (const chunk of ffmpeg.stdout) {
546
+ if (!streamStarted) {
547
+ streamStarted = true;
548
+ }
549
+ yield chunk;
550
+ }
551
+ } catch (e) {
552
+ logger.error(`Error streaming video: fileId=${fileId}`, e);
553
+ throw e;
554
+ } finally {
555
+ // Clean up ffmpeg process
556
+ try {
557
+ ffmpeg.kill('SIGKILL');
558
+ } catch (e) {
559
+ // Ignore
560
+ }
561
+ }
562
+ })(), {
563
+ code: 200,
564
+ headers: {
565
+ 'Content-Type': 'video/mp4',
566
+ 'Accept-Ranges': 'bytes',
567
+ 'Cache-Control': 'no-cache',
568
+ },
569
+ });
570
+
571
+ // Handle ffmpeg errors
572
+ ffmpeg.on('close', (code) => {
573
+ if (code !== 0 && code !== null && !streamStarted) {
574
+ logger.error(`FFmpeg proxy failed for video: fileId=${fileId}, code=${code}, error=${ffmpegError}`);
575
+ }
576
+ });
577
+
578
+ ffmpeg.on('error', (error) => {
579
+ logger.error(`FFmpeg spawn error for video proxy: fileId=${fileId}`, error);
580
+ });
581
+
582
+ return;
583
+ }
115
584
  }