@apocaliss92/scrypted-reolink-native 0.4.3 → 0.4.5
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/main.ts +2 -0
- package/src/utils.ts +430 -22
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -356,6 +356,8 @@ class ReolinkNativePlugin
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
if (type === "video") {
|
|
359
|
+
// Use handleVideoClipRequest for all clients (including iOS)
|
|
360
|
+
// It will use HTTP download with chunks which works on all platforms
|
|
359
361
|
await handleVideoClipRequest({
|
|
360
362
|
device,
|
|
361
363
|
deviceId,
|
package/src/utils.ts
CHANGED
|
@@ -311,9 +311,154 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
311
311
|
}
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
const getHeader = (headers: Record<string, any> | undefined, key: string) => {
|
|
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
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
export const getVideoclipClientInfo = (request: HttpRequest) => {
|
|
376
|
+
return {
|
|
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"),
|
|
395
|
+
};
|
|
396
|
+
};
|
|
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
|
+
|
|
314
458
|
/**
|
|
315
459
|
* Handle video clip webhook request
|
|
316
460
|
* Uses progressive streaming for immediate playback.
|
|
461
|
+
* For iOS clients, uses HTTP download which is more compatible.
|
|
317
462
|
* Stream management (stopping previous streams, cooldown) is handled by the API layer
|
|
318
463
|
* in ReolinkBaichuanApi.createRecordingReplayMp4Stream via activeReplayStreams per channel.
|
|
319
464
|
*/
|
|
@@ -327,43 +472,245 @@ export async function handleVideoClipRequest(props: {
|
|
|
327
472
|
}): Promise<void> {
|
|
328
473
|
const { device, fileId, request, response } = props;
|
|
329
474
|
const logger = device.getBaichuanLogger?.() || props.logger || console;
|
|
330
|
-
const useHttpSource =
|
|
331
|
-
device.storageSettings?.values?.videoclipSource === "HTTP";
|
|
332
475
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
);
|
|
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
|
+
}
|
|
336
535
|
|
|
337
536
|
try {
|
|
338
537
|
const api = await device.ensureClient();
|
|
339
538
|
const channel = device.storageSettings?.values?.rtspChannel ?? 0;
|
|
340
539
|
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
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
|
+
});
|
|
344
580
|
|
|
345
|
-
const
|
|
346
|
-
|
|
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,
|
|
347
603
|
});
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
348
606
|
|
|
349
|
-
|
|
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);
|
|
350
694
|
|
|
351
|
-
// Send the buffer as a complete response
|
|
352
|
-
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
|
|
353
695
|
response.sendStream(
|
|
354
696
|
(async function* () {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
yield
|
|
359
|
-
|
|
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
|
+
);
|
|
360
704
|
}
|
|
361
705
|
})(),
|
|
362
706
|
{
|
|
363
|
-
code:
|
|
707
|
+
code: 206,
|
|
364
708
|
headers: {
|
|
709
|
+
"Content-Range": `bytes ${start}-${safeEnd}/${fileSize}`,
|
|
710
|
+
"Accept-Ranges": "bytes",
|
|
711
|
+
"Content-Length": chunkSize.toString(),
|
|
365
712
|
"Content-Type": "video/mp4",
|
|
366
|
-
"Content-
|
|
713
|
+
"Content-Disposition": 'inline; filename="clip.mp4"',
|
|
367
714
|
"Cache-Control": "no-cache",
|
|
368
715
|
},
|
|
369
716
|
},
|
|
@@ -371,7 +718,54 @@ export async function handleVideoClipRequest(props: {
|
|
|
371
718
|
return;
|
|
372
719
|
}
|
|
373
720
|
|
|
374
|
-
|
|
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
|
|
375
769
|
// Add error handler to prevent uncaughtException from client socket errors
|
|
376
770
|
const onClientError = (err: Error) => {
|
|
377
771
|
logger.warn?.(
|
|
@@ -392,13 +786,26 @@ export async function handleVideoClipRequest(props: {
|
|
|
392
786
|
request.headers?.["accept-language"] || "",
|
|
393
787
|
request.headers?.["accept-encoding"] || "",
|
|
394
788
|
].join("|");
|
|
789
|
+
const explicitSessionId =
|
|
790
|
+
getHeader(request.headers, "x-playback-session-id") ??
|
|
791
|
+
getHeader(request.headers, "X-Playback-Session-Id");
|
|
395
792
|
const sessionId =
|
|
793
|
+
explicitSessionId ||
|
|
396
794
|
request.headers?.["x-request-id"] ||
|
|
397
795
|
crypto
|
|
398
796
|
.createHash("sha256")
|
|
399
797
|
.update(clientFingerprint)
|
|
400
798
|
.digest("hex")
|
|
401
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
|
+
|
|
402
809
|
const { mp4: mp4Stream, stop } = await api.createRecordingReplayMp4Stream({
|
|
403
810
|
channel,
|
|
404
811
|
fileName: fileId,
|
|
@@ -409,7 +816,7 @@ export async function handleVideoClipRequest(props: {
|
|
|
409
816
|
|
|
410
817
|
let totalSize = 0;
|
|
411
818
|
|
|
412
|
-
//
|
|
819
|
+
// No Range: stream the full fMP4 response (unknown total size)
|
|
413
820
|
response.sendStream(
|
|
414
821
|
(async function* () {
|
|
415
822
|
try {
|
|
@@ -429,6 +836,7 @@ export async function handleVideoClipRequest(props: {
|
|
|
429
836
|
code: 200,
|
|
430
837
|
headers: {
|
|
431
838
|
"Content-Type": "video/mp4",
|
|
839
|
+
"Content-Disposition": 'inline; filename="clip.mp4"',
|
|
432
840
|
"Cache-Control": "no-cache",
|
|
433
841
|
},
|
|
434
842
|
},
|