@torrent-tv/proxy 2.82.0 → 2.83.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/CLAUDE.md +11 -0
  3. package/docs/disk-architecture.md +161 -0
  4. package/docs/encode-architecture.md +36 -7
  5. package/package.json +1 -1
  6. package/routes/api/sources/stats/get.js +11 -2
  7. package/routes/stream/get.js +74 -3
  8. package/services/delivery-probe.js +248 -43
  9. package/services/disk/keep.js +48 -0
  10. package/services/disk/returns.js +103 -0
  11. package/services/download/SwarmSelection.js +5 -5
  12. package/services/download/registry.js +20 -0
  13. package/services/encode/EncodeRun.js +1 -0
  14. package/services/encode/Encoder.js +15 -0
  15. package/services/encode/QsvEncoder.js +5 -0
  16. package/services/encode/SegmentStore.js +14 -0
  17. package/services/encode/VaapiEncoder.js +5 -0
  18. package/services/encode/encode-exit.js +17 -0
  19. package/services/encode/start-stop-cost.js +6 -2
  20. package/services/files/CompletedFiles.js +276 -0
  21. package/services/files/piece-from-whole-file.js +118 -0
  22. package/services/hls-session-manager.js +17 -1
  23. package/services/hwaccel.js +4 -0
  24. package/services/output/cut-grid.js +13 -3
  25. package/services/piece-store/piece-disk-store.js +143 -8
  26. package/services/piece-store/piece-lru.js +17 -0
  27. package/services/piece-store/shared-piece-store.js +1803 -1549
  28. package/services/torrent-pool.js +246 -26
  29. package/services/torrent-worker/client.js +21 -0
  30. package/services/torrent-worker/protocol.js +9 -1
  31. package/services/torrent-worker/worker.js +183 -2
  32. package/test/completed-files.test.js +115 -0
  33. package/test/cuts-follow-published-grid.test.js +35 -0
  34. package/test/delivery-probe.test.js +114 -1
  35. package/test/encode-exit.test.js +18 -0
  36. package/test/keeping-period.test.js +83 -0
  37. package/test/piece-disk-store.test.js +114 -0
  38. package/test/piece-from-whole-file.test.js +129 -0
  39. package/test/piece-store-eviction.test.js +28 -15
  40. package/test/piece-store-never-refuses.test.js +153 -0
  41. package/test/piece-store-reservations.test.js +16 -3
  42. package/test/probe-wedge-certainty.test.js +3 -3
  43. package/test/shared-piece-store.test.js +27 -13
  44. package/test/stream-route.test.js +41 -0
  45. package/test/swarm-follows-readers.test.js +126 -0
  46. package/test/swarm-reach.test.js +5 -0
  47. package/test/upload-hurry.test.js +27 -0
@@ -8,6 +8,7 @@
8
8
  * download.
9
9
  */
10
10
 
11
+ import { IDLE_KEEP_MS } from "./disk/keep.js";
11
12
  import dns from "node:dns/promises";
12
13
  import os from "node:os";
13
14
  import path from "node:path";
@@ -16,7 +17,7 @@ import WebTorrent from "webtorrent";
16
17
  import { logger } from "../utils/logger.js";
17
18
  import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
18
19
  import { Urgency, urgencyName } from "./demand/index.js";
19
- import { demandFor, forgetTorrent, reconcileAll } from "./download/registry.js";
20
+ import { demandFor, forgetTorrent, reconcileAll, hasUnmetDemand } from "./download/registry.js";
20
21
  import { isAtAWatchingViewer, isBehindEverybody, isNobodyComingNow } from "./priority/PriorityMap.js";
21
22
  import { deriveSourceKey } from "./torrent-source-key.js";
22
23
 
@@ -61,13 +62,13 @@ const DHT_RESOLVE_TIMEOUT_MS = 2000;
61
62
  // torrent data lives under here.
62
63
  const WEBTORRENT_STORE_ROOT = path.join(os.tmpdir(), "webtorrent");
63
64
 
64
- // How long a torrent may sit with zero active file readers before it is
65
- // removed (with its on-disk store). Generous so brief gaps between ffmpeg
66
- // range readsa pause, a backgrounded tab, or a phone turned off for a few
67
- // minutes do not evict an in-use torrent's already-downloaded data, so a
68
- // resume plays from disk instead of re-downloading. A longer idle (viewer truly
69
- // gone) frees the disk; the global disk cap still evicts earlier under pressure.
70
- const TORRENT_IDLE_TTL_MS = 15 * 60 * 1000;
65
+ // How long a torrent may sit with zero active file readers before it is removed
66
+ // with its on-disk store. ONE NUMBER for everything nobody is using, shared with
67
+ // the produced segments see `services/disk/keep.js` for why it is one and what
68
+ // it stands for. It was fifteen minutes here against thirty for the session it
69
+ // feeds, so a viewer returning at the twentieth minute got a session whose source
70
+ // had gone.
71
+ const TORRENT_IDLE_TTL_MS = IDLE_KEEP_MS;
71
72
 
72
73
  // Bytes ahead of a read position to mark CRITICAL on each range request. In
73
74
  // WebTorrent, `critical` does NOT reorder the sequential piece scan — it enables
@@ -163,10 +164,19 @@ const STALL_REPORT_INTERVAL_MS = 30_000;
163
164
  * makes every two seconds.
164
165
  *
165
166
  * @param {import("webtorrent").Torrent} torrent
166
- * @returns {{ connectedPeers: number, knownPeers: number | null, queuedPeers: number | null }}
167
+ * @returns {{ connectedPeers: number, deliveringPeers: number, knownPeers: number | null,
168
+ * queuedPeers: number | null }}
167
169
  */
168
170
  export function describeSwarmReach(torrent) {
169
171
  const wires = Array.isArray(torrent?.wires) ? torrent.wires.length : 0;
172
+ // HOW MANY OF THEM ARE DOING ANYTHING. Connections accumulated without bound
173
+ // in the field — 249 to 596 over one viewing, 15 862 more queued — and
174
+ // whether that helped or merely cost memory and requests is not answerable
175
+ // from a count of wires. A wire that has delivered a byte is the measured
176
+ // unit; the rest are held open for nothing.
177
+ const delivering = Array.isArray(torrent?.wires)
178
+ ? torrent.wires.filter((wire) => Number(wire?.downloaded) > 0).length
179
+ : 0;
170
180
  const read = (value) => (typeof value === "number" && Number.isFinite(value) ? value : null);
171
181
  let knownPeers = null;
172
182
  let queuedPeers = null;
@@ -178,7 +188,7 @@ export function describeSwarmReach(torrent) {
178
188
  // destroyed torrent, and the reading is a diagnostic. Nothing here is
179
189
  // worth failing a stats poll for.
180
190
  }
181
- return { connectedPeers: wires, knownPeers, queuedPeers };
191
+ return { connectedPeers: wires, deliveringPeers: delivering, knownPeers, queuedPeers };
182
192
  }
183
193
 
184
194
  /**
@@ -344,6 +354,11 @@ export function torrentsForUploadPolicy(torrents, usageByTorrent, now) {
344
354
  // Recorded so the policy can tell "nothing is arriving and somebody is
345
355
  // waiting" from "nothing is arriving because nobody asked".
346
356
  torrent.hasActiveReader = hasReader;
357
+ // Whether anybody is still short of bytes of it. Upload is bought with
358
+ // reciprocity and reciprocity is only worth buying while something is
359
+ // missing; a torrent whose declared windows are all present wants nothing
360
+ // from the swarm, and what it gives it gives for nobody.
361
+ torrent.hasUnmetDemand = hasUnmetDemand(torrent);
347
362
  chosen.push(torrent);
348
363
  }
349
364
  }
@@ -389,7 +404,8 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
389
404
  // 2026-08-04: four cycles of 512 -> 50 KB/s in three minutes, each
390
405
  // reported as `earn unchoke ... down=0KB/s`, all of them raising the
391
406
  // upload at moments when no byte was wanted by anyone.
392
- const starving = notDone && torrent?.hasActiveReader !== false && downloadSpeed < starvingSpeed;
407
+ const starving = notDone && torrent?.hasActiveReader !== false
408
+ && torrent?.hasUnmetDemand !== false && downloadSpeed < starvingSpeed;
393
409
  if (starving && chokedInterested >= chokedThreshold) {
394
410
  const name = typeof torrent?.name === "string" ? torrent.name : "?";
395
411
  return {
@@ -401,6 +417,14 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
401
417
  }
402
418
  }
403
419
 
420
+ // NOTHING TO BUY. Every torrent here has a reader, and not one of them is
421
+ // short of a byte anybody asked for: the upload would be given for nobody.
422
+ // Field 2026-09-11 — 512 KB/s held for forty-eight minutes on a file that was
423
+ // complete, 596 peers connected and climbing, and every 16 KB served read a
424
+ // 4 MB piece back off the disk.
425
+ if (activeTorrents.every((torrent) => torrent?.hasUnmetDemand === false)) {
426
+ return { bytesPerSec: idleFloor, reason: "nothing anybody asked for is missing — upload buys nothing" };
427
+ }
404
428
  return { bytesPerSec: floor, reason: "active readers, not choke-starved" };
405
429
  }
406
430
 
@@ -782,6 +806,52 @@ export class TorrentPool {
782
806
  * default is computed from free disk (min(10 GB, half free)). Pass 0 to
783
807
  * disable the cap.
784
808
  */
809
+ /**
810
+ * Sources every file of which this proxy holds whole.
811
+ *
812
+ * A torrent added again for one of these has nothing to fetch and nothing to
813
+ * check: what it would verify was written out of pieces this client had
814
+ * already hashed, and the file's size was checked against what the torrent
815
+ * says. So it is added with verification skipped — otherwise a viewer
816
+ * returning to a film waits for a gigabyte and a half to be hashed to learn
817
+ * what we wrote down.
818
+ *
819
+ * @type {Set<string>}
820
+ */
821
+ #wholeSources = new Set();
822
+
823
+ /**
824
+ * Say that every file of a source is held whole.
825
+ *
826
+ * @param {string} sourceKey
827
+ * @returns {void}
828
+ */
829
+ addWholeSource(sourceKey) {
830
+ this.#wholeSources.add(String(sourceKey));
831
+ }
832
+
833
+ /**
834
+ * Anything else every store of this pool is built with.
835
+ *
836
+ * Today: where a piece can be had when neither of the store's own tiers has
837
+ * it — the files this proxy has assembled. Given to the pool rather than
838
+ * discovered by the store, which knows nothing about files.
839
+ *
840
+ * @type {object}
841
+ */
842
+ #storeExtras = {};
843
+
844
+ /**
845
+ * Say where a piece can be had when the store has neither a resident nor a
846
+ * spilled copy.
847
+ *
848
+ * @param {object} extras
849
+ * @returns {void}
850
+ */
851
+ buildStoresWith(extras) {
852
+ this.#storeExtras = extras && typeof extras === "object" ? extras : {};
853
+ }
854
+
785
855
  constructor({ maxDiskBytes, memoryBytes, dhtBootstrap } = {}) {
786
856
  this.#memoryBytes = Number.isFinite(memoryBytes) && memoryBytes > 0 ? memoryBytes : undefined;
787
857
 
@@ -850,8 +920,8 @@ export class TorrentPool {
850
920
  ? maxDiskBytes
851
921
  : computeDefaultDiskCap(os.tmpdir());
852
922
  if (this.#maxDiskBytes > 0) {
853
- const gb = (this.#maxDiskBytes / (1024 * 1024 * 1024)).toFixed(1);
854
- logger.info(`torrent-pool: disk cap ${gb} GB (LRU eviction of idle torrents above it)`);
923
+ const gigabytes = (this.#maxDiskBytes / (1024 * 1024 * 1024)).toFixed(1);
924
+ logger.info(`torrent-pool: disk cap ${gigabytes} GB (LRU eviction of idle torrents above it)`);
855
925
  this.#diskSweepTimer = setInterval(() => this.#enforceDiskCap(), DISK_CAP_SWEEP_INTERVAL_MS);
856
926
  this.#diskSweepTimer.unref?.();
857
927
  }
@@ -868,6 +938,67 @@ export class TorrentPool {
868
938
  this.#uploadAdjustTimer.unref?.();
869
939
  }
870
940
 
941
+ /**
942
+ * Leave the swarm of a torrent nobody is reading, and rejoin it when
943
+ * somebody is.
944
+ *
945
+ * A PROXY THAT NEEDS NOTHING FROM A SWARM SHOULD NOT BE IN THAT SWARM. While
946
+ * a file is being watched every connection is worth keeping — the one that
947
+ * has delivered nothing yet may deliver next — so this is not a limit on
948
+ * connections and never fires during playback. It fires when NOTHING is
949
+ * stated about this torrent at all: no window from any reader, no file held.
950
+ *
951
+ * What that state cost until now: WebTorrent enforces `maxConns` only on
952
+ * peers it dials (`_drain`), while `_addIncomingPeer` checks that the torrent
953
+ * is neither destroyed nor paused and registers the peer. A proxy with its
954
+ * port mapped is reachable, so connections arrive and are never turned away —
955
+ * field 2026-09-11, 249 connected at the start of one viewing and 596 at the
956
+ * end, 15 862 more queued, on a file complete for three quarters of an hour,
957
+ * each of them served by reading a 4 MB piece off the disk for every 16 KB
958
+ * sent.
959
+ *
960
+ * `paused` is the library's own word for this and the path it already checks,
961
+ * so nothing here fights it. The pause alone does not close what is already
962
+ * open, so the peers are let go by hand; the data stays exactly where it is,
963
+ * and the next reader resumes the torrent rather than fetching it again.
964
+ *
965
+ * @param {import("webtorrent").Torrent} torrent
966
+ * @returns {void}
967
+ */
968
+ followTheReaders(torrent) {
969
+ if (!torrent || torrent.destroyed) {
970
+ return;
971
+ }
972
+ const usage = this.fileUsageByTorrent.get(torrent);
973
+ const isRead = Boolean(usage && usage.size > 0);
974
+ const isWanted = isRead || demandFor(torrent).register.windows().length > 0;
975
+ if (isWanted && torrent.paused === true) {
976
+ torrent.resume();
977
+ logger.info(
978
+ `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] rejoined the swarm — somebody is reading it again`
979
+ );
980
+ return;
981
+ }
982
+ if (isWanted || torrent.paused === true) {
983
+ return;
984
+ }
985
+ torrent.pause();
986
+ const open = Array.isArray(torrent.wires) ? torrent.wires.length : 0;
987
+ let closed = 0;
988
+ for (const peer of [...(torrent._peers?.values?.() ?? [])]) {
989
+ try {
990
+ peer.destroy();
991
+ closed += 1;
992
+ } catch {
993
+ // A peer already going: nothing to do, and nothing worth failing for.
994
+ }
995
+ }
996
+ logger.info(
997
+ `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] left the swarm — nobody is reading it, ` +
998
+ `${open} connection(s) open, ${closed} let go; the data stays and the next reader rejoins`
999
+ );
1000
+ }
1001
+
871
1002
  /**
872
1003
  * Re-evaluate and apply the client-wide upload limit from current swarm state
873
1004
  * (see {@link decideUploadLimit}). Runs on a timer; only calls into WebTorrent
@@ -1200,6 +1331,9 @@ export class TorrentPool {
1200
1331
  this.#stateBackgroundFill(torrent);
1201
1332
  }
1202
1333
  this.#reportStalledDownloads();
1334
+ for (const torrent of this.torrents.values()) {
1335
+ this.followTheReaders(torrent);
1336
+ }
1203
1337
  const { bytesPerSec, reason } = decideUploadLimit(active);
1204
1338
  if (bytesPerSec === this.#uploadLimit) {
1205
1339
  return;
@@ -1240,11 +1374,14 @@ export class TorrentPool {
1240
1374
  }
1241
1375
  // Candidates: pooled torrents with zero active readers, LRU first.
1242
1376
  const candidates = [...this.torrents.values()]
1243
- .filter((t) => {
1244
- const usage = this.fileUsageByTorrent.get(t);
1377
+ .filter((torrent) => {
1378
+ const usage = this.fileUsageByTorrent.get(torrent);
1245
1379
  return !usage || usage.size === 0;
1246
1380
  })
1247
- .sort((a, b) => (this.#lastAccess.get(a) ?? 0) - (this.#lastAccess.get(b) ?? 0));
1381
+ .sort(
1382
+ (earlier, later) =>
1383
+ (this.#lastAccess.get(earlier) ?? 0) - (this.#lastAccess.get(later) ?? 0)
1384
+ );
1248
1385
 
1249
1386
  for (const torrent of candidates) {
1250
1387
  if (used <= this.#maxDiskBytes) {
@@ -1252,9 +1389,9 @@ export class TorrentPool {
1252
1389
  }
1253
1390
  const freed = Math.max(0, torrentDownloadedBytes(torrent));
1254
1391
  const name = typeof torrent?.name === "string" ? torrent.name : "(unknown)";
1255
- const gb = (this.#maxDiskBytes / (1024 * 1024 * 1024)).toFixed(1);
1392
+ const gigabytes = (this.#maxDiskBytes / (1024 * 1024 * 1024)).toFixed(1);
1256
1393
  logger.info(
1257
- `torrent-pool: disk cap ${gb} GB exceeded — evicting idle torrent "${name}" ` +
1394
+ `torrent-pool: disk cap ${gigabytes} GB exceeded — evicting idle torrent "${name}" ` +
1258
1395
  `(~${(freed / (1024 * 1024)).toFixed(0)} MB)`
1259
1396
  );
1260
1397
  this.#cancelIdleRemoval(torrent);
@@ -1329,6 +1466,21 @@ export class TorrentPool {
1329
1466
  logger.warn(`torrent-pool: [${label()}] warning: ${formatWarning(warning)}`);
1330
1467
  });
1331
1468
 
1469
+ // A TORRENT THAT DIED WITHOUT US ASKING LEAVES ITS RECORD BEHIND, and the
1470
+ // record goes on answering. Field 2026-09-11: the store refused a block,
1471
+ // the client destroyed the torrent, and for the rest of the process every
1472
+ // read answered `File 1 not found in torrent:d4022ff4…` while `/stats`
1473
+ // reported `peers=0 connected of 1186 known` — so the browser's own remedy,
1474
+ // adding the source again, returned the corpse and the viewer could not
1475
+ // open anything until the addon was restarted.
1476
+ //
1477
+ // Forgetting the record is the whole fix: the next request builds the
1478
+ // torrent again, from the same magnet, against the same data on disk.
1479
+ torrent.on("error", (error) => {
1480
+ logger.error(`torrent-pool: [${label()}] died: ${formatWarning(error)}`);
1481
+ this.#forgetDeadTorrent(torrent);
1482
+ });
1483
+
1332
1484
  // Everything below needs the torrent to have been PARSED, and `add` returns
1333
1485
  // before that: `announce`, `files` and `private` are all still empty, and
1334
1486
  // `discovery` — which owns the tracker client — is not created until
@@ -1436,7 +1588,7 @@ export class TorrentPool {
1436
1588
  const message = error instanceof Error ? error.message : String(error);
1437
1589
  const dupMatch = /duplicate torrent ([0-9a-f]{40})/i.exec(message);
1438
1590
  if (dupMatch) {
1439
- const existing = this.client.torrents.find((t) => t?.infoHash === dupMatch[1]);
1591
+ const existing = this.client.torrents.find((candidate) => candidate?.infoHash === dupMatch[1]);
1440
1592
  // A torrent already here but WITHOUT metadata is not an answer to a
1441
1593
  // request that carries metadata. A magnet whose swarm never answered
1442
1594
  // leaves exactly that: an entry with the right infohash, no file
@@ -1500,7 +1652,7 @@ export class TorrentPool {
1500
1652
  const addedReplacement = this.client.add(torrentId, {
1501
1653
  store: SharedPieceStore,
1502
1654
  storeCacheSlots: 0,
1503
- storeOpts: { memoryBytes: this.#memoryBytes },
1655
+ storeOpts: { memoryBytes: this.#memoryBytes, ...this.#storeExtras },
1504
1656
  deselect: true
1505
1657
  }, (replacement) => {
1506
1658
  this.torrents.set(key, replacement);
@@ -1533,14 +1685,19 @@ export class TorrentPool {
1533
1685
  const added = this.client.add(torrentId, {
1534
1686
  store: SharedPieceStore,
1535
1687
  storeCacheSlots: 0,
1536
- storeOpts: { memoryBytes: this.#memoryBytes },
1688
+ storeOpts: { memoryBytes: this.#memoryBytes, ...this.#storeExtras },
1537
1689
  // Nothing is fetched until somebody says they want it. WebTorrent's own
1538
1690
  // default is `this.select(0, this.pieces.length - 1)` — the whole
1539
1691
  // torrent — and this proxy used to undo that afterwards by deselecting
1540
1692
  // the files nobody had opened. On a season pack that meant every
1541
1693
  // episode was being fetched for as long as the viewer took to choose
1542
1694
  // one. The download set is built up from stated needs instead.
1543
- deselect: true
1695
+ deselect: true,
1696
+ // Nothing to check when every file of this source is already here
1697
+ // whole: what would be verified was written out of pieces this client
1698
+ // had hashed, and the file's size was checked against what the torrent
1699
+ // says. See `#wholeSources`.
1700
+ skipVerify: this.#wholeSources.has(key)
1544
1701
  }, (readyTorrent) => {
1545
1702
  this.client.off("error", onError);
1546
1703
  this.torrents.set(key, readyTorrent);
@@ -1591,6 +1748,15 @@ export class TorrentPool {
1591
1748
  // it recently accessed so LRU eviction keeps it.
1592
1749
  this.#cancelIdleRemoval(torrent);
1593
1750
  this.#lastAccess.set(torrent, Date.now());
1751
+ // And rejoin its swarm HERE rather than at the next five-second pass: a
1752
+ // reader that has just arrived is about to ask for bytes, and a torrent
1753
+ // still paused answers by fetching nothing at all.
1754
+ if (torrent.paused === true) {
1755
+ torrent.resume();
1756
+ logger.info(
1757
+ `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] rejoined the swarm — a reader arrived`
1758
+ );
1759
+ }
1594
1760
  usage.set(fileIndex, (usage.get(fileIndex) ?? 0) + 1);
1595
1761
 
1596
1762
  let released = false;
@@ -1627,18 +1793,18 @@ export class TorrentPool {
1627
1793
  }
1628
1794
  this.#cancelIdleRemoval(torrent);
1629
1795
  const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
1630
- const ih = String(torrent.infoHash ?? "?").slice(0, 8);
1631
- logger.info(`torrent-pool: scheduling idle removal for "${name}" [${ih}] in ${TORRENT_IDLE_TTL_MS / 1000}s`);
1796
+ const infoHashShort = String(torrent.infoHash ?? "?").slice(0, 8);
1797
+ logger.info(`torrent-pool: scheduling idle removal for "${name}" [${infoHashShort}] in ${TORRENT_IDLE_TTL_MS / 1000}s`);
1632
1798
  const timer = setTimeout(() => {
1633
1799
  this.#idleTimers.delete(torrent);
1634
1800
  // Re-check: a new acquire since scheduling would have cancelled this
1635
1801
  // timer, but guard anyway against a race.
1636
1802
  const usage = this.fileUsageByTorrent.get(torrent);
1637
1803
  if (usage && usage.size > 0) {
1638
- logger.info(`torrent-pool: idle timer fired for "${name}" [${ih}] but refcount ${usage.size} >0 — keep`);
1804
+ logger.info(`torrent-pool: idle timer fired for "${name}" [${infoHashShort}] but refcount ${usage.size} >0 — keep`);
1639
1805
  return;
1640
1806
  }
1641
- logger.warn(`torrent-pool: idle TTL fired for "${name}" [${ih}] — removing torrent (reason=idle-ttl caller=scheduleIdleRemoval)`);
1807
+ logger.warn(`torrent-pool: idle TTL fired for "${name}" [${infoHashShort}] — removing torrent (reason=idle-ttl caller=scheduleIdleRemoval)`);
1642
1808
  this.#removeTorrent(torrent, "idle-ttl");
1643
1809
  }, TORRENT_IDLE_TTL_MS);
1644
1810
  timer.unref?.();
@@ -1667,6 +1833,59 @@ export class TorrentPool {
1667
1833
  * @param {string} [reason="unknown"] - Why removal was requested (idle-ttl, disk-cap, evict, api).
1668
1834
  * @returns {void}
1669
1835
  */
1836
+ /**
1837
+ * Forget a torrent that died on its own, keeping its bytes.
1838
+ *
1839
+ * NOT `#removeTorrent`: that one destroys the store as well, and the data on
1840
+ * disk is the expensive thing here — re-downloading a film costs the swarm
1841
+ * and the viewer, while re-adding a torrent costs a tracker announce. What
1842
+ * has to go is only the RECORD, which otherwise answers every later request
1843
+ * with a corpse.
1844
+ *
1845
+ * @param {import("webtorrent").Torrent} torrent
1846
+ * @returns {void}
1847
+ */
1848
+ #forgetDeadTorrent(torrent) {
1849
+ if (!torrent) {
1850
+ return;
1851
+ }
1852
+ let forgotten = false;
1853
+ for (const [key, value] of this.torrents) {
1854
+ if (value === torrent) {
1855
+ this.torrents.delete(key);
1856
+ forgotten = true;
1857
+ break;
1858
+ }
1859
+ }
1860
+ if (!forgotten) {
1861
+ return;
1862
+ }
1863
+ forgetTorrent(torrent);
1864
+ this.fileUsageByTorrent.delete(torrent);
1865
+ this.#lastAccess.delete(torrent);
1866
+ this.#readPositionByTorrent.delete(torrent);
1867
+ const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
1868
+ logger.warn(
1869
+ `torrent-pool: forgot "${name}" [${String(torrent.infoHash ?? "?").slice(0, 8)}] — ` +
1870
+ "it died without being asked to; its data stays on disk and the next request adds it again"
1871
+ );
1872
+ }
1873
+
1874
+ /**
1875
+ * Remove a torrent and its store, by name of the reason.
1876
+ *
1877
+ * The public form of what the idle timer and the disk cap already do. Taking
1878
+ * the store with it is the point where a torrent downloaded whole is
1879
+ * concerned: its pieces are a duplicate of the files kept elsewhere.
1880
+ *
1881
+ * @param {import("webtorrent").Torrent} torrent
1882
+ * @param {string} reason
1883
+ * @returns {void}
1884
+ */
1885
+ remove(torrent, reason) {
1886
+ this.#removeTorrent(torrent, reason);
1887
+ }
1888
+
1670
1889
  #removeTorrent(torrent, reason = "unknown") {
1671
1890
  // Everything anybody stated for this torrent goes with it.
1672
1891
  forgetTorrent(torrent);
@@ -1754,6 +1973,7 @@ export class TorrentPool {
1754
1973
  downloadSpeed,
1755
1974
  uploadSpeed,
1756
1975
  connectedPeers: reach.connectedPeers,
1976
+ deliveringPeers: reach.deliveringPeers,
1757
1977
  knownPeers: reach.knownPeers,
1758
1978
  queuedPeers: reach.queuedPeers,
1759
1979
  trackerSeeders: announce.seeders,
@@ -94,6 +94,18 @@ export class TorrentWorkerClient {
94
94
  /** Reads consuming fragments in place, keyed by request id. */
95
95
  #fragmentReaders = new Map();
96
96
 
97
+ /**
98
+ * Files downloaded whole, by `${infoHash}/${fileIndex}`.
99
+ *
100
+ * Held on THIS thread because it is this thread that serves them: a file that
101
+ * is whole is read with an ordinary file read, and asking the torrent thread
102
+ * for anything about it would put back the very hop this removes. The torrent
103
+ * thread writes the files and says so; this is what it says.
104
+ *
105
+ * @type {Map<string, { path: string, length: number, name: string }>}
106
+ */
107
+ wholeFiles = new Map();
108
+
97
109
  /** @type {(event: { sourceKey: string, fileIndex: number, trackIndex: number, cues: object[], language: string }) => void} */
98
110
  #onSubtitleCues;
99
111
 
@@ -208,6 +220,15 @@ export class TorrentWorkerClient {
208
220
  case Event.LOG:
209
221
  logger.info(`torrent-worker: ${message.message}`);
210
222
  break;
223
+ case Event.FILE_COMPLETE:
224
+ // Recorded here so the stream route can answer from the file without
225
+ // going near the torrent thread at all.
226
+ this.wholeFiles.set(`${message.infoHash}/${message.fileIndex}`, {
227
+ path: message.path,
228
+ length: message.length,
229
+ name: message.name
230
+ });
231
+ break;
211
232
  case Event.SUBTITLE_CUES_READY:
212
233
  this.#onSubtitleCues({
213
234
  sourceKey: message.sourceKey,
@@ -164,7 +164,15 @@ export const Event = {
164
164
  * {@link Command.SUBTITLE_CUES} call. Lets the main thread PUSH them to
165
165
  * whichever browser is watching instead of waiting to be asked.
166
166
  */
167
- SUBTITLE_CUES_READY: "subtitle-cues-ready"
167
+ SUBTITLE_CUES_READY: "subtitle-cues-ready",
168
+ /**
169
+ * A file has been downloaded whole and written out as a file.
170
+ *
171
+ * The main thread serves it from disk after this, without asking this thread
172
+ * for anything: an ordinary read of an ordinary file, with no piece store
173
+ * between them and nothing that can refuse it for want of memory.
174
+ */
175
+ FILE_COMPLETE: "file-complete"
168
176
  };
169
177
 
170
178
  /**