@torrent-tv/proxy 2.9.76 → 2.9.78
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 +13 -0
- package/package.json +1 -1
- package/routes/stream/get.js +141 -116
- package/services/piece-store/shared-piece-store.js +488 -416
- package/services/torrent-worker/client.js +306 -275
- package/services/torrent-worker/file-claims.js +91 -0
- package/services/torrent-worker/piece-reader.js +175 -0
- package/services/torrent-worker/pool-adapter.js +188 -179
- package/services/torrent-worker/protocol.js +12 -0
- package/services/torrent-worker/worker.js +153 -68
- package/test/file-claims.test.js +64 -0
- package/test/piece-reader.test.js +162 -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,16 @@
|
|
|
1
|
+
## 2.9.78
|
|
2
|
+
|
|
3
|
+
- **New**: Reads cross the thread boundary as **positions instead of bytes**. The pieces already live in a `SharedArrayBuffer`, so the torrent thread now sends an offset and a length and the main thread reads those bytes where they lie. What this removes is the copy that used to sit on the critical path — 18.84 ms per 10 MB segment on the field host, spent in the same thread that runs the torrent, at the moment a viewer is waiting for that segment. A piece is **pinned** for as long as a fragment of it is outstanding, and unpinned only once the main thread confirms it has finished reading, so eviction cannot take the memory out from under a reader; one fragment is in flight at a time, because the store guarantees only two resident pieces at its smallest budget and holding two pins while asking for a third would deadlock it. Verified against a partially downloaded 5.5 GB torrent: a range read whole matches the same range read in parts, a read spanning a piece boundary matches its two halves, and ffmpeg parses the file through this path (`matroska h264/ac3 5939 s`). The arithmetic is covered by tests, including a file that does not start on a piece boundary — the case where treating file offsets as torrent offsets returns the right number of wrong bytes.
|
|
4
|
+
- **Chore**: `SharedPieceStore` gained `reside`, which brings a piece into memory and reports where it sits without the copy `get` has to make (WebTorrent keeps what `get` returns), and `findSharedStore`, which walks WebTorrent's store wrappers to reach ours rather than assuming their number or order.
|
|
5
|
+
- **Known**: the copy is not gone from the system, only from the torrent thread — the main thread still copies each fragment out of the pool before handing it on, because nothing tells us when the socket has finished with those bytes, and releasing the piece earlier would risk serving whatever landed in the slot next. Removing that last copy needs the body write to report completion, which is a change to the stream route rather than to this transport.
|
|
6
|
+
|
|
7
|
+
## 2.9.77
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
|
|
1
14
|
## 2.9.76
|
|
2
15
|
|
|
3
16
|
- **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
|
+
}
|