pmtiles-swarm 0.3.2 → 0.4.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 +56 -0
- package/README.md +1 -1
- package/docs/publishing.md +65 -2
- package/docs/subscribing.md +59 -1
- package/package.json +1 -1
- package/src/api.js +20 -1
- package/src/config.js +22 -1
- package/src/latest-link.js +72 -0
- package/src/library.js +5 -1
- package/src/retention.js +144 -0
- package/src/sources.js +27 -99
- package/src/subscriptions.js +32 -1
- package/src/watch.js +54 -1
- package/src/web/index.html +103 -7
- package/swarm.config.json.sample +11 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,62 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.4.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **A watched folder can set the torrent comment**, which is where attribution and licence belong —
|
|
13
|
+
it is the one field a torrent carries that says what the thing is, and it reaches anyone who
|
|
14
|
+
opens the file in any client. The setting was passed through from the start and offered nowhere,
|
|
15
|
+
so it could only be reached by editing the config by hand.
|
|
16
|
+
- **Feeds can be followed with the controls a torrent client gives them.** A subscription takes
|
|
17
|
+
one item per check by default, counting from the newest — `newest` raises the cap and `0` lifts
|
|
18
|
+
it — because a feed like the one OpenStreetMap publishes for the planet dumps lists five of them,
|
|
19
|
+
and taking the lot is four hundred gigabytes nobody asked for. `enabled: false` switches one feed
|
|
20
|
+
off without deleting it, `subscriptionsEnabled` switches off all of them at once, and both are in
|
|
21
|
+
the console alongside the check interval.
|
|
22
|
+
- **Watched folders can retire what they have outgrown**, with the `keep` and `keepDays` that
|
|
23
|
+
until now existed only on scheduled sources. A folder receiving a daily 137 GB planet build
|
|
24
|
+
fills any disk within the week, and the alternative was a `find -mtime +35` sweep in the
|
|
25
|
+
generation script — which deletes the file but leaves this node advertising a torrent for it,
|
|
26
|
+
so every peer that asks fails. Retirement takes the two together.
|
|
27
|
+
- **`keepDays` retires by age rather than by count**, on both watched folders and scheduled
|
|
28
|
+
sources, which is what a `find -mtime` sweep actually said. Set alongside `keep` the two are a
|
|
29
|
+
union: whichever rule says a build has to go, it goes. Neither removes the newest build however
|
|
30
|
+
old it is — a source that stops publishing would otherwise erase itself, and a last build going
|
|
31
|
+
stale is a thing to notice rather than a thing to fix by deleting it.
|
|
32
|
+
- **A watched folder can give the newest build a stable name**, with the `latestLink` that
|
|
33
|
+
until now existed only on scheduled sources — `planetiler-openmaptiles-latest.pmtiles`, the
|
|
34
|
+
`ln -sfn latest` a generation script used to run. Off unless set. The dated file stays the
|
|
35
|
+
real one and keeps its own torrent, and the link costs no extra space: a symlink where the
|
|
36
|
+
platform allows one, a hard link where it does not, since Windows refuses symlinks without
|
|
37
|
+
elevation or developer mode. The watcher ignores that one name — a hard link is
|
|
38
|
+
indistinguishable from the file it names, so without that it would be imported as a second
|
|
39
|
+
archive of bytes already being seeded.
|
|
40
|
+
- **The torrent link for a category can be named whatever reads best.**
|
|
41
|
+
`/latest/openmaptiles/planetiler-openmaptiles-latest.torrent` is the same route as
|
|
42
|
+
`/latest/openmaptiles/archive.torrent` — fine in an API, poor in an href on a page. The name
|
|
43
|
+
is cosmetic: the redirect still ends at the immutable URL, which names the download after the
|
|
44
|
+
build it actually is, because a URL that could choose that would be a link on your own domain
|
|
45
|
+
that saves a file called anything at all.
|
|
46
|
+
|
|
47
|
+
### 🐞 Bug fixes
|
|
48
|
+
- **`/archives/<infohash>/archive.torrent` says it can be cached.** An infohash names those
|
|
49
|
+
bytes and no others, so the URL can never answer differently — the tile routes have always
|
|
50
|
+
said so and this one did not, which meant a cache or reverse proxy in front of a node had to
|
|
51
|
+
re-fetch every download from it. `/latest/<category>/archive.torrent` gets a short one, since
|
|
52
|
+
it moves on every build.
|
|
53
|
+
- **A `subscriptionIntervalSeconds` of zero no longer polls as fast as the event loop allows.**
|
|
54
|
+
Zero reads as off everywhere else in the configuration; here it reached `setInterval` unchanged,
|
|
55
|
+
which is not a stopped timer.
|
|
56
|
+
|
|
57
|
+
### 📚 Documentation
|
|
58
|
+
- **Following someone else's RSS feed**, using the one OpenStreetMap publishes for the planet
|
|
59
|
+
dumps as the worked example, including handing what lands to a generation script with
|
|
60
|
+
`onComplete`.
|
|
61
|
+
- **Piece size and network equipment.** Larger pieces are widely assumed to be gentler on a
|
|
62
|
+
router and mostly are not: peers request 16 KiB blocks whatever the piece size, so packet
|
|
63
|
+
volume for the same bytes is identical. Simultaneous connections are what exhaust a NAT table,
|
|
64
|
+
and `maxConnections` is the setting for that.
|
|
65
|
+
|
|
10
66
|
## 0.3.2
|
|
11
67
|
### ✨ Features and improvements
|
|
12
68
|
- **The console has a footer naming the version it is running**, beside `© <year> TechIdiots LLC`
|
package/README.md
CHANGED
|
@@ -614,7 +614,7 @@ matters there is `maxConnections`, since every peer holds a NAT table entry. See
|
|
|
614
614
|
| `GET` | `/archives/:infoHash/:z/:x/:y.:ext` | One tile — **public** |
|
|
615
615
|
| `GET` | `/archives/:infoHash/archive.torrent` | The `.torrent` a torrent-aware client joins with — **public** |
|
|
616
616
|
| `GET` | `/archives/:infoHash/preview` | Map preview for one archive — admin, not public |
|
|
617
|
-
| `GET` | `/latest/:category/tiles.json`,
|
|
617
|
+
| `GET` | `/latest/:category/tiles.json`, `/:name.torrent`, `/magnet` | The newest build in a category — **public**. The torrent name is yours to choose, so a link can read `planetiler-openmaptiles-latest.torrent`; it redirects to the immutable URL, which names the download after the build it actually is |
|
|
618
618
|
| `GET` | `/feed.xml`, `/feed/:category.xml`, `/latest/:category.xml` | RSS — **public** |
|
|
619
619
|
|
|
620
620
|
Everything under `/api/` is guarded once a credential is configured; tiles, TileJSON and the feeds
|
package/docs/publishing.md
CHANGED
|
@@ -377,6 +377,16 @@ A new `.pmtiles` appearing in a watched folder is imported automatically.
|
|
|
377
377
|
|
|
378
378
|
Editable in **Settings → Monitored folders** as a table, rather than by hand.
|
|
379
379
|
|
|
380
|
+
`comment` goes into every torrent this folder produces, and is where the attribution and
|
|
381
|
+
licence belong — it is the one field a torrent carries that says what the thing is, and
|
|
382
|
+
it reaches anyone who opens the file in any client:
|
|
383
|
+
|
|
384
|
+
```json
|
|
385
|
+
{
|
|
386
|
+
"comment": "Planetiler OpenMapTiles export. OpenStreetMap contributors, under ODbL 1.0. OpenMapTiles under BSD 3-Clause / CC-BY 4.0"
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
380
390
|
`stabilitySeconds` is the important one. A map build writes its output over minutes or
|
|
381
391
|
hours, and hashing a half-written archive produces a torrent for bytes that no longer
|
|
382
392
|
exist. Nothing is imported until the file has stopped changing for that long. Raise it if
|
|
@@ -453,8 +463,23 @@ today's URL and see whether it exists yet:
|
|
|
453
463
|
loses that build permanently.
|
|
454
464
|
|
|
455
465
|
`latestLink`
|
|
456
|
-
A
|
|
457
|
-
remains seedable under its own torrent while
|
|
466
|
+
A stable name pointing at the newest build — `planetiler-protomaps-latest.pmtiles`.
|
|
467
|
+
The dated file stays the real one, so it remains seedable under its own torrent while
|
|
468
|
+
a page links to a name that does not change. **Off unless set.**
|
|
469
|
+
|
|
470
|
+
A symlink where the platform allows one, and a hard link where it does not: Windows
|
|
471
|
+
refuses symlinks with `EPERM` unless the process is elevated or the machine is in
|
|
472
|
+
developer mode, which is not a reasonable thing to require of a daemon. Neither costs
|
|
473
|
+
extra space — both are another name for the same bytes rather than a copy, which for a
|
|
474
|
+
137 GB archive is the whole point — and both need the name and the build to be on one
|
|
475
|
+
filesystem, which a link beside the file it names always is.
|
|
476
|
+
|
|
477
|
+
Available on watched folders too, where a generation script writes the builds. There
|
|
478
|
+
the link lands in the folder being watched, so the watcher is taught to ignore that one
|
|
479
|
+
name — a hard link is indistinguishable from the file it names, and without that it
|
|
480
|
+
would be imported as a second archive of bytes already being seeded. It follows that
|
|
481
|
+
**the name must not collide with a real build's**, and that changing it leaves the old
|
|
482
|
+
one behind as a file the watcher will then import.
|
|
458
483
|
|
|
459
484
|
`at`, `everyHours`, `everyMinutes`
|
|
460
485
|
When to look. `at` is a time of day in UTC — `"03:30"`, or a list of them — for an
|
|
@@ -507,6 +532,23 @@ today's URL and see whether it exists yet:
|
|
|
507
532
|
`newest` is above 1, because a poll takes candidates newest first and the *last* build
|
|
508
533
|
imported is then the oldest of them.
|
|
509
534
|
|
|
535
|
+
`keepDays`
|
|
536
|
+
The same thing said as a window rather than a count: how many days old a build may
|
|
537
|
+
get. `keepDays: 35` is the `find -mtime +35` sweep such a script would otherwise run
|
|
538
|
+
itself, except that this takes the torrent with the data instead of leaving the node
|
|
539
|
+
advertising an archive that is gone.
|
|
540
|
+
|
|
541
|
+
Age is read from the date in the build's name where there is one, and from when this
|
|
542
|
+
node took it otherwise. A build that can be dated neither way is never removed — a
|
|
543
|
+
guess is not good enough to delete several hundred gigabytes on.
|
|
544
|
+
|
|
545
|
+
**The newest build is never removed, however old it is.** A source that stops
|
|
546
|
+
publishing would otherwise erase itself, and a last build going stale is a thing to
|
|
547
|
+
notice rather than a thing to fix by deleting it.
|
|
548
|
+
|
|
549
|
+
Set alongside `keep` the two are a union: whichever rule says a build has to go, it
|
|
550
|
+
goes. `keep: 10, keepDays: 35` holds at most ten builds and at most five weeks.
|
|
551
|
+
|
|
510
552
|
`seeding`
|
|
511
553
|
A seeding limit for this source's builds, in the same shape as the global one:
|
|
512
554
|
`{ "ratio": 2, "minutes": 4320, "then": "stop" }`. Useful where one source's archives
|
|
@@ -520,6 +562,27 @@ what you want — old builds stay seedable for as long as anyone still wants the
|
|
|
520
562
|
Every candidate URL is checked with a HEAD, so a build that has not been published yet
|
|
521
563
|
costs one request.
|
|
522
564
|
|
|
565
|
+
### Retention on a watched folder
|
|
566
|
+
|
|
567
|
+
`keep` and `keepDays` work the same way on a watched folder, where builds arrive from a
|
|
568
|
+
generation script rather than from a URL:
|
|
569
|
+
|
|
570
|
+
```json
|
|
571
|
+
{
|
|
572
|
+
"watch": [
|
|
573
|
+
{
|
|
574
|
+
"path": "/mnt/store/generated/openmaptiles/pmtiles",
|
|
575
|
+
"categories": ["openmaptiles", "planet"],
|
|
576
|
+
"keepDays": 35
|
|
577
|
+
}
|
|
578
|
+
]
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
The family there is the folder rather than a named source: only archives this same folder
|
|
583
|
+
imported are ever considered, so one dropped into the same directory by hand — or moved
|
|
584
|
+
elsewhere by `publishDir` and belonging to a different folder — is not caught up in it.
|
|
585
|
+
|
|
523
586
|
### Watching a directory instead
|
|
524
587
|
|
|
525
588
|
Where the naming is not predictable enough to write as a template, give an `index` — a
|
package/docs/subscribing.md
CHANGED
|
@@ -41,11 +41,14 @@ Its feed is then at `https://maps.example.org/feed.xml`, with per-category feeds
|
|
|
41
41
|
"subscriptions": [
|
|
42
42
|
{ "url": "https://maps.example.org/feed.xml", "mode": "cache" }
|
|
43
43
|
],
|
|
44
|
+
"subscriptionsEnabled": true,
|
|
44
45
|
"subscriptionIntervalSeconds": 900
|
|
45
46
|
}
|
|
46
47
|
```
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
`subscriptionsEnabled` is the master switch and `subscriptionIntervalSeconds`
|
|
50
|
+
is how often each feed is checked; zero or less turns polling off. Poll
|
|
51
|
+
immediately rather than waiting for the interval:
|
|
49
52
|
|
|
50
53
|
```sh
|
|
51
54
|
curl -X POST localhost:8090/api/subscriptions/refresh
|
|
@@ -261,6 +264,61 @@ One feed can serve subscribers with different appetites:
|
|
|
261
264
|
|
|
262
265
|
`filter` is a case-insensitive regular expression matched against the item title.
|
|
263
266
|
|
|
267
|
+
## Following someone else's RSS feed
|
|
268
|
+
|
|
269
|
+
A subscription does not have to be another swarm node. Any RSS feed with
|
|
270
|
+
`application/x-bittorrent` enclosures works, which includes the one
|
|
271
|
+
OpenStreetMap publishes for the planet dumps:
|
|
272
|
+
|
|
273
|
+
```json
|
|
274
|
+
{
|
|
275
|
+
"subscriptions": [
|
|
276
|
+
{
|
|
277
|
+
"url": "https://planet.openstreetmap.org/pbf/planet-pbf-rss.xml",
|
|
278
|
+
"protocol": "rss",
|
|
279
|
+
"mode": "mirror",
|
|
280
|
+
"newest": 1,
|
|
281
|
+
"categories": ["osm-planet"],
|
|
282
|
+
"savePath": "/mnt/raid0/work/planet"
|
|
283
|
+
}
|
|
284
|
+
]
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`newest` is how many items one check may take, counting from the newest, and
|
|
289
|
+
it is **1 by default**. That feed lists five planet dumps, so without a cap the
|
|
290
|
+
first poll is roughly four hundred gigabytes. `0` takes everything it lists.
|
|
291
|
+
|
|
292
|
+
The items are `.osm.pbf`, not map archives, and that is fine: joining an
|
|
293
|
+
existing torrent does not require the content to be anything in particular —
|
|
294
|
+
only *creating* one does. The archive simply is not servable as tiles, and
|
|
295
|
+
nothing tries.
|
|
296
|
+
|
|
297
|
+
`enabled: false` switches one feed off without deleting it, and
|
|
298
|
+
`subscriptionsEnabled: false` switches off all of them at once, for when a disk
|
|
299
|
+
is filling or a build has gone wrong.
|
|
300
|
+
|
|
301
|
+
Nothing is downloaded twice: an item already in the catalog is skipped, so a
|
|
302
|
+
feed that keeps listing last week's dump costs one comparison per check.
|
|
303
|
+
|
|
304
|
+
### Feeding a generation script
|
|
305
|
+
|
|
306
|
+
The point of taking a `.osm.pbf` is usually to build something from it. That is
|
|
307
|
+
what `onComplete` is for — it runs when a download finishes, so a planet dump
|
|
308
|
+
landing can start the render that turns it into archives this node publishes:
|
|
309
|
+
|
|
310
|
+
```json
|
|
311
|
+
{
|
|
312
|
+
"onComplete": {
|
|
313
|
+
"command": "/usr/local/bin/planetilerdump-swarm.sh",
|
|
314
|
+
"args": ["%N", "%F", "%I"]
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Which is the same handover qBittorrent's "run external program on torrent
|
|
320
|
+
finished" provided, with the feed replacing its RSS auto-downloader.
|
|
321
|
+
|
|
264
322
|
## Subscribing with plain qBittorrent
|
|
265
323
|
|
|
266
324
|
The feed is ordinary RSS 2.0 with `application/x-bittorrent` enclosures, which is exactly
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/api.js
CHANGED
|
@@ -1597,15 +1597,28 @@ export function createApp({
|
|
|
1597
1597
|
|
|
1598
1598
|
// Redirects rather than serving, so what arrives is the immutable URL and a
|
|
1599
1599
|
// client that keeps it keeps a specific build rather than a moving target.
|
|
1600
|
-
|
|
1600
|
+
// The name in the path is yours to choose: `/latest/openmaptiles/
|
|
1601
|
+
// planetiler-openmaptiles-latest.torrent` is the same route as
|
|
1602
|
+
// `.../archive.torrent`, and reads as what it is where it matters most —
|
|
1603
|
+
// in an href on a page. It does not name the download. That is decided at
|
|
1604
|
+
// the immutable URL this redirects to, which calls the file after the build
|
|
1605
|
+
// it actually is, and a URL that could choose it would be a link on this
|
|
1606
|
+
// domain that saves a file called anything at all.
|
|
1607
|
+
app.get('/latest/:category/:name.torrent', (req, res) => {
|
|
1601
1608
|
const entry = newestIn(req.params.category, req);
|
|
1602
1609
|
if (!entry) return res.status(404).json({ error: 'no such category' });
|
|
1610
|
+
// Short, and said explicitly. A 302 is not cacheable unless a response
|
|
1611
|
+
// says so, but "unless it says so" is a thing intermediaries have been
|
|
1612
|
+
// known to disagree about — and this one moves on every build, which is
|
|
1613
|
+
// the whole point of it.
|
|
1614
|
+
res.setHeader('cache-control', 'public, max-age=300');
|
|
1603
1615
|
res.redirect(302, `${baseUrl(req)}/archives/${entry.infoHash}/archive.torrent`);
|
|
1604
1616
|
});
|
|
1605
1617
|
|
|
1606
1618
|
app.get('/latest/:category/magnet', (req, res) => {
|
|
1607
1619
|
const entry = newestIn(req.params.category, req);
|
|
1608
1620
|
if (!entry) return res.status(404).json({ error: 'no such category' });
|
|
1621
|
+
res.setHeader('cache-control', 'public, max-age=300');
|
|
1609
1622
|
res.type('text/plain').send(entry.magnet ?? '');
|
|
1610
1623
|
});
|
|
1611
1624
|
|
|
@@ -1769,6 +1782,12 @@ export function createApp({
|
|
|
1769
1782
|
if (!body) return res.status(404).json({ error: 'torrent file missing' });
|
|
1770
1783
|
res.setHeader('access-control-allow-origin', '*');
|
|
1771
1784
|
res.type('application/x-bittorrent');
|
|
1785
|
+
// An infohash names these bytes and no others, so this URL can never
|
|
1786
|
+
// answer differently — the same reason the tile routes say it. Worth
|
|
1787
|
+
// saying out loud where a link to it is published: a cache or a reverse
|
|
1788
|
+
// proxy in front of this then serves the download without touching the
|
|
1789
|
+
// node at all.
|
|
1790
|
+
res.setHeader('cache-control', 'public, max-age=31536000, immutable');
|
|
1772
1791
|
res.setHeader(
|
|
1773
1792
|
'content-disposition',
|
|
1774
1793
|
`attachment; filename="${entry.name}.torrent"`,
|
package/src/config.js
CHANGED
|
@@ -475,6 +475,18 @@ const DEFAULTS = {
|
|
|
475
475
|
*
|
|
476
476
|
* `webSeedBase` on its own assumes the watched folder is already the web
|
|
477
477
|
* root, since nothing is moved.
|
|
478
|
+
*
|
|
479
|
+
* `latestLink` gives the newest build a stable second name — a symlink, or
|
|
480
|
+
* a hard link where the platform refuses one. The watcher ignores that name
|
|
481
|
+
* so the link is never imported as an archive of its own.
|
|
482
|
+
*
|
|
483
|
+
* `keep` and `keepDays` retire what the folder has outgrown — the newest N
|
|
484
|
+
* builds, or a window in days. A folder receiving a daily 137 GB planet
|
|
485
|
+
* build fills any disk within the week, and this is the `find -mtime +35`
|
|
486
|
+
* sweep that would otherwise have to sit in the generation script, except
|
|
487
|
+
* that it takes the torrent with the data. Both are off unless set, only
|
|
488
|
+
* ever touch archives this same folder imported, and never remove the
|
|
489
|
+
* newest build however old it gets.
|
|
478
490
|
*/
|
|
479
491
|
watch: [],
|
|
480
492
|
/**
|
|
@@ -661,7 +673,15 @@ const DEFAULTS = {
|
|
|
661
673
|
allowHooksFromApi: false,
|
|
662
674
|
/** How often to look for finished downloads, in seconds. */
|
|
663
675
|
onCompleteCheckIntervalSeconds: 60,
|
|
664
|
-
/**
|
|
676
|
+
/**
|
|
677
|
+
* Whether to follow feeds at all.
|
|
678
|
+
*
|
|
679
|
+
* The master switch, separate from the per-feed one: turning it off stops
|
|
680
|
+
* every feed without editing any of them, which is what you want when a
|
|
681
|
+
* disk is filling or a run has gone wrong.
|
|
682
|
+
*/
|
|
683
|
+
subscriptionsEnabled: true,
|
|
684
|
+
/** How often to poll subscribed feeds, in seconds. Zero or less is off. */
|
|
665
685
|
subscriptionIntervalSeconds: 900,
|
|
666
686
|
/** Republish interval for BEP 46 records, in seconds. DHT items expire. */
|
|
667
687
|
republishIntervalSeconds: 3600,
|
|
@@ -886,6 +906,7 @@ export const RELOADABLE = new Map([
|
|
|
886
906
|
['sourceCheckIntervalHours', 'sources'],
|
|
887
907
|
['subscriptions', 'subscriptions'],
|
|
888
908
|
['subscriptionIntervalSeconds', 'subscriptions'],
|
|
909
|
+
['subscriptionsEnabled', 'subscriptions'],
|
|
889
910
|
['seeding', 'seeding'],
|
|
890
911
|
// Applied to a running session, so a schedule can be corrected at the moment
|
|
891
912
|
// it turns out to be wrong rather than at the next convenient restart.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Points a stable name at the newest build.
|
|
6
|
+
*
|
|
7
|
+
* The dated file stays the real one, so it remains seedable under its own
|
|
8
|
+
* torrent while a page links to a name that does not change — which is what an
|
|
9
|
+
* `ln -sfn latest` in a generation script was for.
|
|
10
|
+
*
|
|
11
|
+
* A symlink first, then a hard link. **Windows refuses symlinks with EPERM**
|
|
12
|
+
* unless the process is elevated or the machine is in developer mode, which is
|
|
13
|
+
* not a reasonable thing to require of a daemon — and a hard link needs
|
|
14
|
+
* neither. It costs no extra space either way, since both are another name for
|
|
15
|
+
* the same bytes rather than a copy, and for a 137 GB archive that distinction
|
|
16
|
+
* is the whole point. Both only work within one filesystem, which is where a
|
|
17
|
+
* link beside the file it names always is.
|
|
18
|
+
*
|
|
19
|
+
* The two are not quite equivalent, and the difference shows when the build
|
|
20
|
+
* this names is eventually deleted: a symlink is left dangling, while a hard
|
|
21
|
+
* link keeps the bytes alive until it too is gone. Retention never removes the
|
|
22
|
+
* newest build, so neither case arises from this node's own housekeeping.
|
|
23
|
+
* @param {object} options - What to link and how to say so.
|
|
24
|
+
* @param {string} options.target - The build the name should resolve to.
|
|
25
|
+
* @param {string} options.name - The stable name, absolute or beside the target.
|
|
26
|
+
* @param {string} options.label - How to name the caller in the log.
|
|
27
|
+
* @returns {Promise<string|undefined>} - The link made, or undefined on failure.
|
|
28
|
+
*/
|
|
29
|
+
export async function linkLatest({ target, name, label }) {
|
|
30
|
+
const link = linkPathFor(target, name);
|
|
31
|
+
|
|
32
|
+
const attempts = [
|
|
33
|
+
// The type is autodetected from the target on Windows and ignored
|
|
34
|
+
// everywhere else, which is right: the target is always a file here.
|
|
35
|
+
['symlink', () => fs.symlink(target, link)],
|
|
36
|
+
['hard link', () => fs.link(target, link)],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const [kind, make] of attempts) {
|
|
40
|
+
try {
|
|
41
|
+
// The previous build's link, which is the usual case — this runs once
|
|
42
|
+
// per build and the name by definition already exists after the first.
|
|
43
|
+
await fs.rm(link, { force: true });
|
|
44
|
+
await make();
|
|
45
|
+
console.log(
|
|
46
|
+
`${label} latest -> ${path.basename(target)}` +
|
|
47
|
+
(kind === 'symlink' ? '' : ` (${kind})`),
|
|
48
|
+
);
|
|
49
|
+
return link;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// Try the next kind rather than giving up on the first refusal; only the
|
|
52
|
+
// last one is worth reporting.
|
|
53
|
+
if (make === attempts.at(-1)[1]) {
|
|
54
|
+
console.warn(
|
|
55
|
+
`${label} could not point ${path.basename(link)} at ` +
|
|
56
|
+
`${path.basename(target)}: ${error.message}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Where a stable name resolves to, absolute or beside the build it names.
|
|
66
|
+
* @param {string} target - The build being named.
|
|
67
|
+
* @param {string} name - The configured name.
|
|
68
|
+
* @returns {string} - The link's path.
|
|
69
|
+
*/
|
|
70
|
+
export function linkPathFor(target, name) {
|
|
71
|
+
return path.isAbsolute(name) ? name : path.join(path.dirname(target), name);
|
|
72
|
+
}
|
package/src/library.js
CHANGED
|
@@ -197,7 +197,11 @@ export class Library {
|
|
|
197
197
|
|
|
198
198
|
return this.#register(created, {
|
|
199
199
|
categories: options.categories ?? options.category,
|
|
200
|
-
|
|
200
|
+
// `watch` names the folder that imported this, where one did. Not the
|
|
201
|
+
// same thing as the directory it sits in: with publishDir it has already
|
|
202
|
+
// moved somewhere else, and an archive dropped into a watched folder by
|
|
203
|
+
// hand is still that folder's to retire.
|
|
204
|
+
source: { type: 'file', location: absolute, watch: options.watch },
|
|
201
205
|
// The torrent names the file, so the save path is its parent directory.
|
|
202
206
|
savePath: path.dirname(absolute),
|
|
203
207
|
pmtiles: summary,
|
package/src/retention.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* When an archive was built, as a timestamp.
|
|
5
|
+
*
|
|
6
|
+
* The date in the name where there is one, since that is what a dated build
|
|
7
|
+
* means by "old" — falling back to when this node took it, which for a folder
|
|
8
|
+
* receiving builds as they are made is close enough to the same thing.
|
|
9
|
+
* @param {object} entry - A catalog entry.
|
|
10
|
+
* @returns {number|undefined} - Milliseconds, or undefined if it cannot be told.
|
|
11
|
+
*/
|
|
12
|
+
function builtAt(entry) {
|
|
13
|
+
const parsed = Date.parse(entry.buildDate ?? entry.createdAt ?? '');
|
|
14
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether any retention rule is set at all.
|
|
19
|
+
*
|
|
20
|
+
* Worth asking before gathering the family, because gathering it means walking
|
|
21
|
+
* the whole catalog — and retention is off unless somebody turned it on, so
|
|
22
|
+
* that walk is usually for nothing.
|
|
23
|
+
* @param {object} rules - Something carrying keep and keepDays.
|
|
24
|
+
* @returns {boolean} - True if either rule would do something.
|
|
25
|
+
*/
|
|
26
|
+
export function retains({ keep, keepDays } = {}) {
|
|
27
|
+
return Number(keep) >= 1 || Number(keepDays) >= 1;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Which of a family of builds have outlived the rules set for them.
|
|
32
|
+
*
|
|
33
|
+
* Pure, so the decision to delete several hundred gigabytes can be tested
|
|
34
|
+
* without any of it existing.
|
|
35
|
+
* @param {object} options - The family and the rules.
|
|
36
|
+
* @param {object[]} options.family - Builds, newest first.
|
|
37
|
+
* @param {number} [options.keep] - How many of the newest to hold.
|
|
38
|
+
* @param {number} [options.keepDays] - How old a build may get, in days.
|
|
39
|
+
* @param {number} [options.now] - The current time, for testing.
|
|
40
|
+
* @returns {object[]} - The entries to retire, in the family's order.
|
|
41
|
+
*/
|
|
42
|
+
export function expired({ family, keep, keepDays, now = Date.now() }) {
|
|
43
|
+
const doomed = new Set();
|
|
44
|
+
|
|
45
|
+
const count = Number(keep);
|
|
46
|
+
if (Number.isFinite(count) && count >= 1) {
|
|
47
|
+
for (const entry of family.slice(count)) doomed.add(entry);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const days = Number(keepDays);
|
|
51
|
+
if (Number.isFinite(days) && days >= 1) {
|
|
52
|
+
const cutoff = now - days * DAY_MS;
|
|
53
|
+
// Never the newest, however old it is. A folder that stops receiving
|
|
54
|
+
// builds would otherwise empty itself and leave the node serving nothing —
|
|
55
|
+
// and "the last build is stale" is a thing to notice, not a thing to fix
|
|
56
|
+
// by deleting it. `find -mtime +35` had no such qualm.
|
|
57
|
+
for (const entry of family.slice(1)) {
|
|
58
|
+
const at = builtAt(entry);
|
|
59
|
+
// No date at all means no way to tell how old it is, and a guess is not
|
|
60
|
+
// good enough to delete on.
|
|
61
|
+
if (at !== undefined && at < cutoff) doomed.add(entry);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return family.filter((entry) => doomed.has(entry));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Retires the builds a family has outgrown, and removes their data.
|
|
70
|
+
*
|
|
71
|
+
* Deliberately narrow, because this deletes data:
|
|
72
|
+
*
|
|
73
|
+
* * Off unless `keep` or `keepDays` is set. Silence has to mean "keep
|
|
74
|
+
* everything", since the alternative is deleting archives nobody asked to
|
|
75
|
+
* lose.
|
|
76
|
+
* * Only the family it is given — whatever the caller can prove came from
|
|
77
|
+
* the same source or folder. An archive added by hand, adopted from a
|
|
78
|
+
* client, or taken from a peer is never touched, even in the same
|
|
79
|
+
* directory.
|
|
80
|
+
* * Never the newest build, and never the one just imported.
|
|
81
|
+
*
|
|
82
|
+
* Nothing is deleted until the new build is the one being served. A category's
|
|
83
|
+
* feed and its `/latest/<category>/tiles.json` resolve to the newest archive in
|
|
84
|
+
* it, and that is what consumers point at; once they resolve to this build, the
|
|
85
|
+
* ones it replaced are no longer where anyone is being sent. Before that —
|
|
86
|
+
* while an older build is still the answer — deleting it would break the very
|
|
87
|
+
* URL the feed is advertising. It also covers the case that makes this
|
|
88
|
+
* necessary at all: an import run that takes several builds takes them newest
|
|
89
|
+
* first, so an *older* one can be the most recent import. It has superseded
|
|
90
|
+
* nothing and must retire nothing.
|
|
91
|
+
*
|
|
92
|
+
* The torrent goes with the data. Leaving a catalog entry whose file is gone
|
|
93
|
+
* would leave the node advertising an archive it cannot serve, and every peer
|
|
94
|
+
* that asked would fail.
|
|
95
|
+
* @param {object} options - What to retire and how to say so.
|
|
96
|
+
* @param {object} options.library - The library, for removal.
|
|
97
|
+
* @param {object[]} options.family - Builds from one source, newest first.
|
|
98
|
+
* @param {object} options.entry - The build just imported.
|
|
99
|
+
* @param {number} [options.keep] - How many of the newest to hold.
|
|
100
|
+
* @param {number} [options.keepDays] - How old a build may get, in days.
|
|
101
|
+
* @param {string} options.label - How to name this family in the log.
|
|
102
|
+
* @param {number} [options.now] - The current time, for testing.
|
|
103
|
+
* @returns {Promise<string[]>} - The infohashes removed.
|
|
104
|
+
*/
|
|
105
|
+
export async function retire({
|
|
106
|
+
library,
|
|
107
|
+
family,
|
|
108
|
+
entry,
|
|
109
|
+
keep,
|
|
110
|
+
keepDays,
|
|
111
|
+
label,
|
|
112
|
+
now,
|
|
113
|
+
}) {
|
|
114
|
+
const doomed = expired({ family, keep, keepDays, now });
|
|
115
|
+
if (doomed.length === 0) return [];
|
|
116
|
+
|
|
117
|
+
if (family[0]?.infoHash !== entry.infoHash) {
|
|
118
|
+
console.log(
|
|
119
|
+
`${label}: ${entry.name} is not the newest build here, so nothing is retired`,
|
|
120
|
+
);
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const held = [
|
|
125
|
+
Number(keep) >= 1 ? `the newest ${Number(keep)}` : undefined,
|
|
126
|
+
Number(keepDays) >= 1 ? `${Number(keepDays)} days` : undefined,
|
|
127
|
+
]
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.join(' and ');
|
|
130
|
+
|
|
131
|
+
const removed = [];
|
|
132
|
+
for (const old of doomed) {
|
|
133
|
+
try {
|
|
134
|
+
await library.remove(old.infoHash, { deleteData: true });
|
|
135
|
+
removed.push(old.infoHash);
|
|
136
|
+
console.log(`${label}: retired ${old.name} — keeping ${held}`);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// Worth saying rather than swallowing: the disk this exists to protect
|
|
139
|
+
// is now not being protected.
|
|
140
|
+
console.error(`${label}: could not retire ${old.name}: ${error.message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return removed;
|
|
144
|
+
}
|
package/src/sources.js
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
|
|
29
29
|
import fs from 'node:fs/promises';
|
|
30
30
|
import path from 'node:path';
|
|
31
|
+
import { linkLatest } from './latest-link.js';
|
|
32
|
+
import { retains, retire } from './retention.js';
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* Expands date placeholders in a template.
|
|
@@ -622,79 +624,37 @@ export class ScheduledSourceManager {
|
|
|
622
624
|
}
|
|
623
625
|
|
|
624
626
|
/**
|
|
625
|
-
* Removes older builds from the same source, where
|
|
627
|
+
* Removes older builds from the same source, where retention says to.
|
|
626
628
|
*
|
|
627
629
|
* A daily planet build is 137 GB. Kept for ever, a source like that fills
|
|
628
630
|
* any disk within the week — and the older ones are rarely what anyone
|
|
629
631
|
* wants, because the whole point of a dated build is that a newer one
|
|
630
|
-
* replaces it. `keep: 1`
|
|
632
|
+
* replaces it. `keep: 1` holds only the newest; `keepDays: 35` holds five
|
|
633
|
+
* weeks of them.
|
|
631
634
|
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
* even if it sits in the same directory.
|
|
637
|
-
* * Never the one just imported, and never more than the count allows.
|
|
638
|
-
* * Off unless `keep` is set. Silence has to mean "keep everything",
|
|
639
|
-
* since the alternative is deleting archives nobody asked to lose.
|
|
640
|
-
*
|
|
641
|
-
* The torrent goes with the data. Leaving a catalog entry whose file is
|
|
642
|
-
* gone would leave the node advertising an archive it cannot serve, and
|
|
643
|
-
* every peer that asked would fail.
|
|
635
|
+
* The family is the archives this same *named* source imported, and nothing
|
|
636
|
+
* else — one added by hand, adopted from a client, or taken from a peer is
|
|
637
|
+
* never touched, even in the same directory. See `retire` for the rest of
|
|
638
|
+
* what this will not do.
|
|
644
639
|
* @param {object} source - The source definition.
|
|
645
640
|
* @param {object} entry - The build just imported.
|
|
646
641
|
* @returns {Promise<string[]>} - The infohashes removed.
|
|
647
642
|
*/
|
|
648
643
|
async #retire(source, entry) {
|
|
649
|
-
const keep = Number(source.keep);
|
|
650
|
-
if (!Number.isFinite(keep) || keep < 1) return [];
|
|
651
644
|
if (!source.name) return [];
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
// it would break the very URL the feed is advertising.
|
|
666
|
-
//
|
|
667
|
-
// It also covers the case that makes this necessary at all: a poll taking
|
|
668
|
-
// several builds takes them newest first, so an *older* one can be the
|
|
669
|
-
// most recent import. It has superseded nothing and must retire nothing.
|
|
670
|
-
if (family[0]?.infoHash !== entry.infoHash) {
|
|
671
|
-
console.log(
|
|
672
|
-
`[source] ${source.name}: ${entry.name} is not the newest build here, ` +
|
|
673
|
-
'so nothing is retired',
|
|
674
|
-
);
|
|
675
|
-
return [];
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
const doomed = family.slice(keep);
|
|
679
|
-
const removed = [];
|
|
680
|
-
|
|
681
|
-
for (const old of doomed) {
|
|
682
|
-
try {
|
|
683
|
-
await this.#library.remove(old.infoHash, { deleteData: true });
|
|
684
|
-
removed.push(old.infoHash);
|
|
685
|
-
console.log(
|
|
686
|
-
`[source] ${source.name}: retired ${old.name} — keeping the newest ` +
|
|
687
|
-
`${keep}`,
|
|
688
|
-
);
|
|
689
|
-
} catch (error) {
|
|
690
|
-
// Worth saying rather than swallowing: the disk this exists to protect
|
|
691
|
-
// is now not being protected.
|
|
692
|
-
console.error(
|
|
693
|
-
`[source] ${source.name}: could not retire ${old.name}: ${error.message}`,
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
return removed;
|
|
645
|
+
if (!retains(source)) return [];
|
|
646
|
+
|
|
647
|
+
return retire({
|
|
648
|
+
library: this.#library,
|
|
649
|
+
// The catalog's own order, which is exactly what `/latest` follows.
|
|
650
|
+
family: this.#catalog
|
|
651
|
+
.list()
|
|
652
|
+
.filter((candidate) => candidate.source?.name === source.name),
|
|
653
|
+
entry,
|
|
654
|
+
keep: source.keep,
|
|
655
|
+
keepDays: source.keepDays,
|
|
656
|
+
label: `[source] ${source.name}`,
|
|
657
|
+
});
|
|
698
658
|
}
|
|
699
659
|
|
|
700
660
|
/**
|
|
@@ -703,48 +663,16 @@ export class ScheduledSourceManager {
|
|
|
703
663
|
* The dated file stays the real one either way, so it remains seedable under
|
|
704
664
|
* its own torrent while consumers reference a fixed path.
|
|
705
665
|
*
|
|
706
|
-
* A symlink first, then a hard link. Windows refuses symlinks with EPERM
|
|
707
|
-
* unless the process is elevated or the machine is in developer mode, which
|
|
708
|
-
* is not a reasonable thing to require of a daemon — and a hard link needs
|
|
709
|
-
* neither. It costs no extra space, since it is another name for the same
|
|
710
|
-
* bytes rather than a copy, and for a 137 GB archive that distinction is the
|
|
711
|
-
* whole point. It only works within one filesystem, which is where a link
|
|
712
|
-
* beside the file it names always is.
|
|
713
666
|
* @param {object} source - The source definition.
|
|
714
667
|
* @param {object} entry - The freshly imported entry.
|
|
715
668
|
* @returns {Promise<void>} - Resolves once linked, or logs and continues.
|
|
716
669
|
*/
|
|
717
670
|
async #linkLatest(source, entry) {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
:
|
|
722
|
-
|
|
723
|
-
const attempts = [
|
|
724
|
-
['symlink', () => fs.symlink(target, link)],
|
|
725
|
-
['hard link', () => fs.link(target, link)],
|
|
726
|
-
];
|
|
727
|
-
|
|
728
|
-
for (const [kind, make] of attempts) {
|
|
729
|
-
try {
|
|
730
|
-
await fs.rm(link, { force: true });
|
|
731
|
-
await make();
|
|
732
|
-
console.log(
|
|
733
|
-
`[source] latest -> ${path.basename(target)}` +
|
|
734
|
-
(kind === 'symlink' ? '' : ` (${kind})`),
|
|
735
|
-
);
|
|
736
|
-
return;
|
|
737
|
-
} catch (error) {
|
|
738
|
-
// Try the next kind rather than giving up on the first refusal; only
|
|
739
|
-
// the last one is worth reporting.
|
|
740
|
-
if (make === attempts.at(-1)[1]) {
|
|
741
|
-
console.warn(
|
|
742
|
-
`[source] could not point ${path.basename(link)} at ` +
|
|
743
|
-
`${path.basename(target)}: ${error.message}`,
|
|
744
|
-
);
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
}
|
|
671
|
+
await linkLatest({
|
|
672
|
+
target: entry.retainedAt ?? path.join(entry.savePath, entry.name),
|
|
673
|
+
name: source.latestLink,
|
|
674
|
+
label: '[source]',
|
|
675
|
+
});
|
|
748
676
|
}
|
|
749
677
|
|
|
750
678
|
/**
|
package/src/subscriptions.js
CHANGED
|
@@ -39,10 +39,23 @@ export class SubscriptionManager {
|
|
|
39
39
|
start() {
|
|
40
40
|
const feeds = this.#config.subscriptions ?? [];
|
|
41
41
|
|
|
42
|
+
if (this.#config.subscriptionsEnabled === false) {
|
|
43
|
+
console.log('[feed] following is switched off');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
42
47
|
// The timer runs even with nothing to follow. Every refresh reads the list
|
|
43
48
|
// fresh, so this is what lets a peer added through the console start
|
|
44
49
|
// working without a restart; an empty pass costs nothing.
|
|
45
|
-
const
|
|
50
|
+
const seconds = this.#config.subscriptionIntervalSeconds ?? 900;
|
|
51
|
+
// Zero reads as off everywhere else in the configuration, and it has to
|
|
52
|
+
// read as off here too: setInterval(fn, 0) is not a stopped timer, it is
|
|
53
|
+
// one that fires as fast as the loop will let it.
|
|
54
|
+
if (seconds <= 0) {
|
|
55
|
+
console.log('[feed] interval is zero; not polling');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const intervalMs = seconds * 1000;
|
|
46
59
|
// Poll once at startup, then on the interval.
|
|
47
60
|
this.refresh().catch((error) =>
|
|
48
61
|
console.error(`[feed] initial refresh failed: ${error.message}`),
|
|
@@ -67,7 +80,12 @@ export class SubscriptionManager {
|
|
|
67
80
|
*/
|
|
68
81
|
async refresh() {
|
|
69
82
|
const added = [];
|
|
83
|
+
// Checked here as well as in start(), so the switch means the same thing
|
|
84
|
+
// to a refresh asked for through the API as it does to the timer.
|
|
85
|
+
if (this.#config.subscriptionsEnabled === false) return added;
|
|
70
86
|
for (const subscription of this.#config.subscriptions ?? []) {
|
|
87
|
+
// Off, but kept — what qBittorrent's per-feed checkbox does.
|
|
88
|
+
if (subscription.enabled === false) continue;
|
|
71
89
|
try {
|
|
72
90
|
added.push(...(await this.#poll(subscription)));
|
|
73
91
|
} catch (error) {
|
|
@@ -103,7 +121,20 @@ export class SubscriptionManager {
|
|
|
103
121
|
const items = parseFeed(await response.text());
|
|
104
122
|
const added = [];
|
|
105
123
|
|
|
124
|
+
// How many items one poll may take.
|
|
125
|
+
//
|
|
126
|
+
// One by default, because a feed's items are whole files and some of them
|
|
127
|
+
// are enormous: planet.openstreetmap.org lists five planet dumps, and
|
|
128
|
+
// taking the lot on the first poll is four hundred gigabytes nobody asked
|
|
129
|
+
// for. `newest: 0` lifts the cap for feeds where that is what you want.
|
|
130
|
+
//
|
|
131
|
+
// Items arrive newest first, so the cap keeps the newest.
|
|
132
|
+
const limit = subscription.newest === 0
|
|
133
|
+
? Number.POSITIVE_INFINITY
|
|
134
|
+
: Math.max(1, subscription.newest ?? 1);
|
|
135
|
+
|
|
106
136
|
for (const item of items) {
|
|
137
|
+
if (added.length >= limit) break;
|
|
107
138
|
// A regex filter lets one feed serve several consumers with different
|
|
108
139
|
// appetites, e.g. only taking Europe extracts.
|
|
109
140
|
if (subscription.filter && !new RegExp(subscription.filter, 'i').test(item.title)) {
|
package/src/watch.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import chokidar from 'chokidar';
|
|
2
3
|
import { normalizeCategories } from './catalog.js';
|
|
4
|
+
import { linkLatest, linkPathFor } from './latest-link.js';
|
|
5
|
+
import { retains, retire } from './retention.js';
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Watches folders for new PMTiles archives and imports them automatically.
|
|
@@ -26,7 +29,7 @@ export class WatchManager {
|
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Starts watching the configured folders.
|
|
29
|
-
* @param {object[]} folders - Entries of {path, categories, webSeedBase, publishDir, sparse, trackers, addTrackers, stabilitySeconds, pollSeconds}.
|
|
32
|
+
* @param {object[]} folders - Entries of {path, categories, webSeedBase, publishDir, sparse, trackers, addTrackers, stabilitySeconds, pollSeconds, keep, keepDays, latestLink}.
|
|
30
33
|
* @returns {void}
|
|
31
34
|
*/
|
|
32
35
|
start(folders = []) {
|
|
@@ -62,6 +65,12 @@ export class WatchManager {
|
|
|
62
65
|
|
|
63
66
|
watcher.on('add', (file) => {
|
|
64
67
|
if (!/\.pmtiles$/i.test(file)) return;
|
|
68
|
+
// The folder's own `latestLink`, which is a .pmtiles in a watched
|
|
69
|
+
// folder like any other and would otherwise be imported as a second
|
|
70
|
+
// archive — a whole extra torrent for the same bytes under a name that
|
|
71
|
+
// changes every build. A hard link is indistinguishable from the file
|
|
72
|
+
// it names, so the name is the only thing that can tell them apart.
|
|
73
|
+
if (this.#isLatestLink(file, folder)) return;
|
|
65
74
|
this.#import(file, folder);
|
|
66
75
|
});
|
|
67
76
|
watcher.on('error', (error) => {
|
|
@@ -78,6 +87,21 @@ export class WatchManager {
|
|
|
78
87
|
}
|
|
79
88
|
}
|
|
80
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Whether a file is this folder's own "latest" name.
|
|
92
|
+
* @param {string} file - The file that appeared.
|
|
93
|
+
* @param {object} folder - The watch-folder configuration.
|
|
94
|
+
* @returns {boolean} - True when it is the link, not a build.
|
|
95
|
+
*/
|
|
96
|
+
#isLatestLink(file, folder) {
|
|
97
|
+
if (!folder.latestLink) return false;
|
|
98
|
+
// Compared as a path rather than a basename, so an absolute latestLink
|
|
99
|
+
// pointing somewhere else entirely does not silently exclude a real build
|
|
100
|
+
// that happens to share its name.
|
|
101
|
+
const target = path.join(folder.path, 'any.pmtiles');
|
|
102
|
+
return path.resolve(file) === path.resolve(linkPathFor(target, folder.latestLink));
|
|
103
|
+
}
|
|
104
|
+
|
|
81
105
|
/**
|
|
82
106
|
* Imports one archive, guarding against overlapping imports of the same file.
|
|
83
107
|
* @param {string} file - Path to the archive.
|
|
@@ -110,8 +134,37 @@ export class WatchManager {
|
|
|
110
134
|
// not on the node.
|
|
111
135
|
pieceLength: folder.pieceLength,
|
|
112
136
|
comment: folder.comment,
|
|
137
|
+
// Marks this as the folder's, so retention below has a family to work
|
|
138
|
+
// within and nothing outside it can be caught up in one.
|
|
139
|
+
watch: folder.path,
|
|
113
140
|
});
|
|
114
141
|
console.log(`[watch] imported ${entry.name} (${entry.infoHash})`);
|
|
142
|
+
|
|
143
|
+
// Before retirement, so the stable name is already pointing at the new
|
|
144
|
+
// build by the time anything older is considered for removal.
|
|
145
|
+
if (folder.latestLink) {
|
|
146
|
+
await linkLatest({
|
|
147
|
+
target: entry.retainedAt ?? path.join(entry.savePath, entry.name),
|
|
148
|
+
name: folder.latestLink,
|
|
149
|
+
label: `[watch] ${folder.path}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A folder receiving a daily planet build fills any disk within the
|
|
154
|
+
// week. This is the `find -mtime +35` sweep that used to sit in the
|
|
155
|
+
// generation script, except that it takes the torrent with the data
|
|
156
|
+
// rather than leaving the node advertising an archive that is gone.
|
|
157
|
+
if (!retains(folder)) return;
|
|
158
|
+
await retire({
|
|
159
|
+
library: this.#library,
|
|
160
|
+
family: this.#library.catalog
|
|
161
|
+
.list()
|
|
162
|
+
.filter((candidate) => candidate.source?.watch === folder.path),
|
|
163
|
+
entry,
|
|
164
|
+
keep: folder.keep,
|
|
165
|
+
keepDays: folder.keepDays,
|
|
166
|
+
label: `[watch] ${folder.path}`,
|
|
167
|
+
});
|
|
115
168
|
} catch (error) {
|
|
116
169
|
console.error(`[watch] failed to import ${file}: ${error.message}`);
|
|
117
170
|
} finally {
|
package/src/web/index.html
CHANGED
|
@@ -3124,9 +3124,46 @@
|
|
|
3124
3124
|
},
|
|
3125
3125
|
{ field: 'publishDir', label: 'Publish to', placeholder: '/var/www/pmtiles' },
|
|
3126
3126
|
{ field: 'webSeedBase', label: 'Web seed base', placeholder: 'https://…/files' },
|
|
3127
|
+
{
|
|
3128
|
+
field: 'comment',
|
|
3129
|
+
label: 'Torrent comment',
|
|
3130
|
+
placeholder: 'attribution and licence',
|
|
3131
|
+
wide: true,
|
|
3132
|
+
},
|
|
3127
3133
|
{ field: 'pollSeconds', label: 'Poll every (s)', placeholder: 'events', number: true },
|
|
3134
|
+
{
|
|
3135
|
+
field: 'latestLink',
|
|
3136
|
+
label: 'Stable name',
|
|
3137
|
+
placeholder: 'off',
|
|
3138
|
+
},
|
|
3139
|
+
{
|
|
3140
|
+
field: 'keep',
|
|
3141
|
+
label: 'Builds to keep',
|
|
3142
|
+
placeholder: 'all',
|
|
3143
|
+
number: true,
|
|
3144
|
+
},
|
|
3145
|
+
{
|
|
3146
|
+
field: 'keepDays',
|
|
3147
|
+
label: 'Keep for (days)',
|
|
3148
|
+
placeholder: 'for ever',
|
|
3149
|
+
number: true,
|
|
3150
|
+
},
|
|
3128
3151
|
],
|
|
3129
3152
|
rows: config.watch ?? [],
|
|
3153
|
+
footnote:
|
|
3154
|
+
'Stable name gives the newest build a second name that does not ' +
|
|
3155
|
+
'change — planet-latest.pmtiles — so a page can link to it. The ' +
|
|
3156
|
+
'dated file stays the real one and keeps its own torrent; the ' +
|
|
3157
|
+
'link costs no extra space, and is a symlink or a hard link ' +
|
|
3158
|
+
'depending on what the platform allows. Do not give it a name a ' +
|
|
3159
|
+
'real build could have. ' +
|
|
3160
|
+
'Builds to keep and Keep for are both off unless set, and both ' +
|
|
3161
|
+
'delete the archive as well as forgetting it — a catalog entry ' +
|
|
3162
|
+
'whose file is gone leaves this node advertising something it ' +
|
|
3163
|
+
'cannot serve. They only ever touch archives this same folder ' +
|
|
3164
|
+
'imported, never the newest one however old it is, and nothing at ' +
|
|
3165
|
+
'all until the new build is the one the feed resolves to. Set ' +
|
|
3166
|
+
'both and whichever says a build has to go wins.',
|
|
3130
3167
|
});
|
|
3131
3168
|
|
|
3132
3169
|
renderRowEditor({
|
|
@@ -3193,6 +3230,12 @@
|
|
|
3193
3230
|
placeholder: 'all',
|
|
3194
3231
|
number: true,
|
|
3195
3232
|
},
|
|
3233
|
+
{
|
|
3234
|
+
field: 'keepDays',
|
|
3235
|
+
label: 'Keep for (days)',
|
|
3236
|
+
placeholder: 'for ever',
|
|
3237
|
+
number: true,
|
|
3238
|
+
},
|
|
3196
3239
|
],
|
|
3197
3240
|
rows: config.sources ?? [],
|
|
3198
3241
|
preview: true,
|
|
@@ -3223,17 +3266,50 @@
|
|
|
3223
3266
|
'archives this same source imported.',
|
|
3224
3267
|
});
|
|
3225
3268
|
|
|
3269
|
+
const feedGlobals = document.createElement('div');
|
|
3270
|
+
feedGlobals.className = 'panel';
|
|
3271
|
+
feedGlobals.style.marginBottom = '1rem';
|
|
3272
|
+
feedGlobals.innerHTML = `
|
|
3273
|
+
<h2 style="margin-top:0">Feeds</h2>
|
|
3274
|
+
<div class="sub" style="margin-bottom:0.7rem">
|
|
3275
|
+
Applies to every feed below.
|
|
3276
|
+
</div>
|
|
3277
|
+
<div class="field">
|
|
3278
|
+
<label for="cfg-subscriptionsEnabled">Follow feeds</label>
|
|
3279
|
+
<select id="cfg-subscriptionsEnabled" data-key="subscriptionsEnabled" data-scalar="1">
|
|
3280
|
+
<option value="true"${config.subscriptionsEnabled === false ? '' : ' selected'}>on</option>
|
|
3281
|
+
<option value="false"${config.subscriptionsEnabled === false ? ' selected' : ''}>off</option>
|
|
3282
|
+
</select>
|
|
3283
|
+
<div class="sub">
|
|
3284
|
+
Off stops all of them without editing any, for when a disk is
|
|
3285
|
+
filling or a build has gone wrong.
|
|
3286
|
+
</div>
|
|
3287
|
+
</div>
|
|
3288
|
+
<div class="field">
|
|
3289
|
+
<label for="cfg-subscriptionIntervalSeconds">Check every (seconds)</label>
|
|
3290
|
+
<input id="cfg-subscriptionIntervalSeconds" data-key="subscriptionIntervalSeconds"
|
|
3291
|
+
data-scalar="1" style="width:9rem"
|
|
3292
|
+
value="${escapeHtml(config.subscriptionIntervalSeconds ?? 900)}" />
|
|
3293
|
+
<div class="sub">
|
|
3294
|
+
A poll is one request per feed. Publishers post on their own
|
|
3295
|
+
schedule, so checking faster than they publish finds nothing.
|
|
3296
|
+
</div>
|
|
3297
|
+
</div>`;
|
|
3298
|
+
body.append(feedGlobals);
|
|
3299
|
+
|
|
3226
3300
|
renderRowEditor({
|
|
3227
3301
|
into: body,
|
|
3228
3302
|
key: 'subscriptions',
|
|
3229
3303
|
title: 'Remote nodes',
|
|
3230
3304
|
blurb:
|
|
3231
|
-
'
|
|
3232
|
-
'
|
|
3233
|
-
'
|
|
3234
|
-
'
|
|
3235
|
-
'
|
|
3236
|
-
'
|
|
3305
|
+
'Feeds this node follows: another swarm node, or any RSS feed ' +
|
|
3306
|
+
'that carries torrents — planet.openstreetmap.org publishes one ' +
|
|
3307
|
+
'for the planet dumps. An RSS feed says "here is what is new" ' +
|
|
3308
|
+
'and is bounded by the publisher, so a node offline long enough ' +
|
|
3309
|
+
'misses things for good; a catalog URL says "here is ' +
|
|
3310
|
+
'everything", which is what makes reconciling possible. A token, ' +
|
|
3311
|
+
'where the peer issued one, may get you more than it publishes ' +
|
|
3312
|
+
'to the world.',
|
|
3237
3313
|
columns: [
|
|
3238
3314
|
{
|
|
3239
3315
|
field: 'url',
|
|
@@ -3258,6 +3334,12 @@
|
|
|
3258
3334
|
['mirror', 'mirror — whole copy'],
|
|
3259
3335
|
],
|
|
3260
3336
|
},
|
|
3337
|
+
{
|
|
3338
|
+
field: 'newest',
|
|
3339
|
+
label: 'Items per check',
|
|
3340
|
+
placeholder: '1',
|
|
3341
|
+
number: true,
|
|
3342
|
+
},
|
|
3261
3343
|
{ field: 'category', label: 'Tag as', placeholder: 'from-peer' },
|
|
3262
3344
|
{ field: 'filter', label: 'Name filter', placeholder: 'terrain' },
|
|
3263
3345
|
{ field: 'token', label: 'Token', placeholder: 'if issued one', secret: true },
|
|
@@ -3271,6 +3353,15 @@
|
|
|
3271
3353
|
['delete', 'forget and delete'],
|
|
3272
3354
|
],
|
|
3273
3355
|
},
|
|
3356
|
+
{
|
|
3357
|
+
field: 'enabled',
|
|
3358
|
+
label: 'On',
|
|
3359
|
+
boolean: true,
|
|
3360
|
+
options: [
|
|
3361
|
+
['', 'yes'],
|
|
3362
|
+
['false', 'no'],
|
|
3363
|
+
],
|
|
3364
|
+
},
|
|
3274
3365
|
],
|
|
3275
3366
|
rows: config.subscriptions ?? [],
|
|
3276
3367
|
peerPreview: true,
|
|
@@ -3280,7 +3371,10 @@
|
|
|
3280
3371
|
'every archive the peer lists. Dropping stays off unless chosen, ' +
|
|
3281
3372
|
'only ever considers archives this peer sent, and never acts on a ' +
|
|
3282
3373
|
'filtered or partial view — watch a new peer on "report only" ' +
|
|
3283
|
-
'before trusting it with anything more.'
|
|
3374
|
+
'before trusting it with anything more. Items per check is 1 by ' +
|
|
3375
|
+
'default and counts from the newest, because a feed listing five ' +
|
|
3376
|
+
'planet dumps is four hundred gigabytes if you take the lot; 0 ' +
|
|
3377
|
+
'takes everything it lists.',
|
|
3284
3378
|
});
|
|
3285
3379
|
|
|
3286
3380
|
for (const [key, value] of Object.entries(config)) {
|
|
@@ -3289,6 +3383,8 @@
|
|
|
3289
3383
|
// Shown as real fields above rather than as raw JSON.
|
|
3290
3384
|
if (key === 'seeding' || key === 'speed') continue;
|
|
3291
3385
|
if (key === 'watch' || key === 'sources' || key === 'subscriptions') continue;
|
|
3386
|
+
if (key === 'subscriptionsEnabled') continue;
|
|
3387
|
+
if (key === 'subscriptionIntervalSeconds') continue;
|
|
3292
3388
|
if (key === 'onAdded' || key === 'onComplete') continue;
|
|
3293
3389
|
const needsRestart = restartKeys.has(key);
|
|
3294
3390
|
const field = document.createElement('div');
|
package/swarm.config.json.sample
CHANGED
|
@@ -71,8 +71,18 @@
|
|
|
71
71
|
}
|
|
72
72
|
],
|
|
73
73
|
|
|
74
|
+
"subscriptionsEnabled": true,
|
|
75
|
+
"subscriptionIntervalSeconds": 900,
|
|
74
76
|
"subscriptions": [
|
|
75
|
-
{ "url": "https://peer.example.org/api/catalog", "protocol": "api", "mode": "cache" }
|
|
77
|
+
{ "url": "https://peer.example.org/api/catalog", "protocol": "api", "mode": "cache" },
|
|
78
|
+
{
|
|
79
|
+
"url": "https://feed.example.org/pbf/planet-pbf-rss.xml",
|
|
80
|
+
"protocol": "rss",
|
|
81
|
+
"mode": "mirror",
|
|
82
|
+
"newest": 1,
|
|
83
|
+
"categories": ["osm-planet"],
|
|
84
|
+
"enabled": false
|
|
85
|
+
}
|
|
76
86
|
],
|
|
77
87
|
|
|
78
88
|
"tiles": { "sparse": null },
|