@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 +7 -0
- package/package.json +1 -1
- package/routes/stream/get.js +141 -116
- package/services/piece-store/shared-piece-store.js +416 -416
- package/services/torrent-worker/client.js +271 -275
- package/services/torrent-worker/file-claims.js +91 -0
- package/services/torrent-worker/pool-adapter.js +188 -179
- package/services/torrent-worker/worker.js +60 -34
- package/test/file-claims.test.js +64 -0
- package/test/stream-route.test.js +121 -0
- package/test/worker-source-race.test.js +76 -0
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
package/routes/stream/get.js
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
stream.
|
|
113
|
-
stream
|
|
114
|
-
reply.
|
|
115
|
-
|
|
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
|
+
}
|