@torrent-tv/proxy 2.9.44 → 2.9.46

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2.9.46
2
+
3
+ - **New**: `getFileStats` now reports `resumeNeededBytes` / `resumeDownloadedBytes` — the bytes still to download in the 16 MB window ahead of the file's current read position (tracked per file by `prioritizeByteRange`, cleared on torrent removal), counted byte-accurately including partial pieces. Lets the browser show how much is left to download and the time to resume while buffering.
4
+
5
+ ## 2.9.45
6
+
7
+ - **New**: HLS transcode output switched from MPEG-TS (`.ts`) to **fMP4/CMAF** (`.m4s` segments + a shared `init.mp4`). Codec parameter sets (SPS/PPS) now live once in the init segment (referenced by `#EXT-X-MAP`) instead of being repeated in every segment. Benefits: (1) hardware encoders that do not repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce independently-usable segments (on `.ts` the segments after the first lacked SPS/PPS → "non-existing PPS", which is why v4l2m2m was rejected); (2) lower container overhead. The synthetic VOD playlist now emits `#EXT-X-VERSION:7` + `#EXT-X-MAP`; each seek-restart run rewrites `init.mp4`, so `getFileStream` caches and serves the FIRST init for the whole session — it is codec-config-only and position-independent (verified: a single init cleanly decodes segments produced by a later seek-restart run). Raised v4l2m2m `-num_capture_buffers` to 32 (the default 4 deadlocks / drops frames on the CM4). **Verified**: server-side clean decode of the synthetic playlist across seek-restart runs; end-to-end playback **and seek** in hls.js 1.6.16. **Still needs**: verification on native iOS HLS (Safari fMP4) before relying on it. NOTE: v4l2m2m still emits a residual no-picture access unit that the strict startup test rejects, so it continues to fall back to software for now (no regression); fMP4 removes the SPS/PPS blocker — the remaining quirk is separate.
8
+
1
9
  ## 2.9.44
2
10
 
3
11
  - **Fix**: Roll back to WebTorrent **2.8.5** (pinned) — 3.x introduced two regressions that broke downloading. (1) `torrent.downloaded`/`file.downloaded`/`file.progress` throw on a `deselect`-ed null piece (worked around in 2.9.43). (2) Worse: the internal piece picker itself throws `Cannot read properties of null (reading 'reserve'/'missing')` when it tries to request a block from a piece our seek prioritization (`prioritizeByteRange` `deselect`) removed — download freezes dead after a seek (field-observed: file stuck at ~51%, `down=0`, picker crashing every second). 2.8.5 is the known-good version: `select`/`deselect`/`critical` and the byte getters all work (verified — add, multi-file download, and the full deselect+critical seek pattern run with zero crashes on 2.8.5). Also pinned **`uint8-util` 2.2.6**: 2.8.5's own range is `^2.2.5`, which *allows* the incompatible 2.3.x that a fresh global install pulled (the original `arr2hex` crash), so the transitive version must be forced back — webtorrent dedupes to 2.2.6 while sub-deps that need 2.3.x keep their own nested copy. The 2.9.43 null-safe getter helpers are now redundant (2.8.5 getters never throw) but left in as harmless defensive code.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.44",
3
+ "version": "2.9.46",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -31,7 +31,12 @@ import {
31
31
  } from "./ffmpeg-banner.js";
32
32
 
33
33
  const PLAYLIST_FILE_NAME = "index.m3u8";
34
- const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
34
+ // fMP4 (CMAF) segments: SPS/PPS live once in the init segment, so every media
35
+ // segment is small and hardware encoders that do not repeat parameter sets
36
+ // (e.g. v4l2m2m) still produce independently-usable segments. The init segment
37
+ // is referenced by `#EXT-X-MAP` and fetched once by the player.
38
+ const SEGMENT_INIT_FILE_NAME = "init.mp4";
39
+ const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.m4s$/;
35
40
  const CLEANUP_INTERVAL_MS = 30_000;
36
41
  const DEFAULT_SEGMENT_DURATION_SEC = 4;
37
42
  // How many segments ahead of the current encode head a missing-segment request
@@ -203,18 +208,22 @@ function isSafeSessionId(value) {
203
208
  * @returns {boolean}
204
209
  */
205
210
  function isSafeFileName(fileName) {
206
- return fileName === PLAYLIST_FILE_NAME || SEGMENT_FILE_NAME_PATTERN.test(fileName);
211
+ return (
212
+ fileName === PLAYLIST_FILE_NAME ||
213
+ fileName === SEGMENT_INIT_FILE_NAME ||
214
+ SEGMENT_FILE_NAME_PATTERN.test(fileName)
215
+ );
207
216
  }
208
217
 
209
218
  /**
210
219
  * Extract the zero-based segment index from a segment file name.
211
220
  * Returns -1 when the name is not a valid segment file.
212
221
  *
213
- * @param {string} fileName - e.g. "segment-00012.ts"
222
+ * @param {string} fileName - e.g. "segment-00012.m4s"
214
223
  * @returns {number}
215
224
  */
216
225
  function segmentIndexFromName(fileName) {
217
- const match = /^segment-(\d{5})\.ts$/.exec(fileName);
226
+ const match = /^segment-(\d{5})\.m4s$/.exec(fileName);
218
227
  if (!match) {
219
228
  return -1;
220
229
  }
@@ -1000,16 +1009,20 @@ export class HlsSessionManager {
1000
1009
  }
1001
1010
  const lines = [
1002
1011
  "#EXTM3U",
1003
- "#EXT-X-VERSION:3",
1012
+ // Version 7: required for fMP4 media segments + `#EXT-X-MAP`.
1013
+ "#EXT-X-VERSION:7",
1004
1014
  `#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`,
1005
1015
  "#EXT-X-MEDIA-SEQUENCE:0",
1006
1016
  "#EXT-X-PLAYLIST-TYPE:VOD",
1007
- "#EXT-X-INDEPENDENT-SEGMENTS"
1017
+ "#EXT-X-INDEPENDENT-SEGMENTS",
1018
+ // The fMP4 init segment (codec config / SPS/PPS). Fetched once; applies to
1019
+ // every media segment below.
1020
+ `#EXT-X-MAP:URI="${SEGMENT_INIT_FILE_NAME}"`
1008
1021
  ];
1009
1022
  for (let index = 0; index < count; index += 1) {
1010
1023
  const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
1011
1024
  lines.push(`#EXTINF:${duration.toFixed(6)},`);
1012
- lines.push(`segment-${String(index).padStart(5, "0")}.ts`);
1025
+ lines.push(`segment-${String(index).padStart(5, "0")}.m4s`);
1013
1026
  }
1014
1027
  lines.push("#EXT-X-ENDLIST");
1015
1028
  return `${lines.join("\n")}\n`;
@@ -1148,7 +1161,7 @@ export class HlsSessionManager {
1148
1161
  }
1149
1162
  const indices = [];
1150
1163
  for (const name of names) {
1151
- const match = /^segment-(\d{5})\.ts$/.exec(name);
1164
+ const match = /^segment-(\d{5})\.m4s$/.exec(name);
1152
1165
  if (match) {
1153
1166
  indices.push(parseInt(match[1], 10));
1154
1167
  }
@@ -1161,7 +1174,7 @@ export class HlsSessionManager {
1161
1174
  let bytes = 0;
1162
1175
  try {
1163
1176
  for (const index of completed) {
1164
- const st = await stat(path.join(session.dirPath, `segment-${String(index).padStart(5, "0")}.ts`));
1177
+ const st = await stat(path.join(session.dirPath, `segment-${String(index).padStart(5, "0")}.m4s`));
1165
1178
  bytes += st.size;
1166
1179
  }
1167
1180
  } catch {
@@ -1466,10 +1479,18 @@ export class HlsSessionManager {
1466
1479
  "0",
1467
1480
  "-hls_flags",
1468
1481
  "independent_segments+temp_file",
1482
+ // fMP4 (CMAF) segments: codec config goes once into the init segment,
1483
+ // referenced by `#EXT-X-MAP`. Each seek-restart run rewrites init.mp4, but
1484
+ // it is codec-config only (position-independent), so getFileStream caches
1485
+ // and serves the first one for the whole session.
1486
+ "-hls_segment_type",
1487
+ "fmp4",
1488
+ "-hls_fmp4_init_filename",
1489
+ SEGMENT_INIT_FILE_NAME,
1469
1490
  "-start_number",
1470
1491
  String(safeIndex),
1471
1492
  "-hls_segment_filename",
1472
- "segment-%05d.ts",
1493
+ "segment-%05d.m4s",
1473
1494
  // ffmpeg writes its own playlist here; we ignore it and serve the
1474
1495
  // synthetic VOD playlist instead (see getFileStream).
1475
1496
  PLAYLIST_FILE_NAME
@@ -1779,6 +1800,25 @@ export class HlsSessionManager {
1779
1800
  };
1780
1801
  }
1781
1802
 
1803
+ // The fMP4 init segment (referenced by #EXT-X-MAP). It is codec-config only
1804
+ // and position-independent, but each seek-restart run REWRITES init.mp4, so
1805
+ // cache the FIRST one and always serve that — otherwise the init the player
1806
+ // fetched could differ from a later run's, breaking playback after a seek.
1807
+ if (fileName === SEGMENT_INIT_FILE_NAME) {
1808
+ if (session.initBytes) {
1809
+ return { kind: "file", stream: Readable.from([session.initBytes]), contentType: "video/mp4", isPlaylist: false };
1810
+ }
1811
+ try {
1812
+ const bytes = await readFile(path.join(session.dirPath, SEGMENT_INIT_FILE_NAME));
1813
+ session.initBytes = bytes;
1814
+ return { kind: "file", stream: Readable.from([bytes]), contentType: "video/mp4", isPlaylist: false };
1815
+ } catch {
1816
+ // Not produced yet — the encode run started at session creation writes
1817
+ // it early; the caller long-polls until it appears.
1818
+ return { kind: "warming-up" };
1819
+ }
1820
+ }
1821
+
1782
1822
  const filePath = path.join(session.dirPath, fileName);
1783
1823
  try {
1784
1824
  await access(filePath);
@@ -1799,7 +1839,7 @@ export class HlsSessionManager {
1799
1839
  contentType:
1800
1840
  fileName === PLAYLIST_FILE_NAME
1801
1841
  ? "application/vnd.apple.mpegurl"
1802
- : "video/mp2t",
1842
+ : "video/mp4",
1803
1843
  isPlaylist: fileName === PLAYLIST_FILE_NAME
1804
1844
  };
1805
1845
  } catch (_error) {
@@ -303,6 +303,10 @@ function v4l2m2mDescriptor() {
303
303
  "-vf",
304
304
  `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${outFps},format=yuv420p`,
305
305
  "-c:v", "h264_v4l2m2m",
306
+ // More capture buffers than the default 4 — the default deadlocks /
307
+ // drops frames on the CM4 encoder ("All capture buffers returned to
308
+ // userspace").
309
+ "-num_capture_buffers", "32",
306
310
  "-b:v", "3M",
307
311
  "-g", String(outFps * segmentDurationSec),
308
312
  ...keyFrameArgs(segmentDurationSec)
@@ -438,7 +442,7 @@ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
438
442
  encode = ["-c:v", "h264_nvenc", "-preset", "p4", "-cq", "24", "-pix_fmt", "yuv420p", ...kf];
439
443
  break;
440
444
  case "v4l2m2m":
441
- encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
445
+ encode = ["-pix_fmt", "yuv420p", "-c:v", "h264_v4l2m2m", "-num_capture_buffers", "32", "-b:v", "3M", "-g", String(TRANSCODE_FPS * segmentDurationSec), ...kf];
442
446
  break;
443
447
  default:
444
448
  encode = ["-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", ...kf];
@@ -450,7 +454,10 @@ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
450
454
  "-hls_time", String(segmentDurationSec),
451
455
  "-hls_list_size", "0",
452
456
  "-hls_flags", "independent_segments",
453
- "-hls_segment_filename", path.join(outDir, "seg-%03d.ts"),
457
+ // fMP4 (CMAF) — matches the runtime pipeline (hls-session-manager).
458
+ "-hls_segment_type", "fmp4",
459
+ "-hls_fmp4_init_filename", "init.mp4",
460
+ "-hls_segment_filename", path.join(outDir, "seg-%03d.m4s"),
454
461
  path.join(outDir, "index.m3u8")
455
462
  ];
456
463
  return [...pre, ...source, ...encode, ...hlsOut];
@@ -469,24 +476,24 @@ function buildEncoderTestArgs(descriptor, segmentDurationSec, outDir) {
469
476
  async function verifySegmentsDecodeCleanly(ffmpegBin, outDir) {
470
477
  let files;
471
478
  try {
472
- files = readdirSync(outDir).filter((n) => /^seg-\d+\.ts$/.test(n)).sort();
479
+ files = readdirSync(outDir).filter((n) => /^seg-\d+\.m4s$/.test(n));
473
480
  } catch {
474
481
  return false;
475
482
  }
476
483
  if (files.length < 2) {
477
484
  return false;
478
485
  }
479
- for (const file of files) {
480
- const result = await runFfmpeg(
481
- ffmpegBin,
482
- ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, file), "-f", "null", "-"],
483
- 8000
484
- );
485
- if (result.code !== 0 || result.stderr.trim().length > 0) {
486
- return false;
487
- }
488
- }
489
- return true;
486
+ // fMP4: parameter sets (SPS/PPS) live in init.mp4, not in each segment.
487
+ // Decode the whole playlist (ffmpeg's own, which references init.mp4 via
488
+ // #EXT-X-MAP), so every segment is exercised together with the init. Any
489
+ // corrupt / non-conformant segment (e.g. some V4L2 M2M builds emit a stray
490
+ // no-picture access unit) surfaces as a decode error here.
491
+ const result = await runFfmpeg(
492
+ ffmpegBin,
493
+ ["-hide_banner", "-loglevel", "error", "-i", path.join(outDir, "index.m3u8"), "-f", "null", "-"],
494
+ 12000
495
+ );
496
+ return result.code === 0 && result.stderr.trim().length === 0;
490
497
  }
491
498
 
492
499
  /**
@@ -295,6 +295,16 @@ export class TorrentPool {
295
295
  */
296
296
  #lastAccess = new Map();
297
297
 
298
+ /**
299
+ * Last byte offset each active reader is streaming from, keyed by torrent then
300
+ * fileIndex. Set by prioritizeByteRange on every /stream range request; read by
301
+ * getFileStats to report how much of the window ahead of the read head is still
302
+ * to download — the "amount left to resume" shown while buffering.
303
+ *
304
+ * @type {Map<import("webtorrent").Torrent, Map<number, number>>}
305
+ */
306
+ #readPositionByTorrent = new Map();
307
+
298
308
  /** Global disk cap in bytes (0 = disabled). */
299
309
  #maxDiskBytes = 0;
300
310
 
@@ -700,6 +710,7 @@ export class TorrentPool {
700
710
  }
701
711
  this.fileUsageByTorrent.delete(torrent);
702
712
  this.#lastAccess.delete(torrent);
713
+ this.#readPositionByTorrent.delete(torrent);
703
714
  const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
704
715
  try {
705
716
  torrent.destroy({ destroyStore: true }, () => {
@@ -743,6 +754,14 @@ export class TorrentPool {
743
754
 
744
755
  const header = this.#getHeaderRangeProgress(torrent, file);
745
756
 
757
+ // Bytes still to download in the window ahead of where this file is being
758
+ // read — "how much left to resume". Null until a read position is known.
759
+ const readPositions = this.#readPositionByTorrent.get(torrent);
760
+ const readByteStart = readPositions ? readPositions.get(fileIndex) : undefined;
761
+ const resume = typeof readByteStart === "number"
762
+ ? this.#getResumeWindowProgress(torrent, file, readByteStart)
763
+ : null;
764
+
746
765
  // Null-safe downloaded/progress (webtorrent's own getters throw on a
747
766
  // deselected null piece — see fileDownloadedBytes).
748
767
  const fileLength = typeof file.length === "number" ? file.length : 0;
@@ -753,6 +772,9 @@ export class TorrentPool {
753
772
  fileProgress: fileLength > 0 ? Math.max(0, Math.min(1, fileDownloaded / fileLength)) : 0,
754
773
  fileDownloaded,
755
774
  fileLength,
775
+ // Resume window (ahead of the read head): bytes needed vs downloaded.
776
+ resumeNeededBytes: resume ? resume.totalBytes : null,
777
+ resumeDownloadedBytes: resume ? resume.downloadedBytes : null,
756
778
  // Phase-1 progress: how much of the header/index region (the bytes the
757
779
  // codec probe needs before transcoding can start) is downloaded. Counted
758
780
  // by whole pieces from the torrent bitfield, so it advances coarsely
@@ -808,6 +830,45 @@ export class TorrentPool {
808
830
  return { totalBytes, downloadedBytes };
809
831
  }
810
832
 
833
+ /**
834
+ * Count, by whole torrent pieces, how many bytes of the window AHEAD of the
835
+ * current read position are downloaded — i.e. how much is still to download
836
+ * before playback can continue from that point. Mirrors
837
+ * {@link #getHeaderRangeProgress}, for the moving read head.
838
+ *
839
+ * @param {import("webtorrent").Torrent} torrent
840
+ * @param {import("webtorrent").TorrentFile} file
841
+ * @param {number} readByteStart - Byte offset within the file the reader is at.
842
+ * @returns {{ totalBytes: number, downloadedBytes: number } | null}
843
+ */
844
+ #getResumeWindowProgress(torrent, file, readByteStart) {
845
+ const pieceLength = Number(torrent?.pieceLength);
846
+ const bitfield = torrent?.bitfield;
847
+ const fileLength = Number(file?.length);
848
+ if (
849
+ !Number.isFinite(pieceLength) || pieceLength <= 0 ||
850
+ !bitfield || typeof bitfield.get !== "function" ||
851
+ !Number.isFinite(fileLength) || fileLength <= 0
852
+ ) {
853
+ return null;
854
+ }
855
+ const fileOffset = Number.isFinite(file.offset) ? file.offset : 0;
856
+ const windowStart = Math.max(0, Math.min(Number(readByteStart) || 0, fileLength - 1));
857
+ const windowEnd = Math.min(fileLength - 1, windowStart + PRIORITY_WINDOW_BYTES - 1);
858
+ const firstPiece = Math.floor((fileOffset + windowStart) / pieceLength);
859
+ const lastPiece = Math.floor((fileOffset + windowEnd) / pieceLength);
860
+ // Byte-accurate: count the PARTIAL progress of in-progress pieces (not whole
861
+ // pieces), so "amount left" moves smoothly instead of jumping by a whole
862
+ // piece (8 MB here) at a time.
863
+ let totalBytes = 0;
864
+ let downloadedBytes = 0;
865
+ for (let piece = firstPiece; piece <= lastPiece; piece += 1) {
866
+ totalBytes += pieceLength;
867
+ downloadedBytes += pieceDownloadedBytes(torrent, piece);
868
+ }
869
+ return { totalBytes, downloadedBytes };
870
+ }
871
+
811
872
  /**
812
873
  * Pre-fetch the leading and trailing bytes of a torrent file so that
813
874
  * WebTorrent prioritises the pieces that contain file headers and footers.
@@ -967,6 +1028,16 @@ export class TorrentPool {
967
1028
  const fileEndPiece = Math.floor((fileOffset + fileLength - 1) / pieceLength);
968
1029
 
969
1030
  const safeStart = Math.max(0, Number(byteStart) || 0);
1031
+
1032
+ // Remember where this file is being read from, so getFileStats can report the
1033
+ // download progress of the window ahead of the read head (resume amount).
1034
+ let readPositions = this.#readPositionByTorrent.get(torrent);
1035
+ if (!readPositions) {
1036
+ readPositions = new Map();
1037
+ this.#readPositionByTorrent.set(torrent, readPositions);
1038
+ }
1039
+ readPositions.set(fileIndex, safeStart);
1040
+
970
1041
  const absStart = fileOffset + safeStart;
971
1042
  const playheadPiece = Math.floor(absStart / pieceLength);
972
1043
  const absWindowEnd = Math.min(