@torrent-tv/proxy 2.24.0 → 2.24.1

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.24.1
2
+
3
+ - **Fix**: Seeking could leave a film dead. After a seek the encoder restarts at the segment before the target, and every segment it then produces states its own position, read out of the piece. On a file whose container index is wrong those positions disagree with the playlist the player is holding — measured 2026-08-17, a seek to 1590.4 s produced audio segments #292 and #293 carrying 1587.892 s and 1592.692 s against a playlist saying 1585.376 s and 1590.585 s. A fragment landing further from where the playlist put it than a player will bridge (hls.js bridges `maxBufferHole`, 0.5 s by default) is not recognised as buffered, so the browser asks for it again: those two segments were fetched **1908 times each over ten minutes**, every one served in 4 ms, with the picture frozen and nothing in either log saying why. A segment is now stamped where the playlist the player holds says it begins, whenever the piece's own figure is further away than that; within it the piece's own figure is kept, which is what keeps speech and subtitles together on a file whose index is slightly out. The boundary table goes on being corrected from produced segments — that is what lets a re-encoded step be cut like the copy it joins — but the correction no longer moves segments under a player holding the original playlist: the published table is frozen when the playlist text is written from it. Pinned by `test/published-timeline.test.js` with the field figures.
4
+
1
5
  ## 2.24.0
2
6
 
3
7
  - **New**: The budget's two remaining holes are closed, which is the rest of roadmap item 6. A soundtrack published on its own is a second encoder running for as long as the picture does, and it was charged at nothing; a picture being RE-ENCODED beside the step being judged — which is what every quality switch does, two encoders on purpose — was charged at nothing too. Both are priced now: the soundtrack from its own measured speed, the second picture from what it was last seen doing alone, falling back to the same model that judges every step. Nothing is charged for an encode nobody has measured and no model can price: a guess there would refuse steps on arithmetic no one performed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.24.0",
3
+ "version": "2.24.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -541,6 +541,17 @@ const SEGMENT_READ_HIGH_WATER_MARK = 4 * 1024 * 1024;
541
541
  // index being wrong about where a keyframe is rather than rounding.
542
542
  const SEGMENT_START_DISAGREEMENT_SEC = 0.25;
543
543
 
544
+ /**
545
+ * How far a fragment may land from where the playlist put it before the player
546
+ * stops recognising it as buffered.
547
+ *
548
+ * hls.js's own `maxBufferHole`, whose default is 0.5 s: a gap smaller than this
549
+ * is skipped, a gap larger is a hole, and a fragment appended across one is
550
+ * judged not to have loaded — so the player asks for it again, and again.
551
+ * Taken from the player's published default rather than chosen here.
552
+ */
553
+ const PLAYER_BUFFER_HOLE_SEC = 0.5;
554
+
544
555
  /**
545
556
  * Resolve after a given number of milliseconds.
546
557
  *
@@ -1898,6 +1909,12 @@ export class HlsSessionManager {
1898
1909
  containerFormat,
1899
1910
  indexCheck: newIndexCheck(),
1900
1911
  playlistText: hasDuration ? this.#buildVodPlaylist(segmentBoundaries, segmentFormat) : "",
1912
+ // The table AS PUBLISHED, frozen the moment the playlist text is written
1913
+ // from it. `segmentBoundaries` keeps being corrected from produced
1914
+ // segments — that is what makes a re-encoded rung cut like the copy it
1915
+ // joins — but the player's own copy of the timeline never changes, and a
1916
+ // segment must be stamped against the copy the player has.
1917
+ publishedBoundaries: hasDuration ? [...segmentBoundaries] : null,
1901
1918
  // Segment index the current ffmpeg run started producing from.
1902
1919
  encodeStartIndex: 0,
1903
1920
  // Guards against repeatedly restarting to the same seek position.
@@ -2323,6 +2340,65 @@ export class HlsSessionManager {
2323
2340
  return boundaries[clamped];
2324
2341
  }
2325
2342
 
2343
+ /**
2344
+ * Start time of segment `index` AS THE PLAYER WAS TOLD IT — from the boundary
2345
+ * table as it stood when this session's playlist text was built.
2346
+ *
2347
+ * Two tables, deliberately: the live one is corrected as produced segments
2348
+ * reveal where the file's cuts truly are, and those corrections are what let a
2349
+ * re-encoded rung be forced onto a copied stream's real grid. But the playlist
2350
+ * a player is holding was written once and never changes, so a stamp taken
2351
+ * from the corrected table describes a timeline nobody sent the player. That
2352
+ * is not a subtlety: it cost ten minutes of a dead film on 2026-08-17, the
2353
+ * browser asking for two segments 1908 times each.
2354
+ *
2355
+ * @param {HlsSession} session
2356
+ * @param {number} index
2357
+ * @returns {number}
2358
+ */
2359
+ #publishedStartTime(session, index) {
2360
+ const boundaries = Array.isArray(session.publishedBoundaries) && session.publishedBoundaries.length > 0
2361
+ ? session.publishedBoundaries
2362
+ : null;
2363
+ if (!boundaries) {
2364
+ // No playlist was published from a table (no duration, so no synthetic
2365
+ // playlist): the live table is all there is, and it has not been
2366
+ // contradicted by anything the player holds.
2367
+ return this.#segmentStartTime(session, index);
2368
+ }
2369
+ const clamped = Math.max(0, Math.min(index, boundaries.length - 1));
2370
+ return boundaries[clamped];
2371
+ }
2372
+
2373
+ /**
2374
+ * Report a segment whose own timeline disagrees with the playlist by more
2375
+ * than a player will bridge.
2376
+ *
2377
+ * Once per segment per five seconds, like every other repeating condition
2378
+ * here: the same segment is requested again and again while it is refused,
2379
+ * and a line each time buries the first one.
2380
+ *
2381
+ * @param {HlsSession} session
2382
+ * @param {number} index
2383
+ * @param {number} trueStart
2384
+ * @param {number} publishedStart
2385
+ * @returns {void}
2386
+ */
2387
+ #notePlaylistDisagreement(session, index, trueStart, publishedStart) {
2388
+ const now = Date.now();
2389
+ session.stampWarnedAt ??= new Map();
2390
+ if (now - (session.stampWarnedAt.get(index) ?? 0) < 5_000) {
2391
+ return;
2392
+ }
2393
+ session.stampWarnedAt.set(index, now);
2394
+ logger.warn(
2395
+ `transcode ${session.id} segment #${index} carries ${trueStart.toFixed(3)}s while the playlist ` +
2396
+ `the player holds says ${publishedStart.toFixed(3)}s — a gap of ` +
2397
+ `${Math.abs(trueStart - publishedStart).toFixed(3)}s, beyond the ${PLAYER_BUFFER_HOLE_SEC}s a player ` +
2398
+ "bridges; stamping it where the playlist says so the fragment lands where it was asked for"
2399
+ );
2400
+ }
2401
+
2326
2402
  /**
2327
2403
  * Segment index whose span contains time `t` (0-based), via the boundary
2328
2404
  * table.
@@ -6960,8 +7036,36 @@ export class HlsSessionManager {
6960
7036
  if (trueStart !== null) {
6961
7037
  this.#noteIndexAccuracy(session, index, trueStart, declaredStart);
6962
7038
  }
7039
+ // WHERE THE PLAYER WAS TOLD THIS SEGMENT BEGINS, which is the playlist
7040
+ // it holds and nothing else. The published text is fixed when the
7041
+ // session is created; `#segmentStartTime` reads a table that a
7042
+ // correction may since have moved, and a stamp taken from the moved
7043
+ // table describes a timeline the player has never seen.
7044
+ const publishedStart = this.#publishedStartTime(session, index);
7045
+ // A player places a fragment by the playlist. If the bytes claim a
7046
+ // different position, the fragment does not land where the fragment was
7047
+ // expected, hls.js finds the range still unbuffered and asks for the
7048
+ // same fragment again — for ever. Measured 2026-08-17: a seek to
7049
+ // 1590.4 s produced audio segments #292/#293 whose own timeline said
7050
+ // 1587.892 and 1592.692 against a playlist saying 1585.376 and
7051
+ // 1590.585, and the browser fetched those two segments 1908 times each
7052
+ // over ten minutes, every one of them served in 4 ms. The film was dead
7053
+ // and no line said why.
7054
+ //
7055
+ // So the stamp follows the playlist whenever the two disagree by more
7056
+ // than a player will bridge. hls.js bridges up to `maxBufferHole`,
7057
+ // which it defaults to 0.5 s — that is the player's own published
7058
+ // figure, not a number chosen here. Within it the file's own position
7059
+ // is kept, because it is the honest one and it is what keeps speech and
7060
+ // subtitles together on a file whose index is slightly out (2026-08-06,
7061
+ // 4.17 s of drift on a Matroska index that lied).
7062
+ let stampStart = trueStart ?? publishedStart;
7063
+ if (trueStart !== null && Math.abs(trueStart - publishedStart) > PLAYER_BUFFER_HOLE_SEC) {
7064
+ stampStart = publishedStart;
7065
+ this.#notePlaylistDisagreement(session, index, trueStart, publishedStart);
7066
+ }
6963
7067
  const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
6964
- startSeconds: trueStart ?? declaredStart,
7068
+ startSeconds: stampStart,
6965
7069
  initBytes: session.initBytes ?? null
6966
7070
  });
6967
7071
  this.#noteRunProducedSegment(session, filePath);
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @file A segment must be stamped where the playlist the player holds says.
3
+ *
4
+ * The field case this pins, 2026-08-17: a seek to 1590.4 s restarted the audio
5
+ * rendition at #291; the produced segments #292 and #293 carried their own
6
+ * positions, 1587.892 s and 1592.692 s, while the playlist the browser was
7
+ * holding said 1585.376 s and 1590.585 s. The browser fetched those two
8
+ * segments **1908 times each** over ten minutes, every one served in 4 ms, and
9
+ * the film never moved: a fragment appended more than `maxBufferHole` away from
10
+ * where the playlist put it is not recognised as buffered, so the player asks
11
+ * for it again.
12
+ *
13
+ * The rule, and the reason it is not simply "always use the file's own time":
14
+ * the boundary table keeps being corrected from produced segments, and the
15
+ * playlist does not. Whichever is right about the FILE, only one of them was
16
+ * sent to the player.
17
+ */
18
+
19
+ import assert from "node:assert/strict";
20
+ import test from "node:test";
21
+
22
+ /**
23
+ * hls.js's own default. A gap smaller than this is skipped; a larger one is a
24
+ * hole, and the fragment across it counts as not loaded.
25
+ */
26
+ const PLAYER_BUFFER_HOLE_SEC = 0.5;
27
+
28
+ /**
29
+ * The decision under test, in the shape the session manager applies it.
30
+ *
31
+ * @param {{ trueStart: number | null, publishedStart: number }} reading
32
+ * @returns {{ stamp: number, followedPlaylist: boolean }}
33
+ */
34
+ function stampFor({ trueStart, publishedStart }) {
35
+ if (trueStart === null) {
36
+ return { stamp: publishedStart, followedPlaylist: true };
37
+ }
38
+ if (Math.abs(trueStart - publishedStart) > PLAYER_BUFFER_HOLE_SEC) {
39
+ return { stamp: publishedStart, followedPlaylist: true };
40
+ }
41
+ return { stamp: trueStart, followedPlaylist: false };
42
+ }
43
+
44
+ test("the field case: a segment far from its playlist position is stamped where the playlist says", () => {
45
+ const segment292 = stampFor({ trueStart: 1587.892, publishedStart: 1585.376 });
46
+ assert.equal(segment292.stamp, 1585.376);
47
+ assert.equal(segment292.followedPlaylist, true);
48
+
49
+ const segment293 = stampFor({ trueStart: 1592.692, publishedStart: 1590.585 });
50
+ assert.equal(segment293.stamp, 1590.585);
51
+ });
52
+
53
+ test("a file whose index is honest keeps its own position", () => {
54
+ // Within what a player bridges, the file's own figure is the truthful one and
55
+ // it is what keeps speech and subtitles together (2026-08-06, 4.17 s of drift
56
+ // when the picture was stamped from a lying index).
57
+ const reading = stampFor({ trueStart: 120.13, publishedStart: 120.0 });
58
+ assert.equal(reading.stamp, 120.13);
59
+ assert.equal(reading.followedPlaylist, false);
60
+ });
61
+
62
+ test("the boundary is exactly the player's own tolerance", () => {
63
+ assert.equal(stampFor({ trueStart: 10.5, publishedStart: 10.0 }).stamp, 10.5);
64
+ assert.equal(stampFor({ trueStart: 10.51, publishedStart: 10.0 }).stamp, 10.0);
65
+ });
66
+
67
+ test("a segment that does not say where it begins is stamped from the playlist", () => {
68
+ assert.equal(stampFor({ trueStart: null, publishedStart: 42.0 }).stamp, 42.0);
69
+ });
70
+
71
+ test("a corrected boundary does not move the stamp of a session already playing", () => {
72
+ // The correction writes into the live table; the published one is frozen. If
73
+ // the stamp followed the live table, every correction would move segments
74
+ // under a player holding the original playlist — which is the same failure
75
+ // seen from the other side.
76
+ const published = [0, 4, 8, 12];
77
+ const live = [...published];
78
+ live[2] = 9.7; // corrected from a produced segment
79
+ const publishedStart = published[2];
80
+ const reading = stampFor({ trueStart: live[2], publishedStart });
81
+ assert.equal(publishedStart, 8);
82
+ assert.equal(reading.stamp, 8, "the player was told 8 s and must be given 8 s");
83
+ });
@@ -171,7 +171,7 @@ test("serving a segment records what its real start says about the container's i
171
171
  });
172
172
 
173
173
  test("a segment that exists is served, not reported as still being produced", async (t) => {
174
- const { manager, dirPath } = await managerWithReadySegment();
174
+ const { manager, dirPath, session } = await managerWithReadySegment();
175
175
  t.after(async () => {
176
176
  await manager.disposeAll();
177
177
  await rm(dirPath, { recursive: true, force: true });
@@ -189,12 +189,26 @@ test("a segment that exists is served, not reported as still being produced", as
189
189
  const served = Buffer.concat(chunks);
190
190
  assert.equal(served.toString("latin1", 4, 8), "moof", "the init header must be stripped off a media segment");
191
191
 
192
- // The position the PIECE states, carried into the fragment it belongs to.
193
- // Reading it is the step that threw in 2.9.124.
192
+ // Where the PLAYLIST the player holds puts this segment which is what the
193
+ // fragment must be stamped with whenever the piece's own position is further
194
+ // away than a player will bridge. This fixture's piece says 12.5 s while the
195
+ // playlist says 0, and stamping the piece's figure is what killed a film on
196
+ // 2026-08-17: the browser asked for two segments 1908 times each over ten
197
+ // minutes, each served in 4 ms, because a fragment landing 2.5 s from where
198
+ // it was expected is not recognised as buffered. Reading the piece's own
199
+ // position still happens — it is the step that threw in 2.9.124, it feeds the
200
+ // index tally and it corrects the grid for rungs made later — it just no
201
+ // longer contradicts the timeline the player was sent. See
202
+ // test/published-timeline.test.js for the rule itself.
194
203
  assert.equal(
195
204
  Number(served.readBigUInt64BE(served.indexOf("tfdt") + 8)),
196
- Math.round(SEGMENT_START_SECONDS * VIDEO_TIMESCALE),
197
- "the segment must be stamped with where it really begins"
205
+ 0,
206
+ "the segment must be stamped where the playlist the player holds says it begins"
207
+ );
208
+ assert.equal(
209
+ session.indexCheck.checked,
210
+ 1,
211
+ "and the piece's own position must still have been read, or nothing measures the index"
198
212
  );
199
213
  });
200
214