@torrent-tv/proxy 2.9.83 → 2.9.85

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.85
2
+
3
+ - **Fix**: fMP4 playback did not start at all in 2.9.84 — every request for the init segment answered 500. ffmpeg had refused to open the output: `Could not write header (incorrect codec parameters ?)`, because the `segment` muxer determines the container from the file extension and does not recognise `.m4s` for MP4, whatever `-segment_format` says. Segments are now written and named `.mp4` on both paths. The extension is internal: it appears only in our own playlist and in the temporary directory, so nothing outside changes.
4
+
5
+ ## 2.9.84
6
+
7
+ - **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.
8
+
1
9
  ## 2.9.83
2
10
 
3
11
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.83",
3
+ "version": "2.9.85",
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)) {
@@ -2472,8 +2507,13 @@ export class HlsSessionManager {
2472
2507
  };
2473
2508
  }
2474
2509
  try {
2475
- const bytes = await readFile(path.join(session.dirPath, initFileName));
2476
- 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) {
2477
2517
  return { kind: "warming-up" };
2478
2518
  }
2479
2519
  session.initBytes = bytes;
@@ -2528,7 +2568,11 @@ export class HlsSessionManager {
2528
2568
  // module; the rest stream straight off disk.
2529
2569
  if (!isPlaylist && this.segmentFormat.needsSegmentRewrite) {
2530
2570
  const index = this.segmentFormat.segmentIndexFromName(fileName);
2531
- 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;
2532
2576
  const prepared = this.segmentFormat.prepareSegmentBytes(bytes, {
2533
2577
  startSeconds: this.#segmentStartTime(session, index),
2534
2578
  initBytes: session.initBytes ?? null
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file fMP4 (CMAF) segment format — `.m4s` media segments plus one shared
2
+ * @file fMP4 (CMAF) segment format — `.mp4` media segments plus one shared
3
3
  * `init.mp4` referenced by `#EXT-X-MAP`.
4
4
  *
5
5
  * Codec configuration (SPS/PPS) lives once in the init segment instead of being
@@ -12,8 +12,49 @@
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
- const SEGMENT_PATTERN = /^segment-(\d{5})\.m4s$/;
57
+ const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
17
58
 
18
59
  /**
19
60
  * @type {import("./index.js").SegmentFormat}
@@ -33,26 +74,72 @@ export const fmp4Format = {
33
74
  "-hls_fmp4_init_filename",
34
75
  INIT_FILE_NAME,
35
76
  "-hls_segment_filename",
36
- "segment-%05d.m4s"
77
+ "segment-%05d.mp4"
37
78
  ];
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.mp4";
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() {
@@ -64,7 +151,7 @@ export const fmp4Format = {
64
151
  },
65
152
 
66
153
  segmentFileName(index) {
67
- return `segment-${String(index).padStart(5, "0")}.m4s`;
154
+ return `segment-${String(index).padStart(5, "0")}.mp4`;
68
155
  },
69
156
 
70
157
  isSegmentFileName(fileName) {
@@ -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
+ });