@torrent-tv/proxy 2.9.85 → 2.9.87

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,12 @@
1
+ ## 2.9.87
2
+
3
+ - **Fix**: fMP4 playback no longer stops after the first segment. A segment's position was being written into **every** fragment it contains, and the explicit-cut muxer puts several in one segment — `frag_keyframe` opens a fragment at each keyframe while a cut point comes only every few keyframes. Measured: a 6 s piece carries three fragments per track, at 0, 2 and 4 s of its own clock; all three were stamped with the segment's start, so they claimed the same decode time and the player rejected the segment. In the field (2.9.86, this session) that showed as segments 1 and 2 requested in an endless alternation, each served in tens of milliseconds with the transcode healthy at 12x, while the picture froze a few seconds in. The position is now applied as a shift: each track's first fragment sets the base and the rest keep their distance from it. With one fragment per track — what the `hls` muxer produces — a shift and a write are the same thing, so the other path is unchanged. Verified end to end on the addon host: four pieces cut, split, stamped and reassembled the way a player does, then probed — 600 frames over 24 s, decode timestamps rising by exactly 0.04 s across every segment join, no duplicates, clean decode.
4
+
5
+ ## 2.9.86
6
+
7
+ - **Fix**: fMP4 playback starts again. The real reason ffmpeg exited before writing anything was the audio, not the file names: the MP4 muxer derives a copied AC-3 track's `dac3` box from the bitstream, so it cannot write `moov` until the first audio packet arrives, while our `empty_moov` demands it at header time — `Cannot write moov atom before AC3 packets. Set the delay_moov flag to fix this.`, captured in the field on a copied AC-3 source. `delay_moov` is now passed alongside it. The `hls` muxer sets that flag itself, which is why the fault appeared only once the muxing moved to the `segment` muxer in 2.9.84; MPEG-TS has no `moov` and was never affected. Verified in the addon container on an AC-3 source: without the flag the exact command the proxy runs fails, with it the segments are written, and the piece layout is unchanged (`ftyp moov moof mdat … mfra`), so the init split added in 2.9.84 still cuts in the same places — headers of consecutive pieces differ in four bytes, all inside `elst`, which the `tfdt` rewriting already overrides.
8
+ - **Chore**: Correcting the 2.9.85 entry below. It blames the `.m4s` extension, and that is false: with the arguments this proxy passes, ffmpeg 8.1.2 writes `.m4s` without complaint (re-measured on the same host, and on 6.1.1). The quoted error is what the same command produces when `-segment_format mp4` is missing — which the proxy never omits — and the field failure ends in `Invalid argument`, not `Muxer not found`. The rename is harmless and stays, but it fixed nothing.
9
+
1
10
  ## 2.9.85
2
11
 
3
12
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.85",
3
+ "version": "2.9.87",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -102,8 +102,18 @@ export const fmp4Format = {
102
102
  // `empty_moov` is what makes each piece self-describing, which is what
103
103
  // lets the init be lifted out of it; `default_base_moof` keeps fragment
104
104
  // offsets relative, so removing the header does not invalidate them.
105
+ //
106
+ // `delay_moov` is not optional here. The MP4 muxer builds a copied AC-3
107
+ // track's `dac3` box out of the bitstream, so it cannot write `moov`
108
+ // until the first audio packet has arrived — while `empty_moov` asks for
109
+ // it at header time. Without this flag ffmpeg exits before producing
110
+ // anything: "Cannot write moov atom before AC3 packets", which is exactly
111
+ // how fMP4 playback died in the field on 2.9.84/2.9.85. The `hls` muxer
112
+ // sets this flag itself, which is why the fault only appeared once the
113
+ // muxing moved here. Delaying `moov` does not change the piece layout —
114
+ // measured: still `ftyp moov moof mdat … mfra`.
105
115
  "-segment_format_options",
106
- "movflags=+frag_keyframe+empty_moov+default_base_moof"
116
+ "movflags=+frag_keyframe+empty_moov+default_base_moof+delay_moov"
107
117
  ];
108
118
  },
109
119
 
@@ -113,6 +113,19 @@ export function readTrackTimescales(initSegment) {
113
113
  * independently-addressable segment anyway: it carries its own position, so it
114
114
  * is valid against any init for the same tracks.
115
115
  *
116
+ * A SEGMENT MAY HOLD SEVERAL FRAGMENTS PER TRACK, so the segment's start is
117
+ * applied as a SHIFT, not as a value written into every `tfdt`. The muxer that
118
+ * takes explicit cut times uses `frag_keyframe`, which opens a fragment at each
119
+ * keyframe, while a cut point is only every few keyframes — measured on the
120
+ * field host: a 6 s piece carried three fragments per track, at 0, 2 and 4 s of
121
+ * its own clock. Writing the segment's start into all three made them claim the
122
+ * same decode time; the player rejected the segment and re-fetched it forever
123
+ * (field 2026-08-04: segments 1 and 2 alternating for minutes, each served in
124
+ * tens of milliseconds, transcode healthy at 12x). Each track's first fragment
125
+ * therefore defines the base and the rest keep their distance from it. With one
126
+ * fragment per track — what the `hls` muxer produces — a shift and a write are
127
+ * the same thing, so both paths are served by this.
128
+ *
116
129
  * Mutates a copy; the caller's buffer is untouched.
117
130
  *
118
131
  * @param {Buffer} segment
@@ -128,6 +141,8 @@ export function stampSegmentStartTime(segment, startSeconds, trackTimescales) {
128
141
  // `tfhd` carries the track id and always precedes the `tfdt` inside the same
129
142
  // `traf`, so an ordered pass pairs each `tfdt` with its track's timescale.
130
143
  let currentTrackId = null;
144
+ /** @type {Map<number, number>} trackId → decode time of that track's first fragment. */
145
+ const fragmentBase = new Map();
131
146
  walkBoxes(stamped, (type, bodyStart, bodyEnd) => {
132
147
  if (type === "tfhd") {
133
148
  if (bodyStart + 8 <= bodyEnd) {
@@ -143,17 +158,27 @@ export function stampSegmentStartTime(segment, startSeconds, trackTimescales) {
143
158
  return;
144
159
  }
145
160
  const version = stamped[bodyStart];
146
- const value = Math.round(startSeconds * timescale);
161
+ if (version === 1 ? bodyStart + 12 > bodyEnd : bodyStart + 8 > bodyEnd) {
162
+ return;
163
+ }
164
+ const existing =
165
+ version === 1
166
+ ? Number(stamped.readBigUInt64BE(bodyStart + 4))
167
+ : stamped.readUInt32BE(bodyStart + 4);
168
+ if (!fragmentBase.has(currentTrackId)) {
169
+ fragmentBase.set(currentTrackId, existing);
170
+ }
171
+ // Distance from the track's first fragment in this segment. Never negative:
172
+ // decode times only move forward, and a malformed one must not drag a later
173
+ // fragment behind the segment's start.
174
+ const withinSegment = Math.max(0, existing - (fragmentBase.get(currentTrackId) ?? 0));
175
+ const value = Math.round(startSeconds * timescale) + withinSegment;
147
176
  if (version === 1) {
148
- if (bodyStart + 12 <= bodyEnd) {
149
- stamped.writeBigUInt64BE(BigInt(value), bodyStart + 4);
150
- }
151
- } else if (bodyStart + 8 <= bodyEnd) {
177
+ stamped.writeBigUInt64BE(BigInt(value), bodyStart + 4);
178
+ } else if (value <= 0xffffffff) {
152
179
  // A 32-bit field cannot express beyond ~2^32 ticks; leave it rather than
153
180
  // write a wrapped value (the player would land somewhere arbitrary).
154
- if (value <= 0xffffffff) {
155
- stamped.writeUInt32BE(value, bodyStart + 4);
156
- }
181
+ stamped.writeUInt32BE(value, bodyStart + 4);
157
182
  }
158
183
  });
159
184
  return stamped;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Stamping a segment's position onto its fragments.
3
+ *
4
+ * A segment produced by the explicit-cut muxer holds SEVERAL fragments per
5
+ * track — `frag_keyframe` opens one at every keyframe, while a cut point comes
6
+ * only every few keyframes. Writing the segment's start into each of them made
7
+ * them all claim the same decode time, and the player re-fetched the segment
8
+ * forever. The positions inside a segment must therefore survive.
9
+ */
10
+
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { stampSegmentStartTime } from "../services/segment-formats/mp4-boxes.js";
14
+
15
+ /**
16
+ * @param {string} type
17
+ * @param {Buffer} body
18
+ * @returns {Buffer}
19
+ */
20
+ function box(type, body) {
21
+ const header = Buffer.alloc(8);
22
+ header.writeUInt32BE(8 + body.length, 0);
23
+ header.write(type, 4, "latin1");
24
+ return Buffer.concat([header, body]);
25
+ }
26
+
27
+ /**
28
+ * One fragment: `moof > traf > (tfhd, tfdt)`, plus the payload it describes.
29
+ *
30
+ * @param {{ trackId: number, decodeTime: number, version?: 0 | 1 }} fragment
31
+ * @returns {Buffer}
32
+ */
33
+ function makeFragment({ trackId, decodeTime, version = 1 }) {
34
+ const tfhdBody = Buffer.alloc(8);
35
+ tfhdBody.writeUInt32BE(trackId, 4);
36
+ const tfdtBody = Buffer.alloc(version === 1 ? 12 : 8);
37
+ tfdtBody[0] = version;
38
+ if (version === 1) {
39
+ tfdtBody.writeBigUInt64BE(BigInt(decodeTime), 4);
40
+ } else {
41
+ tfdtBody.writeUInt32BE(decodeTime, 4);
42
+ }
43
+ const traf = box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
44
+ return Buffer.concat([box("moof", traf), box("mdat", Buffer.alloc(16, 0x5a))]);
45
+ }
46
+
47
+ /**
48
+ * Every `tfdt` in order, as `[trackId, decodeTime]`.
49
+ *
50
+ * @param {Buffer} segment
51
+ * @returns {Array<[number, number]>}
52
+ */
53
+ function readDecodeTimes(segment) {
54
+ const found = [];
55
+ let offset = 0;
56
+ let trackId = 0;
57
+ while (offset + 8 <= segment.length) {
58
+ const size = segment.readUInt32BE(offset);
59
+ const type = segment.toString("latin1", offset + 4, offset + 8);
60
+ if (type === "tfhd") {
61
+ trackId = segment.readUInt32BE(offset + 12);
62
+ } else if (type === "tfdt") {
63
+ const version = segment[offset + 8];
64
+ found.push([
65
+ trackId,
66
+ version === 1 ? Number(segment.readBigUInt64BE(offset + 12)) : segment.readUInt32BE(offset + 12)
67
+ ]);
68
+ }
69
+ // Descend into the containers on the way to `tfdt`; skip anything else
70
+ // whole, so `mdat` is never walked into.
71
+ offset += type === "moof" || type === "traf" ? 8 : size;
72
+ }
73
+ return found;
74
+ }
75
+
76
+ test("fragments keep their distance from the start of the segment", () => {
77
+ // A 6 s segment holding three fragments at 0, 2 and 4 s of its own clock —
78
+ // the shape measured on the field host.
79
+ const timescale = 16_000;
80
+ const segment = Buffer.concat([
81
+ makeFragment({ trackId: 1, decodeTime: 0 }),
82
+ makeFragment({ trackId: 1, decodeTime: 2 * timescale }),
83
+ makeFragment({ trackId: 1, decodeTime: 4 * timescale })
84
+ ]);
85
+
86
+ const stamped = stampSegmentStartTime(segment, 6, new Map([[1, timescale]]));
87
+
88
+ assert.deepEqual(
89
+ readDecodeTimes(stamped).map(([, time]) => time / timescale),
90
+ [6, 8, 10],
91
+ "each fragment must land at the segment's start plus its own offset"
92
+ );
93
+ });
94
+
95
+ test("each track is shifted by its own base", () => {
96
+ const videoScale = 16_000;
97
+ const audioScale = 44_100;
98
+ // Audio does not start at zero: its frames do not align with the video's.
99
+ const segment = Buffer.concat([
100
+ makeFragment({ trackId: 1, decodeTime: 0 }),
101
+ makeFragment({ trackId: 2, decodeTime: 1024 }),
102
+ makeFragment({ trackId: 1, decodeTime: 2 * videoScale }),
103
+ makeFragment({ trackId: 2, decodeTime: 1024 + 2 * audioScale })
104
+ ]);
105
+
106
+ const stamped = stampSegmentStartTime(
107
+ segment,
108
+ 6,
109
+ new Map([
110
+ [1, videoScale],
111
+ [2, audioScale]
112
+ ])
113
+ );
114
+
115
+ assert.deepEqual(readDecodeTimes(stamped), [
116
+ [1, 6 * videoScale],
117
+ [2, 6 * audioScale],
118
+ [1, 8 * videoScale],
119
+ [2, 8 * audioScale]
120
+ ]);
121
+ });
122
+
123
+ test("a single fragment is written outright, as before", () => {
124
+ const stamped = stampSegmentStartTime(
125
+ makeFragment({ trackId: 1, decodeTime: 1280 }),
126
+ 12,
127
+ new Map([[1, 16_000]])
128
+ );
129
+ assert.deepEqual(readDecodeTimes(stamped), [[1, 12 * 16_000]]);
130
+ });
131
+
132
+ test("a 32-bit field too small for the position is left alone", () => {
133
+ const segment = makeFragment({ trackId: 1, decodeTime: 0, version: 0 });
134
+ const stamped = stampSegmentStartTime(segment, 1_000_000, new Map([[1, 90_000]]));
135
+ assert.deepEqual(
136
+ readDecodeTimes(stamped),
137
+ [[1, 0]],
138
+ "a wrapped value would send the player somewhere arbitrary; leaving it is the lesser harm"
139
+ );
140
+ });