pmtiles-swarm 0.2.0
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 +53 -0
- package/LICENSE +28 -0
- package/NOTICE.md +47 -0
- package/README.md +172 -0
- package/docs/architecture-diagram.md +287 -0
- package/docs/engines.md +140 -0
- package/docs/publishing.md +387 -0
- package/docs/serving-tiles.md +218 -0
- package/docs/subscribing.md +187 -0
- package/package.json +56 -0
- package/src/api.js +472 -0
- package/src/catalog.js +159 -0
- package/src/config.js +231 -0
- package/src/engines/libtorrent.js +384 -0
- package/src/engines/qbittorrent.js +320 -0
- package/src/engines/types.js +59 -0
- package/src/engines/webtorrent.js +264 -0
- package/src/feed.js +226 -0
- package/src/file-source.js +67 -0
- package/src/index.js +177 -0
- package/src/library.js +567 -0
- package/src/mutable.js +197 -0
- package/src/origin.js +205 -0
- package/src/pmtiles-probe.js +94 -0
- package/src/read-engine.js +175 -0
- package/src/sources.js +245 -0
- package/src/subscriptions.js +158 -0
- package/src/tilejson.js +112 -0
- package/src/tiles.js +284 -0
- package/src/torrent-create.js +250 -0
- package/src/warm.js +251 -0
- package/src/watch.js +98 -0
- package/src/web/index.html +254 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SeedEngine driving qBittorrent over its WebUI API (v2).
|
|
3
|
+
*
|
|
4
|
+
* qBittorrent is libtorrent underneath, so it handles multi-terabyte libraries,
|
|
5
|
+
* hybrid v1+v2 torrents and resume data far better than anything we would
|
|
6
|
+
* write. What it does not expose is piece-level control — only per-file
|
|
7
|
+
* priorities — which is why random-access reads still go through
|
|
8
|
+
* pmtiles-torrent rather than here.
|
|
9
|
+
*
|
|
10
|
+
* API reference: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Map qBittorrent's many states onto the handful we report. */
|
|
14
|
+
const STATE_MAP = {
|
|
15
|
+
error: 'error',
|
|
16
|
+
missingFiles: 'error',
|
|
17
|
+
uploading: 'seeding',
|
|
18
|
+
pausedUP: 'paused',
|
|
19
|
+
stoppedUP: 'paused',
|
|
20
|
+
queuedUP: 'stalled',
|
|
21
|
+
stalledUP: 'seeding',
|
|
22
|
+
checkingUP: 'checking',
|
|
23
|
+
forcedUP: 'seeding',
|
|
24
|
+
allocating: 'checking',
|
|
25
|
+
downloading: 'downloading',
|
|
26
|
+
metaDL: 'downloading',
|
|
27
|
+
pausedDL: 'paused',
|
|
28
|
+
stoppedDL: 'paused',
|
|
29
|
+
queuedDL: 'stalled',
|
|
30
|
+
stalledDL: 'downloading',
|
|
31
|
+
checkingDL: 'checking',
|
|
32
|
+
forcedDL: 'downloading',
|
|
33
|
+
checkingResumeData: 'checking',
|
|
34
|
+
moving: 'checking',
|
|
35
|
+
unknown: 'error',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Options for the qBittorrent engine.
|
|
40
|
+
* @typedef {object} QBittorrentEngineOptions
|
|
41
|
+
* @property {string} url - Base URL of the WebUI, e.g. http://172.16.1.49:9091
|
|
42
|
+
* @property {string} [username] - WebUI username. Omit if auth is bypassed for this host.
|
|
43
|
+
* @property {string} [password] - WebUI password.
|
|
44
|
+
* @property {number} [timeoutMs] - Per-request timeout. Default 15s.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Drives a qBittorrent instance.
|
|
49
|
+
* @implements {import('./types.js').SeedEngine}
|
|
50
|
+
*/
|
|
51
|
+
export class QBittorrentEngine {
|
|
52
|
+
#options;
|
|
53
|
+
#cookie = null;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates the engine.
|
|
57
|
+
* @param {QBittorrentEngineOptions} options - Connection details.
|
|
58
|
+
*/
|
|
59
|
+
constructor(options) {
|
|
60
|
+
if (!options?.url) throw new Error('qBittorrent engine requires a url');
|
|
61
|
+
this.name = 'qbittorrent';
|
|
62
|
+
this.#options = { timeoutMs: 15000, ...options };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Logs in, or confirms that auth is not required.
|
|
67
|
+
* @returns {Promise<void>} - Resolves when usable.
|
|
68
|
+
*/
|
|
69
|
+
async connect() {
|
|
70
|
+
// qBittorrent can be configured to bypass auth for local subnets, in which
|
|
71
|
+
// case there is no session cookie and requests just work.
|
|
72
|
+
if (!this.#options.username) {
|
|
73
|
+
await this.#request('/api/v2/app/version');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const body = new URLSearchParams({
|
|
78
|
+
username: this.#options.username,
|
|
79
|
+
password: this.#options.password ?? '',
|
|
80
|
+
});
|
|
81
|
+
const response = await this.#fetch('/api/v2/auth/login', {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
body,
|
|
84
|
+
});
|
|
85
|
+
const text = (await response.text()).trim();
|
|
86
|
+
if (text !== 'Ok.') {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`qBittorrent login rejected (${response.status}): ${text || 'no reason given'}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const setCookie = response.headers.get('set-cookie');
|
|
92
|
+
if (setCookie) this.#cookie = setCookie.split(';')[0];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Adds a torrent.
|
|
97
|
+
* @param {import('./types.js').AddRequest} request - What to add.
|
|
98
|
+
* @returns {Promise<string>} - The infohash.
|
|
99
|
+
*/
|
|
100
|
+
async add(request) {
|
|
101
|
+
const form = new FormData();
|
|
102
|
+
if (request.torrentFile) {
|
|
103
|
+
form.append(
|
|
104
|
+
'torrents',
|
|
105
|
+
new Blob([request.torrentFile], { type: 'application/x-bittorrent' }),
|
|
106
|
+
'archive.torrent',
|
|
107
|
+
);
|
|
108
|
+
} else if (request.magnet) {
|
|
109
|
+
form.append('urls', request.magnet);
|
|
110
|
+
} else {
|
|
111
|
+
throw new Error('add requires either torrentFile or magnet');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (request.savePath) form.append('savepath', request.savePath);
|
|
115
|
+
if (request.category) form.append('category', request.category);
|
|
116
|
+
if (request.paused) form.append('paused', 'true');
|
|
117
|
+
|
|
118
|
+
// Cache mode needs piece-level selection, and qBittorrent's WebUI exposes
|
|
119
|
+
// only per-file priorities — for a single-file archive that is all or
|
|
120
|
+
// nothing. Adding it stopped is the closest honest approximation: the
|
|
121
|
+
// torrent is registered and can be read on demand by a client that does
|
|
122
|
+
// have piece-level control, without this engine pulling the whole archive.
|
|
123
|
+
if (request.mode === 'cache') form.append('stopped', 'true');
|
|
124
|
+
// The data is already on disk; qBittorrent still verifies it, but this
|
|
125
|
+
// stops it trying to download what it already has.
|
|
126
|
+
if (request.seedOnly) form.append('skip_checking', 'false');
|
|
127
|
+
|
|
128
|
+
const response = await this.#request('/api/v2/torrents/add', {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
body: form,
|
|
131
|
+
});
|
|
132
|
+
const text = (await response.text()).trim();
|
|
133
|
+
if (text && text !== 'Ok.') {
|
|
134
|
+
throw new Error(`qBittorrent refused the torrent: ${text}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const infoHash = await this.#infoHashOf(request);
|
|
138
|
+
if (!infoHash) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
'torrent was accepted but its infohash could not be determined',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return infoHash;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Removes a torrent.
|
|
148
|
+
* @param {string} infoHash - The torrent to remove.
|
|
149
|
+
* @param {{deleteData?: boolean}} [options] - Whether to delete the data too.
|
|
150
|
+
* @returns {Promise<void>} - Resolves once removed.
|
|
151
|
+
*/
|
|
152
|
+
async remove(infoHash, options = {}) {
|
|
153
|
+
const body = new URLSearchParams({
|
|
154
|
+
hashes: infoHash.toLowerCase(),
|
|
155
|
+
deleteFiles: options.deleteData ? 'true' : 'false',
|
|
156
|
+
});
|
|
157
|
+
await this.#request('/api/v2/torrents/delete', { method: 'POST', body });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Lists every torrent qBittorrent holds.
|
|
162
|
+
* @returns {Promise<import('./types.js').TorrentStatus[]>} - Normalised statuses.
|
|
163
|
+
*/
|
|
164
|
+
async list() {
|
|
165
|
+
const response = await this.#request('/api/v2/torrents/info');
|
|
166
|
+
const rows = await response.json();
|
|
167
|
+
return rows.map((row) => this.#normalise(row));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One torrent's state.
|
|
172
|
+
* @param {string} infoHash - The torrent to look up.
|
|
173
|
+
* @returns {Promise<import('./types.js').TorrentStatus | null>} - Its status, or null.
|
|
174
|
+
*/
|
|
175
|
+
async get(infoHash) {
|
|
176
|
+
const response = await this.#request(
|
|
177
|
+
`/api/v2/torrents/info?hashes=${encodeURIComponent(infoHash.toLowerCase())}`,
|
|
178
|
+
);
|
|
179
|
+
const rows = await response.json();
|
|
180
|
+
return rows.length > 0 ? this.#normalise(rows[0]) : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Per-peer detail for a torrent.
|
|
185
|
+
* @param {string} infoHash - The torrent to inspect.
|
|
186
|
+
* @returns {Promise<object[]>} - One entry per connected peer.
|
|
187
|
+
*/
|
|
188
|
+
async peers(infoHash) {
|
|
189
|
+
const response = await this.#request(
|
|
190
|
+
`/api/v2/sync/torrentPeers?hash=${encodeURIComponent(infoHash.toLowerCase())}&rid=0`,
|
|
191
|
+
);
|
|
192
|
+
const body = await response.json();
|
|
193
|
+
return Object.entries(body.peers ?? {}).map(([address, peer]) => ({
|
|
194
|
+
address,
|
|
195
|
+
client: peer.client,
|
|
196
|
+
country: peer.country,
|
|
197
|
+
progress: peer.progress,
|
|
198
|
+
downloadSpeed: peer.dl_speed,
|
|
199
|
+
uploadSpeed: peer.up_speed,
|
|
200
|
+
flags: peer.flags,
|
|
201
|
+
connection: peer.connection,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Drops the session. qBittorrent itself keeps running.
|
|
207
|
+
* @returns {Promise<void>} - Resolves immediately.
|
|
208
|
+
*/
|
|
209
|
+
async destroy() {
|
|
210
|
+
this.#cookie = null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Normalises a qBittorrent torrent record.
|
|
215
|
+
* @param {object} row - A row from /torrents/info.
|
|
216
|
+
* @returns {import('./types.js').TorrentStatus} - Normalised status.
|
|
217
|
+
*/
|
|
218
|
+
#normalise(row) {
|
|
219
|
+
return {
|
|
220
|
+
infoHash: row.hash,
|
|
221
|
+
name: row.name,
|
|
222
|
+
size: row.total_size ?? row.size,
|
|
223
|
+
progress: row.progress,
|
|
224
|
+
// eslint-disable-next-line security/detect-object-injection -- state comes from qBittorrent and falls back
|
|
225
|
+
state: STATE_MAP[row.state] ?? 'error',
|
|
226
|
+
peers: row.num_leechs ?? 0,
|
|
227
|
+
seeds: row.num_seeds ?? 0,
|
|
228
|
+
downloadSpeed: row.dlspeed ?? 0,
|
|
229
|
+
uploadSpeed: row.upspeed ?? 0,
|
|
230
|
+
downloaded: row.downloaded ?? 0,
|
|
231
|
+
uploaded: row.uploaded ?? 0,
|
|
232
|
+
ratio: row.ratio ?? 0,
|
|
233
|
+
category: row.category || undefined,
|
|
234
|
+
savePath: row.save_path,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Works out the infohash of something we just added.
|
|
240
|
+
*
|
|
241
|
+
* qBittorrent's add endpoint returns only "Ok.", so for a magnet we read the
|
|
242
|
+
* hash out of the URI, and for a torrent file we look for the newest torrent
|
|
243
|
+
* that was not there before.
|
|
244
|
+
* @param {import('./types.js').AddRequest} request - What was added.
|
|
245
|
+
* @returns {Promise<string | null>} - The infohash, if it can be determined.
|
|
246
|
+
*/
|
|
247
|
+
async #infoHashOf(request) {
|
|
248
|
+
if (request.magnet) {
|
|
249
|
+
const match = /xt=urn:btih:([a-z0-9]+)/i.exec(request.magnet);
|
|
250
|
+
if (match) return match[1].toLowerCase();
|
|
251
|
+
}
|
|
252
|
+
if (request.torrentFile) {
|
|
253
|
+
const { default: parseTorrent } = await import('parse-torrent');
|
|
254
|
+
const parsed = await parseTorrent(request.torrentFile);
|
|
255
|
+
return parsed?.infoHash ?? null;
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Issues an authenticated request, retrying once through a fresh login if
|
|
262
|
+
* the session has expired.
|
|
263
|
+
* @param {string} path - API path.
|
|
264
|
+
* @param {object} [init] - Fetch options.
|
|
265
|
+
* @returns {Promise<Response>} - The response.
|
|
266
|
+
*/
|
|
267
|
+
async #request(path, init = {}) {
|
|
268
|
+
let response = await this.#fetch(path, init);
|
|
269
|
+
if (response.status === 403 && this.#options.username) {
|
|
270
|
+
this.#cookie = null;
|
|
271
|
+
await this.connect();
|
|
272
|
+
response = await this.#fetch(path, init);
|
|
273
|
+
}
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`qBittorrent ${path} failed: ${response.status} ${response.statusText}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return response;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Raw fetch against the WebUI, carrying the session cookie.
|
|
284
|
+
* @param {string} path - API path.
|
|
285
|
+
* @param {object} [init] - Fetch options.
|
|
286
|
+
* @returns {Promise<Response>} - The response.
|
|
287
|
+
*/
|
|
288
|
+
async #fetch(path, init = {}) {
|
|
289
|
+
const headers = new Headers(init.headers ?? {});
|
|
290
|
+
if (this.#cookie) headers.set('cookie', this.#cookie);
|
|
291
|
+
// qBittorrent rejects cross-origin requests unless Referer matches.
|
|
292
|
+
headers.set('Referer', this.#options.url);
|
|
293
|
+
|
|
294
|
+
const controller = new AbortController();
|
|
295
|
+
const timer = setTimeout(
|
|
296
|
+
() => controller.abort(),
|
|
297
|
+
this.#options.timeoutMs,
|
|
298
|
+
);
|
|
299
|
+
try {
|
|
300
|
+
return await fetch(new URL(path, this.#options.url), {
|
|
301
|
+
...init,
|
|
302
|
+
headers,
|
|
303
|
+
signal: controller.signal,
|
|
304
|
+
redirect: 'manual',
|
|
305
|
+
});
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (error.name === 'AbortError') {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`qBittorrent ${path} timed out after ${this.#options.timeoutMs}ms`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
throw new Error(
|
|
313
|
+
`qBittorrent ${path} unreachable at ${this.#options.url}: ${error.message}`,
|
|
314
|
+
{ cause: error },
|
|
315
|
+
);
|
|
316
|
+
} finally {
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seeding-engine abstraction.
|
|
3
|
+
*
|
|
4
|
+
* This is the distribution counterpart to the read-side engine in
|
|
5
|
+
* pmtiles-torrent: that one answers "give me these bytes", this one answers
|
|
6
|
+
* "hold this archive in the swarm and tell me how it is doing".
|
|
7
|
+
*
|
|
8
|
+
* Keeping it behind an interface is what lets qBittorrent do the bulk seeding
|
|
9
|
+
* — it is libtorrent underneath, handles multi-terabyte libraries, and speaks
|
|
10
|
+
* BitTorrent v2 — while an embedded WebTorrent client covers the things it
|
|
11
|
+
* cannot: serving browser peers over WebRTC, and running with no external
|
|
12
|
+
* dependency at all.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Live state of one torrent, normalised across engines.
|
|
17
|
+
* @typedef {object} TorrentStatus
|
|
18
|
+
* @property {string} infoHash - Hex v1 infohash.
|
|
19
|
+
* @property {string} name - Display name.
|
|
20
|
+
* @property {number} size - Total bytes.
|
|
21
|
+
* @property {number} progress - Fraction complete, 0 to 1.
|
|
22
|
+
* @property {string} state - Normalised state: 'seeding' | 'downloading' | 'cache' | 'stalled' | 'checking' | 'paused' | 'error'. 'cache' means joined and seeding what it holds, but fetching nothing on its own.
|
|
23
|
+
* @property {number} peers - Connected non-seeding peers.
|
|
24
|
+
* @property {number} seeds - Connected seeds.
|
|
25
|
+
* @property {number} downloadSpeed - Bytes per second.
|
|
26
|
+
* @property {number} uploadSpeed - Bytes per second.
|
|
27
|
+
* @property {number} downloaded - Bytes downloaded this session and before.
|
|
28
|
+
* @property {number} uploaded - Bytes uploaded.
|
|
29
|
+
* @property {number} ratio - Share ratio.
|
|
30
|
+
* @property {string} [category] - Engine-side category, where supported.
|
|
31
|
+
* @property {string} [savePath] - Where the data lives.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What to add to the swarm. Either a torrent file or a magnet must be given.
|
|
36
|
+
* @typedef {object} AddRequest
|
|
37
|
+
* @property {Uint8Array} [torrentFile] - Raw .torrent contents.
|
|
38
|
+
* @property {string} [magnet] - Magnet URI.
|
|
39
|
+
* @property {string} [savePath] - Directory holding (or to hold) the data.
|
|
40
|
+
* @property {string} [category] - Category to file it under.
|
|
41
|
+
* @property {boolean} [seedOnly] - The data is already complete locally; skip downloading.
|
|
42
|
+
* @property {boolean} [paused] - Add without starting.
|
|
43
|
+
* @property {'mirror' | 'cache'} [mode] - 'mirror' downloads the whole archive and becomes a full seeder. 'cache' joins the swarm but downloads nothing up front, leaving a tile server to pull byte ranges on demand — the difference between spending 72 GiB of disk and spending what is actually viewed. Cache mode needs piece-level control, so it is only honoured by engines that have it.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A seeding backend.
|
|
48
|
+
* @typedef {object} SeedEngine
|
|
49
|
+
* @property {string} name - Short identifier, e.g. 'qbittorrent'.
|
|
50
|
+
* @property {() => Promise<void>} connect - Establishes the connection, or throws.
|
|
51
|
+
* @property {(request: AddRequest) => Promise<string>} add - Adds a torrent, resolving with its infohash.
|
|
52
|
+
* @property {(infoHash: string, options?: {deleteData?: boolean}) => Promise<void>} remove - Removes a torrent.
|
|
53
|
+
* @property {() => Promise<TorrentStatus[]>} list - Lists everything the engine holds.
|
|
54
|
+
* @property {(infoHash: string) => Promise<TorrentStatus | null>} get - One torrent's state.
|
|
55
|
+
* @property {(infoHash: string) => Promise<object[]>} [peers] - Per-peer detail, where the engine exposes it.
|
|
56
|
+
* @property {() => Promise<void>} destroy - Releases resources.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SeedEngine backed by an embedded WebTorrent client.
|
|
3
|
+
*
|
|
4
|
+
* Covers the two things qBittorrent cannot: serving browser peers over WebRTC,
|
|
5
|
+
* and running with no external dependency at all. It is a weaker bulk seeder
|
|
6
|
+
* than libtorrent and is BitTorrent v1 only, so for a multi-terabyte library
|
|
7
|
+
* prefer the qBittorrent engine and run this alongside as the browser bridge.
|
|
8
|
+
*
|
|
9
|
+
* Written against WebTorrent's public API (MIT, Copyright (c) Feross
|
|
10
|
+
* Aboukhadijeh and WebTorrent, LLC). See NOTICE.md.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Options for the embedded engine.
|
|
15
|
+
* @typedef {object} WebTorrentEngineOptions
|
|
16
|
+
* @property {string} savePath - Default directory for torrent data.
|
|
17
|
+
* @property {object} [clientOptions] - Options passed to new WebTorrent().
|
|
18
|
+
* @property {number} [readyTimeoutMs] - How long to wait for metadata. Default 300s, because a magnet must complete a BEP 9 exchange first.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Seeds through an in-process WebTorrent client.
|
|
23
|
+
* @implements {import('./types.js').SeedEngine}
|
|
24
|
+
*/
|
|
25
|
+
export class WebTorrentSeedEngine {
|
|
26
|
+
#options;
|
|
27
|
+
#client = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates the engine.
|
|
31
|
+
* @param {WebTorrentEngineOptions} options - Save path and client options.
|
|
32
|
+
*/
|
|
33
|
+
constructor(options) {
|
|
34
|
+
if (!options?.savePath) {
|
|
35
|
+
throw new Error('WebTorrent engine requires a savePath');
|
|
36
|
+
}
|
|
37
|
+
this.name = 'webtorrent';
|
|
38
|
+
this.#options = { readyTimeoutMs: 300000, ...options };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The underlying client, once connected.
|
|
43
|
+
* @returns {object | null} - The WebTorrent client.
|
|
44
|
+
*/
|
|
45
|
+
get client() {
|
|
46
|
+
return this.#client;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Starts the client.
|
|
51
|
+
* @returns {Promise<void>} - Resolves once running.
|
|
52
|
+
*/
|
|
53
|
+
async connect() {
|
|
54
|
+
if (this.#client) return;
|
|
55
|
+
const WebTorrent = await loadWebTorrent();
|
|
56
|
+
this.#client = new WebTorrent({
|
|
57
|
+
maxConns: this.#options.maxConnections,
|
|
58
|
+
...this.#options.clientOptions,
|
|
59
|
+
});
|
|
60
|
+
this.#client.on('error', (error) => {
|
|
61
|
+
console.error(`[webtorrent] client error: ${error.message}`);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Adds a torrent and waits for its metadata.
|
|
67
|
+
* @param {import('./types.js').AddRequest} request - What to add.
|
|
68
|
+
* @returns {Promise<string>} - The infohash.
|
|
69
|
+
*/
|
|
70
|
+
async add(request) {
|
|
71
|
+
await this.connect();
|
|
72
|
+
const id = request.torrentFile ?? request.magnet;
|
|
73
|
+
if (!id) throw new Error('add requires either torrentFile or magnet');
|
|
74
|
+
|
|
75
|
+
const addOptions = {
|
|
76
|
+
path: request.savePath ?? this.#options.savePath,
|
|
77
|
+
// Cache mode selects nothing, so joining a 72 GiB archive costs nothing
|
|
78
|
+
// until something actually reads from it. Mirror mode takes the lot.
|
|
79
|
+
deselect: request.mode === 'cache',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const timeoutMs = this.#options.readyTimeoutMs;
|
|
83
|
+
const torrent = await new Promise((resolve, reject) => {
|
|
84
|
+
let settled = false;
|
|
85
|
+
/**
|
|
86
|
+
* Settles once, clearing the timer.
|
|
87
|
+
* @param {() => void} fn - The settle action.
|
|
88
|
+
* @returns {void}
|
|
89
|
+
*/
|
|
90
|
+
const finish = (fn) => {
|
|
91
|
+
if (settled) return;
|
|
92
|
+
settled = true;
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
fn();
|
|
95
|
+
};
|
|
96
|
+
const timer = setTimeout(
|
|
97
|
+
() =>
|
|
98
|
+
finish(() =>
|
|
99
|
+
reject(
|
|
100
|
+
new Error(
|
|
101
|
+
`timed out after ${timeoutMs}ms waiting for torrent metadata`,
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
timeoutMs,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
let added;
|
|
109
|
+
try {
|
|
110
|
+
added = this.#client.add(id, addOptions, (t) =>
|
|
111
|
+
finish(() => resolve(t)),
|
|
112
|
+
);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
finish(() => reject(error));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
added.once('error', (error) => {
|
|
118
|
+
// A duplicate is not fatal: the callback still fires with the torrent
|
|
119
|
+
// the client already holds.
|
|
120
|
+
if (/duplicate torrent/i.test(error?.message ?? '')) return;
|
|
121
|
+
finish(() => reject(error));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (request.paused) torrent.pause();
|
|
126
|
+
return torrent.infoHash;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Removes a torrent.
|
|
131
|
+
* @param {string} infoHash - The torrent to remove.
|
|
132
|
+
* @param {{deleteData?: boolean}} [options] - Whether to delete the data too.
|
|
133
|
+
* @returns {Promise<void>} - Resolves once removed.
|
|
134
|
+
*/
|
|
135
|
+
async remove(infoHash, options = {}) {
|
|
136
|
+
if (!this.#client) return;
|
|
137
|
+
await new Promise((resolve) => {
|
|
138
|
+
this.#client.remove(
|
|
139
|
+
infoHash,
|
|
140
|
+
{ destroyStore: Boolean(options.deleteData) },
|
|
141
|
+
() => resolve(),
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Lists every torrent the client holds.
|
|
148
|
+
* @returns {Promise<import('./types.js').TorrentStatus[]>} - Normalised statuses.
|
|
149
|
+
*/
|
|
150
|
+
async list() {
|
|
151
|
+
if (!this.#client) return [];
|
|
152
|
+
return this.#client.torrents.map((torrent) => this.#normalise(torrent));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One torrent's state.
|
|
157
|
+
* @param {string} infoHash - The torrent to look up.
|
|
158
|
+
* @returns {Promise<import('./types.js').TorrentStatus | null>} - Its status, or null.
|
|
159
|
+
*/
|
|
160
|
+
async get(infoHash) {
|
|
161
|
+
if (!this.#client) return null;
|
|
162
|
+
const torrent = this.#client.get(infoHash);
|
|
163
|
+
return torrent ? this.#normalise(torrent) : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Per-peer detail for a torrent.
|
|
168
|
+
* @param {string} infoHash - The torrent to inspect.
|
|
169
|
+
* @returns {Promise<object[]>} - One entry per connected peer.
|
|
170
|
+
*/
|
|
171
|
+
async peers(infoHash) {
|
|
172
|
+
const torrent = this.#client?.get(infoHash);
|
|
173
|
+
if (!torrent) return [];
|
|
174
|
+
return torrent.wires.map((wire) => ({
|
|
175
|
+
address: wire.remoteAddress
|
|
176
|
+
? `${wire.remoteAddress}:${wire.remotePort}`
|
|
177
|
+
: 'unknown',
|
|
178
|
+
client: wire.peerExtendedHandshake?.v ?? 'unknown',
|
|
179
|
+
progress: torrent.pieces.length
|
|
180
|
+
? countBits(wire.peerPieces, torrent.pieces.length) /
|
|
181
|
+
torrent.pieces.length
|
|
182
|
+
: 0,
|
|
183
|
+
downloadSpeed: wire.downloadSpeed(),
|
|
184
|
+
uploadSpeed: wire.uploadSpeed(),
|
|
185
|
+
connection: wire.type ?? 'tcp',
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Shuts the client down, announcing 'stopped' to trackers.
|
|
191
|
+
* @returns {Promise<void>} - Resolves once destroyed.
|
|
192
|
+
*/
|
|
193
|
+
async destroy() {
|
|
194
|
+
if (!this.#client) return;
|
|
195
|
+
const client = this.#client;
|
|
196
|
+
this.#client = null;
|
|
197
|
+
await new Promise((resolve) => client.destroy(() => resolve()));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Normalises a WebTorrent torrent.
|
|
202
|
+
* @param {object} torrent - The torrent.
|
|
203
|
+
* @returns {import('./types.js').TorrentStatus} - Normalised status.
|
|
204
|
+
*/
|
|
205
|
+
#normalise(torrent) {
|
|
206
|
+
const seeds = torrent.wires.filter((w) => w.isSeeder).length;
|
|
207
|
+
return {
|
|
208
|
+
infoHash: torrent.infoHash,
|
|
209
|
+
name: torrent.name,
|
|
210
|
+
size: torrent.length ?? 0,
|
|
211
|
+
progress: torrent.progress ?? 0,
|
|
212
|
+
state: torrent.paused
|
|
213
|
+
? 'paused'
|
|
214
|
+
: torrent.done
|
|
215
|
+
? 'seeding'
|
|
216
|
+
: torrent.numPeers > 0
|
|
217
|
+
? 'downloading'
|
|
218
|
+
: 'stalled',
|
|
219
|
+
peers: Math.max(0, torrent.numPeers - seeds),
|
|
220
|
+
seeds,
|
|
221
|
+
downloadSpeed: torrent.downloadSpeed ?? 0,
|
|
222
|
+
uploadSpeed: torrent.uploadSpeed ?? 0,
|
|
223
|
+
downloaded: torrent.downloaded ?? 0,
|
|
224
|
+
uploaded: torrent.uploaded ?? 0,
|
|
225
|
+
ratio: torrent.ratio ?? 0,
|
|
226
|
+
savePath: torrent.path,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Counts set bits in a peer's bitfield.
|
|
233
|
+
* @param {object} bitfield - A BitField instance.
|
|
234
|
+
* @param {number} pieces - Total piece count.
|
|
235
|
+
* @returns {number} - How many pieces the peer has.
|
|
236
|
+
*/
|
|
237
|
+
function countBits(bitfield, pieces) {
|
|
238
|
+
if (!bitfield) return 0;
|
|
239
|
+
let count = 0;
|
|
240
|
+
for (let i = 0; i < pieces; i++) if (bitfield.get(i)) count++;
|
|
241
|
+
return count;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Loads WebTorrent lazily, so it stays an optional dependency.
|
|
246
|
+
* @returns {Promise<new (opts?: object) => object>} - The constructor.
|
|
247
|
+
*/
|
|
248
|
+
async function loadWebTorrent() {
|
|
249
|
+
try {
|
|
250
|
+
const specifier = 'webtorrent';
|
|
251
|
+
const mod = await import(specifier);
|
|
252
|
+
const ctor = mod.default ?? mod;
|
|
253
|
+
if (typeof ctor !== 'function') {
|
|
254
|
+
throw new Error('webtorrent module did not export a constructor');
|
|
255
|
+
}
|
|
256
|
+
return ctor;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
"The webtorrent engine needs the optional dependency 'webtorrent'. " +
|
|
260
|
+
`Install it, or use the qbittorrent engine instead. (${error.message})`,
|
|
261
|
+
{ cause: error },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|