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
package/src/config.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Defaults. Every one of these can be overridden in the config file, and a few
|
|
6
|
+
* of the deployment-specific ones by environment variables.
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
port: 8090,
|
|
10
|
+
host: '0.0.0.0',
|
|
11
|
+
/** Where the catalog, generated .torrent files and keys live. */
|
|
12
|
+
dataDir: './data',
|
|
13
|
+
/** Which SeedEngine to use: 'webtorrent' or 'qbittorrent'. */
|
|
14
|
+
engine: 'webtorrent',
|
|
15
|
+
qbittorrent: {
|
|
16
|
+
url: 'http://127.0.0.1:8080',
|
|
17
|
+
username: undefined,
|
|
18
|
+
password: undefined,
|
|
19
|
+
},
|
|
20
|
+
webtorrent: {
|
|
21
|
+
savePath: './data/torrents-data',
|
|
22
|
+
},
|
|
23
|
+
/**
|
|
24
|
+
* Piece length for torrents we create. 4 MiB is a deliberate compromise:
|
|
25
|
+
* tools default much higher for large files, which is fine for whole-file
|
|
26
|
+
* downloads but terrible for the random access a tile server does, since
|
|
27
|
+
* every cold tile costs a whole piece. Smaller pieces cut that, at the cost
|
|
28
|
+
* of a larger hash list that peers must transfer before any tile is served.
|
|
29
|
+
*
|
|
30
|
+
* Overridable per source and per request, so an archive nobody will read
|
|
31
|
+
* randomly can keep the larger pieces its size would normally suggest.
|
|
32
|
+
*
|
|
33
|
+
* Note this has little bearing on load imposed on network equipment: peers
|
|
34
|
+
* request data in 16 KiB blocks whatever the piece size, so packet volume is
|
|
35
|
+
* unchanged. What strains a consumer router is the number of simultaneous
|
|
36
|
+
* connections holding NAT table entries — see maxConnections.
|
|
37
|
+
*/
|
|
38
|
+
pieceLength: 4 * 1024 * 1024,
|
|
39
|
+
/**
|
|
40
|
+
* Cap on simultaneous peer connections.
|
|
41
|
+
*
|
|
42
|
+
* This is the setting that decides how hard the node leans on a router:
|
|
43
|
+
* every peer is a NAT table entry, and cheap consumer hardware starts
|
|
44
|
+
* dropping or stalling connections well before a server would. Lower it if
|
|
45
|
+
* the network misbehaves while seeding.
|
|
46
|
+
*/
|
|
47
|
+
maxConnections: 100,
|
|
48
|
+
/** Trackers baked into every torrent we create. */
|
|
49
|
+
trackers: [
|
|
50
|
+
'udp://tracker.opentrackr.org:1337/announce',
|
|
51
|
+
'udp://tracker.torrent.eu.org:451/announce',
|
|
52
|
+
],
|
|
53
|
+
/**
|
|
54
|
+
* Copy every .torrent we create into this directory as well.
|
|
55
|
+
*
|
|
56
|
+
* Most clients, qBittorrent included, can watch a folder and add whatever
|
|
57
|
+
* appears in it. For the single job of "start seeding this", that is simpler
|
|
58
|
+
* and more robust than an API, and it works when the client shares a disk but
|
|
59
|
+
* is not reachable over HTTP.
|
|
60
|
+
*/
|
|
61
|
+
torrentDropDir: undefined,
|
|
62
|
+
/** Rights statement for the RSS channel. */
|
|
63
|
+
feedCopyright: undefined,
|
|
64
|
+
/**
|
|
65
|
+
* Most items to include in a feed, newest first. Zero means no limit.
|
|
66
|
+
*
|
|
67
|
+
* Choose it against how often subscribers poll, not how tidy the feed looks:
|
|
68
|
+
* a feed holding a single item is only safe if everyone polls more often than
|
|
69
|
+
* you publish. A consumer that was down overnight would otherwise miss that
|
|
70
|
+
* build entirely, with nothing to indicate it had.
|
|
71
|
+
*/
|
|
72
|
+
feedMaxItems: 50,
|
|
73
|
+
/** Scheduled upstreams that publish a new archive per date. See sources.js. */
|
|
74
|
+
sources: [],
|
|
75
|
+
/** How often to poll scheduled sources, in hours. */
|
|
76
|
+
sourceCheckIntervalHours: 6,
|
|
77
|
+
/** Public base URL, used to build absolute links in the RSS feed and TileJSON. */
|
|
78
|
+
publicUrl: undefined,
|
|
79
|
+
/**
|
|
80
|
+
* Trust X-Forwarded-* headers, for running behind a reverse proxy or CDN.
|
|
81
|
+
*
|
|
82
|
+
* Takes anything Express accepts: `true`, a hop count, or a subnet list such
|
|
83
|
+
* as "loopback, 10.0.0.0/8". Off by default, because trusting these headers
|
|
84
|
+
* from an untrusted client lets it claim any protocol or address it likes.
|
|
85
|
+
*
|
|
86
|
+
* Set it when a proxy terminates TLS, or the TileJSON will advertise http://
|
|
87
|
+
* tile URLs that browsers block as mixed content. Setting `publicUrl`
|
|
88
|
+
* instead sidesteps the question entirely.
|
|
89
|
+
*/
|
|
90
|
+
trustProxy: false,
|
|
91
|
+
/**
|
|
92
|
+
* Tile serving: a TileJSON endpoint and z/x/y tiles per archive.
|
|
93
|
+
*
|
|
94
|
+
* A node holding a complete copy reads its local file. A node in cache mode
|
|
95
|
+
* reads through the swarm, pulling only the pieces a requested tile lives in
|
|
96
|
+
* — which is what lets a machine with 10 GiB free serve a 700 GiB planet.
|
|
97
|
+
*/
|
|
98
|
+
tiles: {
|
|
99
|
+
/**
|
|
100
|
+
* Open archives kept alive at once. Each holds a file descriptor or a
|
|
101
|
+
* torrent reader plus its piece cache, so this bounds both.
|
|
102
|
+
*/
|
|
103
|
+
maxOpenArchives: 16,
|
|
104
|
+
/** Header and directory cache entries, shared across every archive. */
|
|
105
|
+
directoryCacheEntries: 200,
|
|
106
|
+
/**
|
|
107
|
+
* Byte budget for the piece cache of one swarm-read archive. Left unset it
|
|
108
|
+
* is sized from the torrent's piece length, which is the safer default: a
|
|
109
|
+
* fixed budget is a trap with 16 MiB pieces, since 64 MiB holds only four.
|
|
110
|
+
*/
|
|
111
|
+
pieceCacheBytes: undefined,
|
|
112
|
+
/** How long no read must be in flight before background hydration resumes. */
|
|
113
|
+
hydrateIdleMs: undefined,
|
|
114
|
+
/** How long to wait for one piece before giving up on a tile. */
|
|
115
|
+
pieceTimeoutMs: 120000,
|
|
116
|
+
/** How long to wait for torrent metadata when opening an archive. */
|
|
117
|
+
readyTimeoutMs: 60000,
|
|
118
|
+
},
|
|
119
|
+
/** Folders scanned for new archives: [{ path, category, webSeedBase }]. */
|
|
120
|
+
watch: [],
|
|
121
|
+
/** Feeds to follow: [{ url, mode, category }] where mode is 'mirror' or 'cache'. */
|
|
122
|
+
subscriptions: [],
|
|
123
|
+
/**
|
|
124
|
+
* How often to re-check whether the sources archives were built from have
|
|
125
|
+
* changed, in seconds. Zero disables it. A check is one HEAD request or stat
|
|
126
|
+
* per archive, so this is cheap; it defaults off only because a node that
|
|
127
|
+
* merely joins other people's torrents has nothing to check.
|
|
128
|
+
*/
|
|
129
|
+
originCheckIntervalSeconds: 0,
|
|
130
|
+
/**
|
|
131
|
+
* Rebuild an archive automatically when its source changes.
|
|
132
|
+
*
|
|
133
|
+
* Off by default, and guarded when on, because a rebuild re-hashes the
|
|
134
|
+
* archive and for a remote source re-downloads it — potentially hours of
|
|
135
|
+
* transfer started by nobody. Enable it for local build outputs, where the
|
|
136
|
+
* cost is a local read; think harder before enabling it for http sources.
|
|
137
|
+
*/
|
|
138
|
+
autoRebuild: {
|
|
139
|
+
enabled: false,
|
|
140
|
+
/** Source types eligible. 'http' means re-downloading the whole archive. */
|
|
141
|
+
sources: ['file'],
|
|
142
|
+
/** Skip anything larger than this. Zero disables the cap. */
|
|
143
|
+
maxBytes: 50 * 1024 * 1024 * 1024,
|
|
144
|
+
/** The source must be unchanged for this long before rebuilding. */
|
|
145
|
+
stabilitySeconds: 300,
|
|
146
|
+
},
|
|
147
|
+
/** How often to poll subscribed feeds, in seconds. */
|
|
148
|
+
subscriptionIntervalSeconds: 900,
|
|
149
|
+
/** Republish interval for BEP 46 records, in seconds. DHT items expire. */
|
|
150
|
+
republishIntervalSeconds: 3600,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Merges a config object over the defaults, one level deep so nested engine
|
|
155
|
+
* settings can be partially overridden.
|
|
156
|
+
* @param {object} base - Defaults.
|
|
157
|
+
* @param {object} override - User values.
|
|
158
|
+
* @returns {object} - The merged config.
|
|
159
|
+
*/
|
|
160
|
+
function merge(base, override) {
|
|
161
|
+
const out = { ...base };
|
|
162
|
+
for (const [key, value] of Object.entries(override ?? {})) {
|
|
163
|
+
if (value === undefined) continue;
|
|
164
|
+
// eslint-disable-next-line security/detect-object-injection -- keys come from a config file the operator controls
|
|
165
|
+
out[key] =
|
|
166
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
167
|
+
? // eslint-disable-next-line security/detect-object-injection -- as above
|
|
168
|
+
merge(base[key] ?? {}, value)
|
|
169
|
+
: value;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Loads configuration from a JSON file, applying defaults and environment
|
|
176
|
+
* overrides. A missing file is fine — the defaults run.
|
|
177
|
+
* @param {string} [configPath] - Path to a JSON config file.
|
|
178
|
+
* @returns {Promise<object>} - The resolved configuration.
|
|
179
|
+
*/
|
|
180
|
+
export async function loadConfig(configPath) {
|
|
181
|
+
let fileConfig = {};
|
|
182
|
+
if (configPath) {
|
|
183
|
+
try {
|
|
184
|
+
fileConfig = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error.code !== 'ENOENT') {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`could not read config ${configPath}: ${error.message}`,
|
|
189
|
+
{ cause: error },
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const config = merge(DEFAULTS, fileConfig);
|
|
196
|
+
|
|
197
|
+
// Environment overrides, for containerised deployments.
|
|
198
|
+
if (process.env.PMTILES_SWARM_PORT) {
|
|
199
|
+
config.port = Number(process.env.PMTILES_SWARM_PORT);
|
|
200
|
+
}
|
|
201
|
+
if (process.env.PMTILES_SWARM_DATA_DIR) {
|
|
202
|
+
config.dataDir = process.env.PMTILES_SWARM_DATA_DIR;
|
|
203
|
+
}
|
|
204
|
+
if (process.env.PMTILES_SWARM_ENGINE) {
|
|
205
|
+
config.engine = process.env.PMTILES_SWARM_ENGINE;
|
|
206
|
+
}
|
|
207
|
+
if (process.env.PMTILES_SWARM_QBT_URL) {
|
|
208
|
+
config.qbittorrent.url = process.env.PMTILES_SWARM_QBT_URL;
|
|
209
|
+
}
|
|
210
|
+
if (process.env.PMTILES_SWARM_QBT_USERNAME) {
|
|
211
|
+
config.qbittorrent.username = process.env.PMTILES_SWARM_QBT_USERNAME;
|
|
212
|
+
}
|
|
213
|
+
if (process.env.PMTILES_SWARM_QBT_PASSWORD) {
|
|
214
|
+
config.qbittorrent.password = process.env.PMTILES_SWARM_QBT_PASSWORD;
|
|
215
|
+
}
|
|
216
|
+
if (process.env.PMTILES_SWARM_PUBLIC_URL) {
|
|
217
|
+
config.publicUrl = process.env.PMTILES_SWARM_PUBLIC_URL;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Resolve paths relative to the config file, so a config can be moved as a
|
|
221
|
+
// unit with the data it points at.
|
|
222
|
+
const base = configPath ? path.dirname(path.resolve(configPath)) : process.cwd();
|
|
223
|
+
config.dataDir = path.resolve(base, config.dataDir);
|
|
224
|
+
config.webtorrent.savePath = path.resolve(base, config.webtorrent.savePath);
|
|
225
|
+
config.watch = config.watch.map((entry) => ({
|
|
226
|
+
...entry,
|
|
227
|
+
path: path.resolve(base, entry.path),
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
return config;
|
|
231
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Locates the libtorrent sidecar script.
|
|
7
|
+
*
|
|
8
|
+
* It lives in pmtiles-torrent rather than here. The two projects used to carry
|
|
9
|
+
* a copy each, which drifted — the read side grew `info` and `priority` ops
|
|
10
|
+
* that this copy never got. Since pmtiles-torrent is now a dependency and
|
|
11
|
+
* ships the script, there is one file again, and the read and seed sides
|
|
12
|
+
* cannot disagree about the protocol they speak over the same pipe.
|
|
13
|
+
* @returns {string} - Absolute path to the sidecar script.
|
|
14
|
+
*/
|
|
15
|
+
function resolveSidecar() {
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
try {
|
|
18
|
+
return path.join(
|
|
19
|
+
path.dirname(require.resolve('pmtiles-torrent/package.json')),
|
|
20
|
+
'sidecar',
|
|
21
|
+
'libtorrent_sidecar.py',
|
|
22
|
+
);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'cannot locate the libtorrent sidecar: pmtiles-torrent is not resolvable. ' +
|
|
26
|
+
`Run npm install, or pass libtorrent.script to point at it. (${error.message})`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A SeedEngine backed by libtorrent, through a sidecar process.
|
|
33
|
+
*
|
|
34
|
+
* libtorrent is the only one of the three engines that offers what large-scale
|
|
35
|
+
* map distribution actually wants: BitTorrent v2 and hybrid torrents, resume
|
|
36
|
+
* data so a restart does not re-hash the store, piece-level control for
|
|
37
|
+
* on-demand reads, and seeding that holds up at multi-terabyte scale.
|
|
38
|
+
*
|
|
39
|
+
* It reaches it through a child process rather than a native binding because
|
|
40
|
+
* Node has no maintained libtorrent binding — the packages on npm are
|
|
41
|
+
* abandoned 2022 stubs, and the one live fork exposes neither piece deadlines
|
|
42
|
+
* nor v2. A sidecar also keeps the install honest: one distro package rather
|
|
43
|
+
* than a C++ toolchain plus Boost.
|
|
44
|
+
*
|
|
45
|
+
* The protocol is line-delimited JSON, so this class is unchanged if the other
|
|
46
|
+
* end is later replaced by a real N-API addon.
|
|
47
|
+
*/
|
|
48
|
+
export class LibtorrentEngine {
|
|
49
|
+
#options;
|
|
50
|
+
#child = null;
|
|
51
|
+
#pending = new Map();
|
|
52
|
+
#nextId = 1;
|
|
53
|
+
#buffer = '';
|
|
54
|
+
#ready = null;
|
|
55
|
+
#version = null;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Creates the engine.
|
|
59
|
+
* @param {object} options - Engine options.
|
|
60
|
+
* @param {string} options.savePath - Default directory for torrent data.
|
|
61
|
+
* @param {string} [options.resumeDir] - Where resume data is kept.
|
|
62
|
+
* @param {string} [options.python] - Python executable. Default 'python3'.
|
|
63
|
+
* @param {string} [options.script] - Override the sidecar script path.
|
|
64
|
+
* @param {string} [options.listen] - Listen interfaces, e.g. '0.0.0.0:6881'.
|
|
65
|
+
* @param {number} [options.startTimeoutMs] - How long to wait for the sidecar. Default 20s.
|
|
66
|
+
*/
|
|
67
|
+
constructor(options) {
|
|
68
|
+
if (!options?.savePath) {
|
|
69
|
+
throw new Error('libtorrent engine requires a savePath');
|
|
70
|
+
}
|
|
71
|
+
this.name = 'libtorrent';
|
|
72
|
+
this.#options = { python: 'python3', startTimeoutMs: 20000, ...options };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @returns {string | null} - libtorrent version, once connected. */
|
|
76
|
+
get version() {
|
|
77
|
+
return this.#version;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Starts the sidecar and waits for it to report readiness.
|
|
82
|
+
* @returns {Promise<void>} - Resolves once usable.
|
|
83
|
+
*/
|
|
84
|
+
connect() {
|
|
85
|
+
if (this.#ready) return this.#ready;
|
|
86
|
+
|
|
87
|
+
this.#ready = new Promise((resolve, reject) => {
|
|
88
|
+
const script = this.#options.script ?? resolveSidecar();
|
|
89
|
+
|
|
90
|
+
const child = spawn(this.#options.python, [script], {
|
|
91
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
92
|
+
env: {
|
|
93
|
+
...process.env,
|
|
94
|
+
SIDECAR_SETTINGS: JSON.stringify({
|
|
95
|
+
listen: this.#options.listen,
|
|
96
|
+
resumeDir: this.#options.resumeDir,
|
|
97
|
+
maxConnections: this.#options.maxConnections,
|
|
98
|
+
dht: this.#options.dht,
|
|
99
|
+
uploadLimit: this.#options.uploadLimit,
|
|
100
|
+
downloadLimit: this.#options.downloadLimit,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
this.#child = child;
|
|
105
|
+
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
reject(
|
|
108
|
+
new Error(
|
|
109
|
+
`libtorrent sidecar did not start within ${this.#options.startTimeoutMs}ms`,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
child.kill();
|
|
113
|
+
}, this.#options.startTimeoutMs);
|
|
114
|
+
|
|
115
|
+
child.stdout.setEncoding('utf8');
|
|
116
|
+
child.stdout.on('data', (chunk) => {
|
|
117
|
+
this.#buffer += chunk;
|
|
118
|
+
let newline;
|
|
119
|
+
while ((newline = this.#buffer.indexOf('\n')) >= 0) {
|
|
120
|
+
const line = this.#buffer.slice(0, newline).trim();
|
|
121
|
+
this.#buffer = this.#buffer.slice(newline + 1);
|
|
122
|
+
if (!line) continue;
|
|
123
|
+
|
|
124
|
+
let message;
|
|
125
|
+
try {
|
|
126
|
+
message = JSON.parse(line);
|
|
127
|
+
} catch {
|
|
128
|
+
console.error(`[libtorrent] unparseable output: ${line}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (message.event === 'ready') {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
this.#version = message.libtorrent;
|
|
135
|
+
resolve();
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
this.#settle(message);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// The sidecar reports missing bindings and tracebacks on stderr; those
|
|
143
|
+
// are the errors an operator most needs to see.
|
|
144
|
+
child.stderr.setEncoding('utf8');
|
|
145
|
+
child.stderr.on('data', (text) => {
|
|
146
|
+
for (const line of text.split('\n')) {
|
|
147
|
+
if (line.trim()) console.error(`[libtorrent] ${line}`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
child.on('error', (error) => {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
reject(
|
|
154
|
+
new Error(
|
|
155
|
+
`could not start ${this.#options.python}: ${error.message}. ` +
|
|
156
|
+
'Install python3 and libtorrent (apt install python3-libtorrent).',
|
|
157
|
+
{ cause: error },
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
child.on('exit', (code) => {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
this.#child = null;
|
|
165
|
+
const error = new Error(`libtorrent sidecar exited (code ${code})`);
|
|
166
|
+
for (const { reject: fail } of this.#pending.values()) fail(error);
|
|
167
|
+
this.#pending.clear();
|
|
168
|
+
reject(error);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return this.#ready;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Adds a torrent.
|
|
177
|
+
* @param {import('./types.js').AddRequest} request - What to add.
|
|
178
|
+
* @returns {Promise<string>} - The infohash.
|
|
179
|
+
*/
|
|
180
|
+
async add(request) {
|
|
181
|
+
const result = await this.#call('add', {
|
|
182
|
+
torrentFile: request.torrentFile
|
|
183
|
+
? Buffer.from(request.torrentFile).toString('base64')
|
|
184
|
+
: undefined,
|
|
185
|
+
magnet: request.magnet,
|
|
186
|
+
savePath: request.savePath ?? this.#options.savePath,
|
|
187
|
+
mode: request.mode,
|
|
188
|
+
paused: request.paused,
|
|
189
|
+
});
|
|
190
|
+
return result.infoHash;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Removes a torrent.
|
|
195
|
+
* @param {string} infoHash - The torrent to remove.
|
|
196
|
+
* @param {{deleteData?: boolean}} [options] - Whether to delete data too.
|
|
197
|
+
* @returns {Promise<void>} - Resolves once removed.
|
|
198
|
+
*/
|
|
199
|
+
async remove(infoHash, options = {}) {
|
|
200
|
+
await this.#call('remove', {
|
|
201
|
+
infoHash,
|
|
202
|
+
deleteData: Boolean(options.deleteData),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Lists every torrent in the session.
|
|
208
|
+
* @returns {Promise<import('./types.js').TorrentStatus[]>} - Statuses.
|
|
209
|
+
*/
|
|
210
|
+
async list() {
|
|
211
|
+
return this.#call('list', {});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* One torrent's state.
|
|
216
|
+
* @param {string} infoHash - The torrent to look up.
|
|
217
|
+
* @returns {Promise<import('./types.js').TorrentStatus | null>} - Its status.
|
|
218
|
+
*/
|
|
219
|
+
async get(infoHash) {
|
|
220
|
+
return this.#call('get', { infoHash });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Per-peer detail.
|
|
225
|
+
* @param {string} infoHash - The torrent to inspect.
|
|
226
|
+
* @returns {Promise<object[]>} - One entry per peer.
|
|
227
|
+
*/
|
|
228
|
+
async peers(infoHash) {
|
|
229
|
+
return this.#call('peers', { infoHash });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Creates a torrent from a local file.
|
|
234
|
+
*
|
|
235
|
+
* Defaults to hybrid v1+v2, which is the capability that justifies this
|
|
236
|
+
* engine: v2 gives per-file merkle trees with 16 KiB leaf blocks, so a peer
|
|
237
|
+
* can verify a small block without holding the whole hash list, while the v1
|
|
238
|
+
* half keeps every existing client working.
|
|
239
|
+
* @param {string} filePath - Path to the archive.
|
|
240
|
+
* @param {object} [options] - Piece length, trackers, web seeds, format.
|
|
241
|
+
* @returns {Promise<object>} - The created torrent, torrentFile as bytes.
|
|
242
|
+
*/
|
|
243
|
+
async createTorrent(filePath, options = {}) {
|
|
244
|
+
const result = await this.#call(
|
|
245
|
+
'create',
|
|
246
|
+
{
|
|
247
|
+
path: filePath,
|
|
248
|
+
pieceLength: options.pieceLength,
|
|
249
|
+
trackers: options.trackers ?? [],
|
|
250
|
+
webSeeds: options.webSeeds ?? [],
|
|
251
|
+
comment: options.comment,
|
|
252
|
+
format: options.format ?? 'hybrid',
|
|
253
|
+
},
|
|
254
|
+
// Hashing a large archive takes as long as it takes.
|
|
255
|
+
options.timeoutMs ?? 6 * 60 * 60 * 1000,
|
|
256
|
+
);
|
|
257
|
+
return {
|
|
258
|
+
...result,
|
|
259
|
+
torrentFile: new Uint8Array(Buffer.from(result.torrentFile, 'base64')),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Reads one piece, promoted ahead of the normal picker.
|
|
265
|
+
*
|
|
266
|
+
* This is the primitive on-demand tile serving wants, and the reason
|
|
267
|
+
* qBittorrent cannot do cache mode properly — its WebUI has no equivalent.
|
|
268
|
+
* @param {string} infoHash - The torrent.
|
|
269
|
+
* @param {number} piece - Piece index.
|
|
270
|
+
* @param {object} [options] - Deadline and timeout.
|
|
271
|
+
* @returns {Promise<Uint8Array>} - The piece contents.
|
|
272
|
+
*/
|
|
273
|
+
async readPiece(infoHash, piece, options = {}) {
|
|
274
|
+
const result = await this.#call(
|
|
275
|
+
'read_piece',
|
|
276
|
+
{
|
|
277
|
+
infoHash,
|
|
278
|
+
piece,
|
|
279
|
+
deadlineMs: options.deadlineMs,
|
|
280
|
+
timeoutMs: options.timeoutMs,
|
|
281
|
+
},
|
|
282
|
+
(options.timeoutMs ?? 60000) + 5000,
|
|
283
|
+
);
|
|
284
|
+
return new Uint8Array(Buffer.from(result.data, 'base64'));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Reports the piece geometry a reader needs to map byte ranges onto pieces.
|
|
289
|
+
*
|
|
290
|
+
* Note the two coordinate systems: `pieceLength` and `numPieces` describe the
|
|
291
|
+
* torrent's global byte space, while `fileOffset` locates the archive inside
|
|
292
|
+
* it. A single-file torrent has a zero offset; a multi-file one does not, and
|
|
293
|
+
* getting that wrong reads the neighbouring file.
|
|
294
|
+
* @param {string} infoHash - The torrent.
|
|
295
|
+
* @param {number} [fileIndex] - Which file in a multi-file torrent.
|
|
296
|
+
* @returns {Promise<object>} - {infoHash, pieceLength, numPieces, fileLength, fileOffset, name}.
|
|
297
|
+
*/
|
|
298
|
+
async info(infoHash, fileIndex = 0) {
|
|
299
|
+
return this.#call('info', { infoHash, fileIndex });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Sets the download priority of a piece range.
|
|
304
|
+
*
|
|
305
|
+
* Zero means "do not fetch", which is how cache mode avoids pulling an entire
|
|
306
|
+
* archive while still seeding what it holds.
|
|
307
|
+
* @param {string} infoHash - The torrent.
|
|
308
|
+
* @param {number} first - First piece index, inclusive.
|
|
309
|
+
* @param {number} last - Last piece index, inclusive.
|
|
310
|
+
* @param {number} priority - libtorrent piece priority, 0 to 7.
|
|
311
|
+
* @returns {Promise<void>} - Resolves once applied.
|
|
312
|
+
*/
|
|
313
|
+
async setPriority(infoHash, first, last, priority) {
|
|
314
|
+
await this.#call('set_priority', { infoHash, first, last, priority });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Persists resume data, so the next start skips re-hashing the store.
|
|
319
|
+
* @param {string} [infoHash] - One torrent, or all when omitted.
|
|
320
|
+
* @returns {Promise<void>} - Resolves once saved.
|
|
321
|
+
*/
|
|
322
|
+
async saveResume(infoHash) {
|
|
323
|
+
await this.#call('save_resume', { infoHash });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Saves resume data and stops the sidecar.
|
|
328
|
+
* @returns {Promise<void>} - Resolves once stopped.
|
|
329
|
+
*/
|
|
330
|
+
async destroy() {
|
|
331
|
+
if (!this.#child) return;
|
|
332
|
+
await this.#call('shutdown', {}, 15000).catch(() => {});
|
|
333
|
+
this.#child?.kill();
|
|
334
|
+
this.#child = null;
|
|
335
|
+
this.#ready = null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Sends a request and waits for its reply.
|
|
340
|
+
* @param {string} op - Operation name.
|
|
341
|
+
* @param {object} params - Operation parameters.
|
|
342
|
+
* @param {number} [timeoutMs] - How long to wait. Default 60s.
|
|
343
|
+
* @returns {Promise<any>} - The result.
|
|
344
|
+
*/
|
|
345
|
+
async #call(op, params, timeoutMs = 60000) {
|
|
346
|
+
if (op !== 'shutdown') await this.connect();
|
|
347
|
+
const child = this.#child;
|
|
348
|
+
if (!child) throw new Error('libtorrent sidecar is not running');
|
|
349
|
+
|
|
350
|
+
const id = this.#nextId++;
|
|
351
|
+
return new Promise((resolve, reject) => {
|
|
352
|
+
const timer = setTimeout(() => {
|
|
353
|
+
this.#pending.delete(id);
|
|
354
|
+
reject(new Error(`libtorrent ${op} timed out after ${timeoutMs}ms`));
|
|
355
|
+
}, timeoutMs);
|
|
356
|
+
|
|
357
|
+
this.#pending.set(id, {
|
|
358
|
+
resolve: (value) => {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
resolve(value);
|
|
361
|
+
},
|
|
362
|
+
reject: (error) => {
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
reject(error);
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
child.stdin.write(`${JSON.stringify({ id, op, params })}\n`);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Routes a reply to whoever is waiting for it.
|
|
374
|
+
* @param {object} message - A decoded reply.
|
|
375
|
+
* @returns {void}
|
|
376
|
+
*/
|
|
377
|
+
#settle(message) {
|
|
378
|
+
const waiter = this.#pending.get(message.id);
|
|
379
|
+
if (!waiter) return;
|
|
380
|
+
this.#pending.delete(message.id);
|
|
381
|
+
if (message.ok) waiter.resolve(message.result);
|
|
382
|
+
else waiter.reject(new Error(message.error ?? 'unknown sidecar error'));
|
|
383
|
+
}
|
|
384
|
+
}
|