@torrent-tv/proxy 2.70.0 → 2.71.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.
- package/CHANGELOG.md +20 -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 +367 -0
- package/services/download/index.js +8 -0
- package/services/download/registry.js +96 -0
- package/services/piece-store/shared-piece-store.js +16 -0
- package/services/playback-planner.js +15 -1
- package/services/torrent-pool.js +64 -197
- package/services/torrent-worker/fastest-wires.js +319 -279
- package/services/torrent-worker/piece-reader.js +149 -183
- package/services/torrent-worker/worker.js +23 -3
- package/test/demand-register.test.js +195 -0
- package/test/fastest-wires.test.js +23 -1
- package/test/read-bands.test.js +17 -10
- package/test/read-window.test.js +20 -8
- package/test/swarm-selection.test.js +220 -0
- package/utils/logger.js +62 -20
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file One demand register and one swarm selection per torrent, found from the
|
|
3
|
+
* torrent itself.
|
|
4
|
+
*
|
|
5
|
+
* The same shape the piece store already uses — `findSharedStore(torrent)`
|
|
6
|
+
* reaches the store without it being threaded through every call — and for the
|
|
7
|
+
* same reason: the reader, the pool and the background fill all need the same
|
|
8
|
+
* instance, and passing it through six layers of arguments would make the
|
|
9
|
+
* plumbing bigger than the thing.
|
|
10
|
+
*
|
|
11
|
+
* Kept in a live set as well as a weak map, because one question cannot be
|
|
12
|
+
* answered per torrent: whether ANYTHING anywhere is still missing something
|
|
13
|
+
* urgent. The link and the machine are shared between torrents, so a viewer
|
|
14
|
+
* starving on one film must stop the speculative fetching on the other. Asked
|
|
15
|
+
* per torrent, that question has the wrong answer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { DemandRegister } from "../demand/DemandRegister.js";
|
|
19
|
+
import { SwarmSelection } from "./SwarmSelection.js";
|
|
20
|
+
|
|
21
|
+
/** @type {WeakMap<object, { register: DemandRegister, selection: SwarmSelection }>} */
|
|
22
|
+
const byTorrent = new WeakMap();
|
|
23
|
+
/** @type {Set<{ register: DemandRegister, selection: SwarmSelection }>} */
|
|
24
|
+
const live = new Set();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The register and selection for a torrent, made on first use.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} torrent
|
|
30
|
+
* @returns {{ register: DemandRegister, selection: SwarmSelection }}
|
|
31
|
+
*/
|
|
32
|
+
export function demandFor(torrent) {
|
|
33
|
+
const held = byTorrent.get(torrent);
|
|
34
|
+
if (held) {
|
|
35
|
+
return held;
|
|
36
|
+
}
|
|
37
|
+
const register = new DemandRegister();
|
|
38
|
+
const entry = { register, selection: new SwarmSelection({ torrent, register }) };
|
|
39
|
+
byTorrent.set(torrent, entry);
|
|
40
|
+
live.add(entry);
|
|
41
|
+
return entry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Give up everything stated for a torrent that is going.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} torrent
|
|
48
|
+
* @returns {void}
|
|
49
|
+
*/
|
|
50
|
+
export function forgetTorrent(torrent) {
|
|
51
|
+
const held = byTorrent.get(torrent);
|
|
52
|
+
if (!held) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
held.selection.releaseAll();
|
|
56
|
+
held.register.clear();
|
|
57
|
+
byTorrent.delete(torrent);
|
|
58
|
+
live.delete(held);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Bring every torrent's download set into line with what is stated.
|
|
63
|
+
*
|
|
64
|
+
* The cross-torrent rule lives here and not in a selection, because it is not a
|
|
65
|
+
* per-torrent question: two films on one proxy share the link, so filling the
|
|
66
|
+
* tail of one while a viewer of the other has a still picture spends the same
|
|
67
|
+
* bandwidth twice over. The answer is worked out once and given to all.
|
|
68
|
+
*
|
|
69
|
+
* @returns {{ torrents: number, speculativeAllowed: boolean, stated: number, withdrawn: number }}
|
|
70
|
+
*/
|
|
71
|
+
export function reconcileAll() {
|
|
72
|
+
const entries = [...live];
|
|
73
|
+
const speculativeAllowed = !entries.some((entry) => entry.selection.hasUrgentMissing());
|
|
74
|
+
let stated = 0;
|
|
75
|
+
let withdrawn = 0;
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
const result = entry.selection.reconcile({ speculativeAllowed });
|
|
78
|
+
stated += result.stated;
|
|
79
|
+
withdrawn += result.withdrawn;
|
|
80
|
+
}
|
|
81
|
+
return { torrents: entries.length, speculativeAllowed, stated, withdrawn };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether anybody, on any torrent, is still waiting for something urgent.
|
|
86
|
+
*
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
export function anythingUrgentIsMissing() {
|
|
90
|
+
return [...live].some((entry) => entry.selection.hasUrgentMissing());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Every live register and selection, for the periodic reconcile. */
|
|
94
|
+
export function liveDemand() {
|
|
95
|
+
return [...live];
|
|
96
|
+
}
|
|
@@ -373,6 +373,8 @@ export class SharedPieceStore {
|
|
|
373
373
|
#blocksAllocated = 0;
|
|
374
374
|
/** Whether a reader has ever declared a window here. See `wantedBytes`. */
|
|
375
375
|
#everHadReader = false;
|
|
376
|
+
/** Whether the last revision had to exceed the machine's share. */
|
|
377
|
+
#beyondTheMachine = false;
|
|
376
378
|
/**
|
|
377
379
|
* How long a block sat free before it was taken again, in milliseconds.
|
|
378
380
|
* Bounded, because what is wanted is the longest gap of RECENT work: an
|
|
@@ -467,6 +469,19 @@ export class SharedPieceStore {
|
|
|
467
469
|
};
|
|
468
470
|
}
|
|
469
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Whether the machine's share of memory is smaller than one reader's window.
|
|
474
|
+
*
|
|
475
|
+
* The store holds the window anyway — refusing would leave the read it is
|
|
476
|
+
* serving unable to finish, which is worse — but it is the honest measure of
|
|
477
|
+
* "this machine cannot take any more", and it is measured rather than
|
|
478
|
+
* guessed: it is the last revision's own comparison of what the machine
|
|
479
|
+
* allowed against what the widest reader declared.
|
|
480
|
+
*/
|
|
481
|
+
get isBeyondTheMachine() {
|
|
482
|
+
return this.#beyondTheMachine;
|
|
483
|
+
}
|
|
484
|
+
|
|
470
485
|
/** What this store holds right now, in bytes. */
|
|
471
486
|
get residentBytes() {
|
|
472
487
|
return this.#buffers.size * this.#chunkLength;
|
|
@@ -527,6 +542,7 @@ export class SharedPieceStore {
|
|
|
527
542
|
const belowAWindow = demand.readers > 0
|
|
528
543
|
&& Number.isFinite(wanted)
|
|
529
544
|
&& wanted < demand.widestPieces;
|
|
545
|
+
this.#beyondTheMachine = belowAWindow;
|
|
530
546
|
// The LRU is told too. It was constructed with the store's original
|
|
531
547
|
// capacity and never revised, so `isFull()` answered against a number that
|
|
532
548
|
// had not been the limit for some time — dormant only because nothing calls
|
|
@@ -475,7 +475,7 @@ export function createPlaybackPlanner({
|
|
|
475
475
|
}
|
|
476
476
|
|
|
477
477
|
function withHostTimings(plan) {
|
|
478
|
-
|
|
478
|
+
const withOffer = {
|
|
479
479
|
...plan,
|
|
480
480
|
expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
|
|
481
481
|
expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
|
|
@@ -490,6 +490,20 @@ export function createPlaybackPlanner({
|
|
|
490
490
|
: null,
|
|
491
491
|
mediaInfoForOffer: undefined
|
|
492
492
|
};
|
|
493
|
+
// Refused rather than served badly. Both lists empty means this machine
|
|
494
|
+
// cannot sustain this file at ANY height — not even by copying the picture,
|
|
495
|
+
// which costs no encoder at all — so a session made here would produce a
|
|
496
|
+
// slideshow and take the swarm and the processor from whoever is already
|
|
497
|
+
// watching. Field 2026-08-28: five sessions on one file put every rung at
|
|
498
|
+
// 0.04x of realtime and the viewer watched one before the process was
|
|
499
|
+
// killed. The viewer is told why, which is a different thing from a spinner
|
|
500
|
+
// that never ends.
|
|
501
|
+
const offer = withOffer.offeredHeights;
|
|
502
|
+
if (offer && offer.copy.length === 0 && offer.transcode.length === 0) {
|
|
503
|
+
withOffer.cannotServe =
|
|
504
|
+
"This proxy cannot keep up with this file at any quality right now.";
|
|
505
|
+
}
|
|
506
|
+
return withOffer;
|
|
493
507
|
}
|
|
494
508
|
|
|
495
509
|
return {
|
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
|