@torrent-tv/proxy 2.9.82 → 2.9.84

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.84
2
+
3
+ - **Fix**: fMP4 now cuts segments where the playlist says too, closing the gap left by 2.9.82 (which covered MPEG-TS only). The muxer that takes explicit cut times writes each fMP4 piece self-contained — `ftyp moov moof mdat … mfra`, confirmed on the field host — which is not what HLS wants, so the pieces are split on serve: the header is lifted out of the first one to become the init segment named by `#EXT-X-MAP`, and removed from every media segment along with the trailing random-access index, whose offsets describe a file that no longer exists. Timestamps still need stamping exactly as before: measured, all pieces of a run report a start of 0.080 s, each carrying its own zero, which is the same defect the existing rewriting already corrects. Verified end to end on a real piece from the field host — split into a 779-byte init and 221 KB of fragments, recombined, and decoded clean.
4
+
5
+ ## 2.9.83
6
+
7
+ - **Fix**: Playback died a few seconds in after 2.9.82. The previous muxer wrote each segment under a temporary name and renamed it once complete, so a file appearing WAS a finished segment; the one that takes explicit cut times has no such option and creates the file when writing starts. The route kept judging readiness by existence, so the player was handed a segment that was still being written, rejected it and stopped — while the encoder ran happily ahead, which is exactly how it looked in the field: three segments served, then silence with the transcode at 7.5x. A segment is now considered finished once the next one has been started, or once the run producing it has ended.
8
+
1
9
  ## 2.9.82
2
10
 
3
11
  - **Fix**: The playlist and the real segments now describe the same thing. On the copy path ffmpeg was given only a target duration and chose its own cut points, while the playlist was built from the container keyframe index — two independent calculations tied together by nothing but the assumption that they agree. They do not: the index is a navigation table and is not obliged to list every keyframe. On a field file it held 1902 while ffmpeg found roughly twice as many and cut twice as often, so segment #876 meant 1:26:50 to the player and about minute 58 to ffmpeg. A seek into the middle landed at the end and the reported duration drifted. ffmpeg now receives the very boundaries the playlist was built from, via the `segment` muxer, which takes the list outright — agreement by construction instead of by luck. Verified on deliberately uneven keyframes: cuts requested at 4.44, 10.36, 16.28, 22.2 and 28.12 s landed exactly there. Two measured details are encoded in the code: those times count from the start of the RUN, not of the file (starting at 12 s and asking for 18 s put the cut at 29.4), and a tolerance absorbs rounding so a boundary recorded a hair late cannot skip to the next keyframe and silently double a segment. MPEG-TS only for now — fMP4 can do this too, but only as self-contained fragments, which removes the shared init segment and the `tfdt` rewriting built around it; that is a separate change and not one to make blind.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.82",
3
+ "version": "2.9.84",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1222,6 +1222,41 @@ export class HlsSessionManager {
1222
1222
  * @returns {Promise<number[] | null>} Ascending seconds, or null when this
1223
1223
  * file carries no readable index.
1224
1224
  */
1225
+ /**
1226
+ * The init header, lifted out of the first segment that exists.
1227
+ *
1228
+ * Needed only on the explicit-cut path, where the muxer produces no init file
1229
+ * of its own. Scans rather than assuming segment 0: a run started by a seek
1230
+ * begins at whatever index the viewer asked for.
1231
+ *
1232
+ * @param {HlsSession} session
1233
+ * @returns {Promise<Buffer | null>}
1234
+ */
1235
+ async #initFromFirstSegment(session) {
1236
+ if (typeof this.segmentFormat.extractInit !== "function") {
1237
+ return null;
1238
+ }
1239
+ let names;
1240
+ try {
1241
+ names = (await readdir(session.dirPath))
1242
+ .filter((name) => this.segmentFormat.isSegmentFileName(name))
1243
+ .sort();
1244
+ } catch {
1245
+ return null;
1246
+ }
1247
+ for (const name of names) {
1248
+ try {
1249
+ const init = this.segmentFormat.extractInit(await readFile(path.join(session.dirPath, name)));
1250
+ if (init && init.length > 0) {
1251
+ return init;
1252
+ }
1253
+ } catch {
1254
+ // Being written right now — try the next one.
1255
+ }
1256
+ }
1257
+ return null;
1258
+ }
1259
+
1225
1260
  async #readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName }) {
1226
1261
  const cacheKey = `${sourceKey}:${fileIndex}`;
1227
1262
  if (this.keyframeIndexCache.has(cacheKey)) {
@@ -1868,6 +1903,9 @@ export class HlsSessionManager {
1868
1903
  stdio: ["ignore", "pipe", "pipe"]
1869
1904
  });
1870
1905
  session.ffmpeg = ffmpeg;
1906
+ // Whether this run cuts at times we gave it. Decides how a segment is
1907
+ // judged finished — see getFileStream.
1908
+ session.usesExplicitCuts = Boolean(cutTimes && cutTimes.length > 0);
1871
1909
  session.encodeStartIndex = safeIndex;
1872
1910
  session.pendingRestartIndex = -1;
1873
1911
  session.lastRestartAt = Date.now();
@@ -2469,8 +2507,13 @@ export class HlsSessionManager {
2469
2507
  };
2470
2508
  }
2471
2509
  try {
2472
- const bytes = await readFile(path.join(session.dirPath, initFileName));
2473
- if (bytes.length === 0) {
2510
+ // With explicit cut times there is no init file: that muxer writes each
2511
+ // piece self-contained, header and all. The header is identical in every
2512
+ // piece, so the first one to exist supplies it.
2513
+ const bytes = session.usesExplicitCuts
2514
+ ? await this.#initFromFirstSegment(session)
2515
+ : await readFile(path.join(session.dirPath, initFileName));
2516
+ if (!bytes || bytes.length === 0) {
2474
2517
  return { kind: "warming-up" };
2475
2518
  }
2476
2519
  session.initBytes = bytes;
@@ -2491,6 +2534,27 @@ export class HlsSessionManager {
2491
2534
  const isPlaylist = fileName === PLAYLIST_FILE_NAME;
2492
2535
  try {
2493
2536
  await access(filePath);
2537
+
2538
+ // Existing is not the same as finished. The `hls` muxer wrote each
2539
+ // segment to a temporary name and renamed it once complete, so a file
2540
+ // appearing WAS a finished segment. The `segment` muxer has no such
2541
+ // option: the file appears when writing begins. Serving it then hands the
2542
+ // player a truncated segment, which it rejects and then simply stops —
2543
+ // observed as playback dying a few seconds in with the encoder still
2544
+ // running happily ahead. A segment is finished once the NEXT one has been
2545
+ // started, or once the run producing it has ended.
2546
+ if (!isPlaylist && session.usesExplicitCuts) {
2547
+ const index = this.segmentFormat.segmentIndexFromName(fileName);
2548
+ const isLast = index >= Math.max(0, (session.segmentBoundaries?.length ?? 1) - 2);
2549
+ if (!isLast && session.ffmpeg) {
2550
+ const nextPath = path.join(session.dirPath, this.segmentFormat.segmentFileName(index + 1));
2551
+ try {
2552
+ await access(nextPath);
2553
+ } catch {
2554
+ return { kind: "warming-up" };
2555
+ }
2556
+ }
2557
+ }
2494
2558
  // Cold-start: log the first servable SEGMENT of this session exactly once
2495
2559
  // — the time from session-create entry to a playable first segment.
2496
2560
  if (!isPlaylist && !session.firstSegmentLogged) {
@@ -2504,7 +2568,11 @@ export class HlsSessionManager {
2504
2568
  // module; the rest stream straight off disk.
2505
2569
  if (!isPlaylist && this.segmentFormat.needsSegmentRewrite) {
2506
2570
  const index = this.segmentFormat.segmentIndexFromName(fileName);
2507
- const bytes = await readFile(filePath);
2571
+ const raw = await readFile(filePath);
2572
+ // Self-contained pieces carry the init header; a media segment must not.
2573
+ const bytes = session.usesExplicitCuts && this.segmentFormat.stripInit
2574
+ ? this.segmentFormat.stripInit(raw)
2575
+ : raw;
2508
2576
  const prepared = this.segmentFormat.prepareSegmentBytes(bytes, {
2509
2577
  startSeconds: this.#segmentStartTime(session, index),
2510
2578
  initBytes: session.initBytes ?? null
@@ -12,6 +12,47 @@
12
12
 
13
13
  import { readTrackTimescales, stampSegmentStartTime } from "./mp4-boxes.js";
14
14
 
15
+ /**
16
+ * Where the fragments begin and where the trailing index starts, as offsets of
17
+ * whole boxes at the TOP level.
18
+ *
19
+ * Deliberately not `walkBoxes`: that reports the start of a box's *body* and
20
+ * descends into containers, so it cannot say where a box begins — and cutting a
21
+ * file at the wrong offset by eight bytes produces something that parses as
22
+ * garbage rather than failing outright.
23
+ *
24
+ * @param {Buffer} bytes
25
+ * @returns {{ firstFragment: number, trailingIndex: number }} Offsets, or -1.
26
+ */
27
+ function findFragmentBounds(bytes) {
28
+ let firstFragment = -1;
29
+ let trailingIndex = -1;
30
+ let offset = 0;
31
+ while (offset + 8 <= bytes.length) {
32
+ let size = bytes.readUInt32BE(offset);
33
+ const type = bytes.toString("latin1", offset + 4, offset + 8);
34
+ if (size === 1) {
35
+ if (offset + 16 > bytes.length) {
36
+ break;
37
+ }
38
+ size = Number(bytes.readBigUInt64BE(offset + 8));
39
+ } else if (size === 0) {
40
+ size = bytes.length - offset;
41
+ }
42
+ if (size < 8) {
43
+ break;
44
+ }
45
+ if (firstFragment === -1 && (type === "moof" || type === "sidx")) {
46
+ firstFragment = offset;
47
+ }
48
+ if (type === "mfra") {
49
+ trailingIndex = offset;
50
+ }
51
+ offset += size;
52
+ }
53
+ return { firstFragment, trailingIndex };
54
+ }
55
+
15
56
  const INIT_FILE_NAME = "init.mp4";
16
57
  const SEGMENT_PATTERN = /^segment-(\d{5})\.m4s$/;
17
58
 
@@ -38,21 +79,67 @@ export const fmp4Format = {
38
79
  },
39
80
 
40
81
  /**
41
- * Not supported on this format deliberately, for now.
82
+ * Cut at times we choose rather than times ffmpeg picks.
83
+ *
84
+ * The `segment` muxer writes each fMP4 piece as a **self-contained** file:
85
+ * `ftyp moov moof mdat … mfra`, verified on the field host. That is not what
86
+ * HLS wants — it wants one init segment named by `#EXT-X-MAP` and media
87
+ * segments carrying only fragments — so the pieces are split on serve:
88
+ * {@link extractInit} takes the header off the first one to serve as the init,
89
+ * and {@link stripInit} removes it from every one.
42
90
  *
43
- * The `segment` muxer can produce fMP4 (verified: explicit times cut exactly
44
- * where asked), but only as self-contained fragments carrying their own
45
- * `moov`. That removes the shared init segment this format is built around
46
- * `#EXT-X-MAP`, and with it the whole `tfdt` rewriting that took a field
47
- * failure to get right. Changing all of that at once, on a path no current
48
- * deployment exercises and that cannot be verified without a real browser, is
49
- * how the last round of regressions happened. MPEG-TS, which is what runs in
50
- * the field, gets the fix first.
91
+ * The timestamps still need stamping, exactly as before: measured on the
92
+ * field host, all three pieces of a run reported a start time of 0.080 s,
93
+ * i.e. each carries its own zero. That is the same defect the shared-init
94
+ * path already corrects, so `prepareSegmentBytes` handles both.
51
95
  *
52
- * @returns {null}
96
+ * @returns {string[]}
53
97
  */
54
98
  explicitTimesMuxerArgs() {
55
- return null;
99
+ return [
100
+ "-segment_format",
101
+ "mp4",
102
+ // `empty_moov` is what makes each piece self-describing, which is what
103
+ // lets the init be lifted out of it; `default_base_moof` keeps fragment
104
+ // offsets relative, so removing the header does not invalidate them.
105
+ "-segment_format_options",
106
+ "movflags=+frag_keyframe+empty_moov+default_base_moof"
107
+ ];
108
+ },
109
+
110
+ /** The output path template for the `segment` muxer. */
111
+ segmentFileNameTemplate() {
112
+ return "segment-%05d.m4s";
113
+ },
114
+
115
+ /**
116
+ * The init part of a self-contained piece: everything before the first
117
+ * fragment.
118
+ *
119
+ * @param {Buffer} bytes
120
+ * @returns {Buffer | null} `null` when the piece carries no fragment, which
121
+ * means it is not one of these and must not be cut up.
122
+ */
123
+ extractInit(bytes) {
124
+ const { firstFragment } = findFragmentBounds(bytes);
125
+ return firstFragment > 0 ? bytes.subarray(0, firstFragment) : null;
126
+ },
127
+
128
+ /**
129
+ * A piece with its init header removed, and the trailing random-access index
130
+ * dropped — a player reading a media segment has no use for either, and
131
+ * `mfra` describes offsets that stop being true once the header is gone.
132
+ *
133
+ * @param {Buffer} bytes
134
+ * @returns {Buffer}
135
+ */
136
+ stripInit(bytes) {
137
+ const { firstFragment, trailingIndex } = findFragmentBounds(bytes);
138
+ if (firstFragment < 0) {
139
+ return bytes;
140
+ }
141
+ const end = trailingIndex > firstFragment ? trailingIndex : bytes.length;
142
+ return bytes.subarray(firstFragment, end);
56
143
  },
57
144
 
58
145
  playlistHeaderLines() {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Splitting a self-contained fMP4 piece into an init segment and a media
3
+ * segment.
4
+ *
5
+ * The muxer that takes explicit cut times writes every piece whole —
6
+ * `ftyp moov moof mdat … mfra` — but HLS wants one init named by `#EXT-X-MAP`
7
+ * and media segments carrying only fragments. Cutting at the wrong offset does
8
+ * not fail loudly; it produces something that parses as garbage, which is why
9
+ * the boundaries are pinned here rather than trusted.
10
+ */
11
+
12
+ import test from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
15
+
16
+ /**
17
+ * A piece shaped like the muxer's output. Sizes are what matters, not contents.
18
+ *
19
+ * @param {{ withIndex?: boolean }} [options]
20
+ * @returns {{ piece: Buffer, initLength: number, fragmentsLength: number }}
21
+ */
22
+ function makePiece({ withIndex = true } = {}) {
23
+ const box = (type, bodyLength) => {
24
+ const buffer = Buffer.alloc(8 + bodyLength);
25
+ buffer.writeUInt32BE(8 + bodyLength, 0);
26
+ buffer.write(type, 4, "latin1");
27
+ buffer.fill(0x5a, 8);
28
+ return buffer;
29
+ };
30
+ const ftyp = box("ftyp", 24);
31
+ const moov = box("moov", 400);
32
+ const moof = box("moof", 120);
33
+ const mdat = box("mdat", 900);
34
+ const mfra = box("mfra", 40);
35
+ const parts = withIndex ? [ftyp, moov, moof, mdat, mfra] : [ftyp, moov, moof, mdat];
36
+ return {
37
+ piece: Buffer.concat(parts),
38
+ initLength: ftyp.length + moov.length,
39
+ fragmentsLength: moof.length + mdat.length
40
+ };
41
+ }
42
+
43
+ test("the init is everything before the first fragment", () => {
44
+ const { piece, initLength } = makePiece();
45
+ const init = fmp4Format.extractInit(piece);
46
+ assert.ok(init, "no init found in a piece that has one");
47
+ assert.equal(init.length, initLength, "init must end exactly where the first fragment begins");
48
+ assert.equal(init.toString("latin1", 4, 8), "ftyp", "init must start at the file header");
49
+ });
50
+
51
+ test("the media segment is the fragments alone, index dropped", () => {
52
+ const { piece, initLength, fragmentsLength } = makePiece();
53
+ const media = fmp4Format.stripInit(piece);
54
+ assert.equal(media.toString("latin1", 4, 8), "moof", "a media segment must begin at a fragment");
55
+ assert.equal(
56
+ media.length,
57
+ fragmentsLength,
58
+ "the trailing random-access index must be dropped: its offsets describe a file that no longer exists"
59
+ );
60
+ assert.equal(piece.length, initLength + fragmentsLength + 48, "test fixture sanity");
61
+ });
62
+
63
+ test("a piece without a trailing index keeps everything to the end", () => {
64
+ const { piece, fragmentsLength } = makePiece({ withIndex: false });
65
+ assert.equal(fmp4Format.stripInit(piece).length, fragmentsLength);
66
+ });
67
+
68
+ test("a segment that is already fragments-only is left alone", () => {
69
+ const alreadyMedia = fmp4Format.stripInit(makePiece().piece);
70
+ assert.deepEqual(
71
+ fmp4Format.stripInit(alreadyMedia),
72
+ alreadyMedia,
73
+ "stripping twice must not eat the first fragment"
74
+ );
75
+ assert.equal(
76
+ fmp4Format.extractInit(alreadyMedia),
77
+ null,
78
+ "there is no init to lift out of a media segment, and inventing one would corrupt playback"
79
+ );
80
+ });