@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
package/routes/stream/get.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* and HTTP 200 for full-file requests.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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" }]]);
|