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/feed.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * RSS publishing and subscribing.
3
+ *
4
+ * The feed is deliberately plain RSS 2.0 with a torrent enclosure, because
5
+ * that is what qBittorrent's built-in RSS auto-downloader already understands
6
+ * — an operator can subscribe to a pmtiles-swarm feed today, with no new
7
+ * software, and have new archives download automatically.
8
+ *
9
+ * On top of that it carries a small namespaced extension describing the map:
10
+ * coverage, zoom range and tile format. That is the part a generic torrent feed
11
+ * cannot offer, and it is what lets a subscriber decide whether it wants a
12
+ * 72 GiB download before starting one.
13
+ */
14
+
15
+ const PMTILES_NS = 'https://github.com/TechIdiots-LLC/pmtiles-swarm/ns/1.0';
16
+
17
+ /**
18
+ * Escapes text for XML content.
19
+ * @param {unknown} value - The value to escape.
20
+ * @returns {string} - Escaped text.
21
+ */
22
+ function xml(value) {
23
+ return String(value ?? '')
24
+ .replace(/&/g, '&')
25
+ .replace(/</g, '&lt;')
26
+ .replace(/>/g, '&gt;')
27
+ .replace(/"/g, '&quot;');
28
+ }
29
+
30
+ /**
31
+ * Renders the catalog as an RSS 2.0 feed.
32
+ * @param {object[]} entries - Catalog entries to publish.
33
+ * @param {object} options - Feed options.
34
+ * @param {string} options.title - Feed title.
35
+ * @param {string} options.baseUrl - Public base URL for building links.
36
+ * @param {string} [options.description] - Feed description.
37
+ * @param {string} [options.copyright] - Rights statement for the channel.
38
+ * @param {string} [options.category] - Category this feed covers.
39
+ * @param {number} [options.maxItems] - Keep only this many newest items. Zero or absent means all.
40
+ * @returns {string} - The feed XML.
41
+ */
42
+ export function renderFeed(entries, options) {
43
+ const self = options.category
44
+ ? `${options.baseUrl}/feed/${encodeURIComponent(options.category)}.xml`
45
+ : `${options.baseUrl}/feed.xml`;
46
+
47
+ // Entries arrive newest first, so a cap keeps the most recent builds. Set it
48
+ // with a subscriber's poll interval in mind: a feed holding one item is only
49
+ // safe if every subscriber polls more often than you publish, or a consumer
50
+ // that was down overnight silently misses a build.
51
+ const shown =
52
+ options.maxItems > 0 ? entries.slice(0, options.maxItems) : entries;
53
+
54
+ const items = shown
55
+ .map((entry) => renderItem(entry, options.baseUrl))
56
+ .join('\n');
57
+
58
+ return `<?xml version="1.0" encoding="UTF-8"?>
59
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:pmtiles="${PMTILES_NS}">
60
+ <channel>
61
+ <title>${xml(options.title)}</title>
62
+ <link>${xml(options.baseUrl)}</link>
63
+ <description>${xml(options.description ?? 'PMTiles map archives distributed over BitTorrent')}</description>
64
+ ${options.copyright ? ` <copyright>${xml(options.copyright)}</copyright>\n` : ''} <generator>pmtiles-swarm</generator>
65
+ <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
66
+ <atom:link href="${xml(self)}" rel="self" type="application/rss+xml"/>
67
+ ${items}
68
+ </channel>
69
+ </rss>
70
+ `;
71
+ }
72
+
73
+ /**
74
+ * Renders one catalog entry as a feed item.
75
+ * @param {object} entry - The catalog entry.
76
+ * @param {string} baseUrl - Public base URL.
77
+ * @returns {string} - The item XML.
78
+ */
79
+ function renderItem(entry, baseUrl) {
80
+ const torrentUrl = `${baseUrl}/api/torrents/${entry.infoHash}/file`;
81
+ const map = entry.pmtiles;
82
+
83
+ const mapFields = map
84
+ ? [
85
+ ` <pmtiles:format>${xml(map.format)}</pmtiles:format>`,
86
+ ` <pmtiles:minzoom>${xml(map.minZoom)}</pmtiles:minzoom>`,
87
+ ` <pmtiles:maxzoom>${xml(map.maxZoom)}</pmtiles:maxzoom>`,
88
+ ` <pmtiles:bounds>${xml((map.bounds ?? []).join(','))}</pmtiles:bounds>`,
89
+ map.tileCount
90
+ ? ` <pmtiles:tiles>${xml(map.tileCount)}</pmtiles:tiles>`
91
+ : '',
92
+ map.attribution
93
+ ? ` <pmtiles:attribution>${xml(map.attribution)}</pmtiles:attribution>`
94
+ : '',
95
+ ]
96
+ .filter(Boolean)
97
+ .join('\n')
98
+ : '';
99
+
100
+ const summary = map
101
+ ? `${map.format} tiles, zoom ${map.minZoom}-${map.maxZoom}, ${formatBytes(entry.size)}`
102
+ : formatBytes(entry.size);
103
+
104
+ return ` <item>
105
+ <title>${xml(entry.name)}</title>
106
+ <link>${xml(torrentUrl)}</link>
107
+ <guid isPermaLink="false">${xml(entry.infoHash)}</guid>
108
+ <pubDate>${new Date(entry.createdAt).toUTCString()}</pubDate>
109
+ <description>${xml(map?.description ?? summary)}</description>
110
+ ${entry.category ? ` <category>${xml(entry.category)}</category>` : ''}
111
+ <enclosure url="${xml(torrentUrl)}" length="${xml(entry.size)}" type="application/x-bittorrent"/>
112
+ <pmtiles:infohash>${xml(entry.infoHash)}</pmtiles:infohash>
113
+ <pmtiles:magnet>${xml(entry.magnet)}</pmtiles:magnet>
114
+ ${mapFields}
115
+ </item>`;
116
+ }
117
+
118
+ /**
119
+ * Formats a byte count for human-readable descriptions.
120
+ * @param {number} bytes - The size.
121
+ * @returns {string} - A short label.
122
+ */
123
+ export function formatBytes(bytes) {
124
+ if (!bytes) return 'unknown size';
125
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
126
+ let value = bytes;
127
+ let unit = 0;
128
+ while (value >= 1024 && unit < units.length - 1) {
129
+ value /= 1024;
130
+ unit++;
131
+ }
132
+ return `${value.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`;
133
+ }
134
+
135
+ /**
136
+ * One item pulled from a subscribed feed.
137
+ * @typedef {object} FeedItem
138
+ * @property {string} title - Item title.
139
+ * @property {string} [infoHash] - Infohash, when the feed states it.
140
+ * @property {string} [magnet] - Magnet URI, when present.
141
+ * @property {string} [torrentUrl] - URL of a .torrent enclosure.
142
+ * @property {string} [category] - Item category.
143
+ */
144
+
145
+ /**
146
+ * Parses a subscribed feed.
147
+ *
148
+ * Written against the shape of RSS rather than with a full XML parser: feeds
149
+ * in this ecosystem are simple, and this keeps the dependency list short. It
150
+ * reads any RSS feed carrying torrent enclosures or magnets, not only ours.
151
+ * @param {string} body - The feed XML.
152
+ * @returns {FeedItem[]} - The items found.
153
+ */
154
+ export function parseFeed(body) {
155
+ const items = [];
156
+ const itemPattern = /<item[\s>][\s\S]*?<\/item>/gi;
157
+
158
+ for (const [block] of body.matchAll(itemPattern)) {
159
+ const title = tag(block, 'title');
160
+ const enclosure = /<enclosure\b[^>]*\burl=["']([^"']+)["'][^>]*>/i.exec(
161
+ block,
162
+ );
163
+ const enclosureType =
164
+ /<enclosure\b[^>]*\btype=["']([^"']+)["'][^>]*>/i.exec(block)?.[1] ?? '';
165
+ const link = tag(block, 'link');
166
+ const magnetTag = tag(block, 'pmtiles:magnet');
167
+
168
+ // A magnet can arrive in its own element, as the link, or as the enclosure.
169
+ const candidates = [magnetTag, link, enclosure?.[1]].filter(Boolean);
170
+ const magnet = candidates.find((value) => value.startsWith('magnet:'));
171
+
172
+ // Only treat an enclosure as a torrent if it says so or ends in .torrent;
173
+ // otherwise it may be an image or an audio file.
174
+ const torrentUrl = [enclosure?.[1], link]
175
+ .filter(Boolean)
176
+ .find(
177
+ (value) =>
178
+ !value.startsWith('magnet:') &&
179
+ (/bittorrent/i.test(enclosureType) ||
180
+ /\.torrent(\?|$)/i.test(value) ||
181
+ /\/file$/i.test(value)),
182
+ );
183
+
184
+ if (!magnet && !torrentUrl) continue;
185
+
186
+ items.push({
187
+ title: title ?? 'untitled',
188
+ infoHash: tag(block, 'pmtiles:infohash')?.toLowerCase(),
189
+ magnet,
190
+ torrentUrl,
191
+ category: tag(block, 'category'),
192
+ });
193
+ }
194
+ return items;
195
+ }
196
+
197
+ /**
198
+ * Reads the text content of the first matching element.
199
+ * @param {string} block - The XML fragment to search.
200
+ * @param {string} name - Element name.
201
+ * @returns {string | undefined} - Decoded text, if found.
202
+ */
203
+ function tag(block, name) {
204
+ const pattern = new RegExp(
205
+ `<${name}\\b[^>]*>([\\s\\S]*?)</${name}>`,
206
+ 'i',
207
+ );
208
+ const match = pattern.exec(block);
209
+ if (!match) return undefined;
210
+ return decode(match[1].trim());
211
+ }
212
+
213
+ /**
214
+ * Decodes CDATA and the XML entities a feed may carry.
215
+ * @param {string} value - Raw element content.
216
+ * @returns {string} - Decoded text.
217
+ */
218
+ function decode(value) {
219
+ return value
220
+ .replace(/^<!\[CDATA\[([\s\S]*?)\]\]>$/, '$1')
221
+ .replace(/&lt;/g, '<')
222
+ .replace(/&gt;/g, '>')
223
+ .replace(/&quot;/g, '"')
224
+ .replace(/&#39;/g, "'")
225
+ .replace(/&amp;/g, '&');
226
+ }
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * A PMTiles source reading a local file through a descriptor.
5
+ *
6
+ * Used both by the prober, which only touches the header and directories, and
7
+ * by the tile server when this node holds a complete copy of an archive. A
8
+ * complete local file needs no swarm involvement at all: reading it directly is
9
+ * both faster and simpler than routing through a torrent engine that would only
10
+ * hand back pieces it already has on disk.
11
+ */
12
+ export class NodeFileSource {
13
+ #fd;
14
+ #path;
15
+
16
+ /**
17
+ * Opens the file.
18
+ * @param {string} filePath - Path to the archive.
19
+ */
20
+ constructor(filePath) {
21
+ this.#path = filePath;
22
+ this.#fd = fs.openSync(filePath, 'r');
23
+ }
24
+
25
+ /**
26
+ * A stable key for PMTiles' internal caching.
27
+ * @returns {string} - The file path.
28
+ */
29
+ getKey() {
30
+ return this.#path;
31
+ }
32
+
33
+ /**
34
+ * Reads a byte range.
35
+ * @param {number} offset - Byte offset.
36
+ * @param {number} length - Byte count.
37
+ * @returns {Promise<{data: ArrayBuffer}>} - The bytes.
38
+ */
39
+ async getBytes(offset, length) {
40
+ const buffer = Buffer.alloc(length);
41
+ const bytesRead = await new Promise((resolve, reject) => {
42
+ fs.read(this.#fd, buffer, 0, length, offset, (error, read) =>
43
+ error ? reject(error) : resolve(read),
44
+ );
45
+ });
46
+ // A short read at the end of the file is normal: PMTiles over-reads the
47
+ // header. Hand back only what exists.
48
+ const slice = buffer.subarray(0, bytesRead);
49
+ return {
50
+ data: slice.buffer.slice(
51
+ slice.byteOffset,
52
+ slice.byteOffset + slice.byteLength,
53
+ ),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Closes the descriptor.
59
+ * @returns {void}
60
+ */
61
+ close() {
62
+ if (this.#fd !== undefined) {
63
+ fs.closeSync(this.#fd);
64
+ this.#fd = undefined;
65
+ }
66
+ }
67
+ }
package/src/index.js ADDED
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import { parseArgs } from 'node:util';
4
+ import { createApp } from './api.js';
5
+ import { Catalog } from './catalog.js';
6
+ import { loadConfig } from './config.js';
7
+ import { LibtorrentEngine } from './engines/libtorrent.js';
8
+ import { QBittorrentEngine } from './engines/qbittorrent.js';
9
+ import { WebTorrentSeedEngine } from './engines/webtorrent.js';
10
+ import { Library } from './library.js';
11
+ import { ScheduledSourceManager } from './sources.js';
12
+ import { SubscriptionManager } from './subscriptions.js';
13
+ import { TileStore } from './tiles.js';
14
+ import { WarmRunner } from './warm.js';
15
+ import { WatchManager } from './watch.js';
16
+
17
+ /**
18
+ * Builds the seeding engine named by the config.
19
+ * @param {object} config - Resolved configuration.
20
+ * @returns {import('./engines/types.js').SeedEngine} - The engine.
21
+ */
22
+ function createEngine(config) {
23
+ switch (config.engine) {
24
+ case 'qbittorrent':
25
+ return new QBittorrentEngine(config.qbittorrent);
26
+ case 'libtorrent':
27
+ return new LibtorrentEngine({
28
+ savePath: config.libtorrent?.savePath ?? config.webtorrent.savePath,
29
+ resumeDir: config.libtorrent?.resumeDir,
30
+ python: config.libtorrent?.python,
31
+ maxConnections: config.maxConnections,
32
+ listen: config.libtorrent?.listen,
33
+ });
34
+ case 'webtorrent':
35
+ return new WebTorrentSeedEngine({
36
+ savePath: config.webtorrent.savePath,
37
+ clientOptions: config.webtorrent.clientOptions,
38
+ maxConnections: config.maxConnections,
39
+ });
40
+ default:
41
+ throw new Error(
42
+ `unknown engine "${config.engine}"; expected libtorrent, qbittorrent or webtorrent`,
43
+ );
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Starts the daemon.
49
+ * @returns {Promise<void>} - Resolves once listening.
50
+ */
51
+ async function main() {
52
+ const { values } = parseArgs({
53
+ options: {
54
+ config: { type: 'string', short: 'c' },
55
+ port: { type: 'string', short: 'p' },
56
+ help: { type: 'boolean', short: 'h' },
57
+ },
58
+ allowPositionals: false,
59
+ });
60
+
61
+ if (values.help) {
62
+ console.log(`pmtiles-swarm — BitTorrent distribution for PMTiles archives
63
+
64
+ --config, -c path to a JSON config file
65
+ --port, -p override the listen port
66
+ --help, -h this message
67
+
68
+ Environment: PMTILES_SWARM_PORT, PMTILES_SWARM_DATA_DIR, PMTILES_SWARM_ENGINE,
69
+ PMTILES_SWARM_QBT_URL, PMTILES_SWARM_QBT_USERNAME, PMTILES_SWARM_QBT_PASSWORD,
70
+ PMTILES_SWARM_PUBLIC_URL
71
+ `);
72
+ return;
73
+ }
74
+
75
+ const config = await loadConfig(values.config);
76
+ if (values.port) config.port = Number(values.port);
77
+
78
+ await fs.mkdir(config.dataDir, { recursive: true });
79
+ if (config.engine === 'webtorrent') {
80
+ await fs.mkdir(config.webtorrent.savePath, { recursive: true });
81
+ }
82
+
83
+ const catalog = new Catalog(config.dataDir);
84
+ await catalog.load();
85
+
86
+ const engine = createEngine(config);
87
+ try {
88
+ await engine.connect();
89
+ console.log(`[engine] ${engine.name} ready`);
90
+ } catch (error) {
91
+ // A dead engine should not stop the daemon: the catalog and feed still
92
+ // work, and the engine may come back.
93
+ console.error(`[engine] ${engine.name} unavailable: ${error.message}`);
94
+ }
95
+
96
+ const library = new Library({ catalog, engine, config });
97
+ const subscriptions = new SubscriptionManager(library, config);
98
+ const watch = new WatchManager(library);
99
+ const sources = new ScheduledSourceManager(library, catalog, config);
100
+
101
+ const tiles = new TileStore({ catalog, engine, config });
102
+ const warm = new WarmRunner(tiles);
103
+
104
+ const app = createApp({
105
+ library,
106
+ catalog,
107
+ engine,
108
+ subscriptions,
109
+ tiles,
110
+ warm,
111
+ config,
112
+ });
113
+ const server = app.listen(config.port, config.host, () => {
114
+ console.log(
115
+ `[http] listening on http://${config.host}:${config.port} (${catalog.list().length} archives)`,
116
+ );
117
+ });
118
+
119
+ watch.start(config.watch);
120
+ subscriptions.start();
121
+ sources.start();
122
+
123
+ // Watch the sources archives were built from. A changed source does not
124
+ // invalidate its torrent, but it does mean any web seed pointing there will
125
+ // fail hash verification for every peer that tries it.
126
+ let originTimer;
127
+ if (config.originCheckIntervalSeconds > 0) {
128
+ const intervalMs = config.originCheckIntervalSeconds * 1000;
129
+ const runCheck = () =>
130
+ library
131
+ .checkAllOrigins()
132
+ .then((changed) => {
133
+ if (changed.length > 0) {
134
+ console.warn(
135
+ `[origin] ${changed.length} archive(s) no longer match their source`,
136
+ );
137
+ }
138
+ })
139
+ .catch((error) =>
140
+ console.error(`[origin] check failed: ${error.message}`),
141
+ );
142
+ runCheck();
143
+ originTimer = setInterval(runCheck, intervalMs);
144
+ originTimer.unref?.();
145
+ console.log(
146
+ `[origin] checking sources every ${config.originCheckIntervalSeconds}s`,
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Shuts everything down in order, so trackers see a clean stop.
152
+ * @param {string} signal - The signal received.
153
+ * @returns {Promise<void>} - Resolves once stopped.
154
+ */
155
+ const shutdown = async (signal) => {
156
+ console.log(`\n[shutdown] ${signal}`);
157
+ if (originTimer) clearInterval(originTimer);
158
+ sources.stop();
159
+ subscriptions.stop();
160
+ await watch.stop().catch(() => {});
161
+ await new Promise((resolve) => server.close(resolve));
162
+ warm.stop();
163
+ // Before the engine, so readers let go of their torrents while the client
164
+ // that owns them is still alive.
165
+ await tiles.close().catch(() => {});
166
+ await engine.destroy().catch(() => {});
167
+ process.exit(0);
168
+ };
169
+
170
+ process.on('SIGINT', () => shutdown('SIGINT'));
171
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
172
+ }
173
+
174
+ main().catch((error) => {
175
+ console.error(error.stack ?? error.message);
176
+ process.exit(1);
177
+ });