@torrent-tv/proxy 2.24.1 → 2.25.0

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,8 @@
1
+ ## 2.25.0
2
+
3
+ - **Fix**: Picture and sound drifted apart after a seek, by exactly the amount the grid had been corrected. 2.24.1 made every segment stamp itself against the playlist its own session published — but each session froze that playlist at its own creation, and a soundtrack or a quality step is created later than the picture it accompanies, so it froze a table that had since been corrected. Two members of one family then stated the same moment differently, and the corrections measured on the field file are 0.6-2.9 s. A family now publishes ONE timeline: a session created inside a family takes its base's published table verbatim and writes its own playlist from it, while the live table goes on being corrected for cutting, which is what keeps a re-encoded step aligned with the copy it joins.
4
+ - **New**: The read window grows into a lead instead of staying a fixed length. Every wait that cost time widens it by a piece; every piece already in hand gives one back, down to the size the caller sized from the file's own byte rate. The ceiling is this reader's share of the store's memory, so widening can never ask for more than the store can hold. Measured 2026-08-17, the swarm delivered 5.1-5.9 MB/s against a film consumed at about 1 MB/s while the reader still blocked 47 times in two minutes — a fivefold surplus that never became distance ahead of the head.
5
+
1
6
  ## 2.24.1
2
7
 
3
8
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.24.1",
3
+ "version": "2.25.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1741,6 +1741,14 @@ export class HlsSessionManager {
1741
1741
  })
1742
1742
  : []);
1743
1743
  const usingKeyframeBoundaries = useKeyframeGrid;
1744
+ // What this session will PUBLISH. A member of a family takes its base's
1745
+ // published table verbatim; a session with no base publishes what it cuts
1746
+ // at. The two differ exactly by the corrections made since the family's
1747
+ // first playlist was written, and that difference is what must never reach
1748
+ // the player as two different timelines.
1749
+ const publishedGrid = Array.isArray(inheritedGrid?.published) && inheritedGrid.published.length > 1
1750
+ ? inheritedGrid.published
1751
+ : (hasDuration ? [...segmentBoundaries] : null);
1744
1752
  const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
1745
1753
 
1746
1754
  // Realtime budget (software encoder): pick the output resolution + libx264
@@ -1908,13 +1916,18 @@ export class HlsSessionManager {
1908
1916
  // spliced into the copy (roadmap item 28).
1909
1917
  containerFormat,
1910
1918
  indexCheck: newIndexCheck(),
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,
1919
+ playlistText: hasDuration ? this.#buildVodPlaylist(publishedGrid, segmentFormat) : "",
1920
+ // The table AS PUBLISHED what every playlist of this family states, and
1921
+ // what every segment of it is stamped against. Inherited whole from the
1922
+ // base when there is one, so a rung or a soundtrack created later
1923
+ // publishes the same timeline as the picture it plays with; only a family
1924
+ // with no base freezes a copy of its own.
1925
+ //
1926
+ // `segmentBoundaries` keeps being corrected from produced segments — that
1927
+ // is what makes a re-encoded rung cut like the copy it joins — and those
1928
+ // corrections deliberately do NOT reach this copy: the player's timeline
1929
+ // was sent once and cannot be revised.
1930
+ publishedBoundaries: publishedGrid,
1918
1931
  // Segment index the current ffmpeg run started producing from.
1919
1932
  encodeStartIndex: 0,
1920
1933
  // Guards against repeatedly restarting to the same seek position.
@@ -6134,8 +6147,14 @@ export class HlsSessionManager {
6134
6147
  inheritedGrid: base.cutGrid === "keyframe"
6135
6148
  ? {
6136
6149
  // The table as it stands NOW, corrections included — not the index
6137
- // it was first built from.
6150
+ // it was first built from. This is what the new session CUTS at.
6138
6151
  boundaries: base.segmentBoundaries,
6152
+ // And this is what it must SAY, which is not the same thing: every
6153
+ // member of a family has to publish one timeline, or two sessions
6154
+ // stamp the same moment differently and the picture and the sound
6155
+ // drift apart by exactly the corrections made between their two
6156
+ // creations (field 2026-08-17, corrections of 0.6-2.9 s).
6157
+ published: base.publishedBoundaries,
6139
6158
  keyframeTimes: base.keyframeTimes,
6140
6159
  containerFormat: base.containerFormat
6141
6160
  }
@@ -6670,6 +6689,7 @@ export class HlsSessionManager {
6670
6689
  inheritedGrid: base.cutGrid === "keyframe"
6671
6690
  ? {
6672
6691
  boundaries: base.segmentBoundaries,
6692
+ published: base.publishedBoundaries,
6673
6693
  keyframeTimes: base.keyframeTimes,
6674
6694
  containerFormat: base.containerFormat
6675
6695
  }
@@ -60,6 +60,37 @@ export function readWindowFor({ pieceIndex, lastPiece, windowPieces }) {
60
60
  return { from: pieceIndex, to: Math.min(lastPiece, pieceIndex + span - 1) };
61
61
  }
62
62
 
63
+ /**
64
+ * How wide the window should be after a piece that made the reader wait — or
65
+ * did not.
66
+ *
67
+ * The swarm's surplus is what pays for this. Measured 2026-08-17 on the field
68
+ * torrent: 5.1-5.9 MB/s delivered against a film consumed at about 1 MB/s, and
69
+ * the reader still blocked 47 times in two minutes, median 1.5 s, worst 4.5 s.
70
+ * A fivefold surplus never became distance ahead of the head, because the
71
+ * window is a fixed number of seconds of playback and everything past it is
72
+ * ordinary background fill at no priority.
73
+ *
74
+ * So the window follows the evidence: every wait that mattered widens it by a
75
+ * piece, every piece that was already there narrows it back toward the size the
76
+ * caller asked for. Nothing here is chosen — the wait is measured, the
77
+ * threshold is the one that already defines "a wait worth recording", and the
78
+ * ceiling is this reader's share of the store's memory, so widening can never
79
+ * cost more than the store can hold.
80
+ *
81
+ * @param {{ current: number, base: number, ceiling: number, waitedMs: number, waitThresholdMs: number }} params
82
+ * @returns {number}
83
+ */
84
+ export function nextWindowPieces({ current, base, ceiling, waitedMs, waitThresholdMs }) {
85
+ const floor = Math.max(1, Math.floor(base));
86
+ const top = Math.max(floor, Math.floor(ceiling));
87
+ const now = Math.min(top, Math.max(floor, Math.floor(current)));
88
+ if (waitedMs >= waitThresholdMs) {
89
+ return Math.min(top, now + 1);
90
+ }
91
+ return Math.max(floor, now - 1);
92
+ }
93
+
63
94
  /**
64
95
  * Add this reader's window to the download set as a stream selection.
65
96
  *
@@ -310,7 +341,26 @@ export async function* readFragments({
310
341
  // critical, and never deselected anything — so ffmpeg's opening
311
342
  // `bytes 0-<EOF>` left a permanent selection over the entire file, and no
312
343
  // later prioritisation could outrank it.
313
- const windowPieces = Math.max(1, Math.ceil(Math.max(1, windowBytes) / pieceLength));
344
+ const basePieces = Math.max(1, Math.ceil(Math.max(1, windowBytes) / pieceLength));
345
+ // What the window is RIGHT NOW. It starts at what the caller sized in seconds
346
+ // of playback and grows while the reader keeps being made to wait — see
347
+ // `nextWindowPieces`.
348
+ let windowPieces = basePieces;
349
+ /**
350
+ * The widest this reader may go: its share of what the store can hold in
351
+ * memory. Measured rather than chosen — the capacity is the store's own, and
352
+ * the number of readers is how many windows are declared on it right now.
353
+ *
354
+ * @returns {number}
355
+ */
356
+ const ceilingPieces = () => {
357
+ const capacity = Number(store?.capacity);
358
+ if (!Number.isFinite(capacity) || capacity <= 0) {
359
+ return basePieces;
360
+ }
361
+ const readers = Math.max(1, store.protectedRanges?.().length ?? 1);
362
+ return Math.max(basePieces, Math.floor(capacity / readers));
363
+ };
314
364
  /** @type {{ from: number, to: number } | null} */
315
365
  let window = null;
316
366
  /** @type {{ from: number, to: number } | null} */
@@ -408,6 +458,20 @@ export async function* readFragments({
408
458
  // whether that is the swarm, the picker, or ffmpeg. Logged only when the
409
459
  // wait is long enough to matter, so ordinary sequential reading is silent.
410
460
  const waitedMs = Date.now() - waitStartedAt;
461
+ // The window answers to what just happened: a wait means the lead was too
462
+ // short, an immediate hit means it is longer than it needs to be. Applied
463
+ // before the logging below so the line reports the window the next piece
464
+ // will actually use.
465
+ const widened = nextWindowPieces({
466
+ current: windowPieces,
467
+ base: basePieces,
468
+ ceiling: ceilingPieces(),
469
+ waitedMs,
470
+ waitThresholdMs: PIECE_WAIT_LOG_MS
471
+ });
472
+ if (widened !== windowPieces) {
473
+ windowPieces = widened;
474
+ }
411
475
  if (waitedMs >= PIECE_WAIT_LOG_MS) {
412
476
  const rateKbps = Math.round(pieceLength / 1024 / (waitedMs / 1000));
413
477
  logger.info(
@@ -19,7 +19,11 @@ import { EventEmitter } from "node:events";
19
19
  import os from "node:os";
20
20
  import path from "node:path";
21
21
  import fs from "node:fs/promises";
22
- import { readFragments, readWindowFor } from "../services/torrent-worker/piece-reader.js";
22
+ import {
23
+ nextWindowPieces,
24
+ readFragments,
25
+ readWindowFor
26
+ } from "../services/torrent-worker/piece-reader.js";
23
27
  import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js";
24
28
 
25
29
  const PIECE = 1024;
@@ -276,3 +280,50 @@ test("a reader that is abandoned mid-fragment does not keep the piece pinned", a
276
280
  await fs.rm(directory, { recursive: true, force: true });
277
281
  }
278
282
  });
283
+
284
+ // ------------------------------------------ the window that grows into a lead
285
+
286
+ test("a wait that mattered widens the window by a piece", () => {
287
+ // Field 2026-08-17: 5.1-5.9 MB/s delivered against ~1 MB/s consumed, and the
288
+ // reader still blocked 47 times in two minutes. The surplus never became
289
+ // distance ahead of the head.
290
+ assert.equal(
291
+ nextWindowPieces({ current: 4, base: 4, ceiling: 12, waitedMs: 1457, waitThresholdMs: 1000 }),
292
+ 5
293
+ );
294
+ });
295
+
296
+ test("a piece that was already there gives a piece back", () => {
297
+ assert.equal(
298
+ nextWindowPieces({ current: 7, base: 4, ceiling: 12, waitedMs: 0, waitThresholdMs: 1000 }),
299
+ 6
300
+ );
301
+ });
302
+
303
+ test("it never shrinks below what the caller asked for", () => {
304
+ assert.equal(
305
+ nextWindowPieces({ current: 4, base: 4, ceiling: 12, waitedMs: 0, waitThresholdMs: 1000 }),
306
+ 4
307
+ );
308
+ });
309
+
310
+ test("it never grows past this reader's share of the store", () => {
311
+ assert.equal(
312
+ nextWindowPieces({ current: 12, base: 4, ceiling: 12, waitedMs: 4453, waitThresholdMs: 1000 }),
313
+ 12
314
+ );
315
+ // A ceiling below the base cannot pull the window under it: the caller sized
316
+ // the base from the file's own byte rate, and a store too small to hold it is
317
+ // an argument about memory, not about what the reader needs next.
318
+ assert.equal(
319
+ nextWindowPieces({ current: 4, base: 4, ceiling: 1, waitedMs: 2000, waitThresholdMs: 1000 }),
320
+ 4
321
+ );
322
+ });
323
+
324
+ test("a wait exactly at the threshold counts as a wait", () => {
325
+ assert.equal(
326
+ nextWindowPieces({ current: 4, base: 4, ceiling: 9, waitedMs: 1000, waitThresholdMs: 1000 }),
327
+ 5
328
+ );
329
+ });