@apocaliss92/scrypted-reolink-native 0.1.35 → 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/.vscode/settings.json +1 -1
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/common.ts +139 -66
- package/src/main.ts +9 -5
- package/src/utils.ts +66 -30
package/src/utils.ts
CHANGED
|
@@ -8,6 +8,25 @@ import fs from "fs";
|
|
|
8
8
|
import path from "path";
|
|
9
9
|
import crypto from "crypto";
|
|
10
10
|
import { CommonCameraMixin } from "./common";
|
|
11
|
+
/**
|
|
12
|
+
* Sanitize FFmpeg output or URLs to avoid leaking credentials
|
|
13
|
+
*/
|
|
14
|
+
export function sanitizeFfmpegOutput(text: string): string {
|
|
15
|
+
if (!text)
|
|
16
|
+
return text;
|
|
17
|
+
|
|
18
|
+
let sanitized = text;
|
|
19
|
+
|
|
20
|
+
// Remove user/password query parameters from URLs: ?user=xxx&password=yyy
|
|
21
|
+
sanitized = sanitized.replace(/(\buser=)[^&\s]*/gi, '$1***');
|
|
22
|
+
sanitized = sanitized.replace(/(\bpassword=)[^&\s]*/gi, '$1***');
|
|
23
|
+
|
|
24
|
+
// Remove credentials from URLs like rtmp://user:pass@host/...
|
|
25
|
+
sanitized = sanitized.replace(/(rtmp:\/\/)([^:@\/\s]+):([^@\/\s]+)@/gi, '$1$2:***@');
|
|
26
|
+
|
|
27
|
+
return sanitized;
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
|
|
12
31
|
/**
|
|
13
32
|
* Enumeration of operation types that may require specific channel assignments
|
|
@@ -186,6 +205,7 @@ export async function recordingFileToVideoClip(
|
|
|
186
205
|
deviceId,
|
|
187
206
|
fileId: id,
|
|
188
207
|
plugin,
|
|
208
|
+
logger,
|
|
189
209
|
});
|
|
190
210
|
videoHref = videoUrl;
|
|
191
211
|
thumbnailHref = thumbnailUrl;
|
|
@@ -316,10 +336,12 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
316
336
|
deviceId: string;
|
|
317
337
|
fileId: string;
|
|
318
338
|
plugin: ScryptedDeviceBase;
|
|
339
|
+
logger?: Console;
|
|
319
340
|
}): Promise<{ videoUrl: string; thumbnailUrl: string }> {
|
|
320
|
-
const { deviceId, fileId, plugin } = props;
|
|
341
|
+
const { deviceId, fileId, plugin, logger } = props;
|
|
342
|
+
const log = logger || plugin.console;
|
|
321
343
|
|
|
322
|
-
|
|
344
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Starting URL generation: deviceId=${deviceId}, fileId=${fileId}`);
|
|
323
345
|
|
|
324
346
|
try {
|
|
325
347
|
let endpoint: string;
|
|
@@ -327,13 +349,13 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
327
349
|
try {
|
|
328
350
|
endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, { public: true });
|
|
329
351
|
endpointSource = 'cloud';
|
|
330
|
-
|
|
352
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
|
|
331
353
|
} catch (e) {
|
|
332
354
|
// Fallback to local endpoint if cloud is not available (e.g., not logged in)
|
|
333
|
-
|
|
355
|
+
log.debug?.(`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e instanceof Error ? e.message : String(e)}`);
|
|
334
356
|
endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
|
|
335
357
|
endpointSource = 'local';
|
|
336
|
-
|
|
358
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
|
|
337
359
|
}
|
|
338
360
|
|
|
339
361
|
const encodedDeviceId = encodeURIComponent(deviceId);
|
|
@@ -341,8 +363,6 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
341
363
|
const cleanFileId = fileId.startsWith('/') ? fileId.substring(1) : fileId;
|
|
342
364
|
const encodedFileId = encodeURIComponent(cleanFileId);
|
|
343
365
|
|
|
344
|
-
plugin.console.debug(`[getVideoClipWebhookUrls] Encoding: deviceId="${deviceId}" -> "${encodedDeviceId}", fileId="${fileId}" -> cleanFileId="${cleanFileId}" -> encodedFileId="${encodedFileId}"`);
|
|
345
|
-
|
|
346
366
|
// Parse endpoint URL to extract query parameters (for authentication)
|
|
347
367
|
const endpointUrl = new URL(endpoint);
|
|
348
368
|
// Preserve query parameters (e.g., user_token for authentication)
|
|
@@ -350,22 +370,19 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
350
370
|
// Remove query parameters from the base endpoint URL
|
|
351
371
|
endpointUrl.search = '';
|
|
352
372
|
|
|
353
|
-
plugin.console.debug(`[getVideoClipWebhookUrls] Parsed endpoint URL: base="${endpointUrl.toString()}", queryParams="${queryParams}"`);
|
|
354
|
-
|
|
355
373
|
// Ensure endpoint has trailing slash
|
|
356
374
|
const normalizedEndpoint = endpointUrl.toString().endsWith('/') ? endpointUrl.toString() : `${endpointUrl.toString()}/`;
|
|
357
375
|
|
|
358
|
-
plugin.console.debug(`[getVideoClipWebhookUrls] Normalized endpoint: "${normalizedEndpoint}"`);
|
|
359
|
-
|
|
360
376
|
// Build webhook URLs and append query parameters at the end
|
|
361
377
|
const videoUrl = `${normalizedEndpoint}webhook/video/${encodedDeviceId}/${encodedFileId}${queryParams}`;
|
|
362
378
|
const thumbnailUrl = `${normalizedEndpoint}webhook/thumbnail/${encodedDeviceId}/${encodedFileId}${queryParams}`;
|
|
363
379
|
|
|
364
|
-
plugin.console.debug(`[getVideoClipWebhookUrls] Generated URLs: videoUrl="${videoUrl}", thumbnailUrl="${thumbnailUrl}"`);
|
|
365
|
-
|
|
366
380
|
return { videoUrl, thumbnailUrl };
|
|
367
381
|
} catch (e) {
|
|
368
|
-
|
|
382
|
+
log.error?.(
|
|
383
|
+
`[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`,
|
|
384
|
+
e
|
|
385
|
+
);
|
|
369
386
|
throw e;
|
|
370
387
|
}
|
|
371
388
|
}
|
|
@@ -378,9 +395,12 @@ export async function extractThumbnailFromVideo(props: {
|
|
|
378
395
|
filePath?: string;
|
|
379
396
|
fileId: string;
|
|
380
397
|
deviceId: string;
|
|
381
|
-
logger
|
|
398
|
+
logger?: Console;
|
|
399
|
+
device?: CommonCameraMixin;
|
|
382
400
|
}): Promise<MediaObject> {
|
|
383
|
-
const { rtmpUrl, filePath, fileId, deviceId,
|
|
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;
|
|
384
404
|
|
|
385
405
|
// Use file path if available, otherwise use RTMP URL
|
|
386
406
|
const inputSource = filePath || rtmpUrl;
|
|
@@ -427,12 +447,14 @@ export async function handleVideoClipRequest(props: {
|
|
|
427
447
|
fileId: string;
|
|
428
448
|
request: HttpRequest;
|
|
429
449
|
response: HttpResponse;
|
|
430
|
-
logger
|
|
450
|
+
logger?: Console;
|
|
431
451
|
}): Promise<void> {
|
|
432
|
-
const { device, deviceId, fileId, request, response
|
|
452
|
+
const { device, deviceId, fileId, request, response } = props;
|
|
453
|
+
const logger = device.getBaichuanLogger?.() || props.logger || console;
|
|
433
454
|
|
|
434
455
|
// Check if file is cached
|
|
435
456
|
const cachePath = getVideoClipCachePath(deviceId, fileId);
|
|
457
|
+
const MIN_VIDEO_CACHE_BYTES = 16 * 1024; // 16KB, evita file quasi vuoti/corrotti
|
|
436
458
|
|
|
437
459
|
try {
|
|
438
460
|
// Check if cached file exists
|
|
@@ -440,6 +462,17 @@ export async function handleVideoClipRequest(props: {
|
|
|
440
462
|
const fileSize = stat.size;
|
|
441
463
|
const range = request.headers.range;
|
|
442
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
|
+
|
|
443
476
|
logger.log(`Serving cached video clip: fileId=${fileId}, size=${fileSize}, range=${range}`);
|
|
444
477
|
|
|
445
478
|
if (range) {
|
|
@@ -503,12 +536,12 @@ export async function handleVideoClipRequest(props: {
|
|
|
503
536
|
|
|
504
537
|
if (isHttpUrl) {
|
|
505
538
|
// For HTTP URLs (Playback/Download from NVR), do direct HTTP proxy with ranged headers support
|
|
506
|
-
logger.
|
|
539
|
+
logger.debug(`Proxying HTTP URL directly: fileId=${fileId}, url=${rtmpVodUrl}`);
|
|
507
540
|
|
|
508
541
|
const sendVideo = async () => {
|
|
509
542
|
// Pre-fetch ffmpeg path in case we need it for FLV conversion
|
|
510
543
|
const ffmpegPathPromise = sdk.mediaManager.getFFmpegPath();
|
|
511
|
-
|
|
544
|
+
|
|
512
545
|
return new Promise<void>(async (resolve, reject) => {
|
|
513
546
|
const urlObj = new URL(rtmpVodUrl);
|
|
514
547
|
const httpModule = urlObj.protocol === 'https:' ? https : http;
|
|
@@ -531,7 +564,7 @@ export async function handleVideoClipRequest(props: {
|
|
|
531
564
|
headers: requestHeaders,
|
|
532
565
|
};
|
|
533
566
|
|
|
534
|
-
logger.
|
|
567
|
+
logger.debug(`Starting HTTP request: ${rtmpVodUrl}, headers: ${JSON.stringify(requestHeaders)}`);
|
|
535
568
|
|
|
536
569
|
httpModule.get(options, async (httpResponse) => {
|
|
537
570
|
if (httpResponse.statusCode && httpResponse.statusCode >= 400) {
|
|
@@ -547,9 +580,9 @@ export async function handleVideoClipRequest(props: {
|
|
|
547
580
|
|
|
548
581
|
// Check if we need to convert FLV to MP4
|
|
549
582
|
const isFlv = typeof contentType === 'string' && (contentType === 'video/x-flv' || contentType === 'video/flv');
|
|
550
|
-
|
|
583
|
+
|
|
551
584
|
if (isFlv) {
|
|
552
|
-
logger.
|
|
585
|
+
logger.debug(`Content-Type is FLV (${contentType}), will convert to MP4 using ffmpeg`);
|
|
553
586
|
}
|
|
554
587
|
|
|
555
588
|
const responseHeaders: Record<string, string> = {
|
|
@@ -568,11 +601,11 @@ export async function handleVideoClipRequest(props: {
|
|
|
568
601
|
|
|
569
602
|
const statusCode = httpResponse.statusCode || 200;
|
|
570
603
|
|
|
571
|
-
logger.
|
|
604
|
+
logger.debug(`HTTP response received: status=${statusCode}, contentType=${contentType}, contentLength=${contentLength || 'unknown'}`);
|
|
572
605
|
|
|
573
606
|
try {
|
|
574
|
-
|
|
575
|
-
|
|
607
|
+
if (isFlv) {
|
|
608
|
+
// Convert FLV to MP4 using ffmpeg
|
|
576
609
|
const ffmpegPath = await ffmpegPathPromise;
|
|
577
610
|
// Re-encode instead of copy because FLV codec might not be supported
|
|
578
611
|
const ffmpegArgs: string[] = [
|
|
@@ -648,10 +681,11 @@ export async function handleVideoClipRequest(props: {
|
|
|
648
681
|
// Handle ffmpeg errors
|
|
649
682
|
ffmpeg.on('close', (code) => {
|
|
650
683
|
if (code !== 0 && code !== null && !streamStarted) {
|
|
651
|
-
|
|
684
|
+
const sanitized = sanitizeFfmpegOutput(ffmpegError);
|
|
685
|
+
logger.error(`FFmpeg conversion failed: fileId=${fileId}, code=${code}, error=${sanitized}`);
|
|
652
686
|
reject(new Error(`FFmpeg conversion failed: ${code}`));
|
|
653
687
|
} else {
|
|
654
|
-
logger.
|
|
688
|
+
logger.debug(`FFmpeg conversion completed: fileId=${fileId}, code=${code}`);
|
|
655
689
|
resolve();
|
|
656
690
|
}
|
|
657
691
|
});
|
|
@@ -661,7 +695,7 @@ export async function handleVideoClipRequest(props: {
|
|
|
661
695
|
reject(error);
|
|
662
696
|
});
|
|
663
697
|
|
|
664
|
-
logger.
|
|
698
|
+
logger.debug(`FFmpeg conversion started: fileId=${fileId}`);
|
|
665
699
|
} else {
|
|
666
700
|
// Direct proxy for non-FLV content (should be MP4 already)
|
|
667
701
|
// Stream directly without buffering - yield chunks as they arrive
|
|
@@ -757,7 +791,8 @@ export async function handleVideoClipRequest(props: {
|
|
|
757
791
|
// Handle ffmpeg errors
|
|
758
792
|
ffmpeg.on('close', (code) => {
|
|
759
793
|
if (code !== 0 && code !== null && !streamStarted) {
|
|
760
|
-
|
|
794
|
+
const sanitized = sanitizeFfmpegOutput(ffmpegError);
|
|
795
|
+
logger.error(`FFmpeg proxy failed for video: fileId=${fileId}, code=${code}, error=${sanitized}`);
|
|
761
796
|
}
|
|
762
797
|
});
|
|
763
798
|
|
|
@@ -953,6 +988,7 @@ export async function vodSearchResultsToVideoClips(
|
|
|
953
988
|
deviceId,
|
|
954
989
|
fileId: fileName,
|
|
955
990
|
plugin,
|
|
991
|
+
logger,
|
|
956
992
|
});
|
|
957
993
|
videoHref = videoUrl;
|
|
958
994
|
thumbnailHref = thumbnailUrl;
|