@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/dist/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apocaliss92/scrypted-reolink-native",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Use any reolink camera with Scrypted, even older/unsupported models without HTTP protocol support",
5
5
  "author": "@apocaliss92",
6
6
  "license": "Apache",
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
- logger.log(
334
- `[VideoClip] REQUEST: fileId=${fileId.slice(-40)}, isOnNvr=${device.isOnNvr}, source=${useHttpSource ? "HTTP" : "Native"}`,
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 (useHttpSource) {
342
- // HTTP mode: use CGI API to download the video file
343
- logger.debug(`[VideoClip] Using CGI API (HTTP) to download: ${fileId}`);
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 mp4Buffer = await api.downloadVod(fileId, {
346
- output: fileId,
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
- logger.debug(`[VideoClip] Downloaded via CGI: ${mp4Buffer.length} bytes`);
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
- let offset = 0;
356
- while (offset < mp4Buffer.length) {
357
- const end = Math.min(offset + CHUNK_SIZE, mp4Buffer.length);
358
- yield mp4Buffer.subarray(offset, end);
359
- offset = end;
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: 200,
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-Length": mp4Buffer.length.toString(),
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
- // Native mode: use Baichuan streaming replay
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
- // Simple response - no range support
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
  },