@torrent-tv/proxy 2.20.0 → 2.21.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.
@@ -1,497 +1,510 @@
1
- /**
2
- * @file Main-thread face of the torrent worker.
3
- *
4
- * Presents the same operations the routes and session manager already use, so
5
- * moving the torrent to its own thread does not ripple through calling code.
6
- * The one unavoidable change is that torrents are named by `sourceKey` instead
7
- * of passed around as objects — objects cannot cross a thread boundary, and
8
- * pretending otherwise would mean copying them on every call.
9
- *
10
- * Reads come back as an ordinary `ReadableStream`, so `/stream` and the codec
11
- * probe consume them exactly as they consume WebTorrent's own streams today.
12
- * What that hides is the part that matters: chunks arrive as transferred
13
- * buffers, never copied — 5.3 ms per 10 MB against 37 ms if cloned and 104 ms
14
- * through a transferable stream (measured 2026-08-02, see `protocol.js`).
15
- */
16
-
17
- import { Worker } from "node:worker_threads";
18
- import { Readable } from "node:stream";
19
- import { fileURLToPath } from "node:url";
20
- import { logger } from "../../utils/logger.js";
21
- import { createCaller, createReceiveStream } from "./channel.js";
22
- import { Command, Event } from "./protocol.js";
23
-
24
- const WORKER_URL = new URL("./worker.js", import.meta.url);
25
-
26
- /**
27
- * Runs the torrent client on its own thread and exposes it to the main thread.
28
- *
29
- * Why this exists at all: profiling during a live seek found the main thread
30
- * ~85% busy with WebTorrent (buffer concatenation ~15%, wire updates ~9%,
31
- * garbage collection ~5%), while three of four cores idled. Serving a segment
32
- * queued behind that work, so reading a finished 10 MB file took 12-23 s where
33
- * handing it to the channel took 125 ms.
34
- */
35
- export class TorrentWorkerClient {
36
- #worker;
37
- #caller;
38
- /** Receive-side handles for in-flight reads, keyed by request id. */
39
- #reads = new Map();
40
-
41
- /**
42
- * The last piece served to each open read, so a fragment arriving out of
43
- * order can be named. Cleared when the read ends.
44
- *
45
- * @type {Map<number, number>}
46
- */
47
- #lastPieceByRead = new Map();
48
- /** Each torrent's piece pool, so a fragment can be read where it lies. */
49
- #poolBySource = new Map();
50
- /** Which pool an in-flight read belongs to, keyed by request id. */
51
- #poolByRead = new Map();
52
- /** Reads consuming fragments in place, keyed by request id. */
53
- #fragmentReaders = new Map();
54
-
55
- /**
56
- * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
57
- */
58
- constructor({ maxDiskBytes, memoryBytes } = {}) {
59
- this.#worker = new Worker(fileURLToPath(WORKER_URL), {
60
- workerData: { maxDiskBytes, memoryBytes }
61
- });
62
- this.#caller = createCaller(this.#worker);
63
-
64
- this.#worker.on("message", (message) => {
65
- // A failed read must fail its stream. This is checked BEFORE the caller
66
- // sees the message: until 2.9.76 nothing here handled a read error at
67
- // all, so the worker's report was dropped as unknown, and because the
68
- // worker sent the end-of-read marker from its `finally` even when the
69
- // read had thrown, the reader saw a clean end of file instead. A read
70
- // that failed before it produced anything simply hung forever.
71
- if (message?.type === Event.ERROR && this.#reads.has(message.id)) {
72
- const read = this.#reads.get(message.id);
73
- this.#reads.delete(message.id);
74
- read.fail(new Error(message.error ?? "Torrent worker read failed."));
75
- return;
76
- }
77
- if (message?.type === Event.ERROR && this.#fragmentReaders.has(message.id)) {
78
- const reader = this.#fragmentReaders.get(message.id);
79
- this.#fragmentReaders.delete(message.id);
80
- this.#poolByRead.delete(message.id);
81
- reader.fail(new Error(message.error ?? "Torrent worker read failed."));
82
- return;
83
- }
84
- if (this.#caller.handleReply(message)) {
85
- return;
86
- }
87
- switch (message?.type) {
88
- case Event.FRAGMENT: {
89
- const pool = this.#poolByRead.get(message.id);
90
- if (!pool) {
91
- // No pool means no way to read the fragment; confirm it so the
92
- // worker is not left waiting, and let the read end short.
93
- this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
94
- break;
95
- }
96
- // The pool is a growable SharedArrayBuffer shared with the worker
97
- // thread, and an offset is only meaningful against the buffer of the
98
- // store that produced it. When the two disagree — a second store
99
- // opened for the same torrent, a slot handed out before the buffer
100
- // grew — this threw `RangeError: Invalid typed array length`, which
101
- // the process-wide handler swallowed. Reads then stopped answering
102
- // for good: field 2026-08-09, one segment was held for a minute
103
- // eight times running while the audio decoder was fed cut-up frames
104
- // and reported them as a broken AC-3 stream. A read that cannot be
105
- // satisfied must end short and say so, not take the whole source
106
- // down silently.
107
- if (message.offset + message.length > pool.byteLength) {
108
- logger.warn(
109
- `torrent-worker: fragment ${message.offset}+${message.length} lies outside ` +
110
- `its pool of ${pool.byteLength}B (read ${message.id}) — ending the read short`
111
- );
112
- this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
113
- break;
114
- }
115
- // A sequential read walks the file forwards, so each fragment either
116
- // continues the piece before it or moves to the very next one.
117
- // Anything else means the bytes handed to the decoder are not the
118
- // file's bytes in order — which is what a decoder complaining about
119
- // its input has twice turned out to mean (2.9.126, and the AC-3
120
- // failure of 2026-08-09: the encoder ran at 9.3x, the piece store
121
- // reported no spills and 100% of reads from memory, and the decoder
122
- // still saw "new coupling strategy must be present in block 0"). The
123
- // bounds check catches a fragment outside the pool; this catches one
124
- // inside it that belongs somewhere else.
125
- const lastPiece = this.#lastPieceByRead.get(message.id);
126
- if (lastPiece !== undefined && message.pieceIndex !== lastPiece && message.pieceIndex !== lastPiece + 1) {
127
- logger.warn(
128
- `torrent-worker: read ${message.id} jumped from piece ${lastPiece} to ` +
129
- `${message.pieceIndex} (${message.length}B at pool offset ${message.offset}) — ` +
130
- "the consumer is being handed the file out of order"
131
- );
132
- }
133
- this.#lastPieceByRead.set(message.id, message.pieceIndex);
134
- const view = new Uint8Array(pool, message.offset, message.length);
135
-
136
- const reader = this.#fragmentReaders.get(message.id);
137
- if (reader) {
138
- // Handed on as a view into the pool — no copy anywhere. The piece
139
- // stays pinned until the consumer says it is done with these exact
140
- // bytes, which for a response body means the socket write has
141
- // completed.
142
- reader.push(view, () => {
143
- this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
144
- });
145
- break;
146
- }
147
-
148
- // Plain-stream consumers keep what they are given while the slot may
149
- // be reused, so they get a copy and the piece is released at once.
150
- this.#reads.get(message.id)?.push(Uint8Array.prototype.slice.call(view));
151
- this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
152
- break;
153
- }
154
- case Event.CHUNK: {
155
- const bytes = message.bytes;
156
- this.#reads.get(message.id)?.push(
157
- new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length)
158
- );
159
- break;
160
- }
161
- case Event.READ_END:
162
- this.#lastPieceByRead.delete(message.id);
163
- this.#reads.get(message.id)?.close();
164
- this.#reads.delete(message.id);
165
- this.#fragmentReaders.get(message.id)?.close();
166
- this.#fragmentReaders.delete(message.id);
167
- this.#poolByRead.delete(message.id);
168
- break;
169
- case Event.LOG:
170
- logger.info(`torrent-worker: ${message.message}`);
171
- break;
172
- default:
173
- break;
174
- }
175
- });
176
-
177
- this.#worker.on("error", (error) => {
178
- logger.error(`torrent-worker crashed: ${error?.message ?? error}`);
179
- // Fail everything outstanding rather than leaving callers hanging: a dead
180
- // worker will never answer, and a stalled request is worse than an error
181
- // the loading flow can retry.
182
- const reason = new Error("Torrent worker stopped unexpectedly.");
183
- this.#caller.rejectAll(reason);
184
- for (const [, read] of this.#reads) {
185
- read.fail(reason);
186
- }
187
- this.#reads.clear();
188
- });
189
- }
190
-
191
- /**
192
- * Add (or join) a torrent and register it under `sourceKey`.
193
- *
194
- * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
195
- * @returns {Promise<{ infoHash: string, name: string, files: { index: number, name: string, path: string, length: number }[] }>}
196
- */
197
- async addSource({ sourceKey, sourceType, source }) {
198
- return this.#caller.call(Command.ADD_SOURCE, { sourceKey, sourceType, source });
199
- }
200
-
201
- /**
202
- * The torrent's files, as plain data.
203
- *
204
- * @param {string} sourceKey
205
- * @returns {Promise<{ index: number, name: string, path: string, length: number }[]>}
206
- */
207
- async listFiles(sourceKey) {
208
- return this.#caller.call(Command.LIST_FILES, { sourceKey });
209
- }
210
-
211
- /**
212
- * Claim a file so it is not evicted while being read.
213
- *
214
- * @param {string} sourceKey
215
- * @param {number} fileIndex
216
- * @returns {Promise<string>} The claim's identity, for {@link releaseFile}.
217
- */
218
- async acquireFile(sourceKey, fileIndex) {
219
- return this.#caller.call(Command.ACQUIRE_FILE, { sourceKey, fileIndex });
220
- }
221
-
222
- /**
223
- * Drop one claim taken with {@link acquireFile}.
224
- *
225
- * Named by claim rather than by file: several readers hold the same file at
226
- * once, and releasing "the file" released somebody else's hold.
227
- *
228
- * @param {string} claimId
229
- * @returns {Promise<void>}
230
- */
231
- async releaseFile(claimId) {
232
- await this.#caller.call(Command.RELEASE_FILE, { claimId });
233
- }
234
-
235
- /**
236
- * Live download figures for the progress display.
237
- *
238
- * @param {{ sourceKey: string, fileIndex: number, resumeAnchorByteStart?: number | null }} params
239
- * @returns {Promise<object>}
240
- */
241
- async getFileStats({ sourceKey, fileIndex, resumeAnchorByteStart = null }) {
242
- return this.#caller.call(Command.FILE_STATS, { sourceKey, fileIndex, resumeAnchorByteStart });
243
- }
244
-
245
- /**
246
- * Reorder piece selection around a read position (seek prioritisation).
247
- *
248
- * @param {{ sourceKey: string, fileIndex: number, byteStart: number, windowBytes?: number, wholeFileRead?: boolean }} params
249
- * @returns {Promise<void>}
250
- */
251
- async prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes, wholeFileRead }) {
252
- await this.#caller.call(Command.PRIORITIZE, {
253
- sourceKey,
254
- fileIndex,
255
- byteStart,
256
- windowBytes,
257
- wholeFileRead
258
- });
259
- }
260
-
261
- /**
262
- * Pre-fetch the head and tail the codec probe needs.
263
- *
264
- * @param {{ sourceKey: string, fileIndex: number, options?: { headBytes?: number, tailBytes?: number, timeoutMs?: number } }} params
265
- * @returns {Promise<unknown>}
266
- */
267
- async prefetchFileEdges({ sourceKey, fileIndex, options = {} }) {
268
- return this.#caller.call(Command.PREFETCH_EDGES, { sourceKey, fileIndex, options });
269
- }
270
-
271
- /**
272
- * Read a byte range as a stream.
273
- *
274
- * Returns immediately with a stream that fills as chunks arrive; cancelling it
275
- * (viewer gone, seek superseded) stops the worker reading, so pieces are not
276
- * fetched for a stream nobody will drain.
277
- *
278
- * @param {{ sourceKey: string, fileIndex: number, start?: number | null, end?: number | null, windowBytes?: number }} params
279
- * @returns {ReadableStream<Uint8Array>}
280
- */
281
- createReadStream({ sourceKey, fileIndex, start = null, end = null, windowBytes }) {
282
- // Same id sequence as commands — see `nextId` in `channel.js`.
283
- const readId = this.#caller.nextId();
284
- const receive = createReceiveStream({
285
- port: this.#worker,
286
- requestId: readId,
287
- onCancel: () => {
288
- void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
289
- this.#reads.delete(readId);
290
- this.#poolByRead.delete(readId);
291
- this.#lastPieceByRead.delete(readId);
292
- }
293
- });
294
- this.#reads.set(readId, receive);
295
- // Which pool this read's fragments will point into. Recorded before the
296
- // command is sent, because the first fragment can arrive immediately.
297
- const pool = this.#poolBySource.get(sourceKey);
298
- if (pool) {
299
- this.#poolByRead.set(readId, pool);
300
- }
301
-
302
- // The worker replies to READ_RANGE only once the body is fully sent; a
303
- // failure before that must surface on the stream, not vanish.
304
- this.#worker.postMessage({
305
- command: Command.READ_RANGE,
306
- id: readId,
307
- params: { sourceKey, fileIndex, start, end, windowBytes }
308
- });
309
-
310
- return receive.stream;
311
- }
312
-
313
- /**
314
- * Read a byte range as fragments of shared memory, without copying.
315
- *
316
- * Each fragment is a view straight into the torrent's piece pool, and the
317
- * piece behind it stays pinned until `release()` is called — so the consumer
318
- * must call it once it is genuinely finished with those bytes. For a response
319
- * body that means after the socket write has completed, not when it was
320
- * queued: verified that writing a shared-memory view and then overwriting the
321
- * pool from the write callback leaves the client's copy intact, and that
322
- * overwriting it earlier corrupts it silently.
323
- *
324
- * Returns `null` when this source has no shared pool, so the caller can fall
325
- * back to {@link createReadStream}.
326
- *
327
- * @param {{ sourceKey: string, fileIndex: number, start?: number | null, end?: number | null, windowBytes?: number }} params
328
- * @returns {{ [Symbol.asyncIterator]: () => AsyncGenerator<{ bytes: Uint8Array, release: () => void }>, cancel: () => void } | null}
329
- */
330
- createFragmentReader({ sourceKey, fileIndex, start = null, end = null, windowBytes }) {
331
- const pool = this.#poolBySource.get(sourceKey);
332
- if (!pool) {
333
- return null;
334
- }
335
-
336
- const readId = this.#caller.nextId();
337
- /** @type {{ bytes: Uint8Array, release: () => void }[]} */
338
- const queue = [];
339
- let wake = null;
340
- let finished = false;
341
- let failure = null;
342
-
343
- const notify = () => {
344
- const resume = wake;
345
- wake = null;
346
- resume?.();
347
- };
348
-
349
- this.#fragmentReaders.set(readId, {
350
- push(bytes, confirm) {
351
- queue.push({ bytes, release: confirm });
352
- notify();
353
- },
354
- close() {
355
- finished = true;
356
- notify();
357
- },
358
- fail(error) {
359
- failure = error;
360
- finished = true;
361
- notify();
362
- }
363
- });
364
- this.#poolByRead.set(readId, pool);
365
-
366
- const cancel = () => {
367
- if (this.#fragmentReaders.delete(readId)) {
368
- this.#poolByRead.delete(readId);
369
- void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
370
- }
371
- finished = true;
372
- notify();
373
- };
374
-
375
- this.#worker.postMessage({
376
- command: Command.READ_RANGE,
377
- id: readId,
378
- params: { sourceKey, fileIndex, start, end, windowBytes }
379
- });
380
-
381
- return {
382
- cancel,
383
- async *[Symbol.asyncIterator]() {
384
- try {
385
- for (;;) {
386
- while (queue.length > 0) {
387
- yield queue.shift();
388
- }
389
- if (failure) {
390
- throw failure;
391
- }
392
- if (finished) {
393
- return;
394
- }
395
- await new Promise((resolve) => {
396
- wake = resolve;
397
- });
398
- }
399
- } finally {
400
- // Covers the consumer breaking out early — a client that hung up, a
401
- // superseded seek — which must stop the read rather than leave it
402
- // fetching pieces nobody will take.
403
- if (!finished) {
404
- cancel();
405
- }
406
- }
407
- }
408
- };
409
- }
410
-
411
- /**
412
- * A stand-in for the WebTorrent torrent object, backed by the worker.
413
- *
414
- * Callers already hold a torrent and reach into `torrent.files[i]` for the
415
- * length, the name, or a read stream. Handing back an object of the same
416
- * shape keeps every one of those call sites working unchanged, which matters:
417
- * they are spread across the stream route, the subtitle route, the playback
418
- * planner and the health report, and rewriting all of them to thread a
419
- * `sourceKey` through would be a large change with nothing to show for it.
420
- *
421
- * Only what is actually used is provided. Anything else would be a promise we
422
- * cannot keep — the real object lives on the other thread and its methods are
423
- * not reachable from here.
424
- *
425
- * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
426
- * @returns {Promise<{ infoHash: string, name: string, sourceKey: string, files: object[] }>}
427
- */
428
- async getTorrent({ sourceKey, sourceType, source }) {
429
- const info = await this.addSource({ sourceKey, sourceType, source });
430
- // The torrent's piece pool. Both threads now hold the same memory, so a
431
- // read can be answered with an offset instead of with bytes.
432
- if (info.sharedBuffer) {
433
- this.#poolBySource.set(sourceKey, info.sharedBuffer);
434
- }
435
- const client = this;
436
- return {
437
- infoHash: info.infoHash,
438
- name: info.name,
439
- // Carried so helpers that receive only the torrent can still name it to
440
- // the worker.
441
- sourceKey,
442
- files: info.files.map((file) => ({
443
- ...file,
444
- /**
445
- * @param {{ start?: number, end?: number, windowBytes?: number }} [options]
446
- * @returns {ReadableStream<Uint8Array>}
447
- */
448
- createReadStream(options = {}) {
449
- // Node stream, not a web one: Fastify replies and the ffmpeg pipe
450
- // both expect that shape, and every existing call site passes the
451
- // result straight to one of them. `Readable.fromWeb` adds no copy —
452
- // it wraps the same buffers.
453
- return Readable.fromWeb(
454
- client.createReadStream({
455
- sourceKey,
456
- fileIndex: file.index,
457
- start: options.start ?? null,
458
- end: options.end ?? null,
459
- windowBytes: options.windowBytes
460
- })
461
- );
462
- },
463
-
464
- /**
465
- * Fragments of shared memory, for a caller that can say when it has
466
- * finished with each one. `null` when this source has no shared pool.
467
- *
468
- * @param {{ start?: number, end?: number, windowBytes?: number }} [options]
469
- * @returns {ReturnType<TorrentWorkerClient["createFragmentReader"]>}
470
- */
471
- createFragmentReader(options = {}) {
472
- return client.createFragmentReader({
473
- sourceKey,
474
- fileIndex: file.index,
475
- start: options.start ?? null,
476
- end: options.end ?? null,
477
- windowBytes: options.windowBytes
478
- });
479
- }
480
- }))
481
- };
482
- }
483
-
484
- /**
485
- * Shut the torrent client down and stop the thread.
486
- *
487
- * @returns {Promise<void>}
488
- */
489
- async destroyAll() {
490
- try {
491
- await this.#caller.call(Command.DESTROY_ALL, {});
492
- } catch {
493
- // Already gone — termination below is what matters.
494
- }
495
- await this.#worker.terminate();
496
- }
497
- }
1
+ /**
2
+ * @file Main-thread face of the torrent worker.
3
+ *
4
+ * Presents the same operations the routes and session manager already use, so
5
+ * moving the torrent to its own thread does not ripple through calling code.
6
+ * The one unavoidable change is that torrents are named by `sourceKey` instead
7
+ * of passed around as objects — objects cannot cross a thread boundary, and
8
+ * pretending otherwise would mean copying them on every call.
9
+ *
10
+ * Reads come back as an ordinary `ReadableStream`, so `/stream` and the codec
11
+ * probe consume them exactly as they consume WebTorrent's own streams today.
12
+ * What that hides is the part that matters: chunks arrive as transferred
13
+ * buffers, never copied — 5.3 ms per 10 MB against 37 ms if cloned and 104 ms
14
+ * through a transferable stream (measured 2026-08-02, see `protocol.js`).
15
+ */
16
+
17
+ import { Worker } from "node:worker_threads";
18
+ import { Readable } from "node:stream";
19
+ import { fileURLToPath } from "node:url";
20
+ import { logger } from "../../utils/logger.js";
21
+ import { createCaller, createReceiveStream } from "./channel.js";
22
+ import { Command, Event } from "./protocol.js";
23
+
24
+ const WORKER_URL = new URL("./worker.js", import.meta.url);
25
+
26
+ /**
27
+ * Runs the torrent client on its own thread and exposes it to the main thread.
28
+ *
29
+ * Why this exists at all: profiling during a live seek found the main thread
30
+ * ~85% busy with WebTorrent (buffer concatenation ~15%, wire updates ~9%,
31
+ * garbage collection ~5%), while three of four cores idled. Serving a segment
32
+ * queued behind that work, so reading a finished 10 MB file took 12-23 s where
33
+ * handing it to the channel took 125 ms.
34
+ */
35
+ export class TorrentWorkerClient {
36
+ #worker;
37
+ #caller;
38
+ /** Receive-side handles for in-flight reads, keyed by request id. */
39
+ #reads = new Map();
40
+
41
+ /**
42
+ * The last piece served to each open read, so a fragment arriving out of
43
+ * order can be named. Cleared when the read ends.
44
+ *
45
+ * @type {Map<number, number>}
46
+ */
47
+ #lastPieceByRead = new Map();
48
+ /** Each torrent's piece pool, so a fragment can be read where it lies. */
49
+ #poolBySource = new Map();
50
+ /** Which pool an in-flight read belongs to, keyed by request id. */
51
+ #poolByRead = new Map();
52
+ /** Reads consuming fragments in place, keyed by request id. */
53
+ #fragmentReaders = new Map();
54
+
55
+ /**
56
+ * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
57
+ */
58
+ constructor({ maxDiskBytes, memoryBytes } = {}) {
59
+ this.#worker = new Worker(fileURLToPath(WORKER_URL), {
60
+ workerData: { maxDiskBytes, memoryBytes }
61
+ });
62
+ this.#caller = createCaller(this.#worker);
63
+
64
+ this.#worker.on("message", (message) => {
65
+ // A failed read must fail its stream. This is checked BEFORE the caller
66
+ // sees the message: until 2.9.76 nothing here handled a read error at
67
+ // all, so the worker's report was dropped as unknown, and because the
68
+ // worker sent the end-of-read marker from its `finally` even when the
69
+ // read had thrown, the reader saw a clean end of file instead. A read
70
+ // that failed before it produced anything simply hung forever.
71
+ if (message?.type === Event.ERROR && this.#reads.has(message.id)) {
72
+ const read = this.#reads.get(message.id);
73
+ this.#reads.delete(message.id);
74
+ read.fail(new Error(message.error ?? "Torrent worker read failed."));
75
+ return;
76
+ }
77
+ if (message?.type === Event.ERROR && this.#fragmentReaders.has(message.id)) {
78
+ const reader = this.#fragmentReaders.get(message.id);
79
+ this.#fragmentReaders.delete(message.id);
80
+ this.#poolByRead.delete(message.id);
81
+ reader.fail(new Error(message.error ?? "Torrent worker read failed."));
82
+ return;
83
+ }
84
+ if (this.#caller.handleReply(message)) {
85
+ return;
86
+ }
87
+ switch (message?.type) {
88
+ case Event.FRAGMENT: {
89
+ const pool = this.#poolByRead.get(message.id);
90
+ if (!pool) {
91
+ // No pool means no way to read the fragment; confirm it so the
92
+ // worker is not left waiting, and let the read end short.
93
+ this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
94
+ break;
95
+ }
96
+ // The pool is a growable SharedArrayBuffer shared with the worker
97
+ // thread, and an offset is only meaningful against the buffer of the
98
+ // store that produced it. When the two disagree — a second store
99
+ // opened for the same torrent, a slot handed out before the buffer
100
+ // grew — this threw `RangeError: Invalid typed array length`, which
101
+ // the process-wide handler swallowed. Reads then stopped answering
102
+ // for good: field 2026-08-09, one segment was held for a minute
103
+ // eight times running while the audio decoder was fed cut-up frames
104
+ // and reported them as a broken AC-3 stream. A read that cannot be
105
+ // satisfied must end short and say so, not take the whole source
106
+ // down silently.
107
+ if (message.offset + message.length > pool.byteLength) {
108
+ logger.warn(
109
+ `torrent-worker: fragment ${message.offset}+${message.length} lies outside ` +
110
+ `its pool of ${pool.byteLength}B (read ${message.id}) — ending the read short`
111
+ );
112
+ this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
113
+ break;
114
+ }
115
+ // A sequential read walks the file forwards, so each fragment either
116
+ // continues the piece before it or moves to the very next one.
117
+ // Anything else means the bytes handed to the decoder are not the
118
+ // file's bytes in order — which is what a decoder complaining about
119
+ // its input has twice turned out to mean (2.9.126, and the AC-3
120
+ // failure of 2026-08-09: the encoder ran at 9.3x, the piece store
121
+ // reported no spills and 100% of reads from memory, and the decoder
122
+ // still saw "new coupling strategy must be present in block 0"). The
123
+ // bounds check catches a fragment outside the pool; this catches one
124
+ // inside it that belongs somewhere else.
125
+ const lastPiece = this.#lastPieceByRead.get(message.id);
126
+ if (lastPiece !== undefined && message.pieceIndex !== lastPiece && message.pieceIndex !== lastPiece + 1) {
127
+ logger.warn(
128
+ `torrent-worker: read ${message.id} jumped from piece ${lastPiece} to ` +
129
+ `${message.pieceIndex} (${message.length}B at pool offset ${message.offset}) — ` +
130
+ "the consumer is being handed the file out of order"
131
+ );
132
+ }
133
+ this.#lastPieceByRead.set(message.id, message.pieceIndex);
134
+ const view = new Uint8Array(pool, message.offset, message.length);
135
+
136
+ const reader = this.#fragmentReaders.get(message.id);
137
+ if (reader) {
138
+ // Handed on as a view into the pool — no copy anywhere. The piece
139
+ // stays pinned until the consumer says it is done with these exact
140
+ // bytes, which for a response body means the socket write has
141
+ // completed.
142
+ reader.push(view, () => {
143
+ this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
144
+ });
145
+ break;
146
+ }
147
+
148
+ // Plain-stream consumers keep what they are given while the slot may
149
+ // be reused, so they get a copy and the piece is released at once.
150
+ this.#reads.get(message.id)?.push(Uint8Array.prototype.slice.call(view));
151
+ this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
152
+ break;
153
+ }
154
+ case Event.CHUNK: {
155
+ const bytes = message.bytes;
156
+ this.#reads.get(message.id)?.push(
157
+ new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length)
158
+ );
159
+ break;
160
+ }
161
+ case Event.READ_END:
162
+ this.#lastPieceByRead.delete(message.id);
163
+ this.#reads.get(message.id)?.close();
164
+ this.#reads.delete(message.id);
165
+ this.#fragmentReaders.get(message.id)?.close();
166
+ this.#fragmentReaders.delete(message.id);
167
+ this.#poolByRead.delete(message.id);
168
+ break;
169
+ case Event.LOG:
170
+ logger.info(`torrent-worker: ${message.message}`);
171
+ break;
172
+ default:
173
+ break;
174
+ }
175
+ });
176
+
177
+ this.#worker.on("error", (error) => {
178
+ logger.error(`torrent-worker crashed: ${error?.message ?? error}`);
179
+ // Fail everything outstanding rather than leaving callers hanging: a dead
180
+ // worker will never answer, and a stalled request is worse than an error
181
+ // the loading flow can retry.
182
+ const reason = new Error("Torrent worker stopped unexpectedly.");
183
+ this.#caller.rejectAll(reason);
184
+ for (const [, read] of this.#reads) {
185
+ read.fail(reason);
186
+ }
187
+ this.#reads.clear();
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Add (or join) a torrent and register it under `sourceKey`.
193
+ *
194
+ * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
195
+ * @returns {Promise<{ infoHash: string, name: string, files: { index: number, name: string, path: string, length: number }[] }>}
196
+ */
197
+ async addSource({ sourceKey, sourceType, source }) {
198
+ return this.#caller.call(Command.ADD_SOURCE, { sourceKey, sourceType, source });
199
+ }
200
+
201
+ /**
202
+ * The torrent's files, as plain data.
203
+ *
204
+ * @param {string} sourceKey
205
+ * @returns {Promise<{ index: number, name: string, path: string, length: number }[]>}
206
+ */
207
+ async listFiles(sourceKey) {
208
+ return this.#caller.call(Command.LIST_FILES, { sourceKey });
209
+ }
210
+
211
+ /**
212
+ * Claim a file so it is not evicted while being read.
213
+ *
214
+ * @param {string} sourceKey
215
+ * @param {number} fileIndex
216
+ * @returns {Promise<string>} The claim's identity, for {@link releaseFile}.
217
+ */
218
+ async acquireFile(sourceKey, fileIndex) {
219
+ return this.#caller.call(Command.ACQUIRE_FILE, { sourceKey, fileIndex });
220
+ }
221
+
222
+ /**
223
+ * Drop one claim taken with {@link acquireFile}.
224
+ *
225
+ * Named by claim rather than by file: several readers hold the same file at
226
+ * once, and releasing "the file" released somebody else's hold.
227
+ *
228
+ * @param {string} claimId
229
+ * @returns {Promise<void>}
230
+ */
231
+ async releaseFile(claimId) {
232
+ await this.#caller.call(Command.RELEASE_FILE, { claimId });
233
+ }
234
+
235
+ /**
236
+ * Live download figures for the progress display.
237
+ *
238
+ * @param {{ sourceKey: string, fileIndex: number, resumeAnchorByteStart?: number | null }} params
239
+ * @returns {Promise<object>}
240
+ */
241
+ async getFileStats({ sourceKey, fileIndex, resumeAnchorByteStart = null }) {
242
+ return this.#caller.call(Command.FILE_STATS, { sourceKey, fileIndex, resumeAnchorByteStart });
243
+ }
244
+
245
+ /**
246
+ * Bytes every torrent on the worker has moved, downloaded and uploaded apart.
247
+ *
248
+ * The client lives on the worker thread, so this cannot be read as a property
249
+ * from here — which is exactly the mistake 2.19.0 shipped, leaving the figure
250
+ * always zero and the whole feature inert.
251
+ *
252
+ * @returns {Promise<{ downloaded: number, uploaded: number }>}
253
+ */
254
+ async getTorrentTotals() {
255
+ return this.#caller.call(Command.TORRENT_TOTALS, {});
256
+ }
257
+
258
+ /**
259
+ * Reorder piece selection around a read position (seek prioritisation).
260
+ *
261
+ * @param {{ sourceKey: string, fileIndex: number, byteStart: number, windowBytes?: number, wholeFileRead?: boolean }} params
262
+ * @returns {Promise<void>}
263
+ */
264
+ async prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes, wholeFileRead }) {
265
+ await this.#caller.call(Command.PRIORITIZE, {
266
+ sourceKey,
267
+ fileIndex,
268
+ byteStart,
269
+ windowBytes,
270
+ wholeFileRead
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Pre-fetch the head and tail the codec probe needs.
276
+ *
277
+ * @param {{ sourceKey: string, fileIndex: number, options?: { headBytes?: number, tailBytes?: number, timeoutMs?: number } }} params
278
+ * @returns {Promise<unknown>}
279
+ */
280
+ async prefetchFileEdges({ sourceKey, fileIndex, options = {} }) {
281
+ return this.#caller.call(Command.PREFETCH_EDGES, { sourceKey, fileIndex, options });
282
+ }
283
+
284
+ /**
285
+ * Read a byte range as a stream.
286
+ *
287
+ * Returns immediately with a stream that fills as chunks arrive; cancelling it
288
+ * (viewer gone, seek superseded) stops the worker reading, so pieces are not
289
+ * fetched for a stream nobody will drain.
290
+ *
291
+ * @param {{ sourceKey: string, fileIndex: number, start?: number | null, end?: number | null, windowBytes?: number }} params
292
+ * @returns {ReadableStream<Uint8Array>}
293
+ */
294
+ createReadStream({ sourceKey, fileIndex, start = null, end = null, windowBytes }) {
295
+ // Same id sequence as commands see `nextId` in `channel.js`.
296
+ const readId = this.#caller.nextId();
297
+ const receive = createReceiveStream({
298
+ port: this.#worker,
299
+ requestId: readId,
300
+ onCancel: () => {
301
+ void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
302
+ this.#reads.delete(readId);
303
+ this.#poolByRead.delete(readId);
304
+ this.#lastPieceByRead.delete(readId);
305
+ }
306
+ });
307
+ this.#reads.set(readId, receive);
308
+ // Which pool this read's fragments will point into. Recorded before the
309
+ // command is sent, because the first fragment can arrive immediately.
310
+ const pool = this.#poolBySource.get(sourceKey);
311
+ if (pool) {
312
+ this.#poolByRead.set(readId, pool);
313
+ }
314
+
315
+ // The worker replies to READ_RANGE only once the body is fully sent; a
316
+ // failure before that must surface on the stream, not vanish.
317
+ this.#worker.postMessage({
318
+ command: Command.READ_RANGE,
319
+ id: readId,
320
+ params: { sourceKey, fileIndex, start, end, windowBytes }
321
+ });
322
+
323
+ return receive.stream;
324
+ }
325
+
326
+ /**
327
+ * Read a byte range as fragments of shared memory, without copying.
328
+ *
329
+ * Each fragment is a view straight into the torrent's piece pool, and the
330
+ * piece behind it stays pinned until `release()` is called so the consumer
331
+ * must call it once it is genuinely finished with those bytes. For a response
332
+ * body that means after the socket write has completed, not when it was
333
+ * queued: verified that writing a shared-memory view and then overwriting the
334
+ * pool from the write callback leaves the client's copy intact, and that
335
+ * overwriting it earlier corrupts it silently.
336
+ *
337
+ * Returns `null` when this source has no shared pool, so the caller can fall
338
+ * back to {@link createReadStream}.
339
+ *
340
+ * @param {{ sourceKey: string, fileIndex: number, start?: number | null, end?: number | null, windowBytes?: number }} params
341
+ * @returns {{ [Symbol.asyncIterator]: () => AsyncGenerator<{ bytes: Uint8Array, release: () => void }>, cancel: () => void } | null}
342
+ */
343
+ createFragmentReader({ sourceKey, fileIndex, start = null, end = null, windowBytes }) {
344
+ const pool = this.#poolBySource.get(sourceKey);
345
+ if (!pool) {
346
+ return null;
347
+ }
348
+
349
+ const readId = this.#caller.nextId();
350
+ /** @type {{ bytes: Uint8Array, release: () => void }[]} */
351
+ const queue = [];
352
+ let wake = null;
353
+ let finished = false;
354
+ let failure = null;
355
+
356
+ const notify = () => {
357
+ const resume = wake;
358
+ wake = null;
359
+ resume?.();
360
+ };
361
+
362
+ this.#fragmentReaders.set(readId, {
363
+ push(bytes, confirm) {
364
+ queue.push({ bytes, release: confirm });
365
+ notify();
366
+ },
367
+ close() {
368
+ finished = true;
369
+ notify();
370
+ },
371
+ fail(error) {
372
+ failure = error;
373
+ finished = true;
374
+ notify();
375
+ }
376
+ });
377
+ this.#poolByRead.set(readId, pool);
378
+
379
+ const cancel = () => {
380
+ if (this.#fragmentReaders.delete(readId)) {
381
+ this.#poolByRead.delete(readId);
382
+ void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
383
+ }
384
+ finished = true;
385
+ notify();
386
+ };
387
+
388
+ this.#worker.postMessage({
389
+ command: Command.READ_RANGE,
390
+ id: readId,
391
+ params: { sourceKey, fileIndex, start, end, windowBytes }
392
+ });
393
+
394
+ return {
395
+ cancel,
396
+ async *[Symbol.asyncIterator]() {
397
+ try {
398
+ for (;;) {
399
+ while (queue.length > 0) {
400
+ yield queue.shift();
401
+ }
402
+ if (failure) {
403
+ throw failure;
404
+ }
405
+ if (finished) {
406
+ return;
407
+ }
408
+ await new Promise((resolve) => {
409
+ wake = resolve;
410
+ });
411
+ }
412
+ } finally {
413
+ // Covers the consumer breaking out early — a client that hung up, a
414
+ // superseded seek which must stop the read rather than leave it
415
+ // fetching pieces nobody will take.
416
+ if (!finished) {
417
+ cancel();
418
+ }
419
+ }
420
+ }
421
+ };
422
+ }
423
+
424
+ /**
425
+ * A stand-in for the WebTorrent torrent object, backed by the worker.
426
+ *
427
+ * Callers already hold a torrent and reach into `torrent.files[i]` — for the
428
+ * length, the name, or a read stream. Handing back an object of the same
429
+ * shape keeps every one of those call sites working unchanged, which matters:
430
+ * they are spread across the stream route, the subtitle route, the playback
431
+ * planner and the health report, and rewriting all of them to thread a
432
+ * `sourceKey` through would be a large change with nothing to show for it.
433
+ *
434
+ * Only what is actually used is provided. Anything else would be a promise we
435
+ * cannot keep — the real object lives on the other thread and its methods are
436
+ * not reachable from here.
437
+ *
438
+ * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
439
+ * @returns {Promise<{ infoHash: string, name: string, sourceKey: string, files: object[] }>}
440
+ */
441
+ async getTorrent({ sourceKey, sourceType, source }) {
442
+ const info = await this.addSource({ sourceKey, sourceType, source });
443
+ // The torrent's piece pool. Both threads now hold the same memory, so a
444
+ // read can be answered with an offset instead of with bytes.
445
+ if (info.sharedBuffer) {
446
+ this.#poolBySource.set(sourceKey, info.sharedBuffer);
447
+ }
448
+ const client = this;
449
+ return {
450
+ infoHash: info.infoHash,
451
+ name: info.name,
452
+ // Carried so helpers that receive only the torrent can still name it to
453
+ // the worker.
454
+ sourceKey,
455
+ files: info.files.map((file) => ({
456
+ ...file,
457
+ /**
458
+ * @param {{ start?: number, end?: number, windowBytes?: number }} [options]
459
+ * @returns {ReadableStream<Uint8Array>}
460
+ */
461
+ createReadStream(options = {}) {
462
+ // Node stream, not a web one: Fastify replies and the ffmpeg pipe
463
+ // both expect that shape, and every existing call site passes the
464
+ // result straight to one of them. `Readable.fromWeb` adds no copy —
465
+ // it wraps the same buffers.
466
+ return Readable.fromWeb(
467
+ client.createReadStream({
468
+ sourceKey,
469
+ fileIndex: file.index,
470
+ start: options.start ?? null,
471
+ end: options.end ?? null,
472
+ windowBytes: options.windowBytes
473
+ })
474
+ );
475
+ },
476
+
477
+ /**
478
+ * Fragments of shared memory, for a caller that can say when it has
479
+ * finished with each one. `null` when this source has no shared pool.
480
+ *
481
+ * @param {{ start?: number, end?: number, windowBytes?: number }} [options]
482
+ * @returns {ReturnType<TorrentWorkerClient["createFragmentReader"]>}
483
+ */
484
+ createFragmentReader(options = {}) {
485
+ return client.createFragmentReader({
486
+ sourceKey,
487
+ fileIndex: file.index,
488
+ start: options.start ?? null,
489
+ end: options.end ?? null,
490
+ windowBytes: options.windowBytes
491
+ });
492
+ }
493
+ }))
494
+ };
495
+ }
496
+
497
+ /**
498
+ * Shut the torrent client down and stop the thread.
499
+ *
500
+ * @returns {Promise<void>}
501
+ */
502
+ async destroyAll() {
503
+ try {
504
+ await this.#caller.call(Command.DESTROY_ALL, {});
505
+ } catch {
506
+ // Already gone — termination below is what matters.
507
+ }
508
+ await this.#worker.terminate();
509
+ }
510
+ }