@apocaliss92/scrypted-reolink-native 0.4.4 → 0.4.6
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/baichuan-base.ts +5 -130
- package/src/camera.ts +1 -142
- package/src/main.ts +2 -0
- package/src/nvr.ts +16 -5
- package/src/stream-utils.ts +1 -1
- package/src/utils.ts +428 -41
package/src/utils.ts
CHANGED
|
@@ -202,9 +202,9 @@ export async function recordingFileToVideoClip(
|
|
|
202
202
|
resources:
|
|
203
203
|
videoHref || thumbnailHref
|
|
204
204
|
? {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
...(videoHref ? { video: { href: videoHref } } : {}),
|
|
206
|
+
...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
|
|
207
|
+
}
|
|
208
208
|
: undefined,
|
|
209
209
|
};
|
|
210
210
|
}
|
|
@@ -312,23 +312,153 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
const getHeader = (headers: Record<string, any> | undefined, key: string) => {
|
|
315
|
-
return
|
|
315
|
+
return (
|
|
316
|
+
headers?.[key] ??
|
|
317
|
+
headers?.[key.toLowerCase()] ??
|
|
318
|
+
headers?.[key.toUpperCase()]
|
|
319
|
+
);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const videoclipDownloadCache = new Map<
|
|
323
|
+
string,
|
|
324
|
+
{ ts: number; promise: Promise<Buffer> }
|
|
325
|
+
>();
|
|
326
|
+
const VIDEOCLIP_DOWNLOAD_CACHE_TTL_MS = 2 * 60 * 1000;
|
|
327
|
+
|
|
328
|
+
const looksLikeMp4 = (buf: Buffer) => {
|
|
329
|
+
if (!buf || buf.length < 12) return false;
|
|
330
|
+
// ISO BMFF: [size(4)][ftyp(4)]
|
|
331
|
+
return buf.subarray(4, 8).toString("ascii") === "ftyp";
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const bufferReadable = async (props: {
|
|
335
|
+
readable: AsyncIterable<Buffer>;
|
|
336
|
+
maxBytes: number;
|
|
337
|
+
}): Promise<Buffer> => {
|
|
338
|
+
const chunks: Buffer[] = [];
|
|
339
|
+
let total = 0;
|
|
340
|
+
|
|
341
|
+
for await (const chunk of props.readable) {
|
|
342
|
+
total += chunk.length;
|
|
343
|
+
if (total > props.maxBytes) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`MP4 buffer exceeded maxBytes=${props.maxBytes} (total=${total})`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
chunks.push(chunk);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return Buffer.concat(chunks);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const getCachedDownloadedVideoclip = async (props: {
|
|
355
|
+
cacheKey: string;
|
|
356
|
+
download: () => Promise<Buffer>;
|
|
357
|
+
}): Promise<Buffer> => {
|
|
358
|
+
const now = Date.now();
|
|
359
|
+
const existing = videoclipDownloadCache.get(props.cacheKey);
|
|
360
|
+
if (existing && now - existing.ts < VIDEOCLIP_DOWNLOAD_CACHE_TTL_MS) {
|
|
361
|
+
return await existing.promise;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const promise = props.download();
|
|
365
|
+
videoclipDownloadCache.set(props.cacheKey, { ts: now, promise });
|
|
366
|
+
try {
|
|
367
|
+
return await promise;
|
|
368
|
+
} catch (e) {
|
|
369
|
+
// Do not keep failed promises around.
|
|
370
|
+
videoclipDownloadCache.delete(props.cacheKey);
|
|
371
|
+
throw e;
|
|
372
|
+
}
|
|
316
373
|
};
|
|
317
374
|
|
|
318
375
|
export const getVideoclipClientInfo = (request: HttpRequest) => {
|
|
319
376
|
return {
|
|
320
|
-
userAgent:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
377
|
+
userAgent:
|
|
378
|
+
getHeader(request.headers, "user-agent") ??
|
|
379
|
+
getHeader(request.headers, "User-Agent"),
|
|
380
|
+
accept:
|
|
381
|
+
getHeader(request.headers, "accept") ??
|
|
382
|
+
getHeader(request.headers, "Accept"),
|
|
383
|
+
range:
|
|
384
|
+
getHeader(request.headers, "range") ??
|
|
385
|
+
getHeader(request.headers, "Range"),
|
|
386
|
+
secChUa:
|
|
387
|
+
getHeader(request.headers, "sec-ch-ua") ??
|
|
388
|
+
getHeader(request.headers, "Sec-CH-UA"),
|
|
389
|
+
secChUaMobile:
|
|
390
|
+
getHeader(request.headers, "sec-ch-ua-mobile") ??
|
|
391
|
+
getHeader(request.headers, "Sec-CH-UA-Mobile"),
|
|
392
|
+
secChUaPlatform:
|
|
393
|
+
getHeader(request.headers, "sec-ch-ua-platform") ??
|
|
394
|
+
getHeader(request.headers, "Sec-CH-UA-Platform"),
|
|
326
395
|
};
|
|
327
396
|
};
|
|
328
397
|
|
|
398
|
+
const getQueryParam = (requestUrl: string | undefined, key: string) => {
|
|
399
|
+
if (!requestUrl) return undefined;
|
|
400
|
+
try {
|
|
401
|
+
const url = new URL(requestUrl, "http://localhost");
|
|
402
|
+
const v = url.searchParams.get(key);
|
|
403
|
+
return v === null ? undefined : v;
|
|
404
|
+
} catch {
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const withQueryParam = (requestUrl: string, key: string, value: string) => {
|
|
410
|
+
try {
|
|
411
|
+
const url = new URL(requestUrl, "http://localhost");
|
|
412
|
+
url.searchParams.set(key, value);
|
|
413
|
+
return url.pathname + (url.search ? url.search : "");
|
|
414
|
+
} catch {
|
|
415
|
+
const sep = requestUrl.includes("?") ? "&" : "?";
|
|
416
|
+
return `${requestUrl}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
let baichuanRuntimePromise:
|
|
421
|
+
| Promise<{
|
|
422
|
+
detectIosClient: (userAgent: string | undefined) => {
|
|
423
|
+
isIos: boolean;
|
|
424
|
+
isIosInstalledApp: boolean;
|
|
425
|
+
needsHls: boolean;
|
|
426
|
+
};
|
|
427
|
+
HlsSessionManager: new (
|
|
428
|
+
api: any,
|
|
429
|
+
options?: {
|
|
430
|
+
logger?: any;
|
|
431
|
+
sessionTtlMs?: number;
|
|
432
|
+
cleanupIntervalMs?: number;
|
|
433
|
+
},
|
|
434
|
+
) => any;
|
|
435
|
+
}>
|
|
436
|
+
| undefined;
|
|
437
|
+
|
|
438
|
+
const getBaichuanRuntime = async () => {
|
|
439
|
+
if (!baichuanRuntimePromise) {
|
|
440
|
+
baichuanRuntimePromise = import("@apocaliss92/reolink-baichuan-js") as any;
|
|
441
|
+
}
|
|
442
|
+
return baichuanRuntimePromise;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const hlsManagersByApi = new WeakMap<object, any>();
|
|
446
|
+
|
|
447
|
+
const videoclipLogThrottle = new Map<string, number>();
|
|
448
|
+
const shouldLogThrottled = (key: string, intervalMs: number): boolean => {
|
|
449
|
+
const now = Date.now();
|
|
450
|
+
const last = videoclipLogThrottle.get(key);
|
|
451
|
+
if (last !== undefined && now - last < intervalMs) return false;
|
|
452
|
+
videoclipLogThrottle.set(key, now);
|
|
453
|
+
// Best-effort guard against unbounded growth.
|
|
454
|
+
if (videoclipLogThrottle.size > 5000) videoclipLogThrottle.clear();
|
|
455
|
+
return true;
|
|
456
|
+
};
|
|
457
|
+
|
|
329
458
|
/**
|
|
330
459
|
* Handle video clip webhook request
|
|
331
460
|
* Uses progressive streaming for immediate playback.
|
|
461
|
+
* For iOS clients, uses HTTP download which is more compatible.
|
|
332
462
|
* Stream management (stopping previous streams, cooldown) is handled by the API layer
|
|
333
463
|
* in ReolinkBaichuanApi.createRecordingReplayMp4Stream via activeReplayStreams per channel.
|
|
334
464
|
*/
|
|
@@ -342,43 +472,245 @@ export async function handleVideoClipRequest(props: {
|
|
|
342
472
|
}): Promise<void> {
|
|
343
473
|
const { device, fileId, request, response } = props;
|
|
344
474
|
const logger = device.getBaichuanLogger?.() || props.logger || console;
|
|
345
|
-
const useHttpSource =
|
|
346
|
-
device.storageSettings?.values?.videoclipSource === "HTTP";
|
|
347
475
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
);
|
|
476
|
+
// Check if iOS client
|
|
477
|
+
const clientInfo = getVideoclipClientInfo(request);
|
|
478
|
+
const hlsPath = getQueryParam(request.url, "hls");
|
|
479
|
+
const hlsSocketMode = (getQueryParam(request.url, "hlsSocket") ?? "device")
|
|
480
|
+
.toString()
|
|
481
|
+
.toLowerCase();
|
|
482
|
+
|
|
483
|
+
let ios: {
|
|
484
|
+
isIos: boolean;
|
|
485
|
+
isIosInstalledApp: boolean;
|
|
486
|
+
needsHls: boolean;
|
|
487
|
+
} = {
|
|
488
|
+
isIos: /iphone|ipad|ipod/i.test(clientInfo.userAgent ?? ""),
|
|
489
|
+
isIosInstalledApp: /(installedapp)/i.test(clientInfo.userAgent ?? ""),
|
|
490
|
+
needsHls: false,
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
const mod = await getBaichuanRuntime();
|
|
495
|
+
ios = mod.detectIosClient(clientInfo.userAgent);
|
|
496
|
+
} catch {
|
|
497
|
+
// If dynamic import fails, keep best-effort UA detection.
|
|
498
|
+
ios.needsHls = ios.isIos && ios.isIosInstalledApp;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// iOS InstalledApp playback is most reliable via HLS.
|
|
502
|
+
// If `?hls=` is present, always serve HLS assets (playlist/segments).
|
|
503
|
+
const shouldUseHls = ios.needsHls || hlsPath !== undefined;
|
|
504
|
+
|
|
505
|
+
// Legacy iOS InstalledApp MP4 path: Range probes (e.g. bytes=0-1) expect
|
|
506
|
+
// a proper 206 with a total size in Content-Range.
|
|
507
|
+
const hasRange = !!clientInfo.range;
|
|
508
|
+
const preferDownloadForRange =
|
|
509
|
+
!shouldUseHls && ios.isIosInstalledApp && hasRange;
|
|
510
|
+
const useDownload = !shouldUseHls && ios.isIosInstalledApp && !hasRange;
|
|
511
|
+
|
|
512
|
+
// These endpoints can be very chatty (HLS playlist polling + segment fetch).
|
|
513
|
+
// Keep important transitions visible, but push repetitive per-request noise to debug.
|
|
514
|
+
const requestMode = shouldUseHls
|
|
515
|
+
? "HLS"
|
|
516
|
+
: useDownload
|
|
517
|
+
? "Download"
|
|
518
|
+
: preferDownloadForRange
|
|
519
|
+
? "Download(Range)"
|
|
520
|
+
: "Stream";
|
|
521
|
+
const requestHlsPathForLog = shouldUseHls ? (hlsPath ?? "playlist.m3u8") : "";
|
|
522
|
+
const isHlsSegmentReq =
|
|
523
|
+
shouldUseHls && requestHlsPathForLog.toString().endsWith(".ts");
|
|
524
|
+
const reqLogKey = `VideoClip:REQ:${props.deviceId}:${fileId}:${requestMode}:${requestHlsPathForLog}`;
|
|
525
|
+
const reqLine = `[VideoClip] REQUEST: fileId=${fileId.slice(-40)}, isOnNvr=${device.isOnNvr}, isIos=${ios.isIos}, isIosInstalledApp=${ios.isIosInstalledApp}, hasRange=${hasRange}, hls=${shouldUseHls}, hlsPath=${JSON.stringify(hlsPath)}, hlsSocket=${hlsSocketMode}, mode=${requestMode}`;
|
|
526
|
+
if (hasRange) {
|
|
527
|
+
logger.log(reqLine);
|
|
528
|
+
} else if (isHlsSegmentReq) {
|
|
529
|
+
logger.debug?.(reqLine);
|
|
530
|
+
} else if (shouldLogThrottled(reqLogKey, 2000)) {
|
|
531
|
+
logger.log(reqLine);
|
|
532
|
+
} else {
|
|
533
|
+
logger.debug?.(reqLine);
|
|
534
|
+
}
|
|
351
535
|
|
|
352
536
|
try {
|
|
353
537
|
const api = await device.ensureClient();
|
|
354
538
|
const channel = device.storageSettings?.values?.rtspChannel ?? 0;
|
|
355
539
|
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
540
|
+
if (shouldUseHls) {
|
|
541
|
+
const mod = await getBaichuanRuntime();
|
|
542
|
+
let manager = hlsManagersByApi.get(api as any);
|
|
543
|
+
if (!manager) {
|
|
544
|
+
manager = new mod.HlsSessionManager(api as any, {
|
|
545
|
+
logger: logger as any,
|
|
546
|
+
// Keep very short: iOS HLS requests are frequent; if the client stops
|
|
547
|
+
// requesting playlists/segments we want to tear down quickly.
|
|
548
|
+
sessionTtlMs: 15 * 1000,
|
|
549
|
+
cleanupIntervalMs: 5 * 1000,
|
|
550
|
+
});
|
|
551
|
+
hlsManagersByApi.set(api as any, manager);
|
|
552
|
+
}
|
|
553
|
+
const sessionKey = `hls:${props.deviceId}:ch${channel}:${fileId}`;
|
|
554
|
+
const exclusiveKeyPrefix = `hls:${props.deviceId}:ch${channel}:`;
|
|
555
|
+
|
|
556
|
+
// Treat the base clip URL as a playlist request to avoid an extra 302
|
|
557
|
+
// round-trip on clip switches.
|
|
558
|
+
const effectiveHlsPath = hlsPath ?? "playlist.m3u8";
|
|
559
|
+
|
|
560
|
+
const result = await manager.handleRequest({
|
|
561
|
+
sessionKey,
|
|
562
|
+
hlsPath: effectiveHlsPath,
|
|
563
|
+
requestUrl: request.url ?? "",
|
|
564
|
+
exclusiveKeyPrefix,
|
|
565
|
+
createSession: () => ({
|
|
566
|
+
channel,
|
|
567
|
+
fileName: fileId,
|
|
568
|
+
isNvr: device.isOnNvr,
|
|
569
|
+
// Default: reuse a dedicated replay socket per device for fast clip switching.
|
|
570
|
+
// Override: `?hlsSocket=clip` forces a fresh socket per clip (more robust, slower).
|
|
571
|
+
deviceId:
|
|
572
|
+
hlsSocketMode === "clip"
|
|
573
|
+
? `${props.deviceId}:${fileId}`
|
|
574
|
+
: props.deviceId,
|
|
575
|
+
// Lower duration improves startup latency on iOS.
|
|
576
|
+
hlsSegmentDuration: 1,
|
|
577
|
+
transcodeH265ToH264: true,
|
|
578
|
+
}),
|
|
579
|
+
});
|
|
359
580
|
|
|
360
|
-
const
|
|
361
|
-
|
|
581
|
+
const bodyLen =
|
|
582
|
+
typeof result.body === "string"
|
|
583
|
+
? result.body.length
|
|
584
|
+
: (result.body?.length ?? 0);
|
|
585
|
+
|
|
586
|
+
const respLine = `[VideoClip] HLS RESP: hlsPath=${JSON.stringify(effectiveHlsPath)}, status=${result.statusCode}, len=${bodyLen}`;
|
|
587
|
+
const isError = result.statusCode >= 400;
|
|
588
|
+
const isSegment = effectiveHlsPath.endsWith(".ts");
|
|
589
|
+
const respLogKey = `VideoClip:RESP:${props.deviceId}:${fileId}:${effectiveHlsPath}:${result.statusCode}`;
|
|
590
|
+
if (isError) {
|
|
591
|
+
(logger.warn ?? logger.log).call(logger, respLine);
|
|
592
|
+
} else if (isSegment) {
|
|
593
|
+
logger.debug?.(respLine);
|
|
594
|
+
} else if (shouldLogThrottled(respLogKey, 2000)) {
|
|
595
|
+
logger.log(respLine);
|
|
596
|
+
} else {
|
|
597
|
+
logger.debug?.(respLine);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
response.send(result.body as any, {
|
|
601
|
+
code: result.statusCode,
|
|
602
|
+
headers: result.headers,
|
|
362
603
|
});
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
363
606
|
|
|
364
|
-
|
|
607
|
+
// Range support for iOS: serve a file-like response with known total size.
|
|
608
|
+
// This is closer to the behavior in scrypted-advanced-notifier's sendVideo.
|
|
609
|
+
if (preferDownloadForRange) {
|
|
610
|
+
const rangeHeader = String(clientInfo.range).trim();
|
|
611
|
+
const m = /^bytes=(\d+)-(\d*)$/i.exec(rangeHeader);
|
|
612
|
+
if (!m) {
|
|
613
|
+
response.send("Invalid Range", { code: 416 });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const start = Number.parseInt(m[1], 10);
|
|
618
|
+
const endRaw = m[2];
|
|
619
|
+
|
|
620
|
+
// Obtain an MP4 buffer (cached) so we can answer arbitrary byte ranges.
|
|
621
|
+
// On some NVRs, downloadRecording() returns BcMedia/raw, not an MP4 file.
|
|
622
|
+
// In that case, generate MP4 bytes via createRecordingDownloadMp4Stream.
|
|
623
|
+
let fileBuf: Buffer;
|
|
624
|
+
try {
|
|
625
|
+
fileBuf = await getCachedDownloadedVideoclip({
|
|
626
|
+
cacheKey: `${device.id || "device"}:${channel}:${fileId}`,
|
|
627
|
+
download: async () => {
|
|
628
|
+
logger.log(
|
|
629
|
+
`[VideoClip] Range requested; preparing MP4 buffer for byte-range support: channel=${channel}, fileId=${fileId}`,
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
const rawOrMp4 = await api.downloadRecording({
|
|
633
|
+
channel,
|
|
634
|
+
fileName: fileId,
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
if (looksLikeMp4(rawOrMp4)) {
|
|
638
|
+
return rawOrMp4;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
logger.warn?.(
|
|
642
|
+
`[VideoClip] downloadRecording did not look like MP4 (ftyp missing). Generating MP4 via createRecordingDownloadMp4Stream...`,
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
const { mp4, stop } = await api.createRecordingDownloadMp4Stream({
|
|
646
|
+
channel,
|
|
647
|
+
fileName: fileId,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
const mp4Buf = await bufferReadable({
|
|
652
|
+
readable: mp4 as any,
|
|
653
|
+
maxBytes: 250 * 1024 * 1024,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
if (!looksLikeMp4(mp4Buf)) {
|
|
657
|
+
throw new Error(
|
|
658
|
+
"createRecordingDownloadMp4Stream output did not look like MP4 (ftyp missing)",
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return mp4Buf;
|
|
663
|
+
} finally {
|
|
664
|
+
await stop().catch(() => {});
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
} catch (e: any) {
|
|
669
|
+
logger.error(
|
|
670
|
+
`[VideoClip] Range download failed: ${e?.message || String(e)}`,
|
|
671
|
+
);
|
|
672
|
+
response.send(`Download failed: ${e?.message || "Unknown error"}`, {
|
|
673
|
+
code: 500,
|
|
674
|
+
});
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const fileSize = fileBuf.length;
|
|
679
|
+
const end = endRaw ? Number.parseInt(endRaw, 10) : fileSize - 1;
|
|
680
|
+
if (
|
|
681
|
+
!Number.isFinite(start) ||
|
|
682
|
+
!Number.isFinite(end) ||
|
|
683
|
+
start < 0 ||
|
|
684
|
+
end < start ||
|
|
685
|
+
start >= fileSize
|
|
686
|
+
) {
|
|
687
|
+
response.send("Invalid Range", { code: 416 });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const safeEnd = Math.min(end, fileSize - 1);
|
|
692
|
+
const chunkSize = safeEnd - start + 1;
|
|
693
|
+
const slice = fileBuf.subarray(start, safeEnd + 1);
|
|
365
694
|
|
|
366
|
-
// Send the buffer as a complete response
|
|
367
|
-
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
|
|
368
695
|
response.sendStream(
|
|
369
696
|
(async function* () {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
yield
|
|
374
|
-
|
|
697
|
+
// Yield in chunks to avoid large single-buffer writes.
|
|
698
|
+
const CHUNK = 64 * 1024;
|
|
699
|
+
for (let offset = 0; offset < slice.length; offset += CHUNK) {
|
|
700
|
+
yield slice.subarray(
|
|
701
|
+
offset,
|
|
702
|
+
Math.min(offset + CHUNK, slice.length),
|
|
703
|
+
);
|
|
375
704
|
}
|
|
376
705
|
})(),
|
|
377
706
|
{
|
|
378
|
-
code:
|
|
707
|
+
code: 206,
|
|
379
708
|
headers: {
|
|
709
|
+
"Content-Range": `bytes ${start}-${safeEnd}/${fileSize}`,
|
|
710
|
+
"Accept-Ranges": "bytes",
|
|
711
|
+
"Content-Length": chunkSize.toString(),
|
|
380
712
|
"Content-Type": "video/mp4",
|
|
381
|
-
"Content-
|
|
713
|
+
"Content-Disposition": 'inline; filename="clip.mp4"',
|
|
382
714
|
"Cache-Control": "no-cache",
|
|
383
715
|
},
|
|
384
716
|
},
|
|
@@ -386,7 +718,54 @@ export async function handleVideoClipRequest(props: {
|
|
|
386
718
|
return;
|
|
387
719
|
}
|
|
388
720
|
|
|
389
|
-
|
|
721
|
+
if (useDownload) {
|
|
722
|
+
// Download mode: use native Baichuan download (works for both NVR and standalone)
|
|
723
|
+
logger.log(
|
|
724
|
+
`[VideoClip] Starting native download: channel=${channel}, fileId=${fileId}`,
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
const mp4Buffer = await api.downloadRecording({
|
|
729
|
+
channel,
|
|
730
|
+
fileName: fileId,
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
logger.log(`[VideoClip] Downloaded: ${mp4Buffer.length} bytes`);
|
|
734
|
+
|
|
735
|
+
// Send the buffer as a complete response in chunks
|
|
736
|
+
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
|
|
737
|
+
response.sendStream(
|
|
738
|
+
(async function* () {
|
|
739
|
+
let offset = 0;
|
|
740
|
+
while (offset < mp4Buffer.length) {
|
|
741
|
+
const end = Math.min(offset + CHUNK_SIZE, mp4Buffer.length);
|
|
742
|
+
yield mp4Buffer.subarray(offset, end);
|
|
743
|
+
offset = end;
|
|
744
|
+
}
|
|
745
|
+
})(),
|
|
746
|
+
{
|
|
747
|
+
code: 200,
|
|
748
|
+
headers: {
|
|
749
|
+
"Content-Type": "video/mp4",
|
|
750
|
+
"Content-Length": mp4Buffer.length.toString(),
|
|
751
|
+
"Cache-Control": "no-cache",
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
);
|
|
755
|
+
return;
|
|
756
|
+
} catch (downloadErr: any) {
|
|
757
|
+
logger.error(
|
|
758
|
+
`[VideoClip] Download failed: ${downloadErr?.message || String(downloadErr)}`,
|
|
759
|
+
);
|
|
760
|
+
response.send(
|
|
761
|
+
`Download failed: ${downloadErr?.message || "Unknown error"}`,
|
|
762
|
+
{ code: 500 },
|
|
763
|
+
);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Stream mode: use Baichuan streaming replay
|
|
390
769
|
// Add error handler to prevent uncaughtException from client socket errors
|
|
391
770
|
const onClientError = (err: Error) => {
|
|
392
771
|
logger.warn?.(
|
|
@@ -395,11 +774,6 @@ export async function handleVideoClipRequest(props: {
|
|
|
395
774
|
};
|
|
396
775
|
api.client.on("error", onClientError);
|
|
397
776
|
|
|
398
|
-
const clientInfo = getVideoclipClientInfo(request);
|
|
399
|
-
const ua = (clientInfo.userAgent ?? '').toLowerCase();
|
|
400
|
-
const isIos = /iphone|ipad|ipod/.test(ua);
|
|
401
|
-
const isIosInstalledApp = ua.includes('installedapp');
|
|
402
|
-
|
|
403
777
|
// Use streaming replay - this starts immediately and produces fMP4 chunks
|
|
404
778
|
// Stream management (stopping previous streams, cooldown) is handled by the API layer
|
|
405
779
|
// Generate a unique session ID based on client fingerprint (UA + IP + other factors)
|
|
@@ -407,30 +781,42 @@ export async function handleVideoClipRequest(props: {
|
|
|
407
781
|
const clientFingerprint = [
|
|
408
782
|
request.headers?.["user-agent"] || "",
|
|
409
783
|
request.headers?.["x-forwarded-for"] ||
|
|
410
|
-
|
|
411
|
-
|
|
784
|
+
request.headers?.["x-real-ip"] ||
|
|
785
|
+
"",
|
|
412
786
|
request.headers?.["accept-language"] || "",
|
|
413
787
|
request.headers?.["accept-encoding"] || "",
|
|
414
788
|
].join("|");
|
|
789
|
+
const explicitSessionId =
|
|
790
|
+
getHeader(request.headers, "x-playback-session-id") ??
|
|
791
|
+
getHeader(request.headers, "X-Playback-Session-Id");
|
|
415
792
|
const sessionId =
|
|
793
|
+
explicitSessionId ||
|
|
416
794
|
request.headers?.["x-request-id"] ||
|
|
417
795
|
crypto
|
|
418
796
|
.createHash("sha256")
|
|
419
797
|
.update(clientFingerprint)
|
|
420
798
|
.digest("hex")
|
|
421
799
|
.slice(0, 16);
|
|
800
|
+
|
|
801
|
+
logger.debug(
|
|
802
|
+
`[VideoClip] Client info: ${JSON.stringify({
|
|
803
|
+
clientInfo,
|
|
804
|
+
isIos: ios.isIos,
|
|
805
|
+
isIosInstalledApp: ios.isIosInstalledApp,
|
|
806
|
+
})}`,
|
|
807
|
+
);
|
|
808
|
+
|
|
422
809
|
const { mp4: mp4Stream, stop } = await api.createRecordingReplayMp4Stream({
|
|
423
810
|
channel,
|
|
424
811
|
fileName: fileId,
|
|
425
812
|
isNvr: device.isOnNvr,
|
|
426
813
|
logger,
|
|
427
814
|
deviceId: sessionId,
|
|
428
|
-
transcodeH265ToH264: isIos && isIosInstalledApp,
|
|
429
815
|
});
|
|
430
816
|
|
|
431
817
|
let totalSize = 0;
|
|
432
818
|
|
|
433
|
-
//
|
|
819
|
+
// No Range: stream the full fMP4 response (unknown total size)
|
|
434
820
|
response.sendStream(
|
|
435
821
|
(async function* () {
|
|
436
822
|
try {
|
|
@@ -443,13 +829,14 @@ export async function handleVideoClipRequest(props: {
|
|
|
443
829
|
} finally {
|
|
444
830
|
// Remove the error handler
|
|
445
831
|
api.client.off("error", onClientError);
|
|
446
|
-
await stop().catch(() => {
|
|
832
|
+
await stop().catch(() => {});
|
|
447
833
|
}
|
|
448
834
|
})(),
|
|
449
835
|
{
|
|
450
836
|
code: 200,
|
|
451
837
|
headers: {
|
|
452
838
|
"Content-Type": "video/mp4",
|
|
839
|
+
"Content-Disposition": 'inline; filename="clip.mp4"',
|
|
453
840
|
"Cache-Control": "no-cache",
|
|
454
841
|
},
|
|
455
842
|
},
|