@torrent-tv/proxy 2.83.3 → 2.83.4

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.
@@ -24,6 +24,9 @@ import { deriveSourceKey } from "./torrent-source-key.js";
24
24
  /** How a window stated from the priority map names itself. */
25
25
  const MAP_CLAIMANT = "priority-map";
26
26
 
27
+ /** How the two ends of a file name themselves. */
28
+ const EDGES_CLAIMANT = "file-edges";
29
+
27
30
  // The DHT's entry points. Two of the three the library ships answer nothing —
28
31
  // measured 2026-08-21 from the addon host: `router.bittorrent.com` and
29
32
  // `router.utorrent.com` did not reply to a hand-written `ping` at all, while a
@@ -331,29 +334,25 @@ function describeSwarmDemand(torrent) {
331
334
  /**
332
335
  * The torrents the upload policy is allowed to see.
333
336
  *
334
- * A torrent with a reader qualifies for the obvious reason. A torrent in a
335
- * HURRY qualifies even without one, and that case is the important one: the
336
- * first thing done with a new torrent is fetching the file's head and tail for
337
- * the codec probe, and that read goes straight to `createReadStream` without
338
- * registering a reader. Judged by readers alone the torrent looks unused for
339
- * the whole of that wait — 8.36 s of the 11.46 s before playback in the session
340
- * measured 2026-08-04 — so the upload stayed at the near-silent idle floor
341
- * during the exact seconds peers were deciding whether to serve us.
337
+ * A torrent something is wanted of qualifies for the obvious reason. A torrent
338
+ * in a HURRY qualifies even without that, and that case is the important one:
339
+ * the first seconds of a new torrent are when peers decide whether to serve us,
340
+ * and an upload held at the near-silent idle floor through them costs the whole
341
+ * ramp 8.36 s of the 11.46 s before playback in the session measured
342
+ * 2026-08-04.
342
343
  *
343
344
  * @param {Iterable<{ hurryUntil?: number }>} torrents
344
- * @param {Map<object, { size: number }>} usageByTorrent - fileIndex sets, keyed by torrent.
345
345
  * @param {number} now
346
346
  * @returns {object[]}
347
347
  */
348
- export function torrentsForUploadPolicy(torrents, usageByTorrent, now) {
348
+ export function torrentsForUploadPolicy(torrents, now) {
349
349
  const chosen = [];
350
350
  for (const torrent of torrents) {
351
- const usage = usageByTorrent?.get?.(torrent);
352
- const hasReader = Boolean(usage && usage.size > 0);
353
- if (hasReader || (torrent?.hurryUntil ?? 0) > now) {
351
+ const wanted = isWanted(torrent);
352
+ if (wanted || (torrent?.hurryUntil ?? 0) > now) {
354
353
  // Recorded so the policy can tell "nothing is arriving and somebody is
355
354
  // waiting" from "nothing is arriving because nobody asked".
356
- torrent.hasActiveReader = hasReader;
355
+ torrent.isWanted = wanted;
357
356
  // Whether anybody is still short of bytes of it. Upload is bought with
358
357
  // reciprocity and reciprocity is only worth buying while something is
359
358
  // missing; a torrent whose declared windows are all present wants nothing
@@ -398,13 +397,13 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
398
397
  // iterate the piece array and throw on webtorrent 3.x when a piece is null
399
398
  // (deselected / mid-verify), which would crash this timer every cycle.
400
399
  const notDone = torrent?.done !== true;
401
- // A torrent nobody is reading is not starving, however still its download
402
- // looks. The encoder is held back once it is far enough ahead of the
400
+ // A torrent nothing is wanted of is not starving, however still its
401
+ // download looks. The encoder is held back once it is far enough ahead of the
403
402
  // viewer, and while it is held nothing is requested — measured
404
403
  // 2026-08-04: four cycles of 512 -> 50 KB/s in three minutes, each
405
404
  // reported as `earn unchoke ... down=0KB/s`, all of them raising the
406
405
  // upload at moments when no byte was wanted by anyone.
407
- const starving = notDone && torrent?.hasActiveReader !== false
406
+ const starving = notDone && torrent?.isWanted !== false
408
407
  && torrent?.hasUnmetDemand !== false && downloadSpeed < starvingSpeed;
409
408
  if (starving && chokedInterested >= chokedThreshold) {
410
409
  const name = typeof torrent?.name === "string" ? torrent.name : "?";
@@ -789,6 +788,176 @@ export function dhtNodeCount(client) {
789
788
  // without building a pool, a torrent client or a thread.
790
789
  const lastMapSaid = new WeakMap();
791
790
 
791
+ /**
792
+ * Take back what the priority map had stated for one file, from a band onwards.
793
+ *
794
+ * Every band of a map is a claimant of its own, numbered, so a map that has
795
+ * shrunk — or emptied — is applied by withdrawing the numbers it no longer
796
+ * reaches. Written once because both callers need exactly it: the ordinary
797
+ * application, where the count is how many bands there now are, and a file
798
+ * nobody wants any more, where it is none.
799
+ *
800
+ * A function of a torrent rather than of the pool, like `leaveSwarm` beside it:
801
+ * it needs nothing the pool holds, and it can be exercised without building one.
802
+ *
803
+ * @param {object} torrent
804
+ * @param {number} fileIndex
805
+ * @param {number} [keepBelow] - Bands numbered below this are left alone.
806
+ * @returns {number} How many were withdrawn.
807
+ */
808
+ function withdrawPriorityMap(torrent, fileIndex, keepBelow = 0) {
809
+ const { register } = demandFor(torrent);
810
+ const prefix = `${MAP_CLAIMANT}:${fileIndex}:`;
811
+ let withdrawn = 0;
812
+ for (const window of register.windows()) {
813
+ const claimant = String(window.claimant);
814
+ if (!claimant.startsWith(prefix)) {
815
+ continue;
816
+ }
817
+ const index = Number(claimant.split(":").pop());
818
+ if (!(index < keepBelow)) {
819
+ register.withdraw(claimant);
820
+ withdrawn += 1;
821
+ }
822
+ }
823
+ if (withdrawn > 0 && keepBelow === 0) {
824
+ // Said, because it is the one statement that cannot be read from what
825
+ // follows it: nothing further is stated about this file, so from here on
826
+ // silence is exactly what it looks like.
827
+ logger.info(
828
+ `torrent-pool: [${String(torrent?.infoHash ?? "?").slice(0, 8)}] nobody wants file ${fileIndex} ` +
829
+ `any more — ${withdrawn} band(s) of the map withdrawn`
830
+ );
831
+ }
832
+ return withdrawn;
833
+ }
834
+
835
+ /**
836
+ * WHERE A FILE SAYS WHAT IT IS: the piece at each of its ends.
837
+ *
838
+ * A container keeps its directory at one end or the other — `ftyp` and an EBML
839
+ * header at the front, and for an MP4 that was not written for streaming the
840
+ * `moov` at the very back. Nothing can be read of such a file until those have
841
+ * arrived, and they stay wanted for as long as it is open: a player asks for
842
+ * the file's shape again at every seek.
843
+ *
844
+ * **The size is not chosen, and it cannot be.** One byte is claimed at each
845
+ * end, and the piece is what the swarm delivers — so one byte at each end IS
846
+ * one piece at each end, with nothing rounded up by a number anybody picked.
847
+ * The 256 KB and 2 MB the prefetch reads are its own affair; where a directory
848
+ * is bigger than the piece it starts in, the read that needs it says so itself,
849
+ * at the level of a reader that is stopped.
850
+ *
851
+ * **Stated by the FILE, not by the read that happens to want it.** A read
852
+ * withdraws what it stated the moment it finishes, so until now the two ends of
853
+ * an open film were held by nothing at all once the codec probe was done — and
854
+ * they are exactly the pieces a seek needs and eviction is free to take.
855
+ *
856
+ * A function of a torrent rather than of the pool, like `leaveSwarm` beside it,
857
+ * so both can be exercised without building one.
858
+ *
859
+ * @param {object} torrent
860
+ * @param {number} fileIndex
861
+ * @param {number} urgency - NEAR while somebody is waiting for them, TAIL to
862
+ * keep them afterwards.
863
+ * @returns {boolean} Whether anything was stated.
864
+ */
865
+ export function stateFileEdges(torrent, fileIndex, urgency, { lower = false } = {}) {
866
+ const file = Array.isArray(torrent?.files) ? torrent.files[fileIndex] : null;
867
+ const length = Number(file?.length);
868
+ if (!file || !(length > 0)) {
869
+ return false;
870
+ }
871
+ const { register } = demandFor(torrent);
872
+ const ends = [
873
+ { claimant: `${EDGES_CLAIMANT}:${fileIndex}:head`, byteStart: 0, byteEnd: 0 },
874
+ { claimant: `${EDGES_CLAIMANT}:${fileIndex}:tail`, byteStart: length - 1, byteEnd: length - 1 }
875
+ ];
876
+ for (const end of ends) {
877
+ // RAISED, NEVER LOWERED, unless the caller is the one putting them back
878
+ // down. Two things ask for the same ends — a warm-up that nobody is waiting
879
+ // for and a playback plan that somebody is — and the warm-up's sidecars are
880
+ // fired off without being awaited, so it can arrive second. Taking the
881
+ // plan's urgency away there would leave a person watching a loading screen
882
+ // behind a film somebody else is watching.
883
+ const stated = register.windows().find((window) => String(window.claimant) === end.claimant);
884
+ const level = !lower && stated ? Math.min(stated.urgency, urgency) : urgency;
885
+ register.state({ ...end, fileIndex, urgency: level });
886
+ }
887
+ return true;
888
+ }
889
+
890
+ /**
891
+ * Give up the ends of a file nobody has any use for.
892
+ *
893
+ * @param {object} torrent
894
+ * @param {number} fileIndex
895
+ * @returns {number} How many were withdrawn.
896
+ */
897
+ export function withdrawFileEdges(torrent, fileIndex) {
898
+ const { register } = demandFor(torrent);
899
+ let withdrawn = 0;
900
+ for (const end of ["head", "tail"]) {
901
+ const claimant = `${EDGES_CLAIMANT}:${fileIndex}:${end}`;
902
+ if (register.windows().some((window) => String(window.claimant) === claimant)) {
903
+ register.withdraw(claimant);
904
+ withdrawn += 1;
905
+ }
906
+ }
907
+ return withdrawn;
908
+ }
909
+
910
+ /**
911
+ * IS ANYTHING WANTED OF THIS TORRENT — the one question asked about a torrent's
912
+ * life, and it is answered by what has been stated, never by counting who is
913
+ * reading.
914
+ *
915
+ * Everything that wants bytes says so in the register: the priority map for
916
+ * every file somebody is watching, the ends of a file that is open, the
917
+ * background fill, and a read that is stopped on a piece. So "nothing is
918
+ * stated" is the whole of "nobody wants this", and it becomes true exactly when
919
+ * the last viewer leaves — because that is when the map is published with
920
+ * nothing in it.
921
+ *
922
+ * **A torrent that cannot yet be stated about is wanted.** Until its metadata
923
+ * arrives it has no files, so nothing can name a byte of it, and it is being
924
+ * fetched precisely because somebody asked for it. Judged by the register alone
925
+ * it would look abandoned three seconds after it was added, which is the
926
+ * failure of 2.83.1 exactly.
927
+ *
928
+ * @param {object} torrent
929
+ * @returns {boolean}
930
+ */
931
+ export function isWanted(torrent) {
932
+ if (!torrent || torrent.destroyed) {
933
+ return false;
934
+ }
935
+ if (!Array.isArray(torrent.files) || torrent.files.length === 0) {
936
+ return true;
937
+ }
938
+ return demandFor(torrent).register.size > 0;
939
+ }
940
+
941
+ /**
942
+ * WHAT TO DO ABOUT A TORRENT'S SWARM, from the two facts that decide it.
943
+ *
944
+ * Separated from the doing because this is the rule that has failed twice in
945
+ * the field, and a rule that can only be exercised by building a pool with a
946
+ * live WebTorrent client is a rule nothing checks. Both failures are cases of
947
+ * it: 2.83.1 let a swarm go for a torrent that had never been wanted, and the
948
+ * version before that never let one go at all.
949
+ *
950
+ * @param {{ wanted: boolean, everWanted: boolean }} facts
951
+ * @returns {{ swarm: "take" | "let go" | "leave alone", onTheClock: boolean }}
952
+ * What to do with the swarm, and whether the idle clock should be running.
953
+ */
954
+ export function swarmDecisionFor({ wanted, everWanted }) {
955
+ if (wanted) {
956
+ return { swarm: "take", onTheClock: false };
957
+ }
958
+ return { swarm: everWanted ? "let go" : "leave alone", onTheClock: true };
959
+ }
960
+
792
961
  export class TorrentPool {
793
962
  /**
794
963
  * In-flight `client.add()` promises keyed by the same key as `torrents`.
@@ -984,7 +1153,6 @@ export class TorrentPool {
984
1153
  *
985
1154
  * @type {WeakMap<import("webtorrent").Torrent, Map<number, number>>}
986
1155
  */
987
- this.fileUsageByTorrent = new WeakMap();
988
1156
 
989
1157
  this.client.on("error", (error) => {
990
1158
  logger.error(`WebTorrent client error: ${error.message}`);
@@ -1115,13 +1283,14 @@ export class TorrentPool {
1115
1283
  */
1116
1284
  #stateBackgroundFill(torrent) {
1117
1285
  const { register } = demandFor(torrent);
1118
- const usage = this.fileUsageByTorrent.get(torrent);
1119
1286
  const pieceLength = Number(torrent.pieceLength);
1120
1287
  if (!Number.isFinite(pieceLength) || pieceLength <= 0) {
1121
1288
  return;
1122
1289
  }
1123
- for (const [fileIndex, count] of usage ?? []) {
1124
- const file = count > 0 ? torrent.files?.[fileIndex] : null;
1290
+ // The files anything is stated for which is the same list the reader
1291
+ // counts used to give and is one fact rather than two.
1292
+ for (const fileIndex of register.files()) {
1293
+ const file = torrent.files?.[fileIndex] ?? null;
1125
1294
  const claimant = `background-fill:${fileIndex}`;
1126
1295
  if (!file) {
1127
1296
  register.withdraw(claimant);
@@ -1169,6 +1338,22 @@ export class TorrentPool {
1169
1338
  * @param {number} durationSeconds
1170
1339
  */
1171
1340
  applyPriorityMap(torrent, fileIndex, zones, durationSeconds) {
1341
+ // A MAP WITH NOTHING IN IT IS A STATEMENT, and it is answered before
1342
+ // anything else is looked at. It says nobody wants any of this file, and
1343
+ // that is true whatever the file's length is or whether the torrent still
1344
+ // holds a list — so making it wait on either would be making the departure
1345
+ // depend on facts that are gone by the time it is said.
1346
+ if (Array.isArray(zones) && zones.length === 0) {
1347
+ withdrawPriorityMap(torrent, fileIndex);
1348
+ // The ends of the file go with it. They are kept for as long as the file
1349
+ // is open, and this is what says it is not.
1350
+ withdrawFileEdges(torrent, fileIndex);
1351
+ // And this may have been the last thing anybody wanted of this torrent.
1352
+ // Optional because these two are functions of a torrent and are exercised
1353
+ // as such; called on the pool, the pool also acts on what they said.
1354
+ this?.followTheDemand?.(torrent);
1355
+ return;
1356
+ }
1172
1357
  const file = Array.isArray(torrent?.files) ? torrent.files[fileIndex] : null;
1173
1358
  const length = Number(file?.length);
1174
1359
  const duration = Number(durationSeconds);
@@ -1243,15 +1428,7 @@ export class TorrentPool {
1243
1428
  });
1244
1429
  // Bands the map no longer has: a viewer moved on, and what they had wanted
1245
1430
  // is not wanted by anybody now.
1246
- for (const window of register.windows()) {
1247
- if (!String(window.claimant).startsWith(`${MAP_CLAIMANT}:${fileIndex}:`)) {
1248
- continue;
1249
- }
1250
- const index = Number(String(window.claimant).split(":").pop());
1251
- if (!(index < ordered.length)) {
1252
- register.withdraw(window.claimant);
1253
- }
1254
- }
1431
+ withdrawPriorityMap(torrent, fileIndex, ordered.length);
1255
1432
  // WHAT THE SWARM WAS ACTUALLY TOLD, said on change and never on a timer.
1256
1433
  // The map was applied in silence: that it had been BUILT was visible in the
1257
1434
  // encoding's own line, and that the download had received it was visible
@@ -1275,6 +1452,9 @@ export class TorrentPool {
1275
1452
  .sort((left, right) => right[0] - left[0])
1276
1453
  .map(([level, held]) => `${urgencyName(level)} ${held.zones} zone(s) ${held.megabytes.toFixed(0)}MB`)
1277
1454
  .join(", ");
1455
+ // Something is wanted of this torrent, which may be news: a map published
1456
+ // for a file of a torrent whose swarm was let go is what takes it back.
1457
+ this?.followTheDemand?.(torrent);
1278
1458
  const said = `${fileIndex}:${shape}`;
1279
1459
  if (lastMapSaid.get(torrent) !== said) {
1280
1460
  lastMapSaid.set(torrent, said);
@@ -1288,8 +1468,7 @@ export class TorrentPool {
1288
1468
  #reportStalledDownloads() {
1289
1469
  const now = Date.now();
1290
1470
  for (const torrent of this.torrents.values()) {
1291
- const usage = this.fileUsageByTorrent.get(torrent);
1292
- if (!usage || usage.size === 0 || torrent?.done === true) {
1471
+ if (!isWanted(torrent) || torrent?.done === true) {
1293
1472
  continue;
1294
1473
  }
1295
1474
  const speed = typeof torrent.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
@@ -1329,22 +1508,23 @@ export class TorrentPool {
1329
1508
  if (!this.client || this.client.destroyed || typeof this.client.throttleUpload !== "function") {
1330
1509
  return;
1331
1510
  }
1332
- const active = torrentsForUploadPolicy(
1333
- this.torrents.values(),
1334
- this.fileUsageByTorrent,
1335
- Date.now()
1336
- );
1511
+ const active = torrentsForUploadPolicy(this.torrents.values(), Date.now());
1337
1512
  // The one place the swarm is told anything: it reads what everybody has
1338
1513
  // stated and works out for itself what to ask for, including whether the
1339
1514
  // speculative levels may be stated at all — which is a question about every
1340
1515
  // torrent at once, because they share the link.
1341
1516
  reconcileAll();
1342
1517
  for (const torrent of this.torrents.values()) {
1343
- const usage = this.fileUsageByTorrent.get(torrent);
1344
- if (!usage || usage.size === 0 || torrent?.done === true) {
1518
+ if (!isWanted(torrent) || torrent?.done === true) {
1345
1519
  continue;
1346
1520
  }
1347
1521
  this.#stateBackgroundFill(torrent);
1522
+ // And act on what that just said. The fill withdraws itself once nothing
1523
+ // else wants the file, so a torrent everybody has left can be held by the
1524
+ // fill's own claim until this pass — and without this line the moment it
1525
+ // goes would be noticed by nobody, leaving the torrent in its swarm with
1526
+ // no idle clock running.
1527
+ this.followTheDemand(torrent);
1348
1528
  }
1349
1529
  this.#reportStalledDownloads();
1350
1530
  const { bytesPerSec, reason } = decideUploadLimit(active);
@@ -1385,12 +1565,10 @@ export class TorrentPool {
1385
1565
  if (used <= this.#maxDiskBytes) {
1386
1566
  return;
1387
1567
  }
1388
- // Candidates: pooled torrents with zero active readers, LRU first.
1568
+ // Candidates: pooled torrents nothing is wanted of, the longest unwanted
1569
+ // first.
1389
1570
  const candidates = [...this.torrents.values()]
1390
- .filter((torrent) => {
1391
- const usage = this.fileUsageByTorrent.get(torrent);
1392
- return !usage || usage.size === 0;
1393
- })
1571
+ .filter((torrent) => !isWanted(torrent))
1394
1572
  .sort(
1395
1573
  (earlier, later) =>
1396
1574
  (this.#lastAccess.get(earlier) ?? 0) - (this.#lastAccess.get(later) ?? 0)
@@ -1658,7 +1836,6 @@ export class TorrentPool {
1658
1836
  this.torrents.delete(otherKey);
1659
1837
  }
1660
1838
  }
1661
- this.fileUsageByTorrent.delete(existing);
1662
1839
  this.#lastAccess.delete(existing);
1663
1840
  this.#readPositionByTorrent.delete(existing);
1664
1841
  this.client.remove(existing, { destroyStore: true }, () => {
@@ -1722,6 +1899,11 @@ export class TorrentPool {
1722
1899
  // leave the survivor unwatched. Attaching twice costs nothing — the
1723
1900
  // guard makes the second call a no-op when it is the same object.
1724
1901
  this.#attachSwarmDiagnostics(readyTorrent);
1902
+ // On the clock from the moment it exists. A torrent nobody ever states
1903
+ // anything about — a file list fetched and never played — used to be
1904
+ // held for the life of the process, because the only thing that ever
1905
+ // started the idle timer was a reader letting go.
1906
+ this.followTheDemand(readyTorrent);
1725
1907
  resolve(readyTorrent);
1726
1908
  });
1727
1909
  // Attached to what `add` returns, NOT inside its callback. That callback
@@ -1740,59 +1922,61 @@ export class TorrentPool {
1740
1922
  }
1741
1923
 
1742
1924
  /**
1743
- * Increment the reference count for a file, selecting it for download.
1744
- * Returns a release function that decrements the count; when it reaches
1745
- * zero the file is automatically deselected.
1925
+ * Torrents something has been wanted of at least once, which is what tells a
1926
+ * departure from a beginning.
1927
+ *
1928
+ * @type {WeakSet<object>}
1929
+ */
1930
+ #wasWanted = new WeakSet();
1931
+
1932
+ /**
1933
+ * ACT ON WHAT IS WANTED OF THIS TORRENT, and on nothing else.
1934
+ *
1935
+ * Called wherever what is wanted changes — a priority map applied or emptied,
1936
+ * the ends of a file stated — because those are the moments, and a pass that
1937
+ * asks every torrent every few seconds has the wrong answer between two of
1938
+ * them. That is not a theory: 2.83.1 asked such a question every five seconds
1939
+ * and a torrent added three seconds earlier answered "nobody", so it left its
1940
+ * swarm with 741 connections let go and playback never started.
1941
+ *
1942
+ * What it does NOT do is decide whether a torrent is wanted. That is
1943
+ * {@link isWanted}, which reads what has been stated, and the only reason
1944
+ * this is a method at all is that the idle timer and the eviction order are
1945
+ * the pool's own.
1746
1946
  *
1747
1947
  * @param {import("webtorrent").Torrent} torrent
1748
- * @param {number} fileIndex - Zero-based index into `torrent.files`.
1749
- * @returns {() => void} Release function — call it once when done streaming.
1948
+ * @returns {void}
1750
1949
  */
1751
- acquireFile(torrent, fileIndex) {
1752
- if (!torrent || !Array.isArray(torrent.files) || !Number.isInteger(fileIndex) || fileIndex < 0) {
1753
- return () => undefined;
1950
+ followTheDemand(torrent) {
1951
+ if (!torrent || torrent.destroyed) {
1952
+ return;
1953
+ }
1954
+ const { swarm, onTheClock } = swarmDecisionFor({
1955
+ wanted: isWanted(torrent),
1956
+ everWanted: this.#wasWanted.has(torrent)
1957
+ });
1958
+ if (swarm === "take") {
1959
+ this.#wasWanted.add(torrent);
1960
+ this.#cancelIdleRemoval(torrent);
1961
+ // When it was last wanted, which is the order the disk cap evicts in.
1962
+ this.#lastAccess.set(torrent, Date.now());
1963
+ rejoinSwarm(torrent);
1754
1964
  }
1755
- let usage = this.fileUsageByTorrent.get(torrent);
1756
- if (!usage) {
1757
- usage = new Map();
1758
- this.fileUsageByTorrent.set(torrent, usage);
1965
+ // A PROXY THAT NEEDS NOTHING FROM A SWARM SHOULD NOT BE IN THAT SWARM. The
1966
+ // data stays on disk; the next thing that wants a byte of it takes the
1967
+ // swarm back.
1968
+ if (swarm === "let go") {
1969
+ leaveSwarm(torrent);
1970
+ }
1971
+ if (onTheClock) {
1972
+ this.#scheduleIdleRemoval(torrent);
1759
1973
  }
1760
- // The torrent is in use again — cancel any pending idle removal and mark
1761
- // it recently accessed so LRU eviction keeps it.
1762
- this.#cancelIdleRemoval(torrent);
1763
- this.#lastAccess.set(torrent, Date.now());
1764
- // AND REJOIN ITS SWARM, which is the other half of the pair: a departure
1765
- // lets a swarm go, an arrival takes it back. Both are acted on at the
1766
- // moment they happen, and neither is discovered by asking.
1767
- rejoinSwarm(torrent);
1768
- usage.set(fileIndex, (usage.get(fileIndex) ?? 0) + 1);
1769
-
1770
- let released = false;
1771
- return () => {
1772
- if (released) {
1773
- return;
1774
- }
1775
- released = true;
1776
- const nextCount = (usage.get(fileIndex) ?? 0) - 1;
1777
- if (nextCount > 0) {
1778
- usage.set(fileIndex, nextCount);
1779
- } else {
1780
- usage.delete(fileIndex);
1781
- }
1782
- if (usage.size === 0) {
1783
- this.fileUsageByTorrent.delete(torrent);
1784
- // No active readers — schedule removal (with store) after an idle TTL.
1785
- this.#scheduleIdleRemoval(torrent);
1786
- // THE READER LEFT, AND THAT IS THE ONLY MOMENT A SWARM MAY BE LET GO.
1787
- leaveSwarm(torrent);
1788
- }
1789
- };
1790
1974
  }
1791
1975
 
1792
1976
  /**
1793
1977
  * Schedule removal of a torrent (with its on-disk store) after
1794
- * {@link TORRENT_IDLE_TTL_MS} of zero file refcount. Idempotent — replaces
1795
- * any existing timer for the torrent.
1978
+ * {@link TORRENT_IDLE_TTL_MS} of nothing being wanted of it. Idempotent —
1979
+ * replaces any existing timer for the torrent.
1796
1980
  *
1797
1981
  * @param {import("webtorrent").Torrent} torrent
1798
1982
  * @returns {void}
@@ -1807,11 +1991,13 @@ export class TorrentPool {
1807
1991
  logger.info(`torrent-pool: scheduling idle removal for "${name}" [${infoHashShort}] in ${TORRENT_IDLE_TTL_MS / 1000}s`);
1808
1992
  const timer = setTimeout(() => {
1809
1993
  this.#idleTimers.delete(torrent);
1810
- // Re-check: a new acquire since scheduling would have cancelled this
1811
- // timer, but guard anyway against a race.
1812
- const usage = this.fileUsageByTorrent.get(torrent);
1813
- if (usage && usage.size > 0) {
1814
- logger.info(`torrent-pool: idle timer fired for "${name}" [${infoHashShort}] but refcount ${usage.size} >0 — keep`);
1994
+ // Re-check: anything stated since would have cancelled this timer, but
1995
+ // guard anyway against a race.
1996
+ if (isWanted(torrent)) {
1997
+ logger.info(
1998
+ `torrent-pool: idle timer fired for "${name}" [${infoHashShort}] but ` +
1999
+ `${demandFor(torrent).register.size} thing(s) are stated for it — keep`
2000
+ );
1815
2001
  return;
1816
2002
  }
1817
2003
  logger.warn(`torrent-pool: idle TTL fired for "${name}" [${infoHashShort}] — removing torrent (reason=idle-ttl caller=scheduleIdleRemoval)`);
@@ -1871,7 +2057,6 @@ export class TorrentPool {
1871
2057
  return;
1872
2058
  }
1873
2059
  forgetTorrent(torrent);
1874
- this.fileUsageByTorrent.delete(torrent);
1875
2060
  this.#lastAccess.delete(torrent);
1876
2061
  this.#readPositionByTorrent.delete(torrent);
1877
2062
  const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
@@ -1904,8 +2089,7 @@ export class TorrentPool {
1904
2089
  }
1905
2090
  const infoHash = String(torrent.infoHash ?? "?").slice(0, 8);
1906
2091
  const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
1907
- const usage = this.fileUsageByTorrent.get(torrent);
1908
- const refcount = usage ? usage.size : 0;
2092
+ const stated = demandFor(torrent).register.size;
1909
2093
  const hasData = (() => { try { return torrentDownloadedBytes(torrent); } catch { return -1; } })();
1910
2094
  // Capture caller for diagnostics — not for control flow.
1911
2095
  const caller = new Error().stack?.split("\n")[2]?.trim() ?? "";
@@ -1916,10 +2100,9 @@ export class TorrentPool {
1916
2100
  break;
1917
2101
  }
1918
2102
  }
1919
- this.fileUsageByTorrent.delete(torrent);
1920
2103
  this.#lastAccess.delete(torrent);
1921
2104
  this.#readPositionByTorrent.delete(torrent);
1922
- logger.warn(`torrent-pool: removing torrent "${name}" [${infoHash}] reason=${reason} refcount=${refcount} downloaded=${hasData}B caller=${caller}`);
2105
+ logger.warn(`torrent-pool: removing torrent "${name}" [${infoHash}] reason=${reason} stated=${stated} downloaded=${hasData}B caller=${caller}`);
1923
2106
  try {
1924
2107
  torrent.destroy({ destroyStore: true }, () => {
1925
2108
  logger.info(`torrent-pool: removed torrent "${name}" [${infoHash}] reason=${reason} and its store`);
@@ -2147,16 +2330,31 @@ export class TorrentPool {
2147
2330
  * @param {number} [options.headBytes=262144] - Leading bytes to fetch (default 256 KB).
2148
2331
  * @param {number} [options.tailBytes=2097152] - Trailing bytes to fetch (default 2 MB).
2149
2332
  * @param {number} [options.timeoutMs=300000] - Maximum wait time in milliseconds (default 5 min).
2333
+ * @param {boolean} [options.awaited=false] - Whether somebody is waiting for
2334
+ * these bytes right now. The playback plan is: it cannot answer until the
2335
+ * file has said what is in it, and a person is watching a loading screen
2336
+ * meanwhile. The warm-up is NOT, by its whole purpose — it happens while
2337
+ * the viewer is still choosing, so it must not outrank another film that
2338
+ * somebody is watching on this proxy this minute.
2150
2339
  * @returns {Promise<void>}
2151
2340
  */
2152
2341
  async prefetchFileEdges(
2153
2342
  torrent,
2154
2343
  fileIndex,
2155
- { headBytes = 256 * 1024, tailBytes = 2 * 1024 * 1024, timeoutMs = 300_000 } = {}
2344
+ { headBytes = 256 * 1024, tailBytes = 2 * 1024 * 1024, timeoutMs = 300_000, awaited = false } = {}
2156
2345
  ) {
2157
2346
  if (!torrent || !Array.isArray(torrent.files)) {
2158
2347
  return;
2159
2348
  }
2349
+ // THE ENDS OF THIS FILE ARE WANTED, and they go on being wanted after this
2350
+ // read is over: the file's own directory lives there, and a seek asks for
2351
+ // it again. Stated before the read rather than by it, and kept afterwards
2352
+ // at the level of something nobody is waiting for.
2353
+ stateFileEdges(torrent, fileIndex, awaited ? Urgency.NEAR : Urgency.TAIL);
2354
+ // The first thing ever stated about a torrent being opened, and therefore
2355
+ // what keeps it in its swarm through the seconds when nothing else can say
2356
+ // anything about it.
2357
+ this.followTheDemand(torrent);
2160
2358
  // Two callers can ask for the same edges at once: the warm-up that starts
2161
2359
  // when a torrent is picked, and the playback plan a moment later. Reading
2162
2360
  // the same two pieces twice costs nothing in bandwidth — the torrent
@@ -2173,6 +2371,10 @@ export class TorrentPool {
2173
2371
  return await prefetch;
2174
2372
  } finally {
2175
2373
  this.#edgePrefetches.delete(inFlightKey);
2374
+ // Nobody is waiting for them now. Said with `lower`, because this is the
2375
+ // one caller entitled to put them back down: the read it belongs to is
2376
+ // over, and there is no other — a file's edges are fetched once at a time.
2377
+ stateFileEdges(torrent, fileIndex, Urgency.TAIL, { lower: true });
2176
2378
  }
2177
2379
  }
2178
2380
 
@@ -317,30 +317,6 @@ export class TorrentWorkerClient {
317
317
  return this.#caller.call(Command.LIST_FILES, { sourceKey });
318
318
  }
319
319
 
320
- /**
321
- * Claim a file so it is not evicted while being read.
322
- *
323
- * @param {string} sourceKey
324
- * @param {number} fileIndex
325
- * @returns {Promise<string>} The claim's identity, for {@link releaseFile}.
326
- */
327
- async acquireFile(sourceKey, fileIndex) {
328
- return this.#caller.call(Command.ACQUIRE_FILE, { sourceKey, fileIndex });
329
- }
330
-
331
- /**
332
- * Drop one claim taken with {@link acquireFile}.
333
- *
334
- * Named by claim rather than by file: several readers hold the same file at
335
- * once, and releasing "the file" released somebody else's hold.
336
- *
337
- * @param {string} claimId
338
- * @returns {Promise<void>}
339
- */
340
- async releaseFile(claimId) {
341
- await this.#caller.call(Command.RELEASE_FILE, { claimId });
342
- }
343
-
344
320
  /**
345
321
  * Live download figures for the progress display.
346
322
  *
@@ -11,11 +11,9 @@
11
11
  *
12
12
  * Two accommodations are needed, and both are deliberate:
13
13
  *
14
- * - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
15
- * nothing the caller inspects, so the command is dispatched and not awaited.
16
- * `acquireFile` hands back a release function exactly as before, which sends
17
- * its own command when called. Awaiting them would mean touching every call
18
- * site for no observable gain.
14
+ * - **`prioritizeByteRange` stays synchronous.** It returns nothing the caller
15
+ * inspects, so the command is dispatched and not awaited. Awaiting it would
16
+ * mean touching every call site for no observable gain.
19
17
  * - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
20
18
  * thread, so the worker keys them. Callers that have one pass it; the rest
21
19
  * get one derived from the source itself, so the identity stays stable
@@ -85,34 +83,6 @@ export class WorkerTorrentPool {
85
83
  return this.#client.allowSpillBytes(bytes);
86
84
  }
87
85
 
88
- acquireFile(torrent, fileIndex) {
89
- const sourceKey = torrent?.sourceKey;
90
- if (!sourceKey) {
91
- return () => undefined;
92
- }
93
- // Dispatched, not awaited — callers use the result immediately and inspect
94
- // nothing. But the release MUST NOT overtake it: both are ordinary messages
95
- // to the worker, and if release arrives first the reader count drops to zero
96
- // while a read is still running. The idle sweep then removes the torrent AND
97
- // its downloaded data out from under the encoder — field 2026-08-02:
98
- // "removed idle torrent ... and its store" mid-playback, after which every
99
- // read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
100
- // Chaining the release onto the acquire keeps them in order.
101
- const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => null);
102
- let released = false;
103
- return () => {
104
- if (released) {
105
- return;
106
- }
107
- released = true;
108
- // Release the claim this call opened, not "the file" — waiting for the
109
- // acquire is also what tells us which claim that is.
110
- void acquired
111
- .then((claimId) => (claimId ? this.#client.releaseFile(claimId) : undefined))
112
- .catch(() => undefined);
113
- };
114
- }
115
-
116
86
  /**
117
87
  * Bytes every torrent here has moved.
118
88
  *