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/src/library.js ADDED
@@ -0,0 +1,567 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { checkOrigin, fingerprintOrigin } from './origin.js';
4
+ import { probePMTiles } from './pmtiles-probe.js';
5
+ import {
6
+ createTorrentFromFile,
7
+ createTorrentFromUrl,
8
+ } from './torrent-create.js';
9
+
10
+ /**
11
+ * The service that ties the catalog, the seeding engine and torrent creation
12
+ * together. Every way an archive can enter this node goes through here.
13
+ *
14
+ * There are four:
15
+ * - a local .pmtiles file, which we hash into a new torrent;
16
+ * - a remote .pmtiles URL, likewise, with the URL kept as a web seed;
17
+ * - an existing .torrent or magnet, which we simply join;
18
+ * - adoption of torrents the engine is already seeding, which is how an
19
+ * existing qBittorrent library comes across without re-hashing anything.
20
+ *
21
+ * The last two are the common cases in practice: publishers create torrents,
22
+ * everyone else joins them.
23
+ */
24
+ export class Library {
25
+ #catalog;
26
+ #engine;
27
+ #config;
28
+ /** Serialises rebuilds so a sweep cannot start several multi-hour hashes at once. */
29
+ #rebuildQueue = Promise.resolve();
30
+
31
+ /**
32
+ * Creates the service.
33
+ * @param {object} deps - Collaborators.
34
+ * @param {import('./catalog.js').Catalog} deps.catalog - The catalog.
35
+ * @param {import('./engines/types.js').SeedEngine} deps.engine - The seeding engine.
36
+ * @param {object} deps.config - Resolved configuration.
37
+ */
38
+ constructor({ catalog, engine, config }) {
39
+ this.#catalog = catalog;
40
+ this.#engine = engine;
41
+ this.#config = config;
42
+ }
43
+
44
+ /** @returns {string} - Where generated .torrent files are written. */
45
+ get torrentDir() {
46
+ return path.join(this.#config.dataDir, 'torrents');
47
+ }
48
+
49
+ /**
50
+ * Adds a local PMTiles archive, creating a torrent for it.
51
+ *
52
+ * The data is left where it is and the torrent points at it, so publishing a
53
+ * 700 GiB archive copies nothing.
54
+ * @param {string} filePath - Path to the .pmtiles file.
55
+ * @param {object} [options] - Category, trackers, web seeds, piece length.
56
+ * @returns {Promise<object>} - The catalog entry.
57
+ */
58
+ async addLocalArchive(filePath, options = {}) {
59
+ const absolute = path.resolve(filePath);
60
+ const existing = this.#catalog.findBySource(absolute);
61
+ if (existing) return existing;
62
+
63
+ const summary = await probePMTiles(absolute).catch(() => undefined);
64
+ const created = await createTorrentFromFile(absolute, {
65
+ pieceLength: options.pieceLength ?? this.#config.pieceLength,
66
+ trackers: options.trackers ?? this.#config.trackers,
67
+ webSeeds: options.webSeeds ?? [],
68
+ comment: options.comment,
69
+ });
70
+
71
+ return this.#register(created, {
72
+ category: options.category,
73
+ source: { type: 'file', location: absolute },
74
+ // The torrent names the file, so the save path is its parent directory.
75
+ savePath: path.dirname(absolute),
76
+ pmtiles: summary,
77
+ seedOnly: true,
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Adds a remote PMTiles archive by streaming it past the hasher.
83
+ * @param {string} url - HTTP(S) URL of the archive.
84
+ * @param {object} [options] - Category, trackers, piece length, save path.
85
+ * @returns {Promise<object>} - The catalog entry.
86
+ */
87
+ async addRemoteArchive(url, options = {}) {
88
+ const existing = this.#catalog.findBySource(url);
89
+ if (existing) return existing;
90
+
91
+ // Probing reads only the header and directory, so this is cheap even
92
+ // against a multi-gigabyte archive — worth doing before committing to a
93
+ // download that may take hours.
94
+ const summary = await probePMTiles(url).catch(() => undefined);
95
+
96
+ // Retaining leaves a seedable copy behind. Discarding is explicit, because
97
+ // the result is a torrent this node cannot serve.
98
+ const retain = options.retain !== false;
99
+ const savePath = options.savePath ?? this.#config.webtorrent.savePath;
100
+
101
+ const created = await createTorrentFromUrl(url, {
102
+ // Upstreams often publish under a bare dated name; a source can rename it
103
+ // to something self-describing locally.
104
+ name: options.name,
105
+ pieceLength: options.pieceLength ?? this.#config.pieceLength,
106
+ trackers: options.trackers ?? this.#config.trackers,
107
+ webSeeds: options.webSeeds ?? [],
108
+ comment: options.comment,
109
+ retainPath: retain ? savePath : undefined,
110
+ onProgress: ({ received, total, done }) => {
111
+ const pct = total ? ((received / total) * 100).toFixed(1) : '?';
112
+ console.log(
113
+ `[fetch] ${url} ${pct}%${done ? ' complete' : ''} (${received} bytes)`,
114
+ );
115
+ },
116
+ });
117
+
118
+ return this.#register(created, {
119
+ category: options.category,
120
+ source: { type: 'http', location: url },
121
+ savePath,
122
+ pmtiles: summary,
123
+ webSeeds: created.webSeeds ?? [url],
124
+ // With no local copy there is nothing to seed; peers rely on the web
125
+ // seed until one of them completes a download.
126
+ seedOnly: retain,
127
+ mode: retain ? 'mirror' : 'cache',
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Joins an existing torrent, from a .torrent file, raw bytes or a magnet.
133
+ *
134
+ * Nothing is hashed and nothing is created — this is how a subscriber picks
135
+ * up what a publisher announced, and how an operator adds a torrent they were
136
+ * handed. PMTiles metadata is filled in later, once enough of the archive is
137
+ * readable.
138
+ * @param {object} input - One of {torrentFile}, {torrentPath} or {magnet}.
139
+ * @param {object} [options] - Category and save path.
140
+ * @returns {Promise<object>} - The catalog entry.
141
+ */
142
+ async addExistingTorrent(input, options = {}) {
143
+ const { default: parseTorrent } = await import('parse-torrent');
144
+
145
+ let torrentFile;
146
+ if (input.torrentFile) {
147
+ torrentFile = new Uint8Array(input.torrentFile);
148
+ } else if (input.torrentPath) {
149
+ torrentFile = new Uint8Array(await fs.readFile(input.torrentPath));
150
+ }
151
+
152
+ const parsed = await parseTorrent(torrentFile ?? input.magnet);
153
+ if (!parsed?.infoHash) {
154
+ throw new Error('could not read an infohash from the supplied torrent');
155
+ }
156
+
157
+ const existing = this.#catalog.get(parsed.infoHash);
158
+ if (existing) return existing;
159
+
160
+ const savePath = options.savePath ?? this.#config.webtorrent.savePath;
161
+ // Default to cache: joining a torrent should not silently commit the disk
162
+ // to a full copy of something that may be hundreds of gigabytes. Mirroring
163
+ // is opt-in.
164
+ const mode = options.mode ?? 'cache';
165
+ await this.#engine.add({
166
+ torrentFile,
167
+ magnet: torrentFile ? undefined : input.magnet,
168
+ savePath,
169
+ category: options.category,
170
+ mode,
171
+ });
172
+
173
+ let storedTorrentPath;
174
+ if (torrentFile) {
175
+ storedTorrentPath = path.join(this.torrentDir, `${parsed.infoHash}.torrent`);
176
+ await fs.mkdir(this.torrentDir, { recursive: true });
177
+ await fs.writeFile(storedTorrentPath, torrentFile);
178
+ }
179
+
180
+ return this.#catalog.put({
181
+ infoHash: parsed.infoHash,
182
+ name: parsed.name ?? parsed.infoHash,
183
+ size: parsed.length ?? 0,
184
+ category: options.category,
185
+ source: {
186
+ type: input.magnet ? 'magnet' : 'torrent',
187
+ location: input.magnet ?? input.torrentPath ?? 'uploaded',
188
+ },
189
+ savePath,
190
+ torrentPath: storedTorrentPath,
191
+ magnet: input.magnet ?? magnetFor(parsed, this.#config.trackers),
192
+ webSeeds: parsed.urlList ?? [],
193
+ mode,
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Imports torrents the engine already holds but the catalog does not know
199
+ * about — the migration path for an existing qBittorrent library.
200
+ *
201
+ * Only archives that look like PMTiles are taken, since the rest of a general
202
+ * torrent library is not ours to manage.
203
+ * @param {object} [options] - Import options.
204
+ * @param {boolean} [options.all] - Import every torrent, not just .pmtiles ones.
205
+ * @returns {Promise<object[]>} - The entries that were added.
206
+ */
207
+ async adoptFromEngine(options = {}) {
208
+ const held = await this.#engine.list();
209
+ const added = [];
210
+
211
+ for (const torrent of held) {
212
+ if (this.#catalog.get(torrent.infoHash)) continue;
213
+ if (!options.all && !/\.pmtiles$/i.test(torrent.name)) continue;
214
+
215
+ // The engine knows where the data is, so if it is complete we can read
216
+ // the archive's own metadata straight off disk.
217
+ let summary;
218
+ if (torrent.progress === 1 && torrent.savePath) {
219
+ summary = await probePMTiles(
220
+ path.join(torrent.savePath, torrent.name),
221
+ ).catch(() => undefined);
222
+ }
223
+
224
+ added.push(
225
+ await this.#catalog.put({
226
+ infoHash: torrent.infoHash,
227
+ name: torrent.name,
228
+ size: torrent.size,
229
+ category: torrent.category,
230
+ source: { type: 'adopted', location: torrent.savePath ?? 'engine' },
231
+ savePath: torrent.savePath,
232
+ magnet: `magnet:?xt=urn:btih:${torrent.infoHash}&dn=${encodeURIComponent(torrent.name)}`,
233
+ webSeeds: [],
234
+ pmtiles: summary,
235
+ }),
236
+ );
237
+ }
238
+ return added;
239
+ }
240
+
241
+ /**
242
+ * Checks whether an archive's source has changed since its torrent was made.
243
+ *
244
+ * A changed source does not invalidate the torrent — the bytes it describes
245
+ * are still perfectly good bytes — but it does mean the catalog is
246
+ * advertising something the source no longer has, and that any web seed
247
+ * pointing at that source will now fail hash verification for every peer
248
+ * that tries it. The entry is flagged rather than rebuilt, because rebuilding
249
+ * means re-hashing and, for a remote archive, re-downloading.
250
+ * @param {string} infoHash - The archive to check.
251
+ * @returns {Promise<import('./origin.js').OriginCheck | null>} - What was found.
252
+ */
253
+ async checkOrigin(infoHash) {
254
+ const entry = this.#catalog.get(infoHash);
255
+ if (!entry) return null;
256
+
257
+ const result = await checkOrigin(entry);
258
+ if (result.status === 'unchanged' || result.status === 'unknown') {
259
+ // Refresh the stored fingerprint so validators that only appear later
260
+ // (an origin that starts sending ETags, say) are picked up.
261
+ if (result.fingerprint) {
262
+ await this.#catalog.put({
263
+ infoHash: entry.infoHash,
264
+ origin: result.fingerprint,
265
+ stale: false,
266
+ });
267
+ }
268
+ return result;
269
+ }
270
+
271
+ await this.#catalog.put({
272
+ infoHash: entry.infoHash,
273
+ stale: true,
274
+ staleReason: result.reason,
275
+ staleSince: new Date().toISOString(),
276
+ });
277
+ console.warn(
278
+ `[origin] ${entry.name} no longer matches its source (${result.reason}). ` +
279
+ 'The torrent is still valid, but its web seed will now fail hash ' +
280
+ 'verification for peers.',
281
+ );
282
+
283
+ const auto = this.#autoRebuildDecision(entry);
284
+ if (auto.allowed) {
285
+ // Deliberately not awaited: rebuilding can take hours, and an origin
286
+ // sweep should not block on it.
287
+ this.#queueRebuild(entry, result).catch((error) =>
288
+ console.error(
289
+ `[rebuild] ${entry.name} failed: ${error.message}`,
290
+ ),
291
+ );
292
+ } else {
293
+ console.warn(`[origin] not rebuilding automatically: ${auto.reason}`);
294
+ }
295
+ return result;
296
+ }
297
+
298
+ /**
299
+ * Decides whether this archive may be rebuilt without an operator asking.
300
+ *
301
+ * Rebuilding re-hashes the archive, and for a remote source re-downloads it,
302
+ * so the guards matter more than the feature: it is opt-in, capped by size,
303
+ * and restricted to source types the operator has named.
304
+ * @param {object} entry - The catalog entry.
305
+ * @returns {{allowed: boolean, reason?: string}} - The decision.
306
+ */
307
+ #autoRebuildDecision(entry) {
308
+ const policy = this.#config.autoRebuild ?? {};
309
+ if (!policy.enabled) return { allowed: false, reason: 'autoRebuild is disabled' };
310
+
311
+ const sources = policy.sources ?? ['file'];
312
+ if (!sources.includes(entry.source?.type)) {
313
+ return {
314
+ allowed: false,
315
+ reason: `source type "${entry.source?.type}" is not in autoRebuild.sources (${sources.join(', ')})`,
316
+ };
317
+ }
318
+
319
+ const cap = policy.maxBytes ?? 50 * 1024 * 1024 * 1024;
320
+ if (cap > 0 && entry.size > cap) {
321
+ return {
322
+ allowed: false,
323
+ reason: `${entry.name} is ${entry.size} bytes, over the autoRebuild.maxBytes cap of ${cap}`,
324
+ };
325
+ }
326
+ return { allowed: true };
327
+ }
328
+
329
+ /**
330
+ * Rebuilds an archive once its source has stopped changing.
331
+ *
332
+ * Two things make this safe enough to run unattended. It waits for the source
333
+ * to settle, because a build still writing its output would otherwise be
334
+ * hashed mid-write — the same hazard watch folders guard against. And
335
+ * rebuilds run one at a time, so a sweep that finds five changed archives
336
+ * does not start five concurrent multi-hour hashes.
337
+ * @param {object} entry - The catalog entry.
338
+ * @param {import('./origin.js').OriginCheck} check - What the check found.
339
+ * @returns {Promise<object | null>} - The new entry, or null if it was skipped.
340
+ */
341
+ async #queueRebuild(entry, check) {
342
+ this.#rebuildQueue = this.#rebuildQueue.then(async () => {
343
+ const policy = this.#config.autoRebuild ?? {};
344
+ const settleMs = (policy.stabilitySeconds ?? 300) * 1000;
345
+
346
+ console.log(
347
+ `[rebuild] ${entry.name}: waiting ${settleMs / 1000}s for the source to settle`,
348
+ );
349
+ await new Promise((resolve) => setTimeout(resolve, settleMs));
350
+
351
+ // If it moved again while we waited, it is still being written. Leave it
352
+ // stale and let the next sweep pick it up.
353
+ const after = await fingerprintOrigin(entry.source).catch(() => null);
354
+ if (!after) {
355
+ console.warn(`[rebuild] ${entry.name}: source vanished, skipping`);
356
+ return null;
357
+ }
358
+ if (
359
+ check.fingerprint &&
360
+ (after.size !== check.fingerprint.size ||
361
+ after.lastModified !== check.fingerprint.lastModified)
362
+ ) {
363
+ console.warn(
364
+ `[rebuild] ${entry.name}: source still changing, deferring to the next check`,
365
+ );
366
+ return null;
367
+ }
368
+
369
+ console.log(`[rebuild] ${entry.name}: rebuilding from ${entry.source.location}`);
370
+ const rebuilt = await this.rebuild(entry.infoHash);
371
+ console.log(
372
+ `[rebuild] ${entry.name}: ${entry.infoHash} -> ${rebuilt.infoHash}`,
373
+ );
374
+ return rebuilt;
375
+ });
376
+ return this.#rebuildQueue;
377
+ }
378
+
379
+ /**
380
+ * Checks every archive that has a source worth watching.
381
+ * @returns {Promise<import('./origin.js').OriginCheck[]>} - Results that found a change.
382
+ */
383
+ async checkAllOrigins() {
384
+ const results = [];
385
+ for (const entry of this.#catalog.list()) {
386
+ if (entry.source?.type !== 'http' && entry.source?.type !== 'file') {
387
+ continue;
388
+ }
389
+ const result = await this.checkOrigin(entry.infoHash).catch((error) => ({
390
+ infoHash: entry.infoHash,
391
+ status: 'missing',
392
+ reason: error.message,
393
+ }));
394
+ if (result && result.status !== 'unchanged') results.push(result);
395
+ }
396
+ return results;
397
+ }
398
+
399
+ /**
400
+ * Rebuilds an archive's torrent from its current source.
401
+ *
402
+ * This produces a *new* torrent with a new infohash, because the infohash is
403
+ * a hash of the content — there is no such thing as updating one in place.
404
+ * The old entry is kept and marked superseded, so anything still seeding it
405
+ * keeps working while subscribers move across via the feed.
406
+ * @param {string} infoHash - The archive to rebuild.
407
+ * @param {object} [options] - Passed through to the add.
408
+ * @returns {Promise<object>} - The new catalog entry.
409
+ */
410
+ async rebuild(infoHash, options = {}) {
411
+ const entry = this.#catalog.get(infoHash);
412
+ if (!entry) throw new Error(`no such archive: ${infoHash}`);
413
+ if (entry.source?.type !== 'http' && entry.source?.type !== 'file') {
414
+ throw new Error(
415
+ `${entry.name} was joined rather than created here, so there is nothing to rebuild from`,
416
+ );
417
+ }
418
+
419
+ const shared = {
420
+ category: options.category ?? entry.category,
421
+ trackers: options.trackers,
422
+ webSeeds: options.webSeeds ?? entry.webSeeds,
423
+ pieceLength: options.pieceLength ?? entry.pieceLength,
424
+ ...options,
425
+ };
426
+
427
+ // findBySource would otherwise hand back the stale entry.
428
+ await this.#catalog.remove(entry.infoHash);
429
+
430
+ let rebuilt;
431
+ try {
432
+ rebuilt =
433
+ entry.source.type === 'http'
434
+ ? await this.addRemoteArchive(entry.source.location, shared)
435
+ : await this.addLocalArchive(entry.source.location, shared);
436
+ } catch (error) {
437
+ // Put the old entry back rather than losing the catalog record.
438
+ await this.#catalog.put(entry);
439
+ throw error;
440
+ }
441
+
442
+ if (rebuilt.infoHash === entry.infoHash) {
443
+ // Byte-identical rebuild: same content, same torrent, nothing to move.
444
+ return rebuilt;
445
+ }
446
+
447
+ await this.#catalog.put({
448
+ ...entry,
449
+ stale: true,
450
+ superseded: true,
451
+ supersededBy: rebuilt.infoHash,
452
+ });
453
+ return rebuilt;
454
+ }
455
+
456
+ /**
457
+ * Removes an archive from the catalog and the engine.
458
+ * @param {string} infoHash - The archive to remove.
459
+ * @param {object} [options] - Removal options.
460
+ * @param {boolean} [options.deleteData] - Also delete the downloaded data.
461
+ * @returns {Promise<boolean>} - Whether anything was removed.
462
+ */
463
+ async remove(infoHash, options = {}) {
464
+ const entry = this.#catalog.get(infoHash);
465
+ if (!entry) return false;
466
+ await this.#engine
467
+ .remove(infoHash, { deleteData: options.deleteData })
468
+ .catch(() => {});
469
+ await this.#catalog.remove(infoHash);
470
+ return true;
471
+ }
472
+
473
+ /**
474
+ * The catalog joined with live state from the engine.
475
+ * @returns {Promise<object[]>} - Entries with a `status` field where known.
476
+ */
477
+ async listWithStatus() {
478
+ const live = new Map();
479
+ try {
480
+ for (const status of await this.#engine.list()) {
481
+ live.set(status.infoHash, status);
482
+ }
483
+ } catch (error) {
484
+ // A dead engine should degrade to a catalog listing, not a broken page.
485
+ console.error(`[library] engine unreachable: ${error.message}`);
486
+ }
487
+ return this.#catalog
488
+ .list()
489
+ .map((entry) => ({ ...entry, status: live.get(entry.infoHash) ?? null }));
490
+ }
491
+
492
+ /**
493
+ * Writes the torrent to disk, hands it to the engine and records it.
494
+ * @param {import('./torrent-create.js').CreatedTorrent} created - The new torrent.
495
+ * @param {object} details - Catalog details.
496
+ * @returns {Promise<object>} - The catalog entry.
497
+ */
498
+ async #register(created, details) {
499
+ // Record what the source looked like now, so a later change is detectable
500
+ // without re-reading the archive.
501
+ const origin = await fingerprintOrigin(details.source).catch(() => null);
502
+
503
+ await fs.mkdir(this.torrentDir, { recursive: true });
504
+ const torrentPath = path.join(
505
+ this.torrentDir,
506
+ `${created.infoHash}.torrent`,
507
+ );
508
+ await fs.writeFile(torrentPath, created.torrentFile);
509
+
510
+ // A client watching a drop directory picks this up on its own.
511
+ if (this.#config.torrentDropDir) {
512
+ try {
513
+ await fs.mkdir(this.#config.torrentDropDir, { recursive: true });
514
+ await fs.writeFile(
515
+ path.join(this.#config.torrentDropDir, path.basename(torrentPath)),
516
+ created.torrentFile,
517
+ );
518
+ } catch (error) {
519
+ console.warn(
520
+ `[drop] could not write to ${this.#config.torrentDropDir}: ${error.message}`,
521
+ );
522
+ }
523
+ }
524
+
525
+ await this.#engine.add({
526
+ torrentFile: created.torrentFile,
527
+ savePath: details.savePath,
528
+ category: details.category,
529
+ seedOnly: details.seedOnly,
530
+ mode: details.mode ?? 'mirror',
531
+ });
532
+
533
+ return this.#catalog.put({
534
+ infoHash: created.infoHash,
535
+ name: created.name,
536
+ size: created.size,
537
+ category: details.category,
538
+ source: details.source,
539
+ savePath: details.savePath,
540
+ torrentPath,
541
+ magnet: created.magnet,
542
+ webSeeds: details.webSeeds ?? [],
543
+ pieceLength: created.pieceLength,
544
+ pieceCount: created.pieceCount,
545
+ pmtiles: details.pmtiles,
546
+ mode: details.mode ?? 'mirror',
547
+ retainedAt: created.retainedAt,
548
+ origin,
549
+ stale: false,
550
+ });
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Builds a magnet URI for a parsed torrent.
556
+ * @param {object} parsed - A parse-torrent result.
557
+ * @param {string[]} trackers - Announce URLs to include.
558
+ * @returns {string} - The magnet URI.
559
+ */
560
+ function magnetFor(parsed, trackers = []) {
561
+ const parts = [`magnet:?xt=urn:btih:${parsed.infoHash}`];
562
+ if (parsed.name) parts.push(`dn=${encodeURIComponent(parsed.name)}`);
563
+ for (const tracker of parsed.announce ?? trackers) {
564
+ parts.push(`tr=${encodeURIComponent(tracker)}`);
565
+ }
566
+ return parts.join('&');
567
+ }