pmtiles-swarm 0.22.0 β 0.24.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 +51 -0
- package/package.json +1 -1
- package/src/api.js +21 -1
- package/src/feed.js +58 -0
- package/src/library.js +11 -0
- package/src/mutable.js +16 -0
- package/src/prewarm.js +148 -19
- package/src/publisher.js +8 -1
- package/src/subscriptions.js +10 -0
- package/src/tilejson.js +4 -1
- package/src/web/preview.html +12 -3
- package/src/web/public.html +118 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## master
|
|
4
4
|
### β¨ Features and improvements
|
|
5
|
+
- _...Add new stuff here..._
|
|
6
|
+
|
|
7
|
+
### π Bug fixes
|
|
8
|
+
- _...Add new stuff here..._
|
|
9
|
+
|
|
10
|
+
## 0.24.0
|
|
11
|
+
### β¨ Features and improvements
|
|
12
|
+
- **A mirror now inherits the archive summary from the feed it follows.** `renderItem` has always
|
|
13
|
+
published `pmtiles:format`, the zoom range, the bounds and the tile count, and `parseFeed` has
|
|
14
|
+
always thrown them away β so the fact that a feed is more useful than a generic torrent feed was
|
|
15
|
+
true of the XML and of nothing else. It matters more than it sounds: an archive is servable when
|
|
16
|
+
`entry.pmtiles` exists, so a fresh mirror served nothing at all until it had read the header out
|
|
17
|
+
of the swarm itself. On an 80 GiB planet archive whose only seed is busy that is hours of a node
|
|
18
|
+
that looks joined, downloads steadily, and answers every tile request with 400. The summary now
|
|
19
|
+
comes across with the item and the archive is servable the moment it is added. It is marked
|
|
20
|
+
`source: 'feed'`, because a summary taken on trust is not the same fact as one read off the
|
|
21
|
+
header, and the head warmer still replaces it with the latter as soon as it can read one.
|
|
5
22
|
- **A watched folder can ask for its stable name to be a hard link.**
|
|
6
23
|
`latestLinkType: "hard"` reverses the order the two kinds are attempted in,
|
|
7
24
|
for a folder whose archives are read *through* that name rather than followed
|
|
@@ -58,6 +75,13 @@
|
|
|
58
75
|
`/api/categories`, filtered by the same `feedCategories` rule, so it can show nothing that was
|
|
59
76
|
not already published β and it is not the console, which stays on the admin port.
|
|
60
77
|
`publicIndex: false` turns it off, withdrawing the three paths it needs with it.
|
|
78
|
+
- **A category can be previewed, and the preview is the category's own URL.** `/latest/<category>/preview`
|
|
79
|
+
reads the TileJSON beside it, so it renders whatever build is current rather than pinning to the
|
|
80
|
+
one that was newest when the link was made β the same URL a style holds, demonstrating itself.
|
|
81
|
+
The preview page now derives its source from wherever it is served instead of assembling one from
|
|
82
|
+
an infohash, so one page serves both forms.
|
|
83
|
+
- **The public page gained a filter and a sort.** Search by name, infohash or category; order by name,
|
|
84
|
+
newest or largest. Both re-render from what was already loaded rather than asking the node again.
|
|
61
85
|
- **`GET /latest/` lists the categories, without a credential.** Everything else under
|
|
62
86
|
`/latest/` is public β the TileJSON, the torrent, the magnet, the per-category feed β so the
|
|
63
87
|
index of what it offers belongs beside them rather than behind the console's door. The public
|
|
@@ -77,6 +101,33 @@
|
|
|
77
101
|
never checked.
|
|
78
102
|
|
|
79
103
|
### π Bug fixes
|
|
104
|
+
- **Head warming now says whether it is running.** Every way it could decline was a bare `return`:
|
|
105
|
+
`tiles.prewarm` false, `prewarmIntervalSeconds` at zero, a node with no tile store to read with,
|
|
106
|
+
and β the one that actually bites β a pass that finds no archive eligible to warm. All four
|
|
107
|
+
produced an identical empty log, so "warming is switched off" and "warming is working and has
|
|
108
|
+
nothing to do" could not be told apart, and on a node whose mirrors were stuck at 400 the only
|
|
109
|
+
way to tell was to read the source. It now names the reason at startup, or says how often it will
|
|
110
|
+
look; and after ten idle passes it says either that everything has been summarised or which
|
|
111
|
+
archives it is skipping for want of a recognised kind. That last case is what an archive joined by
|
|
112
|
+
magnet looks like before its metainfo arrives: `guessKind` cannot tell it is PMTiles, `due`
|
|
113
|
+
refuses it, and nothing said so.
|
|
114
|
+
- **One archive whose metainfo never arrived stopped every other archive being warmed.** A sweep
|
|
115
|
+
warms a single archive, chosen as the first one due, and an archive joined by magnet that has no
|
|
116
|
+
metainfo yet is answered "not yet" and deliberately left unstamped so the next pass retries in
|
|
117
|
+
seconds rather than after the full backoff. The two together meant an archive stuck that way
|
|
118
|
+
stayed due at no cost, was chosen again on every pass, and its neighbours were never attempted at
|
|
119
|
+
all β for as long as it was stuck, which where no peer ever answers is indefinitely. A node with
|
|
120
|
+
two mirrors could therefore warm neither, having genuinely tried only one. The fast retry is now
|
|
121
|
+
bounded: after five consecutive passes the wait has plainly stopped being nearly over, and it is
|
|
122
|
+
charged as an attempt like any other so the backoff spreads them out and lets its neighbours
|
|
123
|
+
through. It says so when it makes that switch, which is otherwise the quietest moment in the
|
|
124
|
+
process β the point where an archive goes from "about to work" to "may never work".
|
|
125
|
+
- **A mutable magnet had nowhere to announce.** `mutableMagnet()` was never passed trackers, so the
|
|
126
|
+
BEP 46 form carried `xt`, `xs`, `dn`, `s` and `ws` and no `tr=` at all. The infohash added in
|
|
127
|
+
0.21.0 was therefore unusable from a browser, which has neither DHT nor peer exchange to fall
|
|
128
|
+
back on and so had nothing to ask β leaving the web seed, which is just HTTP, as the only source.
|
|
129
|
+
The archive's own trackers are now lifted across in all three places one is built: the TileJSON's
|
|
130
|
+
torrent block, the `styleUrl` fragment, and the publisher's own magnet.
|
|
80
131
|
- **The public front page could not read its own catalogue.** `/api/catalog` is on the list of
|
|
81
132
|
paths that belong on a public listener, but that is a separate gate from the credential check,
|
|
82
133
|
which guards everything under `/api/` except login and session β so on any node with
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
isPublicSurface,
|
|
15
15
|
} from './auth.js';
|
|
16
16
|
import { normalizeCategories } from './catalog.js';
|
|
17
|
-
import { mutableMagnet } from './mutable.js';
|
|
17
|
+
import { mutableMagnet, trackersFromMagnet } from './mutable.js';
|
|
18
18
|
import { guessKind } from './library.js';
|
|
19
19
|
import { QBittorrentEngine } from './engines/qbittorrent.js';
|
|
20
20
|
import { RESTART_REQUIRED, redactConfig, saveConfig } from './config.js';
|
|
@@ -80,6 +80,10 @@ function styleUrlFor(category, newest, base) {
|
|
|
80
80
|
// of this URL β a client that cannot reach the tiles.json in front of
|
|
81
81
|
// it, or cannot resolve a public key, still has something to join.
|
|
82
82
|
infoHash: newest.infoHash,
|
|
83
|
+
// Somewhere to announce it, lifted from the archive's own magnet. A
|
|
84
|
+
// fragment carrying an infohash and no tracker gives a browser
|
|
85
|
+
// something to join and nobody to ask for it.
|
|
86
|
+
trackers: trackersFromMagnet(newest.magnet),
|
|
83
87
|
salt: newest.mutable.salt ?? category,
|
|
84
88
|
// The category, since that is what this magnet resolves to.
|
|
85
89
|
name: newest.mutable.salt ?? category,
|
|
@@ -1802,6 +1806,11 @@ export function createApp({
|
|
|
1802
1806
|
// newest build's own magnet, which pins that build but still
|
|
1803
1807
|
// beats a blank map when the fallback is needed at all.
|
|
1804
1808
|
styleUrl: servable ? styleUrlFor(category, newest, base) : null,
|
|
1809
|
+
// Points at the category, not at a build. The page reads the
|
|
1810
|
+
// TileJSON beside it, so it renders whatever is current β which
|
|
1811
|
+
// makes it the same URL a style holds, demonstrating itself rather
|
|
1812
|
+
// than pinning to today's infohash.
|
|
1813
|
+
preview: servable ? `${base}/latest/${category}/preview` : null,
|
|
1805
1814
|
torrent: `${base}/latest/${category}/archive.torrent`,
|
|
1806
1815
|
magnet: `${base}/latest/${category}/magnet`,
|
|
1807
1816
|
feed: `${base}/feed/${category}.xml`,
|
|
@@ -1973,6 +1982,17 @@ export function createApp({
|
|
|
1973
1982
|
// shareable and says what it shows. The page reads the TileJSON next to it β
|
|
1974
1983
|
// which is a complete, valid TileJSON already, so nothing here has to invent
|
|
1975
1984
|
// a source description.
|
|
1985
|
+
// The same page for a category, which is the more useful one to hand
|
|
1986
|
+
// somebody: it reads /latest/<category>/tiles.json, so it renders whatever
|
|
1987
|
+
// build is current rather than pinning to the one that happened to be newest
|
|
1988
|
+
// when the link was made. That is exactly what a style points at, so this is
|
|
1989
|
+
// the URL demonstrating itself.
|
|
1990
|
+
//
|
|
1991
|
+
// The page works out which by its own path, so there is nothing to pass.
|
|
1992
|
+
app.get('/latest/:category/preview', (_req, res) => {
|
|
1993
|
+
res.sendFile(path.join(here, 'web', 'preview.html'));
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1976
1996
|
app.get('/archives/:infoHash/preview', (_req, res) => {
|
|
1977
1997
|
res.sendFile(path.join(here, 'web', 'preview.html'));
|
|
1978
1998
|
});
|
package/src/feed.js
CHANGED
|
@@ -165,8 +165,62 @@ export function formatBytes(bytes) {
|
|
|
165
165
|
* @property {string} [category] - Item category.
|
|
166
166
|
* @property {string} [mtime] - The archive's mtime on the node that built it.
|
|
167
167
|
* @property {string} [mutableMagnet] - BEP 46 magnet, when the publisher has an identity for it.
|
|
168
|
+
* @property {object} [pmtiles] - Coverage summary, when the feed carries one.
|
|
168
169
|
*/
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Reads the `pmtiles:*` summary out of a feed item, when it has one.
|
|
173
|
+
*
|
|
174
|
+
* renderItem publishes format, zoom range, bounds, tile count and attribution
|
|
175
|
+
* for exactly this β so a subscriber can tell what an archive holds before
|
|
176
|
+
* committing to 80 GiB of it. For a long time nothing read them back, which had
|
|
177
|
+
* a consequence well beyond the feed being less informative than it looked: a
|
|
178
|
+
* mirror's `servable` flag is `Boolean(entry.pmtiles)`, so an archive arrived
|
|
179
|
+
* with no summary, was unservable, and stayed unservable until the head warmer
|
|
180
|
+
* managed to read the header out of the swarm β while the answer had been in
|
|
181
|
+
* the feed all along.
|
|
182
|
+
*
|
|
183
|
+
* Deliberately partial. There is no `vectorLayers` here, because the publisher
|
|
184
|
+
* has none to give: planetiler writes that section after every tile, so on a
|
|
185
|
+
* planet archive it is the very end of the file. An entry summarised from a
|
|
186
|
+
* feed therefore stays due for warming, which is what fills the rest in.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} block - The item XML.
|
|
189
|
+
* @returns {object | undefined} - The summary, or undefined if absent.
|
|
190
|
+
*/
|
|
191
|
+
function mapSummary(block) {
|
|
192
|
+
const format = tag(block, 'pmtiles:format');
|
|
193
|
+
// Format is the field `servable` and the tile routes key off, so a summary
|
|
194
|
+
// without it is not a summary. Better no object at all than one that reports
|
|
195
|
+
// an archive as readable and then cannot say what is in it.
|
|
196
|
+
if (!format) return undefined;
|
|
197
|
+
|
|
198
|
+
const number = (name) => {
|
|
199
|
+
const raw = tag(block, name);
|
|
200
|
+
if (raw === undefined || raw.trim() === '') return undefined;
|
|
201
|
+
const value = Number(raw);
|
|
202
|
+
return Number.isFinite(value) ? value : undefined;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const bounds = (tag(block, 'pmtiles:bounds') ?? '')
|
|
206
|
+
.split(',')
|
|
207
|
+
.map((part) => Number(part.trim()))
|
|
208
|
+
.filter((part) => Number.isFinite(part));
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
format,
|
|
212
|
+
minZoom: number('pmtiles:minzoom'),
|
|
213
|
+
maxZoom: number('pmtiles:maxzoom'),
|
|
214
|
+
bounds: bounds.length === 4 ? bounds : undefined,
|
|
215
|
+
tileCount: number('pmtiles:tiles'),
|
|
216
|
+
attribution: tag(block, 'pmtiles:attribution'),
|
|
217
|
+
// Says where this came from, because a summary taken on trust from another
|
|
218
|
+
// node is not the same fact as one read off the archive's own header, and
|
|
219
|
+
// the difference matters when the two disagree.
|
|
220
|
+
source: 'feed',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
170
224
|
/**
|
|
171
225
|
* Parses a subscribed feed.
|
|
172
226
|
*
|
|
@@ -222,6 +276,10 @@ export function parseFeed(body) {
|
|
|
222
276
|
// more than once, so take all of them.
|
|
223
277
|
md5: tag(block, 'pmtiles:md5'),
|
|
224
278
|
mtime: isoDate(tag(block, 'pmtiles:mtime')),
|
|
279
|
+
// What the publisher already knows about the archive's contents. Absent
|
|
280
|
+
// from a generic torrent feed, and absent from ours for an archive the
|
|
281
|
+
// publishing node has not summarised either.
|
|
282
|
+
pmtiles: mapSummary(block),
|
|
225
283
|
categories: [...block.matchAll(/<category>([\s\S]*?)<\/category>/g)]
|
|
226
284
|
.map((match) => decode(match[1]).trim())
|
|
227
285
|
.filter(Boolean),
|
package/src/library.js
CHANGED
|
@@ -836,6 +836,17 @@ export class Library {
|
|
|
836
836
|
complete,
|
|
837
837
|
// Held until the download finishes, which may be hours away.
|
|
838
838
|
originMtime: options.originMtime,
|
|
839
|
+
// What the peer that offered this says it holds, where it said anything.
|
|
840
|
+
// The head warmer replaces it with what the archive's own header says as
|
|
841
|
+
// soon as it can read one; until then this is what makes the archive
|
|
842
|
+
// servable at all, since `servable` is simply whether a summary exists.
|
|
843
|
+
//
|
|
844
|
+
// Spread rather than set, because catalog.put merges by spreading the
|
|
845
|
+
// record over the existing one -- and a key present with an undefined
|
|
846
|
+
// value still overwrites. Setting it unconditionally would mean an
|
|
847
|
+
// archive re-added from a feed that carries no summary silently lost the
|
|
848
|
+
// one it had already read for itself.
|
|
849
|
+
...(options.pmtiles ? { pmtiles: options.pmtiles } : {}),
|
|
839
850
|
});
|
|
840
851
|
|
|
841
852
|
// The engine's add resolves once metadata is in hand, so for a magnet
|
package/src/mutable.js
CHANGED
|
@@ -90,6 +90,22 @@ function rawPublicKey(publicKey) {
|
|
|
90
90
|
* @param {string} [options.salt] - Salt, when one key publishes several archives.
|
|
91
91
|
* @returns {string} - A BEP 46 magnet URI.
|
|
92
92
|
*/
|
|
93
|
+
/**
|
|
94
|
+
* The trackers a magnet announces to.
|
|
95
|
+
*
|
|
96
|
+
* A BEP 46 magnet is assembled rather than parsed from a torrent, so it has no
|
|
97
|
+
* announce list of its own β but the archive it names does, in the plain magnet
|
|
98
|
+
* beside it. Lifting them across is what makes the mutable form joinable: an
|
|
99
|
+
* infohash with nowhere to announce is an infohash a browser cannot act on,
|
|
100
|
+
* having neither DHT nor peer exchange to fall back on.
|
|
101
|
+
* @param {string} [magnet] - A magnet URI to read `tr=` out of.
|
|
102
|
+
* @returns {string[]} - The announce URLs, in the order they appeared.
|
|
103
|
+
*/
|
|
104
|
+
export function trackersFromMagnet(magnet) {
|
|
105
|
+
if (typeof magnet !== 'string' || !magnet.startsWith('magnet:?')) return [];
|
|
106
|
+
return new URLSearchParams(magnet.slice('magnet:?'.length)).getAll('tr');
|
|
107
|
+
}
|
|
108
|
+
|
|
93
109
|
export function mutableMagnet(publicKey, options = {}) {
|
|
94
110
|
// Buffer.from(string) would read hex as UTF-8 and produce a 64-byte key, so
|
|
95
111
|
// the two forms have to be told apart rather than coerced.
|
package/src/prewarm.js
CHANGED
|
@@ -20,6 +20,39 @@ const DEFAULT_MAX_BACKOFF_SECONDS = 600;
|
|
|
20
20
|
/** How long to let the node settle before the first attempt. */
|
|
21
21
|
const DEFAULT_INITIAL_DELAY_SECONDS = 10;
|
|
22
22
|
|
|
23
|
+
/** How often to look for an archive that needs its head read. */
|
|
24
|
+
const DEFAULT_INTERVAL_SECONDS = 30;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How many consecutive passes may find nothing to warm before saying so.
|
|
28
|
+
*
|
|
29
|
+
* Silence is the right output for a node whose archives are all summarised, and
|
|
30
|
+
* it is also what a node produces when every archive is being skipped for a
|
|
31
|
+
* reason nobody can see β an entry whose kind is not yet known, most often,
|
|
32
|
+
* because it was joined by magnet and its metainfo has not arrived. One line
|
|
33
|
+
* after a few minutes of that separates the two without turning a healthy node
|
|
34
|
+
* into a log generator.
|
|
35
|
+
*/
|
|
36
|
+
const IDLE_PASSES_BEFORE_REPORTING = 10;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How many passes in a row an archive may claim on the strength of "not yet".
|
|
40
|
+
*
|
|
41
|
+
* An archive joined from a magnet has no metainfo until BEP 9 finishes, which
|
|
42
|
+
* is usually seconds, so charging it the full backoff wastes a wait that was
|
|
43
|
+
* nearly over β hence the fast retry. What that must not do is cost nothing for
|
|
44
|
+
* ever. A pass warms one archive, chosen as the *first* that is due, and an
|
|
45
|
+
* entry that stays due at no cost is chosen again on the next pass and on every
|
|
46
|
+
* pass after it. One archive stuck this way therefore stops every other archive
|
|
47
|
+
* on the node from being warmed at all, silently, and for as long as it is
|
|
48
|
+
* stuck β which on a node whose peer never answers is indefinitely.
|
|
49
|
+
*
|
|
50
|
+
* Past this many passes the wait has plainly stopped being nearly over, and it
|
|
51
|
+
* is charged as an attempt like any other so the backoff can spread the
|
|
52
|
+
* attempts out and let its neighbours through.
|
|
53
|
+
*/
|
|
54
|
+
const MAX_CONSECUTIVE_EARLY_PASSES = 5;
|
|
55
|
+
|
|
23
56
|
/**
|
|
24
57
|
* Whether a failure means "not yet" rather than "not working".
|
|
25
58
|
*
|
|
@@ -47,8 +80,11 @@ export class HeadWarmer {
|
|
|
47
80
|
#tried = new Map();
|
|
48
81
|
#attempts = new Map();
|
|
49
82
|
#waiting = new Set();
|
|
83
|
+
/** Consecutive passes each archive has answered "not yet" to. */
|
|
84
|
+
#early = new Map();
|
|
50
85
|
#running = false;
|
|
51
86
|
#runningSince = 0;
|
|
87
|
+
#idlePasses = 0;
|
|
52
88
|
|
|
53
89
|
/**
|
|
54
90
|
* @param {object} tiles - The tile store, for `summarize`.
|
|
@@ -71,6 +107,31 @@ export class HeadWarmer {
|
|
|
71
107
|
);
|
|
72
108
|
}
|
|
73
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Why this node is not warming, or null when it is.
|
|
112
|
+
*
|
|
113
|
+
* Exists so `start()` can say which of its several reasons applied. They are
|
|
114
|
+
* each a bare `return` on a condition, and the difference between them was
|
|
115
|
+
* invisible: a node with warming switched off, a node whose interval was set
|
|
116
|
+
* to zero, and a node warming perfectly well with nothing to do all produced
|
|
117
|
+
* exactly the same empty log.
|
|
118
|
+
* @returns {string | null} - The reason, or null.
|
|
119
|
+
*/
|
|
120
|
+
get disabledReason() {
|
|
121
|
+
if (this.#config.tiles?.prewarm === false) {
|
|
122
|
+
return 'tiles.prewarm is false';
|
|
123
|
+
}
|
|
124
|
+
if (typeof this.#tiles?.summarize !== 'function') {
|
|
125
|
+
return 'this node has no tile store to read headers with';
|
|
126
|
+
}
|
|
127
|
+
const seconds =
|
|
128
|
+
this.#config.tiles?.prewarmIntervalSeconds ?? DEFAULT_INTERVAL_SECONDS;
|
|
129
|
+
if (seconds <= 0) {
|
|
130
|
+
return `tiles.prewarmIntervalSeconds is ${seconds}`;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
74
135
|
/**
|
|
75
136
|
* Whether an archive is worth reading the head of right now.
|
|
76
137
|
*
|
|
@@ -162,7 +223,11 @@ export class HeadWarmer {
|
|
|
162
223
|
}
|
|
163
224
|
|
|
164
225
|
const entry = this.#catalog.list().find((candidate) => this.due(candidate));
|
|
165
|
-
if (!entry)
|
|
226
|
+
if (!entry) {
|
|
227
|
+
this.#reportIdle();
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
this.#idlePasses = 0;
|
|
166
231
|
|
|
167
232
|
this.#running = true;
|
|
168
233
|
this.#runningSince = this.#now();
|
|
@@ -175,6 +240,11 @@ export class HeadWarmer {
|
|
|
175
240
|
|
|
176
241
|
this.#tried.set(entry.infoHash, this.#now());
|
|
177
242
|
this.#waiting.delete(entry.infoHash);
|
|
243
|
+
// The metainfo evidently arrived, so the run of "not yet" answers is
|
|
244
|
+
// over. Cleared rather than left standing, since a later magnet re-add of
|
|
245
|
+
// the same archive starts its own wait and should get its own fast
|
|
246
|
+
// retries rather than inheriting a spent allowance.
|
|
247
|
+
this.#early.delete(entry.infoHash);
|
|
178
248
|
// Counted even on success, because a read that got the header and not
|
|
179
249
|
// the metadata has not finished and will be back. The count only matters
|
|
180
250
|
// while an archive is still due, and one that is done is never asked
|
|
@@ -210,44 +280,103 @@ export class HeadWarmer {
|
|
|
210
280
|
return stored;
|
|
211
281
|
} catch (error) {
|
|
212
282
|
if (tooEarly(error)) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
283
|
+
const passes = (this.#early.get(entry.infoHash) ?? 0) + 1;
|
|
284
|
+
this.#early.set(entry.infoHash, passes);
|
|
285
|
+
|
|
286
|
+
if (passes <= MAX_CONSECUTIVE_EARLY_PASSES) {
|
|
287
|
+
// Not stamped, so the next pass tries again in seconds rather than in
|
|
288
|
+
// minutes β and said once, because a node that has just started will
|
|
289
|
+
// answer this way until the metainfo lands.
|
|
290
|
+
if (!this.#waiting.has(entry.infoHash)) {
|
|
291
|
+
this.#waiting.add(entry.infoHash);
|
|
292
|
+
console.log(
|
|
293
|
+
`[warm] ${entry.name}: waiting for the torrent metadata before ` +
|
|
294
|
+
'reading its head',
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
222
298
|
}
|
|
223
|
-
|
|
299
|
+
|
|
300
|
+
// Waited long enough to stop calling it a wait. Said out loud, because
|
|
301
|
+
// this is the moment the archive goes from "about to work" to "may
|
|
302
|
+
// never work", and it is otherwise the quietest possible transition.
|
|
303
|
+
console.warn(
|
|
304
|
+
`[warm] ${entry.name}: still has no torrent metadata after ` +
|
|
305
|
+
`${passes} passes; treating that as a failure so other archives ` +
|
|
306
|
+
'get a turn',
|
|
307
|
+
);
|
|
308
|
+
} else {
|
|
309
|
+
// A real attempt: it reached the swarm and found nothing. Ordinary
|
|
310
|
+
// while an archive is young, since the piece holding the header may not
|
|
311
|
+
// exist anywhere reachable yet. Worth saying, because it is the answer
|
|
312
|
+
// to "why is my preview blank", and the backoff keeps it from becoming
|
|
313
|
+
// noise.
|
|
314
|
+
console.warn(`[warm] ${entry.name}: ${error.message}`);
|
|
315
|
+
this.#early.delete(entry.infoHash);
|
|
224
316
|
}
|
|
225
317
|
|
|
226
|
-
// A real attempt: it reached the swarm and found nothing. Ordinary while
|
|
227
|
-
// an archive is young, since the piece holding the header may not exist
|
|
228
|
-
// anywhere reachable yet. Worth saying, because it is the answer to "why
|
|
229
|
-
// is my preview blank", and the backoff keeps it from becoming noise.
|
|
230
318
|
this.#tried.set(entry.infoHash, this.#now());
|
|
231
319
|
this.#waiting.delete(entry.infoHash);
|
|
232
320
|
this.#attempts.set(
|
|
233
321
|
entry.infoHash,
|
|
234
322
|
(this.#attempts.get(entry.infoHash) ?? 0) + 1,
|
|
235
323
|
);
|
|
236
|
-
console.warn(`[warm] ${entry.name}: ${error.message}`);
|
|
237
324
|
return null;
|
|
238
325
|
} finally {
|
|
239
326
|
this.#running = false;
|
|
240
327
|
}
|
|
241
328
|
}
|
|
242
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Says why nothing is being warmed, when that has gone on long enough to be
|
|
332
|
+
* worth explaining.
|
|
333
|
+
*
|
|
334
|
+
* Distinguishes the two ways a pass finds nothing: everything is summarised,
|
|
335
|
+
* which is the goal, and everything is being skipped, which is a fault that
|
|
336
|
+
* otherwise looks identical. The archives that are neither summarised nor
|
|
337
|
+
* eligible are named, because the reason is almost always visible in the name
|
|
338
|
+
* β an entry still called by its infohash has no metainfo yet, so `guessKind`
|
|
339
|
+
* cannot tell it is PMTiles and `due` refuses it.
|
|
340
|
+
* @returns {void}
|
|
341
|
+
*/
|
|
342
|
+
#reportIdle() {
|
|
343
|
+
this.#idlePasses++;
|
|
344
|
+
if (this.#idlePasses !== IDLE_PASSES_BEFORE_REPORTING) return;
|
|
345
|
+
|
|
346
|
+
const unread = this.#catalog
|
|
347
|
+
.list()
|
|
348
|
+
.filter((entry) => !entry.pmtiles?.format);
|
|
349
|
+
if (unread.length === 0) {
|
|
350
|
+
console.log('[warm] every archive has been summarised; nothing to warm');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
console.warn(
|
|
354
|
+
`[warm] ${unread.length} archive(s) have no summary and none are ` +
|
|
355
|
+
'eligible to be warmed; their kind is not PMTiles, or is not yet ' +
|
|
356
|
+
`known: ${unread
|
|
357
|
+
.slice(0, 5)
|
|
358
|
+
.map((entry) => entry.name ?? entry.infoHash)
|
|
359
|
+
.join(', ')}${unread.length > 5 ? ', β¦' : ''}`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
243
363
|
/**
|
|
244
364
|
* Starts warming, and keeps at it.
|
|
245
365
|
* @returns {void}
|
|
246
366
|
*/
|
|
247
367
|
start() {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
368
|
+
// Said once at startup, either way. A node that is not warming is a node
|
|
369
|
+
// whose mirrored archives will not become servable on their own, and that
|
|
370
|
+
// is far too important to be inferred from the absence of log lines --
|
|
371
|
+
// which is exactly how it had to be diagnosed before.
|
|
372
|
+
const reason = this.disabledReason;
|
|
373
|
+
if (reason) {
|
|
374
|
+
console.warn(`[warm] not warming: ${reason}`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const seconds =
|
|
378
|
+
this.#config.tiles?.prewarmIntervalSeconds ?? DEFAULT_INTERVAL_SECONDS;
|
|
379
|
+
console.log(`[warm] reading archive heads every ${seconds}s`);
|
|
251
380
|
|
|
252
381
|
const run = () =>
|
|
253
382
|
this.sweep().catch((error) =>
|
package/src/publisher.js
CHANGED
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
* docs/internals.md β "Why a third DHT".
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
mutableMagnet,
|
|
22
|
+
publishInfoHash,
|
|
23
|
+
trackersFromMagnet,
|
|
24
|
+
} from './mutable.js';
|
|
21
25
|
|
|
22
26
|
/** Republish well inside the ~2h a DHT keeps an item. */
|
|
23
27
|
const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
|
|
@@ -197,6 +201,9 @@ export class MutablePublisher {
|
|
|
197
201
|
// The current build, for a client that cannot resolve the key. Absent
|
|
198
202
|
// when no entry was given, which leaves the series-only form.
|
|
199
203
|
infoHash: entry?.infoHash,
|
|
204
|
+
// Somewhere to announce it, lifted from the archive's own magnet. An
|
|
205
|
+
// infohash with no tracker beside it is one a browser cannot act on.
|
|
206
|
+
trackers: trackersFromMagnet(entry?.magnet),
|
|
200
207
|
salt: category,
|
|
201
208
|
// The category rather than the build. This magnet resolves to whichever
|
|
202
209
|
// archive is current, so naming one of them dates the string the moment
|
package/src/subscriptions.js
CHANGED
|
@@ -409,6 +409,16 @@ export class SubscriptionManager {
|
|
|
409
409
|
// The mtime on the node that built it, which BitTorrent will not carry.
|
|
410
410
|
// Restored when the download completes.
|
|
411
411
|
originMtime: item.mtime,
|
|
412
|
+
// What the publisher says the archive holds: format, zoom range, bounds.
|
|
413
|
+
//
|
|
414
|
+
// This is the difference between a mirror that can serve tiles the moment
|
|
415
|
+
// it joins and one that cannot serve them until it has read the header out
|
|
416
|
+
// of the swarm -- which, for an 80 GiB archive whose only seed is busy,
|
|
417
|
+
// can be a very long time, and used to be the only way an entry ever got
|
|
418
|
+
// a summary at all. The head warmer still runs and still overwrites this
|
|
419
|
+
// with what the archive itself says; this only means the wait is not
|
|
420
|
+
// spent unservable.
|
|
421
|
+
pmtiles: item.pmtiles,
|
|
412
422
|
};
|
|
413
423
|
|
|
414
424
|
// The .torrent is preferred where there is one: it carries the trackers
|
package/src/tilejson.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mutableMagnet } from './mutable.js';
|
|
1
|
+
import { mutableMagnet, trackersFromMagnet } from './mutable.js';
|
|
2
2
|
import { TileStore } from './tiles.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -106,6 +106,9 @@ function buildTorrentBlock(entry, root) {
|
|
|
106
106
|
// can join from this string alone rather than having to come back for
|
|
107
107
|
// an infohash.
|
|
108
108
|
infoHash: entry.infoHash,
|
|
109
|
+
// And somewhere to announce it. Without these the infohash above is
|
|
110
|
+
// unusable from a page: no DHT, no peer exchange, nothing to ask.
|
|
111
|
+
trackers: trackersFromMagnet(entry.magnet),
|
|
109
112
|
salt: entry.mutable.salt,
|
|
110
113
|
// The category, not this build: the record resolves to whichever
|
|
111
114
|
// archive is current, and `dn` is a label the metadata replaces.
|
package/src/web/preview.html
CHANGED
|
@@ -79,8 +79,17 @@
|
|
|
79
79
|
import * as maplibregl from '/vendor/maplibre-gl/maplibre-gl.mjs';
|
|
80
80
|
import MaplibreInspect from '/vendor/maplibre-gl-inspect/maplibre-gl-inspect.mjs';
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// Derived from wherever this page is served rather than assembled from
|
|
83
|
+
// an infohash, so one page previews both an archive and a category:
|
|
84
|
+
// /archives/<infohash>/preview -> /archives/<infohash>/tiles.json
|
|
85
|
+
// /latest/<category>/preview -> /latest/<category>/tiles.json
|
|
86
|
+
// The second is the one worth having, because it is what a style should
|
|
87
|
+
// actually point at β it follows the newest build instead of pinning to
|
|
88
|
+
// the one that happened to be current when the link was made.
|
|
89
|
+
const tileJsonUrl = location.pathname.replace(
|
|
90
|
+
/\/preview\/?$/,
|
|
91
|
+
'/tiles.json',
|
|
92
|
+
);
|
|
84
93
|
const $ = (id) => document.getElementById(id);
|
|
85
94
|
|
|
86
95
|
const fail = (message) => {
|
|
@@ -104,7 +113,7 @@
|
|
|
104
113
|
const vector =
|
|
105
114
|
vectorLayers.length > 0 || /\.(pbf|mvt)(\?|$)/.test(tilejson.tiles?.[0] ?? '');
|
|
106
115
|
|
|
107
|
-
$('name').textContent = tilejson.name ??
|
|
116
|
+
$('name').textContent = tilejson.name ?? tileJsonUrl;
|
|
108
117
|
$('summary').textContent =
|
|
109
118
|
`${vector ? 'vector' : 'raster'} Β· z${tilejson.minzoom ?? 0}β${tilejson.maxzoom ?? 14}` +
|
|
110
119
|
(vector
|
package/src/web/public.html
CHANGED
|
@@ -159,6 +159,42 @@
|
|
|
159
159
|
display: block;
|
|
160
160
|
margin-bottom: 1.5rem;
|
|
161
161
|
}
|
|
162
|
+
.toolbar {
|
|
163
|
+
display: flex;
|
|
164
|
+
flex-wrap: wrap;
|
|
165
|
+
gap: 0.6rem 0.9rem;
|
|
166
|
+
align-items: center;
|
|
167
|
+
margin-bottom: 1.5rem;
|
|
168
|
+
}
|
|
169
|
+
.toolbar input[type='search'] {
|
|
170
|
+
flex: 1 1 18rem;
|
|
171
|
+
min-width: 0;
|
|
172
|
+
padding: 0.45rem 0.6rem;
|
|
173
|
+
border: 1px solid var(--line);
|
|
174
|
+
border-radius: 6px;
|
|
175
|
+
background: var(--card);
|
|
176
|
+
color: inherit;
|
|
177
|
+
font: inherit;
|
|
178
|
+
}
|
|
179
|
+
.toolbar label {
|
|
180
|
+
display: inline-flex;
|
|
181
|
+
align-items: center;
|
|
182
|
+
gap: 0.4rem;
|
|
183
|
+
color: var(--muted);
|
|
184
|
+
font-size: 0.9rem;
|
|
185
|
+
}
|
|
186
|
+
.toolbar select {
|
|
187
|
+
padding: 0.35rem 0.4rem;
|
|
188
|
+
border: 1px solid var(--line);
|
|
189
|
+
border-radius: 6px;
|
|
190
|
+
background: var(--card);
|
|
191
|
+
color: inherit;
|
|
192
|
+
font: inherit;
|
|
193
|
+
}
|
|
194
|
+
.count {
|
|
195
|
+
color: var(--muted);
|
|
196
|
+
font-size: 0.85rem;
|
|
197
|
+
}
|
|
162
198
|
</style>
|
|
163
199
|
</head>
|
|
164
200
|
<body>
|
|
@@ -176,6 +212,25 @@
|
|
|
176
212
|
</p>
|
|
177
213
|
</noscript>
|
|
178
214
|
|
|
215
|
+
<div class="toolbar" id="toolbar" hidden>
|
|
216
|
+
<input
|
|
217
|
+
type="search"
|
|
218
|
+
id="filter"
|
|
219
|
+
placeholder="Filter by name or categoryβ¦"
|
|
220
|
+
autocomplete="off"
|
|
221
|
+
spellcheck="false"
|
|
222
|
+
/>
|
|
223
|
+
<label>
|
|
224
|
+
Sort
|
|
225
|
+
<select id="sort">
|
|
226
|
+
<option value="name">name</option>
|
|
227
|
+
<option value="newest">newest first</option>
|
|
228
|
+
<option value="largest">largest first</option>
|
|
229
|
+
</select>
|
|
230
|
+
</label>
|
|
231
|
+
<span class="count" id="count"></span>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
179
234
|
<div id="categories"></div>
|
|
180
235
|
<div id="archives"><p class="empty">Loadingβ¦</p></div>
|
|
181
236
|
|
|
@@ -337,6 +392,9 @@
|
|
|
337
392
|
};
|
|
338
393
|
const ends = entry.endpoints;
|
|
339
394
|
if (ends.tileJson) add(ends.tileJson, 'TileJSON');
|
|
395
|
+
// The newest build's preview, which is what "show me this
|
|
396
|
+
// category" means.
|
|
397
|
+
if (ends.preview) add(ends.preview, 'preview');
|
|
340
398
|
if (ends.torrent) add(ends.torrent, '.torrent');
|
|
341
399
|
if (ends.magnet) add(ends.magnet, 'magnet');
|
|
342
400
|
if (ends.feed) add(ends.feed, 'RSS');
|
|
@@ -407,6 +465,58 @@
|
|
|
407
465
|
});
|
|
408
466
|
};
|
|
409
467
|
|
|
468
|
+
// Held so the filter and the sort re-render from memory rather than
|
|
469
|
+
// asking the node again on every keystroke.
|
|
470
|
+
let loadedArchives = [];
|
|
471
|
+
let loadedCategories = [];
|
|
472
|
+
|
|
473
|
+
const matches = (text, needle) =>
|
|
474
|
+
String(text ?? '')
|
|
475
|
+
.toLowerCase()
|
|
476
|
+
.includes(needle);
|
|
477
|
+
|
|
478
|
+
const sorters = {
|
|
479
|
+
name: (a, b) => String(a.name ?? '').localeCompare(String(b.name ?? '')),
|
|
480
|
+
newest: (a, b) =>
|
|
481
|
+
new Date(b.createdAt ?? 0) - new Date(a.createdAt ?? 0),
|
|
482
|
+
largest: (a, b) => (b.size ?? 0) - (a.size ?? 0),
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// Re-renders both lists against the current filter and sort.
|
|
486
|
+
const apply = () => {
|
|
487
|
+
const needle = (document.getElementById('filter').value ?? '')
|
|
488
|
+
.trim()
|
|
489
|
+
.toLowerCase();
|
|
490
|
+
const order = document.getElementById('sort').value;
|
|
491
|
+
|
|
492
|
+
const archives = loadedArchives
|
|
493
|
+
.filter(
|
|
494
|
+
(archive) =>
|
|
495
|
+
needle === '' ||
|
|
496
|
+
matches(archive.name, needle) ||
|
|
497
|
+
matches(archive.infoHash, needle) ||
|
|
498
|
+
(archive.categories ?? []).some((c) => matches(c, needle)),
|
|
499
|
+
)
|
|
500
|
+
.sort(sorters[order] ?? sorters.name);
|
|
501
|
+
|
|
502
|
+
const categories = loadedCategories.filter(
|
|
503
|
+
(entry) =>
|
|
504
|
+
needle === '' ||
|
|
505
|
+
matches(entry.category, needle) ||
|
|
506
|
+
matches(entry.newest?.name, needle),
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
renderCategories(categories);
|
|
510
|
+
render(archives);
|
|
511
|
+
|
|
512
|
+
const count = document.getElementById('count');
|
|
513
|
+
count.textContent =
|
|
514
|
+
needle === ''
|
|
515
|
+
? `${loadedArchives.length} archives, ${loadedCategories.length} categories`
|
|
516
|
+
: `${archives.length} of ${loadedArchives.length} archives, ` +
|
|
517
|
+
`${categories.length} of ${loadedCategories.length} categories`;
|
|
518
|
+
};
|
|
519
|
+
|
|
410
520
|
const load = async () => {
|
|
411
521
|
let archives = null;
|
|
412
522
|
|
|
@@ -440,7 +550,9 @@
|
|
|
440
550
|
}
|
|
441
551
|
}
|
|
442
552
|
|
|
443
|
-
|
|
553
|
+
loadedArchives = archives.filter((archive) => archive.infoHash);
|
|
554
|
+
document.getElementById('toolbar').hidden = false;
|
|
555
|
+
apply();
|
|
444
556
|
|
|
445
557
|
// /latest/ rather than /api/categories: the same list, without a
|
|
446
558
|
// credential. This is the part most visitors actually want -- an
|
|
@@ -451,13 +563,17 @@
|
|
|
451
563
|
const response = await fetch('/latest/');
|
|
452
564
|
if (response.ok) {
|
|
453
565
|
const body = await response.json();
|
|
454
|
-
|
|
566
|
+
loadedCategories = body.categories ?? [];
|
|
567
|
+
apply();
|
|
455
568
|
}
|
|
456
569
|
} catch {
|
|
457
570
|
// Nothing to say. The archives above are the substance.
|
|
458
571
|
}
|
|
459
572
|
};
|
|
460
573
|
|
|
574
|
+
document.getElementById('filter').addEventListener('input', apply);
|
|
575
|
+
document.getElementById('sort').addEventListener('change', apply);
|
|
576
|
+
|
|
461
577
|
load();
|
|
462
578
|
</script>
|
|
463
579
|
</body>
|