@torrent-tv/proxy 2.9.86 → 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,7 @@
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
+
1
5
  ## 2.9.86
2
6
 
3
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.86",
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": {
@@ -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
+ });