@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.
@@ -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,8 @@
1
+ export { SwarmSelection } from "./SwarmSelection.js";
2
+ export {
3
+ anythingUrgentIsMissing,
4
+ demandFor,
5
+ forgetTorrent,
6
+ liveDemand,
7
+ reconcileAll
8
+ } from "./registry.js";
@@ -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
+ }