@torrent-tv/proxy 2.9.76 → 2.9.78

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,275 +1,306 @@
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
- * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
43
- */
44
- constructor({ maxDiskBytes, memoryBytes } = {}) {
45
- this.#worker = new Worker(fileURLToPath(WORKER_URL), {
46
- workerData: { maxDiskBytes, memoryBytes }
47
- });
48
- this.#caller = createCaller(this.#worker);
49
-
50
- this.#worker.on("message", (message) => {
51
- // A failed read must fail its stream. This is checked BEFORE the caller
52
- // sees the message: until 2.9.76 nothing here handled a read error at
53
- // all, so the worker's report was dropped as unknown, and because the
54
- // worker sent the end-of-read marker from its `finally` even when the
55
- // read had thrown, the reader saw a clean end of file instead. A read
56
- // that failed before it produced anything simply hung forever.
57
- if (message?.type === Event.ERROR && this.#reads.has(message.id)) {
58
- const read = this.#reads.get(message.id);
59
- this.#reads.delete(message.id);
60
- read.fail(new Error(message.error ?? "Torrent worker read failed."));
61
- return;
62
- }
63
- if (this.#caller.handleReply(message)) {
64
- return;
65
- }
66
- switch (message?.type) {
67
- case Event.CHUNK: {
68
- const bytes = message.bytes;
69
- this.#reads.get(message.id)?.push(
70
- new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length)
71
- );
72
- break;
73
- }
74
- case Event.READ_END:
75
- this.#reads.get(message.id)?.close();
76
- this.#reads.delete(message.id);
77
- break;
78
- case Event.LOG:
79
- logger.info(`torrent-worker: ${message.message}`);
80
- break;
81
- default:
82
- break;
83
- }
84
- });
85
-
86
- this.#worker.on("error", (error) => {
87
- logger.error(`torrent-worker crashed: ${error?.message ?? error}`);
88
- // Fail everything outstanding rather than leaving callers hanging: a dead
89
- // worker will never answer, and a stalled request is worse than an error
90
- // the loading flow can retry.
91
- const reason = new Error("Torrent worker stopped unexpectedly.");
92
- this.#caller.rejectAll(reason);
93
- for (const [, read] of this.#reads) {
94
- read.fail(reason);
95
- }
96
- this.#reads.clear();
97
- });
98
- }
99
-
100
- /**
101
- * Add (or join) a torrent and register it under `sourceKey`.
102
- *
103
- * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
104
- * @returns {Promise<{ infoHash: string, name: string, files: { index: number, name: string, path: string, length: number }[] }>}
105
- */
106
- async addSource({ sourceKey, sourceType, source }) {
107
- return this.#caller.call(Command.ADD_SOURCE, { sourceKey, sourceType, source });
108
- }
109
-
110
- /**
111
- * The torrent's files, as plain data.
112
- *
113
- * @param {string} sourceKey
114
- * @returns {Promise<{ index: number, name: string, path: string, length: number }[]>}
115
- */
116
- async listFiles(sourceKey) {
117
- return this.#caller.call(Command.LIST_FILES, { sourceKey });
118
- }
119
-
120
- /**
121
- * Claim a file so it is not evicted while being read.
122
- *
123
- * @param {string} sourceKey
124
- * @param {number} fileIndex
125
- * @returns {Promise<void>}
126
- */
127
- async acquireFile(sourceKey, fileIndex) {
128
- await this.#caller.call(Command.ACQUIRE_FILE, { sourceKey, fileIndex });
129
- }
130
-
131
- /**
132
- * Drop a claim taken with {@link acquireFile}.
133
- *
134
- * @param {string} sourceKey
135
- * @param {number} fileIndex
136
- * @returns {Promise<void>}
137
- */
138
- async releaseFile(sourceKey, fileIndex) {
139
- await this.#caller.call(Command.RELEASE_FILE, { sourceKey, fileIndex });
140
- }
141
-
142
- /**
143
- * Live download figures for the progress display.
144
- *
145
- * @param {{ sourceKey: string, fileIndex: number, resumeAnchorByteStart?: number | null }} params
146
- * @returns {Promise<object>}
147
- */
148
- async getFileStats({ sourceKey, fileIndex, resumeAnchorByteStart = null }) {
149
- return this.#caller.call(Command.FILE_STATS, { sourceKey, fileIndex, resumeAnchorByteStart });
150
- }
151
-
152
- /**
153
- * Reorder piece selection around a read position (seek prioritisation).
154
- *
155
- * @param {{ sourceKey: string, fileIndex: number, byteStart: number, windowBytes?: number }} params
156
- * @returns {Promise<void>}
157
- */
158
- async prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes }) {
159
- await this.#caller.call(Command.PRIORITIZE, { sourceKey, fileIndex, byteStart, windowBytes });
160
- }
161
-
162
- /**
163
- * Pre-fetch the head and tail the codec probe needs.
164
- *
165
- * @param {{ sourceKey: string, fileIndex: number, headBytes?: number, tailBytes?: number, timeoutMs?: number }} params
166
- * @returns {Promise<unknown>}
167
- */
168
- async prefetchFileEdges({ sourceKey, fileIndex, headBytes, tailBytes, timeoutMs }) {
169
- return this.#caller.call(Command.PREFETCH_EDGES, {
170
- sourceKey,
171
- fileIndex,
172
- headBytes,
173
- tailBytes,
174
- timeoutMs
175
- });
176
- }
177
-
178
- /**
179
- * Read a byte range as a stream.
180
- *
181
- * Returns immediately with a stream that fills as chunks arrive; cancelling it
182
- * (viewer gone, seek superseded) stops the worker reading, so pieces are not
183
- * fetched for a stream nobody will drain.
184
- *
185
- * @param {{ sourceKey: string, fileIndex: number, start?: number | null, end?: number | null }} params
186
- * @returns {ReadableStream<Uint8Array>}
187
- */
188
- createReadStream({ sourceKey, fileIndex, start = null, end = null }) {
189
- // Same id sequence as commands — see `nextId` in `channel.js`.
190
- const readId = this.#caller.nextId();
191
- const receive = createReceiveStream({
192
- port: this.#worker,
193
- requestId: readId,
194
- onCancel: () => {
195
- void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
196
- this.#reads.delete(readId);
197
- }
198
- });
199
- this.#reads.set(readId, receive);
200
-
201
- // The worker replies to READ_RANGE only once the body is fully sent; a
202
- // failure before that must surface on the stream, not vanish.
203
- this.#worker.postMessage({
204
- command: Command.READ_RANGE,
205
- id: readId,
206
- params: { sourceKey, fileIndex, start, end }
207
- });
208
-
209
- return receive.stream;
210
- }
211
-
212
- /**
213
- * A stand-in for the WebTorrent torrent object, backed by the worker.
214
- *
215
- * Callers already hold a torrent and reach into `torrent.files[i]` — for the
216
- * length, the name, or a read stream. Handing back an object of the same
217
- * shape keeps every one of those call sites working unchanged, which matters:
218
- * they are spread across the stream route, the subtitle route, the playback
219
- * planner and the health report, and rewriting all of them to thread a
220
- * `sourceKey` through would be a large change with nothing to show for it.
221
- *
222
- * Only what is actually used is provided. Anything else would be a promise we
223
- * cannot keep — the real object lives on the other thread and its methods are
224
- * not reachable from here.
225
- *
226
- * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
227
- * @returns {Promise<{ infoHash: string, name: string, sourceKey: string, files: object[] }>}
228
- */
229
- async getTorrent({ sourceKey, sourceType, source }) {
230
- const info = await this.addSource({ sourceKey, sourceType, source });
231
- const client = this;
232
- return {
233
- infoHash: info.infoHash,
234
- name: info.name,
235
- // Carried so helpers that receive only the torrent can still name it to
236
- // the worker.
237
- sourceKey,
238
- files: info.files.map((file) => ({
239
- ...file,
240
- /**
241
- * @param {{ start?: number, end?: number }} [options]
242
- * @returns {ReadableStream<Uint8Array>}
243
- */
244
- createReadStream(options = {}) {
245
- // Node stream, not a web one: Fastify replies and the ffmpeg pipe
246
- // both expect that shape, and every existing call site passes the
247
- // result straight to one of them. `Readable.fromWeb` adds no copy —
248
- // it wraps the same buffers.
249
- return Readable.fromWeb(
250
- client.createReadStream({
251
- sourceKey,
252
- fileIndex: file.index,
253
- start: options.start ?? null,
254
- end: options.end ?? null
255
- })
256
- );
257
- }
258
- }))
259
- };
260
- }
261
-
262
- /**
263
- * Shut the torrent client down and stop the thread.
264
- *
265
- * @returns {Promise<void>}
266
- */
267
- async destroyAll() {
268
- try {
269
- await this.#caller.call(Command.DESTROY_ALL, {});
270
- } catch {
271
- // Already gone — termination below is what matters.
272
- }
273
- await this.#worker.terminate();
274
- }
275
- }
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
+ /** Each torrent's piece pool, so a fragment can be read where it lies. */
41
+ #poolBySource = new Map();
42
+ /** Which pool an in-flight read belongs to, keyed by request id. */
43
+ #poolByRead = new Map();
44
+
45
+ /**
46
+ * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
47
+ */
48
+ constructor({ maxDiskBytes, memoryBytes } = {}) {
49
+ this.#worker = new Worker(fileURLToPath(WORKER_URL), {
50
+ workerData: { maxDiskBytes, memoryBytes }
51
+ });
52
+ this.#caller = createCaller(this.#worker);
53
+
54
+ this.#worker.on("message", (message) => {
55
+ // A failed read must fail its stream. This is checked BEFORE the caller
56
+ // sees the message: until 2.9.76 nothing here handled a read error at
57
+ // all, so the worker's report was dropped as unknown, and because the
58
+ // worker sent the end-of-read marker from its `finally` even when the
59
+ // read had thrown, the reader saw a clean end of file instead. A read
60
+ // that failed before it produced anything simply hung forever.
61
+ if (message?.type === Event.ERROR && this.#reads.has(message.id)) {
62
+ const read = this.#reads.get(message.id);
63
+ this.#reads.delete(message.id);
64
+ read.fail(new Error(message.error ?? "Torrent worker read failed."));
65
+ return;
66
+ }
67
+ if (this.#caller.handleReply(message)) {
68
+ return;
69
+ }
70
+ switch (message?.type) {
71
+ case Event.FRAGMENT: {
72
+ // The bytes are already here — this thread maps the same pool. Read
73
+ // them where they lie, then say so, which is what lets the worker
74
+ // unpin the piece and move on. The copy exists only because the
75
+ // consumer keeps what it is given while the slot may be reused; it is
76
+ // one copy on this thread rather than one on the torrent's.
77
+ const pool = this.#poolByRead.get(message.id);
78
+ const bytes = pool
79
+ ? Uint8Array.prototype.slice.call(
80
+ new Uint8Array(pool, message.offset, message.length)
81
+ )
82
+ : null;
83
+ if (bytes) {
84
+ this.#reads.get(message.id)?.push(bytes);
85
+ }
86
+ this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
87
+ break;
88
+ }
89
+ case Event.CHUNK: {
90
+ const bytes = message.bytes;
91
+ this.#reads.get(message.id)?.push(
92
+ new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length)
93
+ );
94
+ break;
95
+ }
96
+ case Event.READ_END:
97
+ this.#reads.get(message.id)?.close();
98
+ this.#reads.delete(message.id);
99
+ this.#poolByRead.delete(message.id);
100
+ break;
101
+ case Event.LOG:
102
+ logger.info(`torrent-worker: ${message.message}`);
103
+ break;
104
+ default:
105
+ break;
106
+ }
107
+ });
108
+
109
+ this.#worker.on("error", (error) => {
110
+ logger.error(`torrent-worker crashed: ${error?.message ?? error}`);
111
+ // Fail everything outstanding rather than leaving callers hanging: a dead
112
+ // worker will never answer, and a stalled request is worse than an error
113
+ // the loading flow can retry.
114
+ const reason = new Error("Torrent worker stopped unexpectedly.");
115
+ this.#caller.rejectAll(reason);
116
+ for (const [, read] of this.#reads) {
117
+ read.fail(reason);
118
+ }
119
+ this.#reads.clear();
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Add (or join) a torrent and register it under `sourceKey`.
125
+ *
126
+ * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
127
+ * @returns {Promise<{ infoHash: string, name: string, files: { index: number, name: string, path: string, length: number }[] }>}
128
+ */
129
+ async addSource({ sourceKey, sourceType, source }) {
130
+ return this.#caller.call(Command.ADD_SOURCE, { sourceKey, sourceType, source });
131
+ }
132
+
133
+ /**
134
+ * The torrent's files, as plain data.
135
+ *
136
+ * @param {string} sourceKey
137
+ * @returns {Promise<{ index: number, name: string, path: string, length: number }[]>}
138
+ */
139
+ async listFiles(sourceKey) {
140
+ return this.#caller.call(Command.LIST_FILES, { sourceKey });
141
+ }
142
+
143
+ /**
144
+ * Claim a file so it is not evicted while being read.
145
+ *
146
+ * @param {string} sourceKey
147
+ * @param {number} fileIndex
148
+ * @returns {Promise<string>} The claim's identity, for {@link releaseFile}.
149
+ */
150
+ async acquireFile(sourceKey, fileIndex) {
151
+ return this.#caller.call(Command.ACQUIRE_FILE, { sourceKey, fileIndex });
152
+ }
153
+
154
+ /**
155
+ * Drop one claim taken with {@link acquireFile}.
156
+ *
157
+ * Named by claim rather than by file: several readers hold the same file at
158
+ * once, and releasing "the file" released somebody else's hold.
159
+ *
160
+ * @param {string} claimId
161
+ * @returns {Promise<void>}
162
+ */
163
+ async releaseFile(claimId) {
164
+ await this.#caller.call(Command.RELEASE_FILE, { claimId });
165
+ }
166
+
167
+ /**
168
+ * Live download figures for the progress display.
169
+ *
170
+ * @param {{ sourceKey: string, fileIndex: number, resumeAnchorByteStart?: number | null }} params
171
+ * @returns {Promise<object>}
172
+ */
173
+ async getFileStats({ sourceKey, fileIndex, resumeAnchorByteStart = null }) {
174
+ return this.#caller.call(Command.FILE_STATS, { sourceKey, fileIndex, resumeAnchorByteStart });
175
+ }
176
+
177
+ /**
178
+ * Reorder piece selection around a read position (seek prioritisation).
179
+ *
180
+ * @param {{ sourceKey: string, fileIndex: number, byteStart: number, windowBytes?: number }} params
181
+ * @returns {Promise<void>}
182
+ */
183
+ async prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes }) {
184
+ await this.#caller.call(Command.PRIORITIZE, { sourceKey, fileIndex, byteStart, windowBytes });
185
+ }
186
+
187
+ /**
188
+ * Pre-fetch the head and tail the codec probe needs.
189
+ *
190
+ * @param {{ sourceKey: string, fileIndex: number, options?: { headBytes?: number, tailBytes?: number, timeoutMs?: number } }} params
191
+ * @returns {Promise<unknown>}
192
+ */
193
+ async prefetchFileEdges({ sourceKey, fileIndex, options = {} }) {
194
+ return this.#caller.call(Command.PREFETCH_EDGES, { sourceKey, fileIndex, options });
195
+ }
196
+
197
+ /**
198
+ * Read a byte range as a stream.
199
+ *
200
+ * Returns immediately with a stream that fills as chunks arrive; cancelling it
201
+ * (viewer gone, seek superseded) stops the worker reading, so pieces are not
202
+ * fetched for a stream nobody will drain.
203
+ *
204
+ * @param {{ sourceKey: string, fileIndex: number, start?: number | null, end?: number | null }} params
205
+ * @returns {ReadableStream<Uint8Array>}
206
+ */
207
+ createReadStream({ sourceKey, fileIndex, start = null, end = null }) {
208
+ // Same id sequence as commands — see `nextId` in `channel.js`.
209
+ const readId = this.#caller.nextId();
210
+ const receive = createReceiveStream({
211
+ port: this.#worker,
212
+ requestId: readId,
213
+ onCancel: () => {
214
+ void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
215
+ this.#reads.delete(readId);
216
+ this.#poolByRead.delete(readId);
217
+ }
218
+ });
219
+ this.#reads.set(readId, receive);
220
+ // Which pool this read's fragments will point into. Recorded before the
221
+ // command is sent, because the first fragment can arrive immediately.
222
+ const pool = this.#poolBySource.get(sourceKey);
223
+ if (pool) {
224
+ this.#poolByRead.set(readId, pool);
225
+ }
226
+
227
+ // The worker replies to READ_RANGE only once the body is fully sent; a
228
+ // failure before that must surface on the stream, not vanish.
229
+ this.#worker.postMessage({
230
+ command: Command.READ_RANGE,
231
+ id: readId,
232
+ params: { sourceKey, fileIndex, start, end }
233
+ });
234
+
235
+ return receive.stream;
236
+ }
237
+
238
+ /**
239
+ * A stand-in for the WebTorrent torrent object, backed by the worker.
240
+ *
241
+ * Callers already hold a torrent and reach into `torrent.files[i]` — for the
242
+ * length, the name, or a read stream. Handing back an object of the same
243
+ * shape keeps every one of those call sites working unchanged, which matters:
244
+ * they are spread across the stream route, the subtitle route, the playback
245
+ * planner and the health report, and rewriting all of them to thread a
246
+ * `sourceKey` through would be a large change with nothing to show for it.
247
+ *
248
+ * Only what is actually used is provided. Anything else would be a promise we
249
+ * cannot keep — the real object lives on the other thread and its methods are
250
+ * not reachable from here.
251
+ *
252
+ * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
253
+ * @returns {Promise<{ infoHash: string, name: string, sourceKey: string, files: object[] }>}
254
+ */
255
+ async getTorrent({ sourceKey, sourceType, source }) {
256
+ const info = await this.addSource({ sourceKey, sourceType, source });
257
+ // The torrent's piece pool. Both threads now hold the same memory, so a
258
+ // read can be answered with an offset instead of with bytes.
259
+ if (info.sharedBuffer) {
260
+ this.#poolBySource.set(sourceKey, info.sharedBuffer);
261
+ }
262
+ const client = this;
263
+ return {
264
+ infoHash: info.infoHash,
265
+ name: info.name,
266
+ // Carried so helpers that receive only the torrent can still name it to
267
+ // the worker.
268
+ sourceKey,
269
+ files: info.files.map((file) => ({
270
+ ...file,
271
+ /**
272
+ * @param {{ start?: number, end?: number }} [options]
273
+ * @returns {ReadableStream<Uint8Array>}
274
+ */
275
+ createReadStream(options = {}) {
276
+ // Node stream, not a web one: Fastify replies and the ffmpeg pipe
277
+ // both expect that shape, and every existing call site passes the
278
+ // result straight to one of them. `Readable.fromWeb` adds no copy —
279
+ // it wraps the same buffers.
280
+ return Readable.fromWeb(
281
+ client.createReadStream({
282
+ sourceKey,
283
+ fileIndex: file.index,
284
+ start: options.start ?? null,
285
+ end: options.end ?? null
286
+ })
287
+ );
288
+ }
289
+ }))
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Shut the torrent client down and stop the thread.
295
+ *
296
+ * @returns {Promise<void>}
297
+ */
298
+ async destroyAll() {
299
+ try {
300
+ await this.#caller.call(Command.DESTROY_ALL, {});
301
+ } catch {
302
+ // Already gone — termination below is what matters.
303
+ }
304
+ await this.#worker.terminate();
305
+ }
306
+ }