@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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Who is holding which file open.
|
|
3
|
+
*
|
|
4
|
+
* A claim keeps a file's data from being removed while something is reading it.
|
|
5
|
+
* Until 2.9.77 claims were keyed by `sourceKey:fileIndex`, which quietly made
|
|
6
|
+
* them **shared**: a second reader of the same file found the key taken and
|
|
7
|
+
* added nothing, so the first reader to finish released the claim out from
|
|
8
|
+
* under the second. The proxy reads one file from several places at once —
|
|
9
|
+
* ffmpeg's input, the keyframe index, the codec probe, a second viewer — so
|
|
10
|
+
* this was the normal case, not an edge one.
|
|
11
|
+
*
|
|
12
|
+
* The fix is not a counter. A counter restores the arithmetic but keeps the
|
|
13
|
+
* ambiguity: a release names a file, not a claim, so a duplicate or late
|
|
14
|
+
* release still decrements someone else's hold and nothing can detect it. Here
|
|
15
|
+
* every claim gets its own identity and a release names exactly that claim —
|
|
16
|
+
* so a stray release matches nothing, is reported, and harms no one.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} FileClaims
|
|
21
|
+
* @property {(sourceKey: string, fileIndex: number, release: () => void) => string} open
|
|
22
|
+
* @property {(claimId: string) => boolean} close
|
|
23
|
+
* @property {() => void} closeAll
|
|
24
|
+
* @property {number} size
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Track file claims by identity.
|
|
29
|
+
*
|
|
30
|
+
* @returns {FileClaims}
|
|
31
|
+
*/
|
|
32
|
+
export function createFileClaims() {
|
|
33
|
+
/** Claim id → the function that releases that one claim. */
|
|
34
|
+
const claims = new Map();
|
|
35
|
+
let counter = 0;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
/**
|
|
39
|
+
* Record a new claim and return its identity.
|
|
40
|
+
*
|
|
41
|
+
* Each call is a distinct claim even for the same file — that is the whole
|
|
42
|
+
* point.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} sourceKey
|
|
45
|
+
* @param {number} fileIndex
|
|
46
|
+
* @param {() => void} release
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
open(sourceKey, fileIndex, release) {
|
|
50
|
+
counter += 1;
|
|
51
|
+
// The file is in the id purely so a log line reads usefully; matching is
|
|
52
|
+
// on the whole string.
|
|
53
|
+
const claimId = `${sourceKey}:${fileIndex}:${counter}`;
|
|
54
|
+
claims.set(claimId, release);
|
|
55
|
+
return claimId;
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Release one claim. Returns false when there was no such claim, which is
|
|
60
|
+
* worth logging: it means a release arrived twice, or after teardown.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} claimId
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
close(claimId) {
|
|
66
|
+
const release = claims.get(claimId);
|
|
67
|
+
if (!release) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
claims.delete(claimId);
|
|
71
|
+
release();
|
|
72
|
+
return true;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Release everything — the worker is shutting down.
|
|
77
|
+
*
|
|
78
|
+
* @returns {void}
|
|
79
|
+
*/
|
|
80
|
+
closeAll() {
|
|
81
|
+
for (const [, release] of claims) {
|
|
82
|
+
release();
|
|
83
|
+
}
|
|
84
|
+
claims.clear();
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
get size() {
|
|
88
|
+
return claims.size;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Reading a byte range as positions in shared memory, not as bytes.
|
|
3
|
+
*
|
|
4
|
+
* The pieces already live in a `SharedArrayBuffer` the main thread can map. So
|
|
5
|
+
* the torrent thread does not need to hand over any bytes at all: it can say
|
|
6
|
+
* *where* a piece sits and let the other side read it there. What crosses the
|
|
7
|
+
* boundary is two numbers per piece.
|
|
8
|
+
*
|
|
9
|
+
* That is the whole point of the exercise. The alternative — copying each piece
|
|
10
|
+
* into memory we own and transferring it — costs 18.84 ms per 10 MB segment on
|
|
11
|
+
* the field host, and costs it **on the critical path**, in the thread that is
|
|
12
|
+
* also running the torrent, at the moment a viewer is waiting for that segment.
|
|
13
|
+
* Here the copy is gone entirely rather than moved.
|
|
14
|
+
*
|
|
15
|
+
* Two obligations come with it, and both are enforced rather than assumed:
|
|
16
|
+
*
|
|
17
|
+
* - a piece being read is **pinned**, so eviction cannot take the memory out
|
|
18
|
+
* from under the reader mid-read;
|
|
19
|
+
* - the pin is released only once the other thread reports it has finished
|
|
20
|
+
* with those bytes — not when they were sent, because nothing was sent.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { findSharedStore } from "../piece-store/shared-piece-store.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wait until a piece has been downloaded and verified.
|
|
27
|
+
*
|
|
28
|
+
* WebTorrent announces this as `verified`. The bitfield is re-checked after the
|
|
29
|
+
* listener is attached because the piece can complete in between, and a missed
|
|
30
|
+
* event here would wait forever.
|
|
31
|
+
*
|
|
32
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
33
|
+
* @param {number} index
|
|
34
|
+
* @param {{ isCancelled: () => boolean }} cancellation
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
function whenPieceReady(torrent, index, cancellation) {
|
|
38
|
+
if (torrent.bitfield?.get(index)) {
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
/** @param {number} verifiedIndex */
|
|
44
|
+
const onVerified = (verifiedIndex) => {
|
|
45
|
+
if (verifiedIndex === index) {
|
|
46
|
+
cleanup();
|
|
47
|
+
resolve();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const onDestroyed = () => {
|
|
51
|
+
cleanup();
|
|
52
|
+
reject(new Error(`Torrent went away while waiting for piece ${index}.`));
|
|
53
|
+
};
|
|
54
|
+
// Cancellation is polled rather than pushed: a superseded seek destroys the
|
|
55
|
+
// read, and without this the wait would outlive it and hold a pin.
|
|
56
|
+
const poll = setInterval(() => {
|
|
57
|
+
if (cancellation.isCancelled()) {
|
|
58
|
+
cleanup();
|
|
59
|
+
reject(new Error(`Read cancelled while waiting for piece ${index}.`));
|
|
60
|
+
}
|
|
61
|
+
}, 250);
|
|
62
|
+
|
|
63
|
+
function cleanup() {
|
|
64
|
+
clearInterval(poll);
|
|
65
|
+
torrent.removeListener("verified", onVerified);
|
|
66
|
+
torrent.removeListener("close", onDestroyed);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
torrent.on("verified", onVerified);
|
|
70
|
+
torrent.once("close", onDestroyed);
|
|
71
|
+
|
|
72
|
+
// The piece may have arrived between the check above and this listener.
|
|
73
|
+
if (torrent.bitfield?.get(index)) {
|
|
74
|
+
cleanup();
|
|
75
|
+
resolve();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A fragment of a read: where to find it, and how to let it go.
|
|
82
|
+
*
|
|
83
|
+
* @typedef {object} PieceFragment
|
|
84
|
+
* @property {number} pieceIndex
|
|
85
|
+
* @property {number} offset - Byte offset into the shared pool.
|
|
86
|
+
* @property {number} length
|
|
87
|
+
* @property {() => void} release - Drops this fragment's pin. Call exactly once.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Walk a byte range of a file, yielding each piece's position in shared memory.
|
|
92
|
+
*
|
|
93
|
+
* Yields at most one fragment per piece; the first and last are usually partial.
|
|
94
|
+
* The caller must `release()` every fragment it receives, including on failure —
|
|
95
|
+
* an unreleased pin permanently costs a slot.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} params
|
|
98
|
+
* @param {import("webtorrent").Torrent} params.torrent
|
|
99
|
+
* @param {number} params.fileIndex
|
|
100
|
+
* @param {number} params.start - Inclusive, relative to the file.
|
|
101
|
+
* @param {number} params.end - Inclusive, relative to the file.
|
|
102
|
+
* @param {{ isCancelled: () => boolean }} params.cancellation
|
|
103
|
+
* @returns {AsyncGenerator<PieceFragment>}
|
|
104
|
+
*/
|
|
105
|
+
export async function* readFragments({ torrent, fileIndex, start, end, cancellation }) {
|
|
106
|
+
const store = findSharedStore(torrent);
|
|
107
|
+
if (!store) {
|
|
108
|
+
throw new Error("This torrent is not backed by a shared piece store.");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const file = torrent.files?.[fileIndex];
|
|
112
|
+
if (!file) {
|
|
113
|
+
throw new Error(`File ${fileIndex} not found.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const pieceLength = torrent.pieceLength;
|
|
117
|
+
// Piece numbers are torrent-wide, so a file's own offsets have to be lifted
|
|
118
|
+
// into the torrent's address space first.
|
|
119
|
+
const absoluteStart = file.offset + start;
|
|
120
|
+
const absoluteEnd = file.offset + end;
|
|
121
|
+
const firstPiece = Math.floor(absoluteStart / pieceLength);
|
|
122
|
+
const lastPiece = Math.floor(absoluteEnd / pieceLength);
|
|
123
|
+
|
|
124
|
+
// Ask for these pieces first. `select` puts them in the download set at all;
|
|
125
|
+
// `critical` marks them as wanted now, which is what allows a piece to be
|
|
126
|
+
// fetched out of sequential order for a reader that is waiting on it.
|
|
127
|
+
if (typeof torrent.select === "function") {
|
|
128
|
+
torrent.select(firstPiece, lastPiece, 1);
|
|
129
|
+
}
|
|
130
|
+
if (typeof torrent.critical === "function") {
|
|
131
|
+
torrent.critical(firstPiece, lastPiece);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
|
|
135
|
+
if (cancellation.isCancelled()) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pieceStart = pieceIndex * pieceLength;
|
|
140
|
+
const fromWithinPiece = Math.max(absoluteStart, pieceStart) - pieceStart;
|
|
141
|
+
const toWithinPiece = Math.min(absoluteEnd, pieceStart + pieceLength - 1) - pieceStart;
|
|
142
|
+
|
|
143
|
+
await whenPieceReady(torrent, pieceIndex, cancellation);
|
|
144
|
+
|
|
145
|
+
// Pinned BEFORE it is located, and before any await that could let an
|
|
146
|
+
// eviction run: the offset is only meaningful while the piece is held.
|
|
147
|
+
store.pin(pieceIndex);
|
|
148
|
+
let located = null;
|
|
149
|
+
try {
|
|
150
|
+
located = await store.reside(pieceIndex);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
store.unpin(pieceIndex);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!located) {
|
|
157
|
+
store.unpin(pieceIndex);
|
|
158
|
+
throw new Error(`Piece ${pieceIndex} is verified but absent from the store.`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let releasedThisPiece = false;
|
|
162
|
+
yield {
|
|
163
|
+
pieceIndex,
|
|
164
|
+
offset: located.offset + fromWithinPiece,
|
|
165
|
+
length: toWithinPiece - fromWithinPiece + 1,
|
|
166
|
+
release() {
|
|
167
|
+
if (releasedThisPiece) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
releasedThisPiece = true;
|
|
171
|
+
store.unpin(pieceIndex);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -1,179 +1,188 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file `TorrentPool`'s interface, served from the worker thread.
|
|
3
|
-
*
|
|
4
|
-
* The routes, the planner, the health report and the session manager all reach
|
|
5
|
-
* for a torrent pool and use it the same handful of ways. Rather than rewrite
|
|
6
|
-
* every one of them to thread a `sourceKey` through and await what used to be
|
|
7
|
-
* immediate, this presents the shape they already expect and does the thread
|
|
8
|
-
* hop behind it. Swapping the implementation is then a one-line change at
|
|
9
|
-
* construction, and the call sites are untouched — which is what keeps a change
|
|
10
|
-
* of this size reviewable.
|
|
11
|
-
*
|
|
12
|
-
* Two accommodations are needed, and both are deliberate:
|
|
13
|
-
*
|
|
14
|
-
* - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
|
|
15
|
-
* nothing the caller inspects, so the command is dispatched and not awaited.
|
|
16
|
-
* `acquireFile` hands back a release function exactly as before, which sends
|
|
17
|
-
* its own command when called. Awaiting them would mean touching every call
|
|
18
|
-
* site for no observable gain.
|
|
19
|
-
* - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
|
|
20
|
-
* thread, so the worker keys them. Callers that have one pass it; the rest
|
|
21
|
-
* get one derived from the source itself, so the identity stays stable
|
|
22
|
-
* across calls for the same torrent.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import crypto from "node:crypto";
|
|
26
|
-
import { TorrentWorkerClient } from "./client.js";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Stable key for a source, matching how the worker keys its torrents.
|
|
30
|
-
*
|
|
31
|
-
* Derived from the source itself rather than handed out per request, so two
|
|
32
|
-
* routes asking for the same torrent name the same thing on the worker side.
|
|
33
|
-
*
|
|
34
|
-
* @param {"magnet" | "torrent"} sourceType
|
|
35
|
-
* @param {string} source
|
|
36
|
-
* @returns {string}
|
|
37
|
-
*/
|
|
38
|
-
function deriveSourceKey(sourceType, source) {
|
|
39
|
-
return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A torrent pool whose work happens on another thread.
|
|
44
|
-
*
|
|
45
|
-
* See `protocol.js` for why: the torrent was taking ~85% of the main thread and
|
|
46
|
-
* everything owed to a viewer queued behind it.
|
|
47
|
-
*/
|
|
48
|
-
export class WorkerTorrentPool {
|
|
49
|
-
#client;
|
|
50
|
-
/** Stand-ins by source key, so repeat calls return the same object. */
|
|
51
|
-
#torrents = new Map();
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
|
|
55
|
-
*/
|
|
56
|
-
constructor(options = {}) {
|
|
57
|
-
this.#client = new TorrentWorkerClient(options);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Load (or join) a torrent and return a stand-in for it.
|
|
62
|
-
*
|
|
63
|
-
* @param {"magnet" | "torrent"} sourceType
|
|
64
|
-
* @param {string} source
|
|
65
|
-
* @returns {Promise<object>}
|
|
66
|
-
*/
|
|
67
|
-
async getTorrent(sourceType, source) {
|
|
68
|
-
const sourceKey = deriveSourceKey(sourceType, source);
|
|
69
|
-
const existing = this.#torrents.get(sourceKey);
|
|
70
|
-
if (existing) {
|
|
71
|
-
return existing;
|
|
72
|
-
}
|
|
73
|
-
const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
|
|
74
|
-
this.#torrents.set(sourceKey, torrent);
|
|
75
|
-
return torrent;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Claim a file for reading; the returned function releases it.
|
|
80
|
-
*
|
|
81
|
-
* Synchronous by design — see the file header.
|
|
82
|
-
*
|
|
83
|
-
* @param {object} torrent - A stand-in from {@link getTorrent}.
|
|
84
|
-
* @param {number} fileIndex
|
|
85
|
-
* @returns {() => void}
|
|
86
|
-
*/
|
|
87
|
-
acquireFile(torrent, fileIndex) {
|
|
88
|
-
const sourceKey = torrent?.sourceKey;
|
|
89
|
-
if (!sourceKey) {
|
|
90
|
-
return () => undefined;
|
|
91
|
-
}
|
|
92
|
-
// Dispatched, not awaited — callers use the result immediately and inspect
|
|
93
|
-
// nothing. But the release MUST NOT overtake it: both are ordinary messages
|
|
94
|
-
// to the worker, and if release arrives first the reader count drops to zero
|
|
95
|
-
// while a read is still running. The idle sweep then removes the torrent AND
|
|
96
|
-
// its downloaded data out from under the encoder — field 2026-08-02:
|
|
97
|
-
// "removed idle torrent ... and its store" mid-playback, after which every
|
|
98
|
-
// read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
|
|
99
|
-
// Chaining the release onto the acquire keeps them in order.
|
|
100
|
-
const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() =>
|
|
101
|
-
let released = false;
|
|
102
|
-
return () => {
|
|
103
|
-
if (released) {
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
released = true;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* @
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
this.#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file `TorrentPool`'s interface, served from the worker thread.
|
|
3
|
+
*
|
|
4
|
+
* The routes, the planner, the health report and the session manager all reach
|
|
5
|
+
* for a torrent pool and use it the same handful of ways. Rather than rewrite
|
|
6
|
+
* every one of them to thread a `sourceKey` through and await what used to be
|
|
7
|
+
* immediate, this presents the shape they already expect and does the thread
|
|
8
|
+
* hop behind it. Swapping the implementation is then a one-line change at
|
|
9
|
+
* construction, and the call sites are untouched — which is what keeps a change
|
|
10
|
+
* of this size reviewable.
|
|
11
|
+
*
|
|
12
|
+
* Two accommodations are needed, and both are deliberate:
|
|
13
|
+
*
|
|
14
|
+
* - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
|
|
15
|
+
* nothing the caller inspects, so the command is dispatched and not awaited.
|
|
16
|
+
* `acquireFile` hands back a release function exactly as before, which sends
|
|
17
|
+
* its own command when called. Awaiting them would mean touching every call
|
|
18
|
+
* site for no observable gain.
|
|
19
|
+
* - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
|
|
20
|
+
* thread, so the worker keys them. Callers that have one pass it; the rest
|
|
21
|
+
* get one derived from the source itself, so the identity stays stable
|
|
22
|
+
* across calls for the same torrent.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
import { TorrentWorkerClient } from "./client.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stable key for a source, matching how the worker keys its torrents.
|
|
30
|
+
*
|
|
31
|
+
* Derived from the source itself rather than handed out per request, so two
|
|
32
|
+
* routes asking for the same torrent name the same thing on the worker side.
|
|
33
|
+
*
|
|
34
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
35
|
+
* @param {string} source
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function deriveSourceKey(sourceType, source) {
|
|
39
|
+
return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A torrent pool whose work happens on another thread.
|
|
44
|
+
*
|
|
45
|
+
* See `protocol.js` for why: the torrent was taking ~85% of the main thread and
|
|
46
|
+
* everything owed to a viewer queued behind it.
|
|
47
|
+
*/
|
|
48
|
+
export class WorkerTorrentPool {
|
|
49
|
+
#client;
|
|
50
|
+
/** Stand-ins by source key, so repeat calls return the same object. */
|
|
51
|
+
#torrents = new Map();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
|
|
55
|
+
*/
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
this.#client = new TorrentWorkerClient(options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load (or join) a torrent and return a stand-in for it.
|
|
62
|
+
*
|
|
63
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
64
|
+
* @param {string} source
|
|
65
|
+
* @returns {Promise<object>}
|
|
66
|
+
*/
|
|
67
|
+
async getTorrent(sourceType, source) {
|
|
68
|
+
const sourceKey = deriveSourceKey(sourceType, source);
|
|
69
|
+
const existing = this.#torrents.get(sourceKey);
|
|
70
|
+
if (existing) {
|
|
71
|
+
return existing;
|
|
72
|
+
}
|
|
73
|
+
const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
|
|
74
|
+
this.#torrents.set(sourceKey, torrent);
|
|
75
|
+
return torrent;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Claim a file for reading; the returned function releases it.
|
|
80
|
+
*
|
|
81
|
+
* Synchronous by design — see the file header.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} torrent - A stand-in from {@link getTorrent}.
|
|
84
|
+
* @param {number} fileIndex
|
|
85
|
+
* @returns {() => void}
|
|
86
|
+
*/
|
|
87
|
+
acquireFile(torrent, fileIndex) {
|
|
88
|
+
const sourceKey = torrent?.sourceKey;
|
|
89
|
+
if (!sourceKey) {
|
|
90
|
+
return () => undefined;
|
|
91
|
+
}
|
|
92
|
+
// Dispatched, not awaited — callers use the result immediately and inspect
|
|
93
|
+
// nothing. But the release MUST NOT overtake it: both are ordinary messages
|
|
94
|
+
// to the worker, and if release arrives first the reader count drops to zero
|
|
95
|
+
// while a read is still running. The idle sweep then removes the torrent AND
|
|
96
|
+
// its downloaded data out from under the encoder — field 2026-08-02:
|
|
97
|
+
// "removed idle torrent ... and its store" mid-playback, after which every
|
|
98
|
+
// read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
|
|
99
|
+
// Chaining the release onto the acquire keeps them in order.
|
|
100
|
+
const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => null);
|
|
101
|
+
let released = false;
|
|
102
|
+
return () => {
|
|
103
|
+
if (released) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
released = true;
|
|
107
|
+
// Release the claim this call opened, not "the file" — waiting for the
|
|
108
|
+
// acquire is also what tells us which claim that is.
|
|
109
|
+
void acquired
|
|
110
|
+
.then((claimId) => (claimId ? this.#client.releaseFile(claimId) : undefined))
|
|
111
|
+
.catch(() => undefined);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Live download figures for the progress display.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} torrent
|
|
119
|
+
* @param {number | null} [fileIndex]
|
|
120
|
+
* @param {{ resumeAnchorByteStart?: number | null }} [options]
|
|
121
|
+
* @returns {Promise<object | null>}
|
|
122
|
+
*/
|
|
123
|
+
async getFileStats(torrent, fileIndex = null, options = {}) {
|
|
124
|
+
const sourceKey = torrent?.sourceKey;
|
|
125
|
+
if (!sourceKey) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return this.#client.getFileStats({
|
|
129
|
+
sourceKey,
|
|
130
|
+
fileIndex,
|
|
131
|
+
resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reorder piece selection around a read position.
|
|
137
|
+
*
|
|
138
|
+
* Synchronous by design — see the file header.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} torrent
|
|
141
|
+
* @param {number} fileIndex
|
|
142
|
+
* @param {number} byteStart
|
|
143
|
+
* @param {number} [windowBytes]
|
|
144
|
+
* @returns {void}
|
|
145
|
+
*/
|
|
146
|
+
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes) {
|
|
147
|
+
const sourceKey = torrent?.sourceKey;
|
|
148
|
+
if (!sourceKey) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
void this.#client
|
|
152
|
+
.prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes })
|
|
153
|
+
.catch(() => undefined);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pre-fetch the head and tail the codec probe needs.
|
|
158
|
+
*
|
|
159
|
+
* Takes an options object, matching `TorrentPool.prefetchFileEdges` — this
|
|
160
|
+
* adapter exists to present that same interface. It previously declared
|
|
161
|
+
* positional parameters instead, so the planner's options object arrived as
|
|
162
|
+
* `headBytes` and only worked because it was passed along far enough to be
|
|
163
|
+
* destructured at the far end. Anyone calling it as documented got the
|
|
164
|
+
* defaults instead of the sizes they asked for.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} torrent
|
|
167
|
+
* @param {number} fileIndex
|
|
168
|
+
* @param {{ headBytes?: number, tailBytes?: number, timeoutMs?: number }} [options]
|
|
169
|
+
* @returns {Promise<unknown>}
|
|
170
|
+
*/
|
|
171
|
+
async prefetchFileEdges(torrent, fileIndex, options = {}) {
|
|
172
|
+
const sourceKey = torrent?.sourceKey;
|
|
173
|
+
if (!sourceKey) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return this.#client.prefetchFileEdges({ sourceKey, fileIndex, options });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Shut the torrent client down and stop the thread.
|
|
181
|
+
*
|
|
182
|
+
* @returns {Promise<void>}
|
|
183
|
+
*/
|
|
184
|
+
async destroyAll() {
|
|
185
|
+
this.#torrents.clear();
|
|
186
|
+
await this.#client.destroyAll();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -80,6 +80,18 @@ export const Event = {
|
|
|
80
80
|
ERROR: "error",
|
|
81
81
|
/** One piece of a READ_RANGE body; its bytes are transferred, never copied. */
|
|
82
82
|
CHUNK: "chunk",
|
|
83
|
+
/**
|
|
84
|
+
* Where a piece of the body sits in the torrent's shared pool — an offset and
|
|
85
|
+
* a length, no bytes at all. The main thread maps the same memory and reads it
|
|
86
|
+
* in place; see `piece-reader.js` for why this replaces sending the bytes.
|
|
87
|
+
*/
|
|
88
|
+
FRAGMENT: "fragment",
|
|
89
|
+
/**
|
|
90
|
+
* The main thread has finished with a FRAGMENT and its pin may be dropped.
|
|
91
|
+
* Distinct from {@link CHUNK_ACK}, which only reports queue capacity: this one
|
|
92
|
+
* is a promise that nothing is reading those bytes any more.
|
|
93
|
+
*/
|
|
94
|
+
FRAGMENT_DONE: "fragment-done",
|
|
83
95
|
/** A READ_RANGE ended; no further CHUNKs bear that request id. */
|
|
84
96
|
READ_END: "read-end",
|
|
85
97
|
/** The main thread consumed a chunk — see {@link STREAM_HIGH_WATER_CHUNKS}. */
|