@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.
- package/CHANGELOG.md +28 -0
- package/CLAUDE.md +12 -0
- package/docs/download-architecture.md +175 -0
- package/docs/logs.md +8 -0
- package/package.json +1 -1
- package/services/demand/DemandRegister.js +182 -0
- package/services/demand/Urgency.js +137 -0
- package/services/demand/Window.js +118 -0
- package/services/demand/index.js +11 -0
- package/services/demand/pieces.js +140 -0
- package/services/download/SwarmSelection.js +313 -0
- package/services/download/index.js +8 -0
- package/services/download/registry.js +96 -0
- package/services/piece-store/piece-lru.js +30 -0
- package/services/piece-store/shared-piece-store.js +494 -34
- package/services/torrent-pool.js +64 -197
- package/services/torrent-worker/fastest-wires.js +319 -279
- package/services/torrent-worker/piece-reader.js +146 -178
- package/services/torrent-worker/worker.js +75 -14
- package/test/demand-register.test.js +195 -0
- package/test/fastest-wires.test.js +23 -1
- package/test/memory-budget.test.js +73 -29
- package/test/piece-store-eviction.test.js +220 -0
- package/test/read-bands.test.js +17 -10
- package/test/read-window.test.js +20 -8
- package/test/swarm-selection.test.js +191 -0
- package/utils/logger.js +62 -20
package/services/torrent-pool.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
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
|
-
*
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
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
|
-
* @
|
|
1058
|
-
* @returns {{ from: number, to: number } | null}
|
|
957
|
+
* @returns {void}
|
|
1059
958
|
*/
|
|
1060
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
|
1997
|
-
*
|
|
1998
|
-
*
|
|
1999
|
-
*
|
|
2000
|
-
*
|
|
2001
|
-
*
|
|
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
|