@torrent-tv/proxy 2.9.76 → 2.9.77

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,10 @@
1
+ ## 2.9.77
2
+
3
+ - **Fix**: Anything naming a source while that source was still being added got `Unknown source` — which is false, because the source exists and is merely not ready. Adding a magnet takes as long as its metadata does, seconds to tens of seconds, and the browser polls stats and asks for a playback plan throughout that window. The worker registered the torrent only once the add had **finished**; it now registers the pending add itself, so callers wait for it. Reproduced with a magnet nobody seeds: stats, the file listing and a read all failed instantly while the add was in flight, and all three now wait. A source that was never added is still an error, and a failed add is forgotten rather than replayed to every later caller.
4
+ - **Fix**: File claims are held per reader instead of per file. The proxy reads one file from several places at once — ffmpeg's input, the keyframe index, the codec probe, a second viewer — and claims keyed by `sourceKey:fileIndex` were therefore shared: the first reader to finish released the hold while the others were still reading, leaving the data free to be evicted under them. Each acquire now returns its own claim identity and a release names exactly that claim, so a duplicate or late release matches nothing, is logged, and harms no one. A counter would have restored the arithmetic but kept the ambiguity.
5
+ - **Fix**: `HEAD /stream` no longer starts a read of the whole file. Fastify serves HEAD from the GET handler, so a HEAD opened a full-file read whose body Node discarded while the read itself ran on, the response never completed, and the next request on that keep-alive connection waited behind it — measured in the field as headers in 23 ms followed by a 15 s stall, which is where a 73 s transcode-session create came from. It also has to report the real size: the keyframe index asks for it with this very request and treats zero as "no index", silently falling back to an invented segment grid, so the response is written to the raw socket rather than through `reply.send()`, which substitutes `content-length: 0` for an empty payload.
6
+ - **Chore**: `prefetchFileEdges` takes an options object at every layer, matching `TorrentPool`. The worker adapter declared positional parameters instead, so the planner's options object arrived as `headBytes` and only worked because it was passed along far enough to be destructured at the far end; anyone calling it as documented silently got the defaults.
7
+
1
8
  ## 2.9.76
2
9
 
3
10
  - **Fix**: A read that failed inside the torrent thread left the reader waiting forever, and a read that failed part-way looked like a file that had simply ended. Two halves of one hole, both present since the thread split: the worker sent the end-of-read marker from its `finally` even when the read had thrown, and the main thread had no handler for a read error at all — so the report was dropped as unknown. That is why the 2.9.71 defect took three releases to find: every symptom said "empty file", never "this read failed, here is why". Now the marker is sent only on success and the failure fails the caller's stream. Covered end to end by a test that hung before the fix.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.76",
3
+ "version": "2.9.77",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1,116 +1,141 @@
1
- /**
2
- * @file Byte-range aware torrent file streaming endpoint.
3
- *
4
- * Accepts either a `sourceKey` (registered via POST /api/sources) or a raw
5
- * `sourceType` + `source` pair. Responds with HTTP 206 for range requests
6
- * and HTTP 200 for full-file requests.
7
- */
8
-
9
- import { parseRange } from "../../utils/parse-range.js";
10
-
11
- /**
12
- * Resolve source parameters from the query string.
13
- * Prefers a registered `sourceKey`; falls back to inline `sourceType`+`source`.
14
- *
15
- * @param {import("fastify").FastifyRequest["query"]} query
16
- * @param {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
17
- * @returns {{ sourceType: string, source: string }}
18
- */
19
- function getSourceParams(query, sourceRegistry) {
20
- const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey : "";
21
- const sourceTypeFromQuery = typeof query.sourceType === "string" ? query.sourceType : "";
22
- const sourceFromQuery = typeof query.source === "string" ? query.source : "";
23
-
24
- const sourceRecord = sourceKey ? sourceRegistry.get(sourceKey) : null;
25
- const sourceType = sourceRecord?.sourceType ?? sourceTypeFromQuery;
26
- const source = sourceRecord?.source ?? sourceFromQuery;
27
- return { sourceType, source };
28
- }
29
-
30
- /**
31
- * Stream a torrent file over HTTP with byte-range support.
32
- *
33
- * GET /stream
34
- *
35
- * @param {import("fastify").FastifyRequest} req
36
- * @param {import("fastify").FastifyReply} reply
37
- * @param {{ sourceRegistry: ReturnType<import("../../store/source-registry.js").createSourceRegistry>, torrentPool: import("../../services/torrent-pool.js").TorrentPool }} deps
38
- * @returns {Promise<void>}
39
- */
40
- export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool }) {
41
- const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
42
- const fileIndex = Number(fileIndexRaw);
43
- const { sourceType, source } = getSourceParams(req.query, sourceRegistry);
44
-
45
- if (!sourceType || !source || !Number.isInteger(fileIndex) || fileIndex < 0) {
46
- return reply
47
- .code(400)
48
- .send({ error: "sourceKey or sourceType+source with fileIndex are required." });
49
- }
50
-
51
- let torrent;
52
- try {
53
- torrent = await torrentPool.getTorrent(sourceType, source);
54
- } catch (error) {
55
- const message = error instanceof Error ? error.message : String(error);
56
- return reply.code(500).send({ error: `Failed to load torrent source: ${message}` });
57
- }
58
-
59
- const file = torrent.files[fileIndex];
60
- if (!file) {
61
- return reply.code(404).send({ error: "File index was not found in torrent." });
62
- }
63
-
64
- const releaseFile = torrentPool.acquireFile(torrent, fileIndex);
65
-
66
- const range = parseRange(req.headers.range, file.length);
67
- // Prioritize the pieces at this read position so a seek (a request at a new
68
- // byte offset) downloads first instead of waiting behind the sequential
69
- // backlog this is what caused ~15-18 s stalls when seeking into an
70
- // undownloaded region.
71
- torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0);
72
- reply.header("Accept-Ranges", "bytes");
73
- reply.header("Content-Type", "application/octet-stream");
74
- reply.header("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`);
75
-
76
- if (!range) {
77
- reply.header("Content-Length", String(file.length));
78
- const stream = file.createReadStream();
79
- bindRelease(stream, reply, releaseFile);
80
- return reply.send(stream);
81
- }
82
-
83
- const contentLength = range.end - range.start + 1;
84
- reply.code(206);
85
- reply.header("Content-Length", String(contentLength));
86
- reply.header("Content-Range", `bytes ${range.start}-${range.end}/${file.length}`);
87
- const stream = file.createReadStream({ start: range.start, end: range.end });
88
- bindRelease(stream, reply, releaseFile);
89
- return reply.send(stream);
90
- }
91
-
92
- /**
93
- * Attach event listeners that release the file reference exactly once when
94
- * the stream or the underlying HTTP connection closes.
95
- *
96
- * @param {import("node:stream").Readable} stream
97
- * @param {import("fastify").FastifyReply} reply
98
- * @param {() => void} release
99
- * @returns {void}
100
- */
101
- function bindRelease(stream, reply, release) {
102
- let released = false;
103
- const releaseOnce = () => {
104
- if (released) {
105
- return;
106
- }
107
- released = true;
108
- release();
109
- };
110
-
111
- stream.on("close", releaseOnce);
112
- stream.on("end", releaseOnce);
113
- stream.on("error", releaseOnce);
114
- reply.raw.once("close", releaseOnce);
115
- reply.raw.once("finish", releaseOnce);
116
- }
1
+ /**
2
+ * @file Byte-range aware torrent file streaming endpoint.
3
+ *
4
+ * Accepts either a `sourceKey` (registered via POST /api/sources) or a raw
5
+ * `sourceType` + `source` pair. Responds with HTTP 206 for range requests
6
+ * and HTTP 200 for full-file requests.
7
+ */
8
+
9
+ import { parseRange } from "../../utils/parse-range.js";
10
+
11
+ /**
12
+ * Resolve source parameters from the query string.
13
+ * Prefers a registered `sourceKey`; falls back to inline `sourceType`+`source`.
14
+ *
15
+ * @param {import("fastify").FastifyRequest["query"]} query
16
+ * @param {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
17
+ * @returns {{ sourceType: string, source: string }}
18
+ */
19
+ function getSourceParams(query, sourceRegistry) {
20
+ const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey : "";
21
+ const sourceTypeFromQuery = typeof query.sourceType === "string" ? query.sourceType : "";
22
+ const sourceFromQuery = typeof query.source === "string" ? query.source : "";
23
+
24
+ const sourceRecord = sourceKey ? sourceRegistry.get(sourceKey) : null;
25
+ const sourceType = sourceRecord?.sourceType ?? sourceTypeFromQuery;
26
+ const source = sourceRecord?.source ?? sourceFromQuery;
27
+ return { sourceType, source };
28
+ }
29
+
30
+ /**
31
+ * Stream a torrent file over HTTP with byte-range support.
32
+ *
33
+ * GET /stream
34
+ *
35
+ * @param {import("fastify").FastifyRequest} req
36
+ * @param {import("fastify").FastifyReply} reply
37
+ * @param {{ sourceRegistry: ReturnType<import("../../store/source-registry.js").createSourceRegistry>, torrentPool: import("../../services/torrent-pool.js").TorrentPool }} deps
38
+ * @returns {Promise<void>}
39
+ */
40
+ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool }) {
41
+ const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
42
+ const fileIndex = Number(fileIndexRaw);
43
+ const { sourceType, source } = getSourceParams(req.query, sourceRegistry);
44
+
45
+ if (!sourceType || !source || !Number.isInteger(fileIndex) || fileIndex < 0) {
46
+ return reply
47
+ .code(400)
48
+ .send({ error: "sourceKey or sourceType+source with fileIndex are required." });
49
+ }
50
+
51
+ let torrent;
52
+ try {
53
+ torrent = await torrentPool.getTorrent(sourceType, source);
54
+ } catch (error) {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ return reply.code(500).send({ error: `Failed to load torrent source: ${message}` });
57
+ }
58
+
59
+ const file = torrent.files[fileIndex];
60
+ if (!file) {
61
+ return reply.code(404).send({ error: "File index was not found in torrent." });
62
+ }
63
+
64
+ // HEAD asks what a GET would return, not for the bytes. Fastify serves HEAD
65
+ // from this same handler, which used to mean a HEAD started a read of the
66
+ // WHOLE file: the body was discarded by Node, but the read ran on, the
67
+ // response never finished, and the next request on that keep-alive connection
68
+ // waited behind it. Measured on the field host: the keyframe-index HEAD
69
+ // returned headers in 23 ms and then held the connection until its 15 s
70
+ // timeout, which is where the 73 s transcode-session create went.
71
+ if (req.method === "HEAD") {
72
+ // Written to the raw response on purpose. Answering through `reply.send()`
73
+ // with no payload makes Fastify set `content-length: 0`, which is worse
74
+ // than useless here: the keyframe index asks for the file size with this
75
+ // very request and treats 0 as "no index available", silently falling back
76
+ // to an invented segment grid. Hijacking leaves the response to us, and
77
+ // Node omits the body for HEAD by itself.
78
+ reply.hijack();
79
+ reply.raw.writeHead(200, {
80
+ "Accept-Ranges": "bytes",
81
+ "Content-Type": "application/octet-stream",
82
+ "Content-Length": String(file.length),
83
+ "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`
84
+ });
85
+ reply.raw.end();
86
+ return;
87
+ }
88
+
89
+ const releaseFile = torrentPool.acquireFile(torrent, fileIndex);
90
+
91
+ const range = parseRange(req.headers.range, file.length);
92
+ // Prioritize the pieces at this read position so a seek (a request at a new
93
+ // byte offset) downloads first instead of waiting behind the sequential
94
+ // backlog this is what caused ~15-18 s stalls when seeking into an
95
+ // undownloaded region.
96
+ torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0);
97
+ reply.header("Accept-Ranges", "bytes");
98
+ reply.header("Content-Type", "application/octet-stream");
99
+ reply.header("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`);
100
+
101
+ if (!range) {
102
+ reply.header("Content-Length", String(file.length));
103
+ const stream = file.createReadStream();
104
+ bindRelease(stream, reply, releaseFile);
105
+ return reply.send(stream);
106
+ }
107
+
108
+ const contentLength = range.end - range.start + 1;
109
+ reply.code(206);
110
+ reply.header("Content-Length", String(contentLength));
111
+ reply.header("Content-Range", `bytes ${range.start}-${range.end}/${file.length}`);
112
+ const stream = file.createReadStream({ start: range.start, end: range.end });
113
+ bindRelease(stream, reply, releaseFile);
114
+ return reply.send(stream);
115
+ }
116
+
117
+ /**
118
+ * Attach event listeners that release the file reference exactly once when
119
+ * the stream or the underlying HTTP connection closes.
120
+ *
121
+ * @param {import("node:stream").Readable} stream
122
+ * @param {import("fastify").FastifyReply} reply
123
+ * @param {() => void} release
124
+ * @returns {void}
125
+ */
126
+ function bindRelease(stream, reply, release) {
127
+ let released = false;
128
+ const releaseOnce = () => {
129
+ if (released) {
130
+ return;
131
+ }
132
+ released = true;
133
+ release();
134
+ };
135
+
136
+ stream.on("close", releaseOnce);
137
+ stream.on("end", releaseOnce);
138
+ stream.on("error", releaseOnce);
139
+ reply.raw.once("close", releaseOnce);
140
+ reply.raw.once("finish", releaseOnce);
141
+ }