@torrent-tv/proxy 2.70.0 → 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 +14 -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/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 +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 +191 -0
- package/utils/logger.js +62 -20
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file One stated need: bytes of one file, by one claimant, at one urgency.
|
|
3
|
+
*
|
|
4
|
+
* **Bytes, not pieces.** A piece is how the protocol verifies data and how
|
|
5
|
+
* memory is allocated; it is not the unit anything else should count in.
|
|
6
|
+
* Counting in pieces means dividing and rounding down at every boundary, and
|
|
7
|
+
* that rounding is where a real failure lives: with 16 MiB pieces a 64 MB
|
|
8
|
+
* allowance is four places, while two readers asking for 96 MB each want six —
|
|
9
|
+
* every resident piece then ends up pinned, the read returns zero bytes, and
|
|
10
|
+
* ffmpeg takes that for the end of the file. In bytes the shortage is plain;
|
|
11
|
+
* in pieces it is hidden behind a floor. The single conversion lives in
|
|
12
|
+
* `pieces.js`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A range of one file that somebody says they will need.
|
|
17
|
+
*/
|
|
18
|
+
export class Window {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} params
|
|
21
|
+
* @param {string} params.claimant - Who states it. A release names this, so a
|
|
22
|
+
* stray release matches nothing rather than cancelling somebody else's
|
|
23
|
+
* need.
|
|
24
|
+
* @param {number} params.fileIndex - Which file of the torrent.
|
|
25
|
+
* @param {number} params.byteStart - First byte, inclusive.
|
|
26
|
+
* @param {number} params.byteEnd - Last byte, inclusive.
|
|
27
|
+
* @param {number} params.urgency - A value of {@link import("./Urgency.js").Urgency}.
|
|
28
|
+
*/
|
|
29
|
+
constructor({ claimant, fileIndex, byteStart, byteEnd, urgency }) {
|
|
30
|
+
if (typeof claimant !== "string" || claimant.length === 0) {
|
|
31
|
+
throw new Error("A window needs a claimant to release it by.");
|
|
32
|
+
}
|
|
33
|
+
if (!Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
34
|
+
throw new Error(`File index must be a non-negative integer, got ${fileIndex}.`);
|
|
35
|
+
}
|
|
36
|
+
if (!Number.isInteger(byteStart) || byteStart < 0) {
|
|
37
|
+
throw new Error(`Byte start must be a non-negative integer, got ${byteStart}.`);
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isInteger(byteEnd) || byteEnd < byteStart) {
|
|
40
|
+
throw new Error(`Byte end must be an integer at or after ${byteStart}, got ${byteEnd}.`);
|
|
41
|
+
}
|
|
42
|
+
if (!Number.isInteger(urgency) || urgency < 0) {
|
|
43
|
+
throw new Error(`Urgency must be a non-negative integer, got ${urgency}.`);
|
|
44
|
+
}
|
|
45
|
+
this.claimant = claimant;
|
|
46
|
+
this.fileIndex = fileIndex;
|
|
47
|
+
this.byteStart = byteStart;
|
|
48
|
+
this.byteEnd = byteEnd;
|
|
49
|
+
this.urgency = urgency;
|
|
50
|
+
Object.freeze(this);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** How many bytes this window covers. */
|
|
54
|
+
get byteLength() {
|
|
55
|
+
return this.byteEnd - this.byteStart + 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether two windows cover any of the same bytes of the same file.
|
|
60
|
+
*
|
|
61
|
+
* @param {Window} other
|
|
62
|
+
* @returns {boolean}
|
|
63
|
+
*/
|
|
64
|
+
overlaps(other) {
|
|
65
|
+
return this.fileIndex === other.fileIndex
|
|
66
|
+
&& this.byteStart <= other.byteEnd
|
|
67
|
+
&& other.byteStart <= this.byteEnd;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether two windows are the same statement — same file, same bytes, same
|
|
72
|
+
* urgency, same claimant.
|
|
73
|
+
*
|
|
74
|
+
* @param {Window} other
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
equals(other) {
|
|
78
|
+
return other instanceof Window
|
|
79
|
+
&& this.claimant === other.claimant
|
|
80
|
+
&& this.fileIndex === other.fileIndex
|
|
81
|
+
&& this.byteStart === other.byteStart
|
|
82
|
+
&& this.byteEnd === other.byteEnd
|
|
83
|
+
&& this.urgency === other.urgency;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @returns {string} */
|
|
87
|
+
toString() {
|
|
88
|
+
return `${this.claimant} wants ${this.fileIndex}:${this.byteStart}-${this.byteEnd}`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The union of a set of ranges of ONE file, in order, with touching and
|
|
94
|
+
* overlapping ones merged.
|
|
95
|
+
*
|
|
96
|
+
* The union and not the sum: a viewer's picture and sound are read by two
|
|
97
|
+
* readers of the same file whose windows overlap by construction, and adding
|
|
98
|
+
* them would report a demand that is not there.
|
|
99
|
+
*
|
|
100
|
+
* @param {Array<{ byteStart: number, byteEnd: number }>} ranges
|
|
101
|
+
* @returns {Array<{ byteStart: number, byteEnd: number }>}
|
|
102
|
+
*/
|
|
103
|
+
export function unionOf(ranges) {
|
|
104
|
+
const sorted = [...ranges].sort((left, right) => left.byteStart - right.byteStart);
|
|
105
|
+
/** @type {Array<{ byteStart: number, byteEnd: number }>} */
|
|
106
|
+
const merged = [];
|
|
107
|
+
for (const range of sorted) {
|
|
108
|
+
const last = merged[merged.length - 1];
|
|
109
|
+
// `+ 1` because ranges are inclusive: 0-99 and 100-199 are one run of 200
|
|
110
|
+
// bytes, not two runs with nothing between them.
|
|
111
|
+
if (last && range.byteStart <= last.byteEnd + 1) {
|
|
112
|
+
last.byteEnd = Math.max(last.byteEnd, range.byteEnd);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
merged.push({ byteStart: range.byteStart, byteEnd: range.byteEnd });
|
|
116
|
+
}
|
|
117
|
+
return merged;
|
|
118
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { DemandRegister } from "./DemandRegister.js";
|
|
2
|
+
export { unionOf, Window } from "./Window.js";
|
|
3
|
+
export {
|
|
4
|
+
isConditional,
|
|
5
|
+
mayDisplaceSlowPeer,
|
|
6
|
+
selectionPriority,
|
|
7
|
+
Urgency,
|
|
8
|
+
URGENCY_ORDER,
|
|
9
|
+
urgencyName
|
|
10
|
+
} from "./Urgency.js";
|
|
11
|
+
export { bytesOf, nearestFirst, piecesNeededFor, piecesOf, piecesWithin } from "./pieces.js";
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The one place bytes become piece numbers.
|
|
3
|
+
*
|
|
4
|
+
* Everything else counts in bytes. A piece is what the protocol hashes and what
|
|
5
|
+
* memory is allocated in, and nothing outside these functions should have to
|
|
6
|
+
* know its length. Before 2026-09-02 the division was done in three places —
|
|
7
|
+
* the memory allowance, the reader's window, the eviction ceiling — each
|
|
8
|
+
* rounding down on its own, and the rounding was where a real failure lived:
|
|
9
|
+
* with 16 MiB pieces a 64 MB allowance is four places while two readers asking
|
|
10
|
+
* for 96 MB each want six, and the shortage was invisible because both figures
|
|
11
|
+
* had already been floored.
|
|
12
|
+
*
|
|
13
|
+
* Pure arithmetic on numbers: no torrent, no file object, nothing to stub.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The pieces that hold a byte range of a file.
|
|
18
|
+
*
|
|
19
|
+
* Inclusive at both ends, because a byte range that ends inside a piece still
|
|
20
|
+
* needs that whole piece: the protocol delivers and verifies nothing smaller.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} params
|
|
23
|
+
* @param {number} params.fileOffset - Where the file starts within the torrent.
|
|
24
|
+
* @param {number} params.byteStart - First byte wanted, relative to the file.
|
|
25
|
+
* @param {number} params.byteEnd - Last byte wanted, relative to the file.
|
|
26
|
+
* @param {number} params.pieceLength
|
|
27
|
+
* @returns {{ from: number, to: number } | null} Null when the range is not a
|
|
28
|
+
* range, or the piece length is not usable.
|
|
29
|
+
*/
|
|
30
|
+
export function piecesOf({ fileOffset, byteStart, byteEnd, pieceLength }) {
|
|
31
|
+
if (!Number.isFinite(pieceLength) || pieceLength <= 0) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (!Number.isFinite(fileOffset) || fileOffset < 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (!Number.isFinite(byteStart) || !Number.isFinite(byteEnd) || byteEnd < byteStart) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
from: Math.floor((fileOffset + Math.max(0, byteStart)) / pieceLength),
|
|
42
|
+
to: Math.floor((fileOffset + byteEnd) / pieceLength)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The bytes of a file that a piece range covers, clamped to the file.
|
|
48
|
+
*
|
|
49
|
+
* The inverse of {@link piecesOf}, and here for the same reason: a caller that
|
|
50
|
+
* still thinks in pieces — the reader, which walks a file piece by piece
|
|
51
|
+
* because that is what arrives — states its need in bytes like everyone else,
|
|
52
|
+
* and the conversion stays in this one file rather than being written out
|
|
53
|
+
* again at the boundary.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} params
|
|
56
|
+
* @param {number} params.fileOffset - Where the file starts within the torrent.
|
|
57
|
+
* @param {number} params.fileLength
|
|
58
|
+
* @param {number} params.from - First piece, inclusive.
|
|
59
|
+
* @param {number} params.to - Last piece, inclusive.
|
|
60
|
+
* @param {number} params.pieceLength
|
|
61
|
+
* @returns {{ byteStart: number, byteEnd: number } | null}
|
|
62
|
+
*/
|
|
63
|
+
export function bytesOf({ fileOffset, fileLength, from, to, pieceLength }) {
|
|
64
|
+
if (!Number.isFinite(pieceLength) || pieceLength <= 0) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (!Number.isFinite(fileOffset) || !Number.isFinite(fileLength) || fileLength <= 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (!Number.isInteger(from) || !Number.isInteger(to) || to < from) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const byteStart = Math.max(0, from * pieceLength - fileOffset);
|
|
74
|
+
const byteEnd = Math.min(fileLength - 1, (to + 1) * pieceLength - 1 - fileOffset);
|
|
75
|
+
return byteEnd < byteStart ? null : { byteStart, byteEnd };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How many pieces a number of bytes needs, at worst.
|
|
80
|
+
*
|
|
81
|
+
* At worst, because a range of one byte can still straddle two pieces. Used
|
|
82
|
+
* where a budget in bytes has to be turned into places, and rounding the other
|
|
83
|
+
* way would promise room that is not there.
|
|
84
|
+
*
|
|
85
|
+
* @param {number} bytes
|
|
86
|
+
* @param {number} pieceLength
|
|
87
|
+
* @returns {number}
|
|
88
|
+
*/
|
|
89
|
+
export function piecesNeededFor(bytes, pieceLength) {
|
|
90
|
+
if (!Number.isFinite(pieceLength) || pieceLength <= 0 || !Number.isFinite(bytes) || bytes <= 0) {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
return Math.ceil(bytes / pieceLength) + 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How many whole pieces fit in a number of bytes.
|
|
98
|
+
*
|
|
99
|
+
* The other direction, and it rounds DOWN: a budget buys only the places it can
|
|
100
|
+
* pay for in full.
|
|
101
|
+
*
|
|
102
|
+
* @param {number} bytes
|
|
103
|
+
* @param {number} pieceLength
|
|
104
|
+
* @returns {number}
|
|
105
|
+
*/
|
|
106
|
+
export function piecesWithin(bytes, pieceLength) {
|
|
107
|
+
if (!Number.isFinite(pieceLength) || pieceLength <= 0 || !Number.isFinite(bytes) || bytes <= 0) {
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
return Math.floor(bytes / pieceLength);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Split a byte range into a few ranges, nearest to a point first.
|
|
115
|
+
*
|
|
116
|
+
* For the gap behind the playhead. WebTorrent walks a selection from its start
|
|
117
|
+
* upwards — `for (piece = next.from + next.offset; piece <= next.to; piece++)` —
|
|
118
|
+
* so one claim over everything behind the viewer would be fetched from the
|
|
119
|
+
* beginning of the file, which is the end furthest from where a backward seek
|
|
120
|
+
* would land. Split, and the part nearest the playhead is stated first.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} params
|
|
123
|
+
* @param {number} params.byteStart - First byte of the gap.
|
|
124
|
+
* @param {number} params.byteEnd - Last byte of the gap, nearest the playhead.
|
|
125
|
+
* @param {number} params.parts - How many ranges to split into.
|
|
126
|
+
* @returns {Array<{ byteStart: number, byteEnd: number }>} Nearest first.
|
|
127
|
+
*/
|
|
128
|
+
export function nearestFirst({ byteStart, byteEnd, parts }) {
|
|
129
|
+
if (!Number.isFinite(byteStart) || !Number.isFinite(byteEnd) || byteEnd < byteStart) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const count = Number.isInteger(parts) && parts > 0 ? parts : 1;
|
|
133
|
+
const total = byteEnd - byteStart + 1;
|
|
134
|
+
const each = Math.ceil(total / count);
|
|
135
|
+
const ranges = [];
|
|
136
|
+
for (let end = byteEnd; end >= byteStart; end -= each) {
|
|
137
|
+
ranges.push({ byteStart: Math.max(byteStart, end - each + 1), byteEnd: end });
|
|
138
|
+
}
|
|
139
|
+
return ranges;
|
|
140
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The only thing in this proxy that tells WebTorrent what to fetch.
|
|
3
|
+
*
|
|
4
|
+
* One per torrent. It reads the demand register — the single statement of what
|
|
5
|
+
* anybody wants — and turns it into the library's `select`, `deselect` and
|
|
6
|
+
* `critical`. Nothing else calls those, so two parts of this program can no
|
|
7
|
+
* longer ask for different things and overwrite each other.
|
|
8
|
+
*
|
|
9
|
+
* That used to happen and it is written into the code this replaces. The reader
|
|
10
|
+
* held a moving window; the pool held a whole-file selection; a third place set
|
|
11
|
+
* a window around the read head. A whole-file read undid a seek that had just
|
|
12
|
+
* happened, and the swarm walked forward from the first hole: measured on a
|
|
13
|
+
* 4.7 GB film, a seek to 89.1 % fetched 2.47 GB over 93 s before the segment
|
|
14
|
+
* could be served.
|
|
15
|
+
*
|
|
16
|
+
* **Why urgency is not a number given to the library.** Measured against the
|
|
17
|
+
* vendored 2.8.5: selections are sorted by priority only when one is inserted,
|
|
18
|
+
* and `shufflePriority` then moves the selection just served to the back of the
|
|
19
|
+
* whole non-zero group. Distinct numbers therefore order the list once and
|
|
20
|
+
* round-robin it afterwards. So the ordering is kept HERE, by choosing what to
|
|
21
|
+
* state at all, and the library is given only the distinction it honours:
|
|
22
|
+
* non-zero for what is wanted now, zero for the speculative tail.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
isConditional,
|
|
27
|
+
piecesOf,
|
|
28
|
+
selectionPriority,
|
|
29
|
+
Urgency,
|
|
30
|
+
urgencyName
|
|
31
|
+
} from "../demand/index.js";
|
|
32
|
+
|
|
33
|
+
export class SwarmSelection {
|
|
34
|
+
#torrent;
|
|
35
|
+
#register;
|
|
36
|
+
/** What was last stated to the library, so a restatement can be a no-op. */
|
|
37
|
+
#stated = new Map();
|
|
38
|
+
/** Pieces this instance marked for displacement, so it clears only its own. */
|
|
39
|
+
#displacing = null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} params
|
|
43
|
+
* @param {import("webtorrent").Torrent} params.torrent
|
|
44
|
+
* @param {import("../demand/index.js").DemandRegister} params.register
|
|
45
|
+
*/
|
|
46
|
+
constructor({ torrent, register }) {
|
|
47
|
+
this.#torrent = torrent;
|
|
48
|
+
this.#register = register;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bring the library's download set into line with what is stated.
|
|
53
|
+
*
|
|
54
|
+
* Called after any change to the register and on a timer. On a timer because
|
|
55
|
+
* WebTorrent DELETES a selection once every piece in it has arrived, so a
|
|
56
|
+
* window that is satisfied and then reopened — the reader moved on, or a
|
|
57
|
+
* piece was evicted and lost — is gone from the library while it is still
|
|
58
|
+
* stated here.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} [options]
|
|
61
|
+
* @param {boolean} [options.speculativeAllowed] - Whether anything on ANY
|
|
62
|
+
* torrent is still waiting for something urgent. The registry works it out
|
|
63
|
+
* once and hands the same answer to every selection, because the link is
|
|
64
|
+
* shared and the question is not a per-torrent one.
|
|
65
|
+
* @returns {{ stated: number, withdrawn: number }}
|
|
66
|
+
*/
|
|
67
|
+
reconcile({ speculativeAllowed = true } = {}) {
|
|
68
|
+
const levels = this.#register.levelsToState(
|
|
69
|
+
(window) => this.#isSatisfied(window),
|
|
70
|
+
speculativeAllowed
|
|
71
|
+
);
|
|
72
|
+
/** @type {Map<string, { from: number, to: number, priority: number }>} */
|
|
73
|
+
const wanted = new Map();
|
|
74
|
+
for (const urgency of levels) {
|
|
75
|
+
for (const window of this.#register.at(urgency)) {
|
|
76
|
+
const range = this.#piecesFor(window);
|
|
77
|
+
if (!range) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Merged by range and priority, not by claimant: two readers wanting
|
|
81
|
+
// the same pieces are one instruction to the swarm.
|
|
82
|
+
const key = `${range.from}-${range.to}-${selectionPriority(urgency)}`;
|
|
83
|
+
wanted.set(key, { from: range.from, to: range.to, priority: selectionPriority(urgency) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let withdrawn = 0;
|
|
88
|
+
for (const [key, range] of [...this.#stated]) {
|
|
89
|
+
if (wanted.has(key)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
this.#deselect(range);
|
|
93
|
+
this.#stated.delete(key);
|
|
94
|
+
withdrawn += 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let stated = 0;
|
|
98
|
+
for (const [key, range] of wanted) {
|
|
99
|
+
// Re-stated when the library has dropped it, even though this instance
|
|
100
|
+
// believes it is stated: that is the whole reason this runs on a timer.
|
|
101
|
+
if (this.#stated.has(key) && this.#libraryHolds(range)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
this.#select(range);
|
|
105
|
+
this.#stated.set(key, range);
|
|
106
|
+
stated += 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.#markDisplacement();
|
|
110
|
+
return { stated, withdrawn };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Take everything back. The torrent is going, or nobody wants anything. */
|
|
114
|
+
releaseAll() {
|
|
115
|
+
for (const range of this.#stated.values()) {
|
|
116
|
+
this.#deselect(range);
|
|
117
|
+
}
|
|
118
|
+
this.#stated.clear();
|
|
119
|
+
this.#clearDisplacement();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What is stated right now.
|
|
124
|
+
*
|
|
125
|
+
* @returns {Array<{ from: number, to: number, priority: number }>}
|
|
126
|
+
*/
|
|
127
|
+
statedRanges() {
|
|
128
|
+
return [...this.#stated.values()];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Whether anything urgent on THIS torrent has not arrived.
|
|
133
|
+
*
|
|
134
|
+
* Read by the registry, which asks every torrent and gives the same answer
|
|
135
|
+
* back to all of them.
|
|
136
|
+
*
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
hasUrgentMissing() {
|
|
140
|
+
for (const urgency of [Urgency.BLOCKED, Urgency.NEAR, Urgency.AHEAD]) {
|
|
141
|
+
for (const window of this.#register.at(urgency)) {
|
|
142
|
+
if (!this.#isSatisfied(window)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Permission to take a block from a slow peer, for the level being waited on
|
|
152
|
+
* and nothing else.
|
|
153
|
+
*
|
|
154
|
+
* Cleared before it is set again, because WebTorrent never clears the flag
|
|
155
|
+
* itself: a reader walking a film would otherwise leave every piece of it
|
|
156
|
+
* marked, and the mark would mean nothing anywhere.
|
|
157
|
+
*
|
|
158
|
+
* @returns {void}
|
|
159
|
+
*/
|
|
160
|
+
#markDisplacement() {
|
|
161
|
+
const blocked = this.#register
|
|
162
|
+
.at(Urgency.BLOCKED)
|
|
163
|
+
.map((window) => this.#piecesFor(window))
|
|
164
|
+
.filter((range) => range !== null);
|
|
165
|
+
if (blocked.length === 0) {
|
|
166
|
+
this.#clearDisplacement();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const from = Math.min(...blocked.map((range) => range.from));
|
|
170
|
+
const to = Math.max(...blocked.map((range) => range.to));
|
|
171
|
+
if (this.#displacing && this.#displacing.from === from && this.#displacing.to === to) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
this.#clearDisplacement();
|
|
175
|
+
try {
|
|
176
|
+
this.#torrent.critical?.(from, to);
|
|
177
|
+
this.#displacing = { from, to };
|
|
178
|
+
} catch {
|
|
179
|
+
// silent-ok: displacement is an optimisation, and a torrent being torn
|
|
180
|
+
// down is not worth failing a read over.
|
|
181
|
+
this.#displacing = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @returns {void} */
|
|
186
|
+
#clearDisplacement() {
|
|
187
|
+
if (!this.#displacing || !Array.isArray(this.#torrent._critical)) {
|
|
188
|
+
this.#displacing = null;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
for (let index = this.#displacing.from; index <= this.#displacing.to; index += 1) {
|
|
192
|
+
this.#torrent._critical[index] = false;
|
|
193
|
+
}
|
|
194
|
+
this.#displacing = null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The pieces a window covers, or null when the file or the torrent cannot
|
|
199
|
+
* answer yet.
|
|
200
|
+
*
|
|
201
|
+
* @param {import("../demand/index.js").Window} window
|
|
202
|
+
* @returns {{ from: number, to: number } | null}
|
|
203
|
+
*/
|
|
204
|
+
#piecesFor(window) {
|
|
205
|
+
const file = this.#torrent?.files?.[window.fileIndex];
|
|
206
|
+
if (!file) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
return piecesOf({
|
|
210
|
+
fileOffset: Number(file.offset),
|
|
211
|
+
byteStart: window.byteStart,
|
|
212
|
+
byteEnd: Math.min(window.byteEnd, Number(file.length) - 1),
|
|
213
|
+
pieceLength: Number(this.#torrent.pieceLength)
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether everything a window asked for has arrived.
|
|
219
|
+
*
|
|
220
|
+
* @param {import("../demand/index.js").Window} window
|
|
221
|
+
* @returns {boolean}
|
|
222
|
+
*/
|
|
223
|
+
#isSatisfied(window) {
|
|
224
|
+
const range = this.#piecesFor(window);
|
|
225
|
+
if (!range) {
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
for (let index = range.from; index <= range.to; index += 1) {
|
|
229
|
+
if (!this.#torrent.bitfield?.get(index)) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Whether the library still holds this instruction.
|
|
238
|
+
*
|
|
239
|
+
* Read from its own list, because it removes a selection once satisfied and
|
|
240
|
+
* says nothing about having done so.
|
|
241
|
+
*
|
|
242
|
+
* @param {{ from: number, to: number }} range
|
|
243
|
+
* @returns {boolean}
|
|
244
|
+
*/
|
|
245
|
+
#libraryHolds({ from, to }) {
|
|
246
|
+
const items = Array.isArray(this.#torrent?._selections?._items)
|
|
247
|
+
? this.#torrent._selections._items
|
|
248
|
+
: [];
|
|
249
|
+
return items.some((item) => item?.from === from && item?.to === to);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @param {{ from: number, to: number, priority: number }} range
|
|
254
|
+
* @returns {void}
|
|
255
|
+
*/
|
|
256
|
+
#select({ from, to, priority }) {
|
|
257
|
+
try {
|
|
258
|
+
// The private form takes the stream flag, which makes a selection several
|
|
259
|
+
// claimants can hold at the same bounds; the public one merges and
|
|
260
|
+
// subtracts intervals and cannot express "one of several wants this". The
|
|
261
|
+
// public call is the fallback if a future version drops the private one.
|
|
262
|
+
if (typeof this.#torrent._select === "function") {
|
|
263
|
+
this.#torrent._select(from, to, priority, null, true);
|
|
264
|
+
} else if (typeof this.#torrent.select === "function") {
|
|
265
|
+
this.#torrent.select(from, to, priority);
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// silent-ok: never fail a read because the download set refused.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {{ from: number, to: number }} range
|
|
274
|
+
* @returns {void}
|
|
275
|
+
*/
|
|
276
|
+
#deselect({ from, to }) {
|
|
277
|
+
try {
|
|
278
|
+
if (typeof this.#torrent._deselect === "function") {
|
|
279
|
+
this.#torrent._deselect(from, to, true);
|
|
280
|
+
} else if (typeof this.#torrent.deselect === "function") {
|
|
281
|
+
this.#torrent.deselect(from, to);
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
// silent-ok.
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* One line saying what the swarm has been told and why.
|
|
290
|
+
*
|
|
291
|
+
* @returns {string}
|
|
292
|
+
*/
|
|
293
|
+
describe(speculativeAllowed = true) {
|
|
294
|
+
const stating = this.#register.levelsToState(
|
|
295
|
+
(window) => this.#isSatisfied(window),
|
|
296
|
+
speculativeAllowed
|
|
297
|
+
);
|
|
298
|
+
const speculative = stating.filter((urgency) => isConditional(urgency)).map(urgencyName);
|
|
299
|
+
const needs = this.#register
|
|
300
|
+
.windows()
|
|
301
|
+
.map((window) => `${urgencyName(window.urgency)}:${window.claimant}`);
|
|
302
|
+
return (
|
|
303
|
+
`download: ${this.#stated.size} instruction(s) to the swarm from ` +
|
|
304
|
+
`${this.#register.size} stated need(s) [${needs.join(" ")}]` +
|
|
305
|
+
(speculative.length > 0
|
|
306
|
+
? `; ${speculative.join(" and ")} also stated — nothing urgent is missing`
|
|
307
|
+
: "; nothing speculative is stated — something urgent is still missing") +
|
|
308
|
+
(this.#displacing
|
|
309
|
+
? `; pieces ${this.#displacing.from}-${this.#displacing.to} may be taken from slow peers`
|
|
310
|
+
: "")
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -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
|
+
}
|