@torrent-tv/proxy 2.87.11 → 2.87.13

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.
@@ -1,365 +1,365 @@
1
- /**
2
- * @file `TorrentPool`'s interface, served from the worker thread.
3
- *
4
- * The routes, the planner, the health report and the session manager all reach
5
- * for a torrent pool and use it the same handful of ways. Rather than rewrite
6
- * every one of them to thread a `sourceKey` through and await what used to be
7
- * immediate, this presents the shape they already expect and does the thread
8
- * hop behind it. Swapping the implementation is then a one-line change at
9
- * construction, and the call sites are untouched — which is what keeps a change
10
- * of this size reviewable.
11
- *
12
- * Two accommodations are needed, and both are deliberate:
13
- *
14
- * - **`prioritizeByteRange` stays synchronous.** It returns nothing the caller
15
- * inspects, so the command is dispatched and not awaited. Awaiting it would
16
- * mean touching every call site for no observable gain.
17
- * - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
18
- * thread, so the worker keys them. Callers that have one pass it; the rest
19
- * get one derived from the source itself, so the identity stays stable
20
- * across calls for the same torrent.
21
- */
22
-
23
- import { TorrentWorkerClient } from "./client.js";
24
- import { deriveSourceKey } from "../../../utils/torrent-source-key.js";
25
-
26
- /**
27
- * A torrent pool whose work happens on another thread.
28
- *
29
- * See `protocol.js` for why: the torrent was taking ~85% of the main thread and
30
- * everything owed to a viewer queued behind it.
31
- */
32
- export class WorkerTorrentPool {
33
- #client;
34
- /** Stand-ins by source key, so repeat calls return the same object. */
35
- #torrents = new Map();
36
-
37
- /**
38
- * @param {{ memoryBytes?: number, stateDir?: string }} [options]
39
- */
40
- constructor(options = {}) {
41
- this.#client = new TorrentWorkerClient(options);
42
- }
43
-
44
- /**
45
- * Load (or join) a torrent and return a stand-in for it.
46
- *
47
- * @param {"magnet" | "torrent"} sourceType
48
- * @param {string} source
49
- * @returns {Promise<object>}
50
- */
51
- async getTorrent(sourceType, source) {
52
- const sourceKey = await deriveSourceKey(sourceType, source);
53
- const existing = this.#torrents.get(sourceKey);
54
- if (existing) {
55
- return existing;
56
- }
57
- const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
58
- this.#torrents.set(sourceKey, torrent);
59
- return torrent;
60
- }
61
-
62
- /**
63
- * Claim a file for reading; the returned function releases it.
64
- *
65
- * Synchronous by design — see the file header.
66
- *
67
- * @param {object} torrent - A stand-in from {@link getTorrent}.
68
- * @param {number} fileIndex
69
- * @returns {() => void}
70
- */
71
- /** What the spilled pieces weigh on the torrent thread, as last revised. */
72
- get spilledBytes() {
73
- return this.#client.spilledBytes;
74
- }
75
-
76
- /**
77
- * Say how much disk those spilled pieces may take between them.
78
- *
79
- * @param {number} bytes
80
- * @returns {Promise<number>} What they hold now.
81
- */
82
- allowSpillBytes(bytes) {
83
- return this.#client.allowSpillBytes(bytes);
84
- }
85
-
86
- get memoryClaim() {
87
- return this.#client.memoryClaim ?? { held: 0, wanted: 0 };
88
- }
89
-
90
- allowMemoryBytes(bytes) {
91
- return this.#client.allowMemoryBytes(bytes);
92
- }
93
-
94
- get wholeFileBytes() {
95
- return this.#client.wholeFileBytes ?? 0;
96
- }
97
-
98
- allowWholeFileBytes(bytes) {
99
- return this.#client.allowWholeFileBytes(bytes);
100
- }
101
-
102
- /**
103
- * Bytes every torrent here has moved.
104
- *
105
- * @returns {Promise<{ downloaded: number, uploaded: number }>}
106
- */
107
- async getTorrentTotals() {
108
- return this.#client.getTorrentTotals();
109
- }
110
-
111
- /**
112
- * Live download figures for the progress display.
113
- *
114
- * @param {object} torrent
115
- * @param {number | null} [fileIndex]
116
- * @param {{ resumeAnchorByteStart?: number | null }} [options]
117
- * @returns {Promise<object | null>}
118
- */
119
- async getFileStats(torrent, fileIndex = null, options = {}) {
120
- const sourceKey = torrent?.sourceKey;
121
- if (!sourceKey) {
122
- return null;
123
- }
124
- return this.#client.getFileStats({
125
- sourceKey,
126
- fileIndex,
127
- resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
128
- });
129
- }
130
-
131
- /**
132
- * Fetch one whole file using only the room the viewer's own reading leaves.
133
- *
134
- * For a soundtrack or subtitle file shipped beside the picture: small next to
135
- * the film, and having it on disk is what turns a later switch into a local
136
- * read instead of a wait on the swarm.
137
- *
138
- * @param {object} torrent
139
- * @param {number} fileIndex
140
- * @returns {Promise<boolean>} Whether a fill was started by this call.
141
- */
142
- async fillFileInBackground(torrent, fileIndex) {
143
- const sourceKey = torrent?.sourceKey;
144
- if (!sourceKey) {
145
- return false;
146
- }
147
- const answer = await this.#client.fillFile({ sourceKey, fileIndex });
148
- return answer?.started === true;
149
- }
150
-
151
- /**
152
- * Ask the torrent to fetch all remaining files at conditional TAIL urgency.
153
- *
154
- * @param {object} torrent
155
- * @returns {Promise<boolean>}
156
- */
157
- async fillTorrent(torrent) {
158
- const sourceKey = torrent?.sourceKey;
159
- if (!sourceKey) {
160
- return false;
161
- }
162
- const answer = await this.#client.fillTorrent(sourceKey);
163
- return answer?.started === true;
164
- }
165
-
166
-
167
-
168
-
169
- /**
170
- * Start fetching the region a viewer is about to resume at. Named in seconds
171
- * here; the worker turns it into bytes, where the file's duration is readable.
172
- *
173
- * @param {object} torrent
174
- * @param {number} fileIndex
175
- * @param {number} positionSeconds
176
- * @returns {Promise<boolean>}
177
- */
178
- async warmResumePosition(torrent, fileIndex, positionSeconds, durationSeconds) {
179
- const sourceKey = torrent?.sourceKey;
180
- if (!sourceKey) {
181
- return false;
182
- }
183
- const answer = await this.#client.warmResumePosition({ sourceKey, fileIndex, positionSeconds, durationSeconds });
184
- return answer?.started === true;
185
- }
186
-
187
-
188
-
189
-
190
-
191
-
192
- /**
193
- * Reorder piece selection around a read position.
194
- *
195
- * Synchronous by design — see the file header.
196
- *
197
- * @param {object} torrent
198
- * @param {number} fileIndex
199
- * @param {number} byteStart
200
- * @param {number} [windowBytes]
201
- * @param {{ wholeFileRead?: boolean }} [options]
202
- * @returns {void}
203
- */
204
- prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes, options) {
205
- const sourceKey = torrent?.sourceKey;
206
- if (!sourceKey) {
207
- return;
208
- }
209
- void this.#client
210
- .prioritizeByteRange({
211
- sourceKey,
212
- fileIndex,
213
- byteStart,
214
- windowBytes,
215
- wholeFileRead: options?.wholeFileRead === true
216
- })
217
- .catch(() => undefined);
218
- }
219
-
220
- /**
221
- * Hand the download the priority map for one file.
222
- *
223
- * The map is republished whenever it changes, so a call that fails costs a
224
- * moment rather than correctness — which is why the caller lets it go rather
225
- * than retrying.
226
- *
227
- * @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: object[] }} params
228
- * @returns {Promise<void>}
229
- */
230
- async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones }) {
231
- if (!sourceKey) {
232
- return;
233
- }
234
- await this.#client.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
235
- }
236
-
237
- /**
238
- * Pre-fetch the head and tail the codec probe needs.
239
- *
240
- * Takes an options object, matching `TorrentPool.prefetchFileEdges` — this
241
- * adapter exists to present that same interface. It previously declared
242
- * positional parameters instead, so the planner's options object arrived as
243
- * `headBytes` and only worked because it was passed along far enough to be
244
- * destructured at the far end. Anyone calling it as documented got the
245
- * defaults instead of the sizes they asked for.
246
- *
247
- * @param {object} torrent
248
- * @param {number} fileIndex
249
- * @param {{ headBytes?: number, tailBytes?: number, timeoutMs?: number }} [options]
250
- * @returns {Promise<unknown>}
251
- */
252
- /**
253
- * One byte range of one file, as bytes, on THIS thread.
254
- *
255
- * What the media layer is built from: a container takes `readRange(start,
256
- * end)` and nothing else, so with this it can be parsed here instead of in
257
- * the torrent thread. It used to be parsed there for one stated reason —
258
- * "the main thread cannot open a read stream on one of its files", which is
259
- * true of WebTorrent's own API and not of the bytes: the pieces live in
260
- * shared memory and this read is the same one that serves every segment,
261
- * so it waits for what has not arrived and steers the swarm toward it.
262
- *
263
- * **It COPIES, deliberately.** The zero-copy path — positions across the
264
- * channel, the piece pinned until the far side says it is done — exists for
265
- * 10 MB segments on the critical path, where the copy was measured at
266
- * 18.84 ms. A container header is 64-256 KB and read once per file, about
267
- * 0.12 ms, against a parse of 0.8 s and a swarm wait of up to a minute. The
268
- * pinning protocol would buy nothing and is not on this path.
269
- *
270
- * @param {object} torrent
271
- * @param {number} fileIndex
272
- * @param {number} start - First byte, inclusive.
273
- * @param {number} end - Last byte, inclusive.
274
- * @returns {Promise<Buffer | null>} Null when the read failed or was cut
275
- * short, which a container reads as "this file does not say".
276
- */
277
- /**
278
- * The byte ranges of one file the torrent holds WHOLE.
279
- *
280
- * What the subtitle walk decides from: it may read only what is already
281
- * downloaded, and a list taken once per pass replaces a question per cluster.
282
- *
283
- * @param {object} torrent
284
- * @param {number} fileIndex
285
- * @returns {Promise<Array<[number, number]>>}
286
- */
287
- async heldRangesOf(torrent, fileIndex) {
288
- const sourceKey = torrent?.sourceKey;
289
- return sourceKey ? this.#client.heldRanges({ sourceKey, fileIndex }) : [];
290
- }
291
-
292
- /**
293
- * Bytes of a range the torrent already holds, never fetched.
294
- *
295
- * Not `readRangeOf`: that one declares demand and steers the swarm, which is
296
- * right for a viewer waiting on a segment and wrong for a walk that must pull
297
- * nothing the viewer is not waiting for.
298
- *
299
- * @param {object} torrent
300
- * @param {number} fileIndex
301
- * @param {number} start
302
- * @param {number} end - Inclusive.
303
- * @returns {Promise<Buffer | null>}
304
- */
305
- async readHeldOf(torrent, fileIndex, start, end) {
306
- const sourceKey = torrent?.sourceKey;
307
- return sourceKey ? this.#client.readHeld({ sourceKey, fileIndex, start, end }) : null;
308
- }
309
-
310
- async readRangeOf(torrent, fileIndex, start, end) {
311
- const sourceKey = torrent?.sourceKey;
312
- if (!sourceKey || !(end >= start) || !(start >= 0)) {
313
- return null;
314
- }
315
- const stream = this.#client.createReadStream({ sourceKey, fileIndex, start, end });
316
- const chunks = [];
317
- let total = 0;
318
- try {
319
- const reader = stream.getReader();
320
- for (;;) {
321
- const { done, value } = await reader.read();
322
- if (done) {
323
- break;
324
- }
325
- chunks.push(Buffer.from(value));
326
- total += value.length;
327
- }
328
- } catch {
329
- return null;
330
- }
331
- return total > 0 ? Buffer.concat(chunks, total) : null;
332
- }
333
-
334
- async prefetchFileEdges(torrent, fileIndex, options = {}) {
335
- const sourceKey = torrent?.sourceKey;
336
- if (!sourceKey) {
337
- return null;
338
- }
339
- return this.#client.prefetchFileEdges({ sourceKey, fileIndex, options });
340
- }
341
-
342
- /**
343
- * Which films this proxy holds right now, and how much of each.
344
- *
345
- * What content affinity is decided from: a viewer of a film somebody here is
346
- * already downloading costs this proxy the encode and nothing else, while the
347
- * same viewer sent anywhere else starts the download from nothing.
348
- *
349
- * @returns {Promise<{ infoHash: string, progress: number, bytes: number }[]>}
350
- */
351
- async heldTorrents() {
352
- const answer = await this.#client.heldTorrents();
353
- return Array.isArray(answer?.held) ? answer.held : [];
354
- }
355
-
356
- /**
357
- * Shut the torrent client down and stop the thread.
358
- *
359
- * @returns {Promise<void>}
360
- */
361
- async destroyAll() {
362
- this.#torrents.clear();
363
- await this.#client.destroyAll();
364
- }
365
- }
1
+ /**
2
+ * @file `TorrentPool`'s interface, served from the worker thread.
3
+ *
4
+ * The routes, the planner, the health report and the session manager all reach
5
+ * for a torrent pool and use it the same handful of ways. Rather than rewrite
6
+ * every one of them to thread a `sourceKey` through and await what used to be
7
+ * immediate, this presents the shape they already expect and does the thread
8
+ * hop behind it. Swapping the implementation is then a one-line change at
9
+ * construction, and the call sites are untouched — which is what keeps a change
10
+ * of this size reviewable.
11
+ *
12
+ * Two accommodations are needed, and both are deliberate:
13
+ *
14
+ * - **`prioritizeByteRange` stays synchronous.** It returns nothing the caller
15
+ * inspects, so the command is dispatched and not awaited. Awaiting it would
16
+ * mean touching every call site for no observable gain.
17
+ * - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
18
+ * thread, so the worker keys them. Callers that have one pass it; the rest
19
+ * get one derived from the source itself, so the identity stays stable
20
+ * across calls for the same torrent.
21
+ */
22
+
23
+ import { TorrentWorkerClient } from "./client.js";
24
+ import { deriveSourceKey } from "../../../utils/torrent-source-key.js";
25
+
26
+ /**
27
+ * A torrent pool whose work happens on another thread.
28
+ *
29
+ * See `protocol.js` for why: the torrent was taking ~85% of the main thread and
30
+ * everything owed to a viewer queued behind it.
31
+ */
32
+ export class WorkerTorrentPool {
33
+ #client;
34
+ /** Stand-ins by source key, so repeat calls return the same object. */
35
+ #torrents = new Map();
36
+
37
+ /**
38
+ * @param {{ memoryBytes?: number, stateDir?: string }} [options]
39
+ */
40
+ constructor(options = {}) {
41
+ this.#client = new TorrentWorkerClient(options);
42
+ }
43
+
44
+ /**
45
+ * Load (or join) a torrent and return a stand-in for it.
46
+ *
47
+ * @param {"magnet" | "torrent"} sourceType
48
+ * @param {string} source
49
+ * @returns {Promise<object>}
50
+ */
51
+ async getTorrent(sourceType, source) {
52
+ const sourceKey = await deriveSourceKey(sourceType, source);
53
+ const existing = this.#torrents.get(sourceKey);
54
+ if (existing) {
55
+ return existing;
56
+ }
57
+ const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
58
+ this.#torrents.set(sourceKey, torrent);
59
+ return torrent;
60
+ }
61
+
62
+ /**
63
+ * Claim a file for reading; the returned function releases it.
64
+ *
65
+ * Synchronous by design — see the file header.
66
+ *
67
+ * @param {object} torrent - A stand-in from {@link getTorrent}.
68
+ * @param {number} fileIndex
69
+ * @returns {() => void}
70
+ */
71
+ /** What the spilled pieces weigh on the torrent thread, as last revised. */
72
+ get spilledBytes() {
73
+ return this.#client.spilledBytes;
74
+ }
75
+
76
+ /**
77
+ * Say how much disk those spilled pieces may take between them.
78
+ *
79
+ * @param {number} bytes
80
+ * @returns {Promise<number>} What they hold now.
81
+ */
82
+ allowSpillBytes(bytes) {
83
+ return this.#client.allowSpillBytes(bytes);
84
+ }
85
+
86
+ get memoryClaim() {
87
+ return this.#client.memoryClaim ?? { held: 0, wanted: 0 };
88
+ }
89
+
90
+ allowMemoryBytes(bytes) {
91
+ return this.#client.allowMemoryBytes(bytes);
92
+ }
93
+
94
+ get wholeFileBytes() {
95
+ return this.#client.wholeFileBytes ?? 0;
96
+ }
97
+
98
+ allowWholeFileBytes(bytes) {
99
+ return this.#client.allowWholeFileBytes(bytes);
100
+ }
101
+
102
+ /**
103
+ * Bytes every torrent here has moved.
104
+ *
105
+ * @returns {Promise<{ downloaded: number, uploaded: number }>}
106
+ */
107
+ async getTorrentTotals() {
108
+ return this.#client.getTorrentTotals();
109
+ }
110
+
111
+ /**
112
+ * Live download figures for the progress display.
113
+ *
114
+ * @param {object} torrent
115
+ * @param {number | null} [fileIndex]
116
+ * @param {{ resumeAnchorByteStart?: number | null }} [options]
117
+ * @returns {Promise<object | null>}
118
+ */
119
+ async getFileStats(torrent, fileIndex = null, options = {}) {
120
+ const sourceKey = torrent?.sourceKey;
121
+ if (!sourceKey) {
122
+ return null;
123
+ }
124
+ return this.#client.getFileStats({
125
+ sourceKey,
126
+ fileIndex,
127
+ resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Fetch one whole file using only the room the viewer's own reading leaves.
133
+ *
134
+ * For a soundtrack or subtitle file shipped beside the picture: small next to
135
+ * the film, and having it on disk is what turns a later switch into a local
136
+ * read instead of a wait on the swarm.
137
+ *
138
+ * @param {object} torrent
139
+ * @param {number} fileIndex
140
+ * @returns {Promise<boolean>} Whether a fill was started by this call.
141
+ */
142
+ async fillFileInBackground(torrent, fileIndex) {
143
+ const sourceKey = torrent?.sourceKey;
144
+ if (!sourceKey) {
145
+ return false;
146
+ }
147
+ const answer = await this.#client.fillFile({ sourceKey, fileIndex });
148
+ return answer?.started === true;
149
+ }
150
+
151
+ /**
152
+ * Ask the torrent to fetch all remaining files at conditional TAIL urgency.
153
+ *
154
+ * @param {object} torrent
155
+ * @returns {Promise<boolean>}
156
+ */
157
+ async fillTorrent(torrent) {
158
+ const sourceKey = torrent?.sourceKey;
159
+ if (!sourceKey) {
160
+ return false;
161
+ }
162
+ const answer = await this.#client.fillTorrent(sourceKey);
163
+ return answer?.started === true;
164
+ }
165
+
166
+
167
+
168
+
169
+ /**
170
+ * Start fetching the region a viewer is about to resume at. Named in seconds
171
+ * here; the worker turns it into bytes, where the file's duration is readable.
172
+ *
173
+ * @param {object} torrent
174
+ * @param {number} fileIndex
175
+ * @param {number} positionSeconds
176
+ * @returns {Promise<boolean>}
177
+ */
178
+ async warmResumePosition(torrent, fileIndex, positionSeconds, durationSeconds) {
179
+ const sourceKey = torrent?.sourceKey;
180
+ if (!sourceKey) {
181
+ return false;
182
+ }
183
+ const answer = await this.#client.warmResumePosition({ sourceKey, fileIndex, positionSeconds, durationSeconds });
184
+ return answer?.started === true;
185
+ }
186
+
187
+
188
+
189
+
190
+
191
+
192
+ /**
193
+ * Reorder piece selection around a read position.
194
+ *
195
+ * Synchronous by design — see the file header.
196
+ *
197
+ * @param {object} torrent
198
+ * @param {number} fileIndex
199
+ * @param {number} byteStart
200
+ * @param {number} [windowBytes]
201
+ * @param {{ wholeFileRead?: boolean }} [options]
202
+ * @returns {void}
203
+ */
204
+ prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes, options) {
205
+ const sourceKey = torrent?.sourceKey;
206
+ if (!sourceKey) {
207
+ return;
208
+ }
209
+ void this.#client
210
+ .prioritizeByteRange({
211
+ sourceKey,
212
+ fileIndex,
213
+ byteStart,
214
+ windowBytes,
215
+ wholeFileRead: options?.wholeFileRead === true
216
+ })
217
+ .catch(() => undefined);
218
+ }
219
+
220
+ /**
221
+ * Hand the download the priority map for one file.
222
+ *
223
+ * The map is republished whenever it changes, so a call that fails costs a
224
+ * moment rather than correctness — which is why the caller lets it go rather
225
+ * than retrying.
226
+ *
227
+ * @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: object[] }} params
228
+ * @returns {Promise<void>}
229
+ */
230
+ async setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones }) {
231
+ if (!sourceKey) {
232
+ return;
233
+ }
234
+ await this.#client.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
235
+ }
236
+
237
+ /**
238
+ * Pre-fetch the head and tail the codec probe needs.
239
+ *
240
+ * Takes an options object, matching `TorrentPool.prefetchFileEdges` — this
241
+ * adapter exists to present that same interface. It previously declared
242
+ * positional parameters instead, so the planner's options object arrived as
243
+ * `headBytes` and only worked because it was passed along far enough to be
244
+ * destructured at the far end. Anyone calling it as documented got the
245
+ * defaults instead of the sizes they asked for.
246
+ *
247
+ * @param {object} torrent
248
+ * @param {number} fileIndex
249
+ * @param {{ headBytes?: number, tailBytes?: number, timeoutMs?: number }} [options]
250
+ * @returns {Promise<unknown>}
251
+ */
252
+ /**
253
+ * One byte range of one file, as bytes, on THIS thread.
254
+ *
255
+ * What the media layer is built from: a container takes `readRange(start,
256
+ * end)` and nothing else, so with this it can be parsed here instead of in
257
+ * the torrent thread. It used to be parsed there for one stated reason —
258
+ * "the main thread cannot open a read stream on one of its files", which is
259
+ * true of WebTorrent's own API and not of the bytes: the pieces live in
260
+ * shared memory and this read is the same one that serves every segment,
261
+ * so it waits for what has not arrived and steers the swarm toward it.
262
+ *
263
+ * **It COPIES, deliberately.** The zero-copy path — positions across the
264
+ * channel, the piece pinned until the far side says it is done — exists for
265
+ * 10 MB segments on the critical path, where the copy was measured at
266
+ * 18.84 ms. A container header is 64-256 KB and read once per file, about
267
+ * 0.12 ms, against a parse of 0.8 s and a swarm wait of up to a minute. The
268
+ * pinning protocol would buy nothing and is not on this path.
269
+ *
270
+ * @param {object} torrent
271
+ * @param {number} fileIndex
272
+ * @param {number} start - First byte, inclusive.
273
+ * @param {number} end - Last byte, inclusive.
274
+ * @returns {Promise<Buffer | null>} Null when the read failed or was cut
275
+ * short, which a container reads as "this file does not say".
276
+ */
277
+ /**
278
+ * The byte ranges of one file the torrent holds WHOLE.
279
+ *
280
+ * What the subtitle walk decides from: it may read only what is already
281
+ * downloaded, and a list taken once per pass replaces a question per cluster.
282
+ *
283
+ * @param {object} torrent
284
+ * @param {number} fileIndex
285
+ * @returns {Promise<Array<[number, number]>>}
286
+ */
287
+ async heldRangesOf(torrent, fileIndex) {
288
+ const sourceKey = torrent?.sourceKey;
289
+ return sourceKey ? this.#client.heldRanges({ sourceKey, fileIndex }) : [];
290
+ }
291
+
292
+ /**
293
+ * Bytes of a range the torrent already holds, never fetched.
294
+ *
295
+ * Not `readRangeOf`: that one declares demand and steers the swarm, which is
296
+ * right for a viewer waiting on a segment and wrong for a walk that must pull
297
+ * nothing the viewer is not waiting for.
298
+ *
299
+ * @param {object} torrent
300
+ * @param {number} fileIndex
301
+ * @param {number} start
302
+ * @param {number} end - Inclusive.
303
+ * @returns {Promise<Buffer | null>}
304
+ */
305
+ async readHeldOf(torrent, fileIndex, start, end) {
306
+ const sourceKey = torrent?.sourceKey;
307
+ return sourceKey ? this.#client.readHeld({ sourceKey, fileIndex, start, end }) : null;
308
+ }
309
+
310
+ async readRangeOf(torrent, fileIndex, start, end) {
311
+ const sourceKey = torrent?.sourceKey;
312
+ if (!sourceKey || !(end >= start) || !(start >= 0)) {
313
+ return null;
314
+ }
315
+ const stream = this.#client.createReadStream({ sourceKey, fileIndex, start, end });
316
+ const chunks = [];
317
+ let total = 0;
318
+ try {
319
+ const reader = stream.getReader();
320
+ for (;;) {
321
+ const { done, value } = await reader.read();
322
+ if (done) {
323
+ break;
324
+ }
325
+ chunks.push(Buffer.from(value));
326
+ total += value.length;
327
+ }
328
+ } catch {
329
+ return null;
330
+ }
331
+ return total > 0 ? Buffer.concat(chunks, total) : null;
332
+ }
333
+
334
+ async prefetchFileEdges(torrent, fileIndex, options = {}) {
335
+ const sourceKey = torrent?.sourceKey;
336
+ if (!sourceKey) {
337
+ return null;
338
+ }
339
+ return this.#client.prefetchFileEdges({ sourceKey, fileIndex, options });
340
+ }
341
+
342
+ /**
343
+ * Which films this proxy holds right now, and how much of each.
344
+ *
345
+ * What content affinity is decided from: a viewer of a film somebody here is
346
+ * already downloading costs this proxy the encode and nothing else, while the
347
+ * same viewer sent anywhere else starts the download from nothing.
348
+ *
349
+ * @returns {Promise<{ infoHash: string, progress: number, bytes: number }[]>}
350
+ */
351
+ async heldTorrents() {
352
+ const answer = await this.#client.heldTorrents();
353
+ return Array.isArray(answer?.held) ? answer.held : [];
354
+ }
355
+
356
+ /**
357
+ * Shut the torrent client down and stop the thread.
358
+ *
359
+ * @returns {Promise<void>}
360
+ */
361
+ async destroyAll() {
362
+ this.#torrents.clear();
363
+ await this.#client.destroyAll();
364
+ }
365
+ }