@torrent-tv/proxy 2.69.2 → 2.71.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.
@@ -15,6 +15,8 @@ import { rmSync, statfsSync } from "node:fs";
15
15
  import WebTorrent from "webtorrent";
16
16
  import { logger } from "../utils/logger.js";
17
17
  import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
18
+ import { Urgency } from "./demand/index.js";
19
+ import { demandFor, forgetTorrent, reconcileAll } from "./download/registry.js";
18
20
  import { deriveSourceKey } from "./torrent-source-key.js";
19
21
 
20
22
  // The DHT's entry points. Two of the three the library ships answer nothing —
@@ -712,13 +714,6 @@ export class TorrentPool {
712
714
  */
713
715
  #readPositionByTorrent = new Map();
714
716
 
715
- /**
716
- * The background-fill selection held for each torrent, so it can be withdrawn
717
- * again. See #updateBackgroundFill.
718
- *
719
- * @type {Map<import("webtorrent").Torrent, { from: number, to: number }>}
720
- */
721
- #backgroundFill = new Map();
722
717
 
723
718
  /** When each torrent's download first fell below the stall threshold. */
724
719
  #stallSince = new Map();
@@ -917,73 +912,6 @@ export class TorrentPool {
917
912
  *
918
913
  * @returns {void}
919
914
  */
920
- /**
921
- * Put back the reader windows WebTorrent has quietly dropped.
922
- *
923
- * A reader claims its window as a stream selection and releases it when it
924
- * ends. That claim is NOT durable: `_gcSelections` deletes a selection the
925
- * moment every piece in it is present, so a window that has been satisfied
926
- * stops existing — and with it, everything the swarm had been asked for.
927
- *
928
- * While a reader keeps moving that is invisible, because the next window is
929
- * claimed immediately. It becomes fatal when the reader STOPS: the encoder is
930
- * held back by the look-ahead cap, ffmpeg stops reading, the reader parks on a
931
- * window that is fully downloaded, the selection disappears — and nothing our
932
- * code runs can notice, because the reader is parked inside a write. Measured
933
- * 2026-08-05: the encoder was suspended at 22:44:51, the download hit zero at
934
- * 22:45:05 and stayed there for **eleven minutes** with 150 peer connections
935
- * open, `0 selection(s) covering 0 piece(s), 0 being asked, 0 blocks in
936
- * flight`. When the encoder was let go there was nothing ahead of it, and the
937
- * viewer's picture stopped.
938
- *
939
- * So the claim is re-asserted from outside, on this timer, using the windows
940
- * the store already knows about — those are declared by live readers and
941
- * withdrawn when they end, which is exactly the set that should be selected.
942
- *
943
- * @returns {void}
944
- */
945
- #reassertReaderWindows() {
946
- for (const torrent of this.torrents.values()) {
947
- const usage = this.fileUsageByTorrent.get(torrent);
948
- if (!usage || usage.size === 0 || torrent?.done === true) {
949
- continue;
950
- }
951
- const store = findSharedStore(torrent);
952
- const ranges = typeof store?.protectedRanges === "function" ? store.protectedRanges() : [];
953
- if (ranges.length === 0) {
954
- continue;
955
- }
956
- const items = Array.isArray(torrent?._selections?._items) ? torrent._selections._items : [];
957
- for (const range of ranges) {
958
- const present = items.some((item) => item?.from === range.from && item?.to === range.to);
959
- if (present) {
960
- continue;
961
- }
962
- // Only worth re-claiming what is actually missing: re-claiming a window
963
- // that is already complete would be deleted again on the next pass and
964
- // the two would take turns forever.
965
- let missing = false;
966
- for (let index = range.from; index <= range.to && !missing; index += 1) {
967
- if (!torrent.bitfield?.get(index)) {
968
- missing = true;
969
- }
970
- }
971
- if (!missing) {
972
- continue;
973
- }
974
- try {
975
- torrent._select(range.from, range.to, 1, null, true);
976
- logger.info(
977
- `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] re-claimed reader window ` +
978
- `${range.from}-${range.to} — the selection had been dropped once it was satisfied`
979
- );
980
- } catch {
981
- // Best effort: a torrent being torn down is not worth failing over.
982
- }
983
- }
984
- }
985
- }
986
-
987
915
  /**
988
916
  * The reader windows of a torrent, and whether any of them still wants
989
917
  * something. Two questions with one answer, because both callers below need
@@ -1012,67 +940,56 @@ export class TorrentPool {
1012
940
  }
1013
941
 
1014
942
  /**
1015
- * Keep fetching the rest of the file while the viewer needs nothing.
943
+ * State what is worth fetching once nothing urgent is missing: the rest of
944
+ * each file being read, from the furthest window in THAT file to its end.
1016
945
  *
1017
- * Owned here rather than by the reader, because the reader cannot act while
1018
- * it is parked and parked is exactly the state this is for. The encoder is
1019
- * held back by the look-ahead cap, the viewer is comfortably ahead, the link
1020
- * is idle: that is the cheapest bandwidth of the whole session and it was
1021
- * going unused, because the fill was re-evaluated only when a reader window
1022
- * MOVED. Priority 0 against the window's 1, and withdrawn the moment any
1023
- * window wants something, so it can never take capacity from the picture.
946
+ * Per file, and that is a fix rather than a detail. It used to take the
947
+ * furthest window across ALL files and the last piece across ALL files and
948
+ * claim everything between: with two viewers on two episodes of one release
949
+ * the ordinary case for a season pack that claimed every episode lying
950
+ * between them, none of which anybody had asked for.
1024
951
  *
1025
- * @param {import("webtorrent").Torrent} torrent
1026
- * @param {{ ranges: Array<{ from: number, to: number }>, missing: boolean }} demand
1027
- * @returns {void}
1028
- */
1029
- #updateBackgroundFill(torrent, demand) {
1030
- const held = this.#backgroundFill.get(torrent) ?? null;
1031
- const wanted = !demand.missing && demand.ranges.length > 0
1032
- ? this.#tailAfterWindows(torrent, demand.ranges)
1033
- : null;
1034
- if (held && (!wanted || held.from !== wanted.from || held.to !== wanted.to)) {
1035
- try {
1036
- torrent._deselect?.(held.from, held.to, false);
1037
- } catch {
1038
- // Best effort.
1039
- }
1040
- this.#backgroundFill.delete(torrent);
1041
- }
1042
- if (wanted && !this.#backgroundFill.has(torrent)) {
1043
- try {
1044
- torrent._select?.(wanted.from, wanted.to, 0, null, false);
1045
- this.#backgroundFill.set(torrent, wanted);
1046
- } catch {
1047
- // Best effort.
1048
- }
1049
- }
1050
- }
1051
-
1052
- /**
1053
- * Everything after the furthest reader window, up to the end of the file it
1054
- * belongs to. Null when there is nothing left.
952
+ * Whether it is stated at all is not decided here. It is a level of urgency
953
+ * like any other, and the swarm layer withholds it while anything urgent, on
954
+ * ANY torrent, is still missing.
1055
955
  *
1056
956
  * @param {import("webtorrent").Torrent} torrent
1057
- * @param {Array<{ from: number, to: number }>} ranges
1058
- * @returns {{ from: number, to: number } | null}
957
+ * @returns {void}
1059
958
  */
1060
- #tailAfterWindows(torrent, ranges) {
959
+ #stateBackgroundFill(torrent) {
960
+ const { register } = demandFor(torrent);
961
+ const usage = this.fileUsageByTorrent.get(torrent);
1061
962
  const pieceLength = Number(torrent.pieceLength);
1062
963
  if (!Number.isFinite(pieceLength) || pieceLength <= 0) {
1063
- return null;
964
+ return;
1064
965
  }
1065
- const usage = this.fileUsageByTorrent.get(torrent);
1066
- let lastPiece = -1;
1067
966
  for (const [fileIndex, count] of usage ?? []) {
1068
967
  const file = count > 0 ? torrent.files?.[fileIndex] : null;
968
+ const claimant = `background-fill:${fileIndex}`;
1069
969
  if (!file) {
970
+ register.withdraw(claimant);
1070
971
  continue;
1071
972
  }
1072
- lastPiece = Math.max(lastPiece, Math.floor((file.offset + file.length - 1) / pieceLength));
973
+ const wanted = register
974
+ .windows()
975
+ .filter((window) => window.fileIndex === fileIndex && window.claimant !== claimant);
976
+ const furthest = wanted.length === 0
977
+ ? -1
978
+ : Math.max(...wanted.map((window) => window.byteEnd));
979
+ const byteStart = furthest + 1;
980
+ const byteEnd = Number(file.length) - 1;
981
+ if (wanted.length === 0 || byteStart > byteEnd) {
982
+ register.withdraw(claimant);
983
+ continue;
984
+ }
985
+ register.state({
986
+ claimant,
987
+ fileIndex,
988
+ byteStart,
989
+ byteEnd,
990
+ urgency: Urgency.TAIL
991
+ });
1073
992
  }
1074
- const from = Math.max(...ranges.map((range) => range.to)) + 1;
1075
- return lastPiece >= from ? { from, to: lastPiece } : null;
1076
993
  }
1077
994
 
1078
995
  #reportStalledDownloads() {
@@ -1124,13 +1041,17 @@ export class TorrentPool {
1124
1041
  this.fileUsageByTorrent,
1125
1042
  Date.now()
1126
1043
  );
1127
- this.#reassertReaderWindows();
1044
+ // The one place the swarm is told anything: it reads what everybody has
1045
+ // stated and works out for itself what to ask for, including whether the
1046
+ // speculative levels may be stated at all — which is a question about every
1047
+ // torrent at once, because they share the link.
1048
+ reconcileAll();
1128
1049
  for (const torrent of this.torrents.values()) {
1129
1050
  const usage = this.fileUsageByTorrent.get(torrent);
1130
1051
  if (!usage || usage.size === 0 || torrent?.done === true) {
1131
1052
  continue;
1132
1053
  }
1133
- this.#updateBackgroundFill(torrent, this.#readerDemand(torrent));
1054
+ this.#stateBackgroundFill(torrent);
1134
1055
  }
1135
1056
  this.#reportStalledDownloads();
1136
1057
  const { bytesPerSec, reason } = decideUploadLimit(active);
@@ -1433,7 +1354,8 @@ export class TorrentPool {
1433
1354
  const addedReplacement = this.client.add(torrentId, {
1434
1355
  store: SharedPieceStore,
1435
1356
  storeCacheSlots: 0,
1436
- storeOpts: { memoryBytes: this.#memoryBytes }
1357
+ storeOpts: { memoryBytes: this.#memoryBytes },
1358
+ deselect: true
1437
1359
  }, (replacement) => {
1438
1360
  this.torrents.set(key, replacement);
1439
1361
  this.#lastAccess.set(replacement, Date.now());
@@ -1465,7 +1387,14 @@ export class TorrentPool {
1465
1387
  const added = this.client.add(torrentId, {
1466
1388
  store: SharedPieceStore,
1467
1389
  storeCacheSlots: 0,
1468
- storeOpts: { memoryBytes: this.#memoryBytes }
1390
+ storeOpts: { memoryBytes: this.#memoryBytes },
1391
+ // Nothing is fetched until somebody says they want it. WebTorrent's own
1392
+ // default is `this.select(0, this.pieces.length - 1)` — the whole
1393
+ // torrent — and this proxy used to undo that afterwards by deselecting
1394
+ // the files nobody had opened. On a season pack that meant every
1395
+ // episode was being fetched for as long as the viewer took to choose
1396
+ // one. The download set is built up from stated needs instead.
1397
+ deselect: true
1469
1398
  }, (readyTorrent) => {
1470
1399
  this.client.off("error", onError);
1471
1400
  this.torrents.set(key, readyTorrent);
@@ -1494,35 +1423,6 @@ export class TorrentPool {
1494
1423
  return promise;
1495
1424
  }
1496
1425
 
1497
- /**
1498
- * Mark a single file as active, deselecting all others.
1499
- * Prefer {@link acquireFile} when the active set may contain multiple files.
1500
- *
1501
- * @param {import("webtorrent").Torrent} torrent
1502
- * @param {number} fileIndex - Zero-based index into `torrent.files`.
1503
- * @returns {void}
1504
- */
1505
- setActiveFile(torrent, fileIndex) {
1506
- if (!torrent || !Array.isArray(torrent.files)) {
1507
- return;
1508
- }
1509
- for (let index = 0; index < torrent.files.length; index += 1) {
1510
- const file = torrent.files[index];
1511
- if (!file) {
1512
- continue;
1513
- }
1514
- if (index === fileIndex) {
1515
- if (typeof file.select === "function") {
1516
- file.select();
1517
- }
1518
- continue;
1519
- }
1520
- if (typeof file.deselect === "function") {
1521
- file.deselect();
1522
- }
1523
- }
1524
- }
1525
-
1526
1426
  /**
1527
1427
  * Increment the reference count for a file, selecting it for download.
1528
1428
  * Returns a release function that decrements the count; when it reaches
@@ -1546,7 +1446,6 @@ export class TorrentPool {
1546
1446
  this.#cancelIdleRemoval(torrent);
1547
1447
  this.#lastAccess.set(torrent, Date.now());
1548
1448
  usage.set(fileIndex, (usage.get(fileIndex) ?? 0) + 1);
1549
- this.#syncSelections(torrent, usage);
1550
1449
 
1551
1450
  let released = false;
1552
1451
  return () => {
@@ -1565,8 +1464,7 @@ export class TorrentPool {
1565
1464
  // No active readers — schedule removal (with store) after an idle TTL.
1566
1465
  this.#scheduleIdleRemoval(torrent);
1567
1466
  }
1568
- this.#syncSelections(torrent, usage);
1569
- };
1467
+ };
1570
1468
  }
1571
1469
 
1572
1470
  /**
@@ -1624,6 +1522,8 @@ export class TorrentPool {
1624
1522
  * @returns {void}
1625
1523
  */
1626
1524
  #removeTorrent(torrent, reason = "unknown") {
1525
+ // Everything anybody stated for this torrent goes with it.
1526
+ forgetTorrent(torrent);
1627
1527
  if (!torrent) {
1628
1528
  return;
1629
1529
  }
@@ -1953,52 +1853,19 @@ export class TorrentPool {
1953
1853
  }
1954
1854
  }
1955
1855
 
1956
- /**
1957
- * Drop the pieces of files nobody is reading from the download set.
1958
- *
1959
- * It does NOT select the files that ARE in use, and that is the point. What a
1960
- * file needs is decided by the readers walking it: each one claims a moving
1961
- * window around its own read head and gives it back when it ends (see
1962
- * `torrent-worker/piece-reader.js`). Selecting the whole file here as well
1963
- * put a second, contradictory claim on the same pieces — one that covered
1964
- * everything and therefore always outranked the window — and it was re-made
1965
- * on every single `/stream` request, so a seek's prioritisation survived at
1966
- * most until the next one. Measured consequence: a seek to 89.1% of a 4.7 GB
1967
- * film waited 93 s while the swarm fetched 2.47 GB in file order.
1968
- *
1969
- * A file with no reader is deselected outright, which is what stops a torrent
1970
- * downloading files the viewer never opened.
1971
- *
1972
- * @param {import("webtorrent").Torrent} torrent
1973
- * @param {Map<number, number>} usage - fileIndex → refCount.
1974
- * @returns {void}
1975
- */
1976
- #syncSelections(torrent, usage) {
1977
- if (!torrent || !Array.isArray(torrent.files)) {
1978
- return;
1979
- }
1980
- for (let index = 0; index < torrent.files.length; index += 1) {
1981
- const file = torrent.files[index];
1982
- if (!file || (usage.get(index) ?? 0) > 0) {
1983
- continue;
1984
- }
1985
- if (typeof file.deselect === "function") {
1986
- file.deselect();
1987
- }
1988
- }
1989
- }
1856
+
1990
1857
 
1991
1858
  /**
1992
1859
  * Record where a file is being read from.
1993
1860
  *
1994
1861
  * This used to also decide what the torrent should download, and that was the
1995
1862
  * mistake: it was one of THREE places claiming pieces for the same file — the
1996
- * whole-file `file.select()` in `#syncSelections`, this method, and the reader
1997
- * itself — and they overwrote each other on every request. The claim now
1998
- * belongs to the reader alone, which holds a moving window around its own read
1999
- * head and gives it back when it ends
2000
- * (`torrent-worker/piece-reader.js`); several readers on one file therefore
2001
- * produce the union of their windows instead of the last caller's opinion.
1863
+ * whole-file selection, a window around the read head, and the reader itself
1864
+ * — and they overwrote each other on every request. Every need is now stated
1865
+ * in one register (`services/demand/`) and one class turns the register into
1866
+ * requests to the swarm (`services/download/SwarmSelection.js`); several
1867
+ * readers on one file therefore produce the union of their windows instead of
1868
+ * the last caller's opinion.
2002
1869
  *
2003
1870
  * What is left here is bookkeeping the readers cannot do: `getFileStats`
2004
1871
  * reports how much of the window ahead of the read head is still missing, so