@torrent-tv/proxy 2.101.20 → 2.101.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.101.20",
3
+ "version": "2.101.21",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { open } from "node:fs/promises";
10
+ import { Readable } from "node:stream";
10
11
  import { parseRange } from "../../utils/parse-range.js";
11
12
  import { logger } from "../../utils/logger.js";
12
13
 
@@ -109,49 +110,66 @@ function wholeFileFor(torrentPool, sourceKey, fileIndex) {
109
110
  }
110
111
 
111
112
  /**
112
- * Open the whole file a response under way can continue from, if there is one.
113
+ * Acquire the whole file a response can continue from, if there is one.
113
114
  *
114
- * Any failure to open it leaves the response on the torrent, which can still
115
+ * Any failure to acquire it leaves the response on the torrent, which can still
115
116
  * answer; a file the disk owner removed is also forgotten here, as the route
116
117
  * does before headers.
117
118
  *
118
- * @param {{ wholeFiles?: Map<string, { path: string, length: number, name: string }> }} torrentPool
119
+ * @param {{ wholeFiles?: Map<string, { path: string, length: number, name: string }>, acquireWholeFile: (source: { sourceKey: string, fileIndex: number }) => Promise<{ length: number, stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> } | null> }} torrentPool
119
120
  * @param {string} sourceKey
120
121
  * @param {number} fileIndex
121
122
  * @param {number} length - The length the response was promised for.
122
- * @returns {Promise<import("node:fs/promises").FileHandle | null>}
123
+ * @returns {Promise<{ length: number, stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> } | null>}
123
124
  */
124
- async function openWholeFileToContinue(torrentPool, sourceKey, fileIndex, length) {
125
+ async function acquireWholeFileRead(torrentPool, sourceKey, fileIndex, length) {
125
126
  const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
126
127
  if (!whole || whole.length !== length) {
127
128
  return null;
128
129
  }
129
130
  try {
130
- return await open(whole.path, "r");
131
+ const read = await torrentPool.acquireWholeFile({ sourceKey, fileIndex });
132
+ if (!read) {
133
+ forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
134
+ return null;
135
+ }
136
+ if (read.length !== length) {
137
+ await read.release();
138
+ forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
139
+ return null;
140
+ }
141
+ return read;
131
142
  } catch (error) {
132
143
  if (error.code === "ENOENT") {
133
- const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
134
- if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
144
+ forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
135
145
  }
136
146
  return null;
137
147
  }
138
148
  }
139
149
 
150
+ function forgetWholeFile(torrentPool, sourceKey, fileIndex, whole) {
151
+ const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
152
+ if (torrentPool.wholeFiles?.get(key) === whole) torrentPool.wholeFiles.delete(key);
153
+ }
154
+
140
155
  /**
141
156
  * Write bytes `from`..`end` of an open file to a response, each write waited
142
157
  * for, and close the file.
143
158
  *
144
159
  * @param {import("node:http").ServerResponse} raw
145
- * @param {import("node:fs/promises").FileHandle} handle
160
+ * @param {{ stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> }} read
146
161
  * @param {number} from - Inclusive.
147
162
  * @param {number} end - Inclusive.
148
163
  * @param {(length: number) => void} onWritten
149
164
  * @returns {Promise<number>} Bytes written.
150
165
  */
151
- async function writeFromWholeFile(raw, handle, from, end, onWritten) {
166
+ async function writeFromWholeFile(raw, read, from, end, onWritten) {
152
167
  let written = 0;
168
+ let stream = null;
153
169
  try {
154
- for await (const chunk of handle.createReadStream({ start: from, end, autoClose: false })) {
170
+ stream = read.stream(from, end, () => {});
171
+ if (!stream) throw new Error("Completed file did not provide the admitted range.");
172
+ for await (const chunk of stream) {
155
173
  if (raw.writableEnded || raw.destroyed) {
156
174
  break;
157
175
  }
@@ -162,7 +180,8 @@ async function writeFromWholeFile(raw, handle, from, end, onWritten) {
162
180
  onWritten(chunk.length);
163
181
  }
164
182
  } finally {
165
- await handle.close().catch(() => undefined);
183
+ stream?.destroy();
184
+ await read.release();
166
185
  }
167
186
  return written;
168
187
  }
@@ -173,35 +192,41 @@ async function writeFromWholeFile(raw, handle, from, end, onWritten) {
173
192
  * @param {import("fastify").FastifyRequest} req
174
193
  * @param {import("fastify").FastifyReply} reply
175
194
  * @param {{ path: string, length: number, name: string }} file
176
- * @param {import("node:fs/promises").FileHandle} handle - Already open; closed for HEAD.
195
+ * @param {{ stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> }} read
177
196
  * @returns {Promise<void> | void}
178
197
  */
179
- function serveWholeFile(req, reply, file, handle) {
198
+ function serveWholeFile(req, reply, file, read) {
180
199
  const disposition = `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`;
181
- if (req.method === "HEAD") {
182
- reply.hijack();
183
- reply.raw.writeHead(200, {
184
- "Accept-Ranges": "bytes",
185
- "Content-Type": "application/octet-stream",
186
- "Content-Length": String(file.length),
187
- "Content-Disposition": disposition
188
- });
189
- reply.raw.end();
190
- return;
191
- }
192
200
  reply.header("Accept-Ranges", "bytes");
193
201
  reply.header("Content-Type", "application/octet-stream");
194
202
  reply.header("Content-Disposition", disposition);
195
203
  const range = parseRange(req.headers.range, file.length);
204
+ if (file.length === 0) {
205
+ reply.header("Content-Length", "0");
206
+ void read.release().catch(() => undefined);
207
+ return reply.send(Readable.from([]));
208
+ }
209
+ const start = range?.start ?? 0;
210
+ const end = range?.end ?? file.length - 1;
211
+ const stream = read.stream(start, end, () => {});
212
+ if (!stream) {
213
+ void read.release().catch(() => undefined);
214
+ return null;
215
+ }
196
216
  if (!range) {
197
217
  reply.header("Content-Length", String(file.length));
198
- return reply.send(handle.createReadStream());
218
+ } else {
219
+ reply.code(206);
220
+ reply.header("Content-Length", String(end - start + 1));
221
+ reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
222
+ }
223
+ void read.release().catch(() => undefined);
224
+ try {
225
+ return reply.send(stream);
226
+ } catch (error) {
227
+ stream.destroy();
228
+ throw error;
199
229
  }
200
- const { start, end } = range;
201
- reply.code(206);
202
- reply.header("Content-Length", String(end - start + 1));
203
- reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
204
- return reply.send(handle.createReadStream({ start, end }));
205
230
  }
206
231
 
207
232
  export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null, noteInputWaiting = null }) {
@@ -224,23 +249,36 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
224
249
  // nothing the torrent would have told us.
225
250
  const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
226
251
  if (whole) {
227
- let handle;
228
- try {
229
- // Open before committing headers: the disk owner may have removed an
230
- // announced file. An open descriptor also preserves a live POSIX read.
231
- handle = await open(whole.path, "r");
232
- } catch (error) {
233
- if (error.code !== "ENOENT") throw error;
234
- const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
235
- if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
236
- }
237
- if (handle) {
238
- if (req.method === "HEAD") await handle.close();
252
+ if (req.method === "HEAD") {
253
+ let handle;
239
254
  try {
240
- return serveWholeFile(req, reply, whole, handle);
255
+ handle = await open(whole.path, "r");
241
256
  } catch (error) {
242
- await handle.close().catch(() => undefined);
243
- throw error;
257
+ if (error.code !== "ENOENT") throw error;
258
+ forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
259
+ }
260
+ if (handle) {
261
+ await handle.close();
262
+ reply.hijack();
263
+ reply.raw.writeHead(200, {
264
+ "Accept-Ranges": "bytes",
265
+ "Content-Type": "application/octet-stream",
266
+ "Content-Length": String(whole.length),
267
+ "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(whole.name)}`
268
+ });
269
+ reply.raw.end();
270
+ return;
271
+ }
272
+ } else {
273
+ const read = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, whole.length);
274
+ if (read) {
275
+ try {
276
+ const sent = serveWholeFile(req, reply, whole, read);
277
+ if (sent) return sent;
278
+ } catch (error) {
279
+ await read.release().catch(() => undefined);
280
+ throw error;
281
+ }
244
282
  }
245
283
  }
246
284
  }
@@ -338,7 +376,7 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
338
376
  fragment.release();
339
377
  break;
340
378
  }
341
- continueFrom = await openWholeFileToContinue(torrentPool, sourceKey, fileIndex, file.length);
379
+ continueFrom = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, file.length);
342
380
  if (continueFrom) {
343
381
  fragment.release();
344
382
  break;
@@ -368,7 +406,7 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
368
406
  }
369
407
  } catch (error) {
370
408
  if (sent >= contentLength || req.raw.aborted || reply.raw.destroyed) throw error;
371
- continueFrom = await openWholeFileToContinue(torrentPool, sourceKey, fileIndex, file.length);
409
+ continueFrom = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, file.length);
372
410
  if (!continueFrom) throw error;
373
411
  }
374
412
  if (continueFrom) {