@torrent-tv/proxy 2.9.92 → 2.9.95

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.
@@ -26,6 +26,9 @@ import { logger } from "../../utils/logger.js";
26
26
  /** Only waits at least this long are reported; sequential reading stays silent. */
27
27
  const PIECE_WAIT_LOG_MS = 1_000;
28
28
 
29
+ /** Distinguishes concurrent readers to the piece store. Never reused. */
30
+ let readerSequence = 0;
31
+
29
32
  /**
30
33
  * How far ahead of the read head pieces are asked for.
31
34
  *
@@ -46,22 +49,6 @@ const PIECE_WAIT_LOG_MS = 1_000;
46
49
  */
47
50
  const READ_WINDOW_BYTES = 32 * 1024 * 1024;
48
51
 
49
- /**
50
- * How many pieces past the one being waited for are marked critical.
51
- *
52
- * `critical` means "a reader is blocked on this now" — it is what lets a piece
53
- * jump the sequential scan. Marking a whole range critical, as this did, says
54
- * it about hundreds of pieces at once and the signal stops meaning anything.
55
- * WebTorrent's own reader marks `min(1 MB / pieceLength, 2)` pieces, i.e. the
56
- * one under the head and at most two more; the same rule is used here.
57
- *
58
- * @param {number} pieceLength
59
- * @returns {number}
60
- */
61
- function criticalRunLength(pieceLength) {
62
- return Math.min(Math.floor((1024 * 1024) / Math.max(1, pieceLength)), 2);
63
- }
64
-
65
52
  /**
66
53
  * The pieces a reader at `pieceIndex` wants next, clamped to its own range.
67
54
  *
@@ -84,14 +71,16 @@ export function readWindowFor({ pieceIndex, lastPiece, windowPieces }) {
84
71
  *
85
72
  * @param {import("webtorrent").Torrent} torrent
86
73
  * @param {{ from: number, to: number }} window
74
+ * @param {number} [priority] - 1 for what a reader needs next, 0 for the
75
+ * background fill of the rest of the file.
87
76
  * @returns {void}
88
77
  */
89
- function claimWindow(torrent, { from, to }) {
78
+ function claimWindow(torrent, { from, to }, priority = 1) {
90
79
  try {
91
80
  if (typeof torrent._select === "function") {
92
- torrent._select(from, to, 1, null, true);
81
+ torrent._select(from, to, priority, null, true);
93
82
  } else if (typeof torrent.select === "function") {
94
- torrent.select(from, to, 1);
83
+ torrent.select(from, to, priority);
95
84
  }
96
85
  } catch {
97
86
  // Best effort — never fail a read because selection bookkeeping refused.
@@ -287,22 +276,120 @@ export async function* readFragments({
287
276
  // `bytes 0-<EOF>` left a permanent selection over the entire file, and no
288
277
  // later prioritisation could outrank it.
289
278
  const windowPieces = Math.max(1, Math.ceil(Math.max(1, windowBytes) / pieceLength));
290
- const criticalRun = criticalRunLength(pieceLength);
291
279
  /** @type {{ from: number, to: number } | null} */
292
280
  let window = null;
293
281
  /** @type {{ from: number, to: number } | null} */
294
282
  let criticalMark = null;
283
+ /**
284
+ * The rest of the file, claimed at the lowest priority so it is fetched with
285
+ * whatever capacity the near window does not need. Null whenever it must not
286
+ * be fetched at all — see {@link updateBackfill}.
287
+ *
288
+ * @type {{ from: number, to: number } | null}
289
+ */
290
+ let backfill = null;
291
+
292
+ // Identity of this read, so the store can tell one reader's window from
293
+ // another's. Each read gets its own; `readerSequence` never repeats within a
294
+ // process.
295
+ const readerId = `read-${(readerSequence += 1)}`;
296
+
297
+ /**
298
+ * Whether every piece of the current window is already downloaded.
299
+ *
300
+ * A handful of pieces, checked against the bitfield, so this is cheap enough
301
+ * to re-run on every window move.
302
+ *
303
+ * @returns {boolean}
304
+ */
305
+ const windowIsComplete = () => {
306
+ if (!window) {
307
+ return false;
308
+ }
309
+ for (let index = window.from; index <= window.to; index += 1) {
310
+ if (!torrent.bitfield?.get(index)) {
311
+ return false;
312
+ }
313
+ }
314
+ return true;
315
+ };
316
+
317
+ /**
318
+ * Keep the rest of the file downloading in the background, but ONLY while
319
+ * that cannot cost the viewer anything.
320
+ *
321
+ * The rule is deliberately blunt rather than clever: the tail is in the
322
+ * download set only when every piece of the near window is already on hand.
323
+ * The moment one is missing — the window slid onto undownloaded content, or a
324
+ * seek moved it somewhere new — the tail leaves the set, so the swarm has
325
+ * nothing else to work on. Relying on WebTorrent's priority ordering alone
326
+ * would be weaker: it decides which selection a wire is offered FIRST, not
327
+ * what a wire already has outstanding, so a seek would still queue behind
328
+ * whatever tail blocks were in flight.
329
+ *
330
+ * Priority 0 against the window's 1 is kept as well, for the moments between
331
+ * one evaluation and the next.
332
+ *
333
+ * What this buys: a file watched for a while ends up downloaded, and every
334
+ * later seek into it is instant. What it costs: the pool owner's bandwidth
335
+ * and disk for a film the viewer may abandon — which is why it never runs
336
+ * ahead of the viewer's own needs.
337
+ *
338
+ * @returns {void}
339
+ */
340
+ const updateBackfill = () => {
341
+ const wanted = window && window.to < lastPiece && windowIsComplete()
342
+ ? { from: window.to + 1, to: lastPiece }
343
+ : null;
344
+ if (backfill && (!wanted || backfill.from !== wanted.from || backfill.to !== wanted.to)) {
345
+ releaseWindow(torrent, backfill);
346
+ backfill = null;
347
+ }
348
+ if (wanted && !backfill) {
349
+ claimWindow(torrent, wanted, 0);
350
+ backfill = wanted;
351
+ }
352
+ };
295
353
 
296
354
  const moveWindowTo = (pieceIndex) => {
297
355
  const next = readWindowFor({ pieceIndex, lastPiece, windowPieces });
298
356
  if (window && window.from === next.from && window.to === next.to) {
357
+ updateBackfill();
299
358
  return;
300
359
  }
360
+ const isJump = !window || next.from > window.to || next.from < window.from;
361
+ // Whatever was being fetched for later stops being wanted the instant the
362
+ // window moves: the new position has to have the whole swarm to itself.
363
+ if (backfill) {
364
+ releaseWindow(torrent, backfill);
365
+ backfill = null;
366
+ }
301
367
  if (window) {
302
368
  releaseWindow(torrent, window);
303
369
  }
304
370
  claimWindow(torrent, next);
305
371
  window = next;
372
+ // Tell the store these pieces are wanted, so it evicts something else.
373
+ // Without it the piece the decoder reads next looks exactly as stale as one
374
+ // the encoder fetched forty minutes ahead, and the second kind is what
375
+ // fills the store while the encoder runs ahead of the viewer.
376
+ store.protectRange?.(readerId, next.from, next.to);
377
+ if (isJump) {
378
+ // A jump — a seek, not the window sliding along — can land on pieces that
379
+ // are already downloaded but have been spilled to disk. Bring the whole
380
+ // window back at once instead of one disk round trip per piece as the
381
+ // reader reaches them.
382
+ const revived = store.warmRange?.(next.from, next.to) ?? 0;
383
+ if (revived > 0) {
384
+ logger.info(
385
+ `piece-reader: reviving ${revived} spilled piece(s) of ${next.from}-${next.to} ` +
386
+ `for a jump to ${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}"`
387
+ );
388
+ }
389
+ }
390
+ // Only now, with the new window claimed and marked, may anything else be
391
+ // asked for — and only if the window needs nothing.
392
+ updateBackfill();
306
393
  };
307
394
 
308
395
  try {
@@ -318,8 +405,19 @@ export async function* readFragments({
318
405
  moveWindowTo(pieceIndex);
319
406
 
320
407
  if (!torrent.bitfield?.get(pieceIndex)) {
321
- // Blocked here and now this is the one case `critical` is meant for.
322
- criticalMark = markCritical(torrent, pieceIndex, Math.min(lastPiece, pieceIndex + criticalRun), criticalMark);
408
+ // Everything from here to the end of the window is wanted NOW, so all
409
+ // of it is marked, not just the piece under the head. `critical`
410
+ // enables hotswap: a block reserved by a slow peer is re-requested from
411
+ // a faster one instead of holding up the reader. Measured 2026-08-04
412
+ // with only the blocked piece marked, the first segment after a seek
413
+ // took 7.2 s while its four 4 MB pieces arrived one after another at
414
+ // ~2.2 MB/s, with waits of 1.3 s and 2.8 s on single pieces.
415
+ //
416
+ // This is not the old behaviour returning: that marked the whole
417
+ // REQUESTED RANGE, which for ffmpeg's input means every piece to the
418
+ // end of the file — hundreds of them, at which point the flag says
419
+ // nothing. A window is what a reader genuinely needs next.
420
+ criticalMark = markCritical(torrent, pieceIndex, window.to, criticalMark);
323
421
  }
324
422
 
325
423
  const waitStartedAt = Date.now();
@@ -372,9 +470,13 @@ export async function* readFragments({
372
470
  // Reached on completion, on cancellation, on a throw, and when the consumer
373
471
  // stops iterating — a window left behind would keep the swarm fetching for
374
472
  // a reader that no longer exists.
473
+ if (backfill) {
474
+ releaseWindow(torrent, backfill);
475
+ }
375
476
  if (window) {
376
477
  releaseWindow(torrent, window);
377
478
  }
479
+ store.releaseProtection?.(readerId);
378
480
  if (criticalMark) {
379
481
  clearCritical(torrent, criticalMark);
380
482
  }