@torrent-tv/proxy 2.87.19 → 2.87.20

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/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 2.87.20
2
+
3
+ - **Fix**: Expose completed files through the worker pool adapter so source HTTP reads use the assembled file; open it before sending headers and return to torrent reads if it has already been removed.
4
+
1
5
  ## 2.87.19
2
6
 
3
7
  - **Fix**: Return shared source bytes after whole-file assembly and retain the buffer held by concurrent readers instead of replacing it; fail malformed fragments explicitly rather than skipping source bytes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.87.19",
3
+ "version": "2.87.20",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -6,7 +6,7 @@
6
6
  * and HTTP 200 for full-file requests.
7
7
  */
8
8
 
9
- import { createReadStream } from "node:fs";
9
+ import { open } from "node:fs/promises";
10
10
  import { parseRange } from "../../utils/parse-range.js";
11
11
  import { logger } from "../../utils/logger.js";
12
12
 
@@ -107,9 +107,10 @@ function wholeFileFor(torrentPool, sourceKey, fileIndex) {
107
107
  * @param {import("fastify").FastifyRequest} req
108
108
  * @param {import("fastify").FastifyReply} reply
109
109
  * @param {{ path: string, length: number, name: string }} file
110
+ * @param {import("node:fs/promises").FileHandle} handle - Already open; closed for HEAD.
110
111
  * @returns {Promise<void> | void}
111
112
  */
112
- function serveWholeFile(req, reply, file) {
113
+ function serveWholeFile(req, reply, file, handle) {
113
114
  const disposition = `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`;
114
115
  if (req.method === "HEAD") {
115
116
  reply.hijack();
@@ -128,13 +129,13 @@ function serveWholeFile(req, reply, file) {
128
129
  const range = parseRange(req.headers.range, file.length);
129
130
  if (!range) {
130
131
  reply.header("Content-Length", String(file.length));
131
- return reply.send(createReadStream(file.path));
132
+ return reply.send(handle.createReadStream());
132
133
  }
133
134
  const { start, end } = range;
134
135
  reply.code(206);
135
136
  reply.header("Content-Length", String(end - start + 1));
136
137
  reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
137
- return reply.send(createReadStream(file.path, { start, end }));
138
+ return reply.send(handle.createReadStream({ start, end }));
138
139
  }
139
140
 
140
141
  export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null }) {
@@ -157,7 +158,25 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
157
158
  // nothing the torrent would have told us.
158
159
  const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
159
160
  if (whole) {
160
- return serveWholeFile(req, reply, whole);
161
+ let handle;
162
+ try {
163
+ // Open before committing headers: the disk owner may have removed an
164
+ // announced file. An open descriptor also preserves a live POSIX read.
165
+ handle = await open(whole.path, "r");
166
+ } catch (error) {
167
+ if (error.code !== "ENOENT") throw error;
168
+ const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
169
+ if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
170
+ }
171
+ if (handle) {
172
+ if (req.method === "HEAD") await handle.close();
173
+ try {
174
+ return serveWholeFile(req, reply, whole, handle);
175
+ } catch (error) {
176
+ await handle.close().catch(() => undefined);
177
+ throw error;
178
+ }
179
+ }
161
180
  }
162
181
 
163
182
  let torrent;
@@ -36,9 +36,10 @@ export class WorkerTorrentPool {
36
36
 
37
37
  /**
38
38
  * @param {{ memoryBytes?: number, stateDir?: string }} [options]
39
+ * @param {object | null} [client] - The worker interface; tests supply a fake.
39
40
  */
40
- constructor(options = {}) {
41
- this.#client = new TorrentWorkerClient(options);
41
+ constructor(options = {}, client = null) {
42
+ this.#client = client ?? new TorrentWorkerClient(options);
42
43
  }
43
44
 
44
45
  /**
@@ -95,6 +96,11 @@ export class WorkerTorrentPool {
95
96
  return this.#client.wholeFileBytes ?? 0;
96
97
  }
97
98
 
99
+ /** Completed files announced by the worker, shared with the HTTP reader. */
100
+ get wholeFiles() {
101
+ return this.#client.wholeFiles;
102
+ }
103
+
98
104
  allowWholeFileBytes(bytes) {
99
105
  return this.#client.allowWholeFileBytes(bytes);
100
106
  }
@@ -14,6 +14,7 @@ import fs from "node:fs/promises";
14
14
  import os from "node:os";
15
15
  import path from "node:path";
16
16
  import { handleStreamGet } from "../routes/stream/get.js";
17
+ import { WorkerTorrentPool } from "../services/torrent/worker/pool-adapter.js";
17
18
 
18
19
  /**
19
20
  * Minimal stand-ins for the parts of Fastify and the pool this route touches.
@@ -151,13 +152,18 @@ test("a file downloaded whole is served from disk without touching the torrent",
151
152
  try {
152
153
  const { req, reply, sent, deps } = harness({ method: "GET", range: "bytes=2-6" });
153
154
  let asked = false;
154
- deps.torrentPool.getTorrent = async () => {
155
- asked = true;
156
- throw new Error("the torrent was asked for, which is what this avoids");
157
- };
158
- deps.torrentPool.wholeFiles = new Map([
155
+ const wholeFiles = new Map([
159
156
  ["abc/0", { path: where, length: bytes.length, name: "film.mkv" }]
160
157
  ]);
158
+ // Supply the worker boundary explicitly: no Worker or torrent is started.
159
+ deps.torrentPool = new WorkerTorrentPool({}, {
160
+ wholeFiles,
161
+ async getTorrent() {
162
+ asked = true;
163
+ throw new Error("the worker must not be asked for completed bytes");
164
+ }
165
+ });
166
+ assert.equal(deps.torrentPool.wholeFiles, wholeFiles);
161
167
  // The source key IS the identity — `torrent:<infohash>` — and it is all the
162
168
  // route needs to find the file.
163
169
  deps.sourceRegistry = { get: () => ({ sourceType: "torrent", source: "magnet:?xt=urn:btih:abc" }) };
@@ -169,11 +175,32 @@ test("a file downloaded whole is served from disk without touching the torrent",
169
175
  assert.equal(sent.code, 206);
170
176
  assert.equal(sent.headers["content-range"], `bytes 2-6/${bytes.length}`);
171
177
  assert.equal(sent.headers["content-length"], "5");
178
+ const chunks = [];
179
+ for await (const chunk of sent.body) chunks.push(chunk);
180
+ assert.deepEqual(Buffer.concat(chunks), bytes.subarray(2, 7));
172
181
  } finally {
173
182
  await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
174
183
  }
175
184
  });
176
185
 
186
+ test("an announced file removed before opening falls back to the torrent before headers", async () => {
187
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "stream-removed-"));
188
+ try {
189
+ const { req, reply, sent, opened, deps } = harness({ method: "GET", range: "bytes=2-6" });
190
+ deps.torrentPool.wholeFiles = new Map([
191
+ ["abc/0", { path: path.join(root, "missing"), length: 10, name: "gone.mkv" }]
192
+ ]);
193
+ deps.sourceRegistry = { get: () => ({ sourceType: "magnet", source: "magnet:?xt=urn:btih:abc" }) };
194
+ req.query = { sourceKey: "torrent:abc", fileIndex: "0" };
195
+ await handleStreamGet(req, reply, deps);
196
+ assert.deepEqual(opened, ["2-6"]);
197
+ assert.equal(deps.torrentPool.wholeFiles.size, 0);
198
+ assert.equal(sent.headers["content-range"], "bytes 2-6/5869669065");
199
+ } finally {
200
+ await fs.rm(root, { recursive: true, force: true });
201
+ }
202
+ });
203
+
177
204
  test("a file this proxy does not have whole still goes to the torrent", async () => {
178
205
  const { req, reply, state, deps } = harness({ method: "GET", range: "bytes=0-99" });
179
206
  deps.torrentPool.wholeFiles = new Map([["other/7", { path: "/nowhere", length: 1, name: "x" }]]);