pmtiles-swarm 0.29.0 β†’ 0.31.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 CHANGED
@@ -7,6 +7,69 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.31.0
11
+ ### ✨ Features and improvements
12
+ - **A Recheck files button, for when an archive's progress and its files disagree.** Every figure a
13
+ node can give you about how much of an archive is present is derived from something written down
14
+ earlier: the catalog's `complete` flag, the engine's resume data, the `seedOnly` claim made when
15
+ the torrent was added. When one of those is wrong there is no path back on its own β€” an archive
16
+ built on this node whose entry says `complete: false` is re-added without `seedOnly`, so the
17
+ engine goes looking for bytes that are already under its nose and sits at 0% beside a finished
18
+ file. Every restart reaches the same conclusion.
19
+
20
+ `POST /api/torrents/<infohash>/recheck` hashes every piece against the torrent and the result
21
+ wins. libtorrent does it with `force_recheck` through the sidecar (pmtiles-torrent 0.5.1 or
22
+ newer); qBittorrent has the same operation in its WebUI API. WebTorrent has none β€” it verifies on
23
+ add and never again β€” so it is re-added with the "the data is already here" claim withheld, which
24
+ is reported as `method: "readd"` rather than dressed up as the same mechanism.
25
+
26
+ Answers as soon as the check is under way, because hashing a planet build is tens of minutes and
27
+ no request should be held open for it. The archive reports state `checking` with progress as the
28
+ fraction hashed; progress running backwards during that is the operation working. Nothing is
29
+ deleted, and nothing is written to the catalog β€” the answer arrives where progress always does,
30
+ and the completion sweep already acts on it.
31
+
32
+ With two engines both are asked, since each keeps its own belief about the same file and a stale
33
+ one on the secondary is why a browser peer would find nothing while the primary seeds happily.
34
+
35
+ - _...Add new stuff here..._
36
+
37
+ ### 🐞 Bug fixes
38
+ - _...Add new stuff here..._
39
+
40
+ ## 0.30.0
41
+ ### ✨ Features and improvements
42
+ - **The style URL now carries the `.torrent` URL as well as the magnet.** Piece hashes reach a
43
+ browser only from a peer, over BEP 9 β€” there is no other route to them β€” so a magnet alone
44
+ leaves a page waiting on a tracker connection and a WebRTC handshake before it can read a byte,
45
+ and never gets there at all on a network that blocks the trackers. One ordinary HTTPS request for
46
+ the metainfo removes that dependency, and saves a conventional client the same round trip.
47
+
48
+ Both handles now ride in the fragment of the `styleUrl` on `/api/categories` and `/latest/`, and
49
+ of what the console's **Copy TileJSON URL + swarm** button produces. A fragment is still never
50
+ sent in a request, so an ordinary client fetches the TileJSON and ignores all of it.
51
+
52
+ For a category the `.torrent` handle is category-scoped too β€” `/latest/<category>/archive.torrent`
53
+ redirects to whatever build is current β€” so unlike a plain magnet it does not go stale on the next
54
+ build. That was previously true only where the node publishes a BEP 46 key.
55
+
56
+ **This is a format change.** The fragment used to be a bare `#magnet:?…`; it is now
57
+ `#torrent=…&magnet=…` with both values percent-encoded, because `&` separates them and a magnet
58
+ is full of them. A reader that took the whole fragment for a magnet must read `magnet=` out of it
59
+ β€” `URLSearchParams` does it in one call. The bare form was not kept for the single-handle case: a
60
+ fragment whose shape depends on what happened to be available means every reader has to handle
61
+ both anyway.
62
+
63
+ - **The Added column shows the same date and time on every row.** It used to shorten today's rows
64
+ to a time and older ones to a date, which reads as two different quantities in one column and
65
+ makes the eye stop to work out which it is looking at. Seconds are dropped rather than the date,
66
+ since nothing here is sorted finely enough for them to matter; hovering still gives them.
67
+
68
+ - _...Add new stuff here..._
69
+
70
+ ### 🐞 Bug fixes
71
+ - _...Add new stuff here..._
72
+
10
73
  ## 0.29.0
11
74
  ### ✨ Features and improvements
12
75
  - **A connection indicator in the header, for whether the swarm can reach this node.** A node
package/README.md CHANGED
@@ -60,9 +60,11 @@ day are followed by a dated URL template or by reading a directory listing, on a
60
60
  per source.
61
61
 
62
62
  **Has a console.** Everything above is done from a web UI in the shape of a torrent client:
63
- archives with progress, ratio and expiry; tabbed detail with trackers, peers, HTTP sources and
64
- content; and a settings screen covering monitored folders, watched web locations, remote nodes,
65
- save locations, access tokens and the external-program hooks.
63
+ archives with progress, ratio, expiry and when each was added, searchable and sortable by any of
64
+ them; tabbed detail with trackers, peers, HTTP sources and content; a chart of what the swarm has
65
+ been sending and receiving, kept in SQLite so it survives a restart; an indicator saying whether
66
+ peers can reach this node at all; and a settings screen covering monitored folders, watched web
67
+ locations, remote nodes, save locations, access tokens and the external-program hooks.
66
68
 
67
69
  **Publishes RSS.** `/feed.xml`, and `/feed/<category>.xml` per category. Plain RSS 2.0 with
68
70
  torrent enclosures, so **qBittorrent's built-in RSS auto-downloader can subscribe today** with no
@@ -739,7 +741,7 @@ which the endpoint answers 501.
739
741
 
740
742
  | Method | Path | Purpose |
741
743
  | --------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
742
- | `GET` | `/api/status` | Engine health, counts, watched folders, save locations and free space |
744
+ | `GET` | `/api/status` | Engine health, counts, watched folders, save locations, free space, and whether the swarm can reach this node |
743
745
  | `GET` | `/api/torrents` | Catalog joined with live swarm state and what is left of each seeding limit |
744
746
  | `POST` | `/api/torrents` | Add via `{path}`, `{url}`, `{magnet}`, `{torrentUrl}`, or a raw `.torrent` body |
745
747
  | `DELETE` | `/api/torrents/:infoHash` | Remove (`?deleteData=true` to delete data too) |
@@ -751,6 +753,7 @@ which the endpoint answers 501.
751
753
  | `PATCH` | `/api/torrents/:infoHash/categories` | Set, add or remove categories |
752
754
  | `PATCH` | `/api/torrents/:infoHash/seeding` | Per-archive seeding limit, or "use the global one" |
753
755
  | `PATCH` `GET` | `/api/torrents/:infoHash/location` | Move the data; poll the move |
756
+ | `POST` | `/api/torrents/:infoHash/recheck` | Hash what is on disk again and believe the result β€” for an archive whose progress and whose files disagree |
754
757
  | `POST` | `/api/torrents/:infoHash/pause`, `/resume` | Stop offering it, without forgetting it |
755
758
  | `POST` | `/api/torrents/:infoHash/webseeds` | Add web seeds β€” does not change the infohash |
756
759
  | `POST` | `/api/torrents/:infoHash/warm` | Pre-fetch a region (`GET` for progress, `DELETE` to cancel) |
@@ -255,19 +255,22 @@ remember where it had got to.
255
255
 
256
256
  ### Bootstrapping without the server
257
257
 
258
- A torrent-aware client still has to _learn_ the magnet from somewhere, and until
259
- it does, the swarm β€” the part that depends on no server β€” is unreachable
260
- precisely when the server is down. The fix is that the magnet travels in the
258
+ A torrent-aware client still has to _learn_ where to join from somewhere, and
259
+ until it does, the swarm β€” the part that depends on no server β€” is unreachable
260
+ precisely when the server is down. The fix is that two handles travel in the
261
261
  **fragment** of the TileJSON URL a style already carries:
262
262
 
263
263
  ```
264
- https://swarm.example.org/latest/openmaptiles/tiles.json#magnet:?xs=urn:btpk:…&s=openmaptiles&ws=…
264
+ https://…/latest/openmaptiles/tiles.json#torrent=<the .torrent URL>&magnet=<magnet:?xs=urn:btpk:…>
265
265
  ```
266
266
 
267
267
  A fragment is never sent in an HTTP request, so ordinary clients fetch the
268
- TileJSON and ignore it while a swarm-aware one reads the magnet before making any
269
- call. With a BEP 46 key in it (`xs=urn:btpk:`) rather than an infohash, that
270
- string does not go stale on the next build either. See
268
+ TileJSON and ignore it while a swarm-aware one joins before making any call. The
269
+ `.torrent` is the fast handle β€” a browser can get piece hashes no other way than
270
+ from a peer, so fetching the metainfo over HTTPS is what lets a page start
271
+ without waiting on one β€” and the magnet is the one that needs no server at all.
272
+ With a BEP 46 key in it (`xs=urn:btpk:`) rather than an infohash, neither goes
273
+ stale on the next build. See
271
274
  [serving-tiles.md](serving-tiles.md#a-fragment-that-survives-a-rebuild).
272
275
 
273
276
  ---
@@ -566,6 +566,28 @@ Note what the client address means behind a proxy: without `X-Forwarded-For` it
566
566
  the proxy's own address, which still answers whether a request arrived directly or
567
567
  through it, but not who sent it.
568
568
 
569
+ ### `traffic`
570
+
571
+ What the swarm side has been moving, at `GET /api/traffic` and as the chart in the
572
+ console. Upload and download speed per archive, sampled on a timer.
573
+
574
+ | setting | default | |
575
+ | ----------------------- | ------- | ------------------------- |
576
+ | `traffic.sampleSeconds` | `15` | how finely it looks |
577
+ | `traffic.keepHours` | `168` | how far back it remembers |
578
+
579
+ Two knobs because they are two questions. Unlike `tileStats` this is kept in
580
+ SQLite, in `stats.db` beside the catalog rather than beside the configuration, and
581
+ the difference is deliberate: tile counters answer a question about now and are
582
+ cheap to rebuild by waiting, while a bandwidth history answers a question about
583
+ the past and cannot be rebuilt at all β€” restarting to pick up a new version would
584
+ erase exactly the week somebody wanted to look at.
585
+
586
+ A week of 15-second samples is roughly 40,000 rows per archive, a few megabytes
587
+ for a node carrying twenty. Both settings reload in place. See
588
+ [running-as-a-service.md](running-as-a-service.md#the-statistics-database) for
589
+ sizing and for what a locked-down unit file has to allow.
590
+
569
591
  ## Checking for changed sources
570
592
 
571
593
  `originCheckIntervalSeconds` (default `0`, disabled) controls how often to
@@ -586,8 +608,8 @@ A changed source does not invalidate the existing torrent. See
586
608
 
587
609
  | setting | default | |
588
610
  | --------------------------- | ------- | ------------------------------------------------------ |
589
- | `fetchAttempts` | `10` | how many times to resume a download that stopped early |
590
- | `fetchRetrySeconds` | `5` | how long to wait before resuming |
611
+ | `fetchAttempts` | `10` | consecutive failures without progress before giving up |
612
+ | `fetchRetrySeconds` | `30` | base delay, multiplied by the consecutive count |
591
613
  | `resumeSaveIntervalSeconds` | `300` | how often to write resume data. `0` disables it |
592
614
 
593
615
  A planet archive is hours of transfer, and a connection that drops partway is
@@ -598,6 +620,28 @@ an ETag or Last-Modified to prove the file has not changed underneath. Where it
598
620
  does not, the download restarts, because splicing two builds together produces a
599
621
  torrent for bytes that never existed.
600
622
 
623
+ **The budget counts consecutive failures that moved nothing, not failures.** An
624
+ attempt that transferred bytes proves the source and the route are alive, so it
625
+ resets the count. Counting every failure made the budget a property of the whole
626
+ download rather than of the trouble it is in, which for a large archive are not
627
+ the same thing: a 700 GiB transfer reached 226 GB across six separate stalls and
628
+ then spent its last four attempts on one bad minute, because a quarter of a
629
+ terabyte of progress counted for nothing. Progress is measured against the
630
+ high-water mark rather than the previous attempt, since an attempt can fail
631
+ having written less than was already on disk.
632
+
633
+ The delay grows with the consecutive count β€” at the default, 30s then 60s then
634
+ 90s, so ten of them span something over twenty minutes rather than the
635
+ forty-five seconds a flat delay gave. Because progress resets the count, an
636
+ unlucky download can go round more times than `fetchAttempts` names; that is
637
+ intended, and bounded by a ceiling of ten times the budget so a source dribbling
638
+ a few bytes before dropping cannot retry for ever.
639
+
640
+ **Giving up keeps the partial file.** The staging path is derived from the URL,
641
+ so re-adding the same URL in the console resumes from where it stopped instead of
642
+ starting again, and the error says how many bytes are there to resume from. Only
643
+ cancelling deletes it.
644
+
601
645
  Resume data is what lets a restart skip re-hashing the store β€” on an 800 GB
602
646
  archive, the difference between instant and half an hour. A clean stop always
603
647
  writes it; `resumeSaveIntervalSeconds` is for the stops that are not clean, where
package/docs/engines.md CHANGED
@@ -223,6 +223,35 @@ unforwarded NAT can never connect to each other, so the ones that need you most
223
223
  cannot serve. UPnP and NAT-PMP are on by default in both engines and will often open it for you;
224
224
  a router with either disabled will not say so.
225
225
 
226
+ ### The console says whether it worked
227
+
228
+ Nothing about a node's own traffic reveals that half the swarm cannot reach it. It dials out, its
229
+ transfers work, and it looks healthy β€” the cost is invisible and permanent. So the console header
230
+ carries an indicator, also in `GET /api/status` as `reachability`:
231
+
232
+ | Colour | State | Means |
233
+ | ------ | ---------- | ------------------------------------------------------ |
234
+ | green | `open` | something has connected inward β€” the port is reachable |
235
+ | amber | `unproven` | listening, and nothing ever has |
236
+ | red | `offline` | not listening at all |
237
+ | hidden | `unknown` | the engine cannot answer |
238
+
239
+ libtorrent answers from `net.has_incoming_connections`; WebTorrent has no such gauge, so it is
240
+ assembled from the wires, each of which carries the direction it was made in. Both latch: the
241
+ question is whether the swarm _can_ reach this node, not whether somebody is connected right now,
242
+ so a reachable node that is merely quiet stays green rather than dropping to amber when its last
243
+ peer leaves.
244
+
245
+ Reported per engine, not blended. Two engines means two listening ports, forwarded separately, and
246
+ one can be reachable while the other is not; the header shows the primary and names both on hover.
247
+
248
+ **Amber is not a fault.** On a node no peer has tried, blocked and untried are the same
249
+ observation, and nothing available separates them β€” which is why it reads "no incoming yet" rather
250
+ than "firewalled". On a busy node it will turn green within minutes; if it does not, the port is
251
+ worth checking. For the same reason an engine that cannot be asked hides the indicator instead of
252
+ showing red: not being able to ask is not the same as being unreachable. The libtorrent side needs
253
+ pmtiles-torrent 0.5.0 or newer.
254
+
226
255
  ### Every libtorrent network setting
227
256
 
228
257
  ```json
@@ -315,6 +344,43 @@ Trackers live outside the torrent's `info` dictionary, so adding them **does not
315
344
  infohash** β€” but it only applies to torrents created after the change. An existing archive keeps
316
345
  announcing where its own torrent says to.
317
346
 
347
+ ## Rechecking what is on disk
348
+
349
+ Every figure a node can give you about how much of an archive is present is derived from something
350
+ written down earlier: the catalog's `complete` flag, the engine's resume data, the `seedOnly` claim
351
+ made when the torrent was added. When one of those is wrong there is no path back on its own β€” the
352
+ archive sits at 0% beside a finished file, downloading bytes it already has, and every restart
353
+ repeats the same conclusion.
354
+
355
+ **Recheck files** in the console (`POST /api/torrents/<infohash>/recheck`) is the one operation
356
+ that goes and looks. It hashes every piece against the torrent and the result wins.
357
+
358
+ | Engine | How |
359
+ | ----------- | --------------------------------------------------------------- |
360
+ | libtorrent | `force_recheck`, via the sidecar. Needs pmtiles-torrent β‰₯ 0.5.1 |
361
+ | qBittorrent | `POST /api/v2/torrents/recheck` β€” the same library underneath |
362
+ | WebTorrent | no such operation; re-added with `seedOnly` withheld instead |
363
+
364
+ It answers as soon as the check is under way. Hashing a planet build is tens of minutes, which is
365
+ longer than any request should be held open for, so the archive reports state `checking` with
366
+ progress as the fraction hashed. **Progress running backwards during that is the operation
367
+ working**, not a fault. Nothing is deleted at any point.
368
+
369
+ WebTorrent's route is cruder and is reported as `method: "readd"` rather than dressed up as the
370
+ same thing: it verifies on add and never again, so the only way to make it look is to add the
371
+ torrent a second time with the "the data is already here" claim withheld. It also refuses on a
372
+ paused archive, since a re-add would start it.
373
+
374
+ Nothing is written to the catalog when the check begins. The answer arrives where progress always
375
+ does β€” the engine's own figures, which the completion sweep already reads. Note the one thing this
376
+ does not repair on its own: the sweep promotes and never demotes, so a recheck finding _less_ than
377
+ the record claims shows the truth in the console but leaves `complete: true` in place. Demoting on
378
+ a progress figure would strip the finished name off any archive that happened to be mid-check.
379
+
380
+ With two engines both are asked. Each keeps its own belief about the same file, so a stale one on
381
+ the secondary is why a browser peer finds nothing while the primary seeds happily. A secondary that
382
+ cannot check is reported and does not fail the operation; the primary failing does.
383
+
318
384
  ## Marking incomplete files
319
385
 
320
386
  An archive that is not whole yet is written under a marked name and renamed when
@@ -346,7 +412,10 @@ checked and the unreadable ones are joined by magnet instead.
346
412
 
347
413
  ## Writing another engine
348
414
 
349
- Implement `connect`, `add`, `remove`, `list`, `get`, `destroy`, and optionally `peers`.
415
+ Implement `connect`, `add`, `remove`, `list`, `get`, `destroy`, and optionally `peers`,
416
+ `reachability` and `recheck`. Anything optional that is missing is simply not offered β€” an engine
417
+ without `reachability` hides the indicator rather than reporting a node unreachable, and one
418
+ without `recheck` is rechecked by re-adding instead.
350
419
  The interface is deliberately small; see
351
420
  [`src/engines/types.js`](../src/engines/types.js) for the contract and
352
421
  [`src/engines/webtorrent.js`](../src/engines/webtorrent.js) for the shortest example.
@@ -231,10 +231,15 @@ rather than exceptional. Each attempt continues from the bytes already on disk w
231
231
  HTTP range request, so a drop costs the retry delay instead of everything transferred so
232
232
  far.
233
233
 
234
+ The budget counts _consecutive_ failures that moved nothing: an attempt that transferred
235
+ bytes proves the source and the route are alive, so it resets the count. The delay grows
236
+ with that count β€” 30s, 60s, 90s β€” so ten of them span an outage rather than a moment. When
237
+ it does give up the partial file is kept, and re-adding the same URL resumes from it.
238
+
234
239
  ```json
235
240
  {
236
241
  "fetchAttempts": 10,
237
- "fetchRetrySeconds": 5
242
+ "fetchRetrySeconds": 30
238
243
  }
239
244
  ```
240
245
 
@@ -566,6 +566,15 @@ own, and not `torrentPort`. `6883` sits clear of both.
566
566
  Note that each engine runs its own DHT as well, so a node with both engines has
567
567
  three UDP participants. Only this one is yours to place.
568
568
 
569
+ ### Checking the forward actually happened
570
+
571
+ A forward that did not take is invisible from here: the node still dials out, its
572
+ transfers still work, and nothing in its own numbers says half the swarm can
573
+ never open a connection to it. The console header answers it β€” green once
574
+ something has connected inward, amber while nothing has, red if the engine is not
575
+ listening at all. Give a new node a few minutes of seeding before reading it;
576
+ amber on a node no peer has tried yet means untried, not blocked.
577
+
569
578
  ## Updating
570
579
 
571
580
  ```sh
@@ -192,20 +192,20 @@ Both peer-to-peer engines reuse the client already seeding the archive rather
192
192
  than starting a second one. One peer pool, one port, one DHT node β€” and for
193
193
  libtorrent, one sidecar process.
194
194
 
195
- ## Carrying the magnet in the URL fragment
195
+ ## Carrying the swarm in the URL fragment
196
196
 
197
197
  The `torrent` block below solves the problem _after_ the TileJSON has been
198
198
  fetched. It does not solve the one before it: a torrent-aware client that cannot
199
199
  reach this server has nothing to work with, so the swarm β€” the part that does
200
200
  not depend on any server β€” is unreachable precisely when the server is down.
201
201
 
202
- The fix is to put the magnet in the URL **fragment**:
202
+ The fix is to put the ways into the swarm in the URL **fragment**:
203
203
 
204
204
  ```json
205
205
  "sources": {
206
206
  "openmaptiles": {
207
207
  "type": "vector",
208
- "url": "https://swarm.example.org/latest/openmaptiles/tiles.json#magnet:?xt=urn:btih:4813a0e6…&dn=…&tr=…&ws=…"
208
+ "url": "https://swarm.example.org/latest/openmaptiles/tiles.json#torrent=https%3A%2F%2Fswarm.example.org%2Flatest%2Fopenmaptiles%2Farchive.torrent&magnet=magnet%3A%3Fxt%3Durn%3Abtih%3A4813a0e6…"
209
209
  }
210
210
  }
211
211
  ```
@@ -213,34 +213,72 @@ The fix is to put the magnet in the URL **fragment**:
213
213
  A fragment is never sent in an HTTP request, so the same string works
214
214
  everywhere:
215
215
 
216
- | Client | What happens |
217
- | --------------------------------- | --------------------------------------------------------------------------------- |
218
- | maplibre-gl-js, Leaflet, anything | fetches the TileJSON, ignores the fragment |
219
- | maplibre-native without a plugin | the same |
220
- | torrent-aware | reads the magnet **before any network call**, and still has it if the fetch fails |
216
+ | Client | What happens |
217
+ | --------------------------------- | ------------------------------------------------------------------- |
218
+ | maplibre-gl-js, Leaflet, anything | fetches the TileJSON, ignores the fragment |
219
+ | maplibre-native without a plugin | the same |
220
+ | torrent-aware | joins **before any network call**, and still can if the fetch fails |
221
221
 
222
- The console's **Copy TileJSON URL + magnet** button produces exactly this.
222
+ The console's **Copy TileJSON URL + swarm** button produces exactly this, and so
223
+ does the `styleUrl` on every row of `/api/categories` and `/latest/`.
223
224
 
224
- A magnet needs no encoding in a fragment β€” RFC 3986 allows `?`, `&`, `=` and `:`
225
- there β€” and leaving it readable matters for something people paste into a style
226
- file by hand.
225
+ ### Two handles, and why both
226
+
227
+ They are not a ladder from worse to better. They fail in different directions.
228
+
229
+ | Handle | Needs | Gets you |
230
+ | ---------- | --------------- | --------------------------------------------------- |
231
+ | `torrent=` | this host | the metainfo itself, over one ordinary HTTP request |
232
+ | `magnet=` | a peer, no host | everything, eventually, from the swarm alone |
233
+
234
+ `torrent=` is not redundant with the URL it is attached to. **Piece hashes reach
235
+ a browser only from a peer, over BEP 9** β€” there is no other route to them β€” so
236
+ a magnet alone leaves a page waiting on a tracker connection and a WebRTC
237
+ handshake before it can read a byte, and never gets there at all on a network
238
+ that blocks the trackers. Fetching the metainfo over HTTPS removes that
239
+ dependency entirely. It also saves a conventional client the same round trip.
240
+
241
+ `magnet=` is the handle that needs nothing of this node, which is the case the
242
+ fragment exists for in the first place. A client with a DHT should prefer it if
243
+ this host is unreachable; a browser, which has neither DHT nor UDP, should reach
244
+ for the `.torrent` first.
245
+
246
+ For a `/latest/<category>/` URL the `.torrent` handle points at the category too,
247
+ so it redirects to whatever build is current and does not go stale β€” see the
248
+ caveat below, which it answers for the half of the fragment that a BEP 46 magnet
249
+ otherwise has to.
250
+
251
+ Both are percent-encoded, since `&` separates them and a magnet is full of them.
252
+ `URLSearchParams` reads the fragment; `get('magnet')` gives back the magnet
253
+ exactly.
254
+
255
+ > **This changed in 0.30.0.** The fragment used to be a bare `#magnet:?…` with
256
+ > nothing else in it. A reader that took the whole fragment for a magnet needs to
257
+ > read `magnet=` out of it now. The bare form was not kept for the single-handle
258
+ > case, because a fragment whose shape depends on what happened to be available
259
+ > means every reader has to handle both anyway.
227
260
 
228
261
  ### What a client should do with it
229
262
 
230
- Three paths, each a strict fallback of the one above:
263
+ Four paths, each a fallback of the one above:
231
264
 
232
265
  1. **The TileJSON URL.** One request, the full document including
233
266
  `vector_layers`. Fastest, and what an ordinary client does anyway.
234
- 2. **The `ws=` web seed.** Two HTTP range requests β€” the header and root
235
- directory near the start of the archive, the JSON metadata at the far end β€”
236
- and the TileJSON can be derived from them. Works when this API is down but
237
- the file is still on a web server, and it is the same order of cost as (1).
238
- 3. **The swarm.** No HTTP at all. Slowest from cold, because BEP 9 has to
267
+ 2. **The `torrent=` metainfo.** One request, and it yields piece hashes, the
268
+ file name and the web seeds β€” enough to join and to range-read without any
269
+ peer having spoken yet. Works when the TileJSON route is down but this host
270
+ is up, and it is the only one of these a browser can use to start quickly.
271
+ 3. **The `ws=` web seed** inside the magnet. Two HTTP range requests β€” the header
272
+ and root directory near the start of the archive, the JSON metadata at the far
273
+ end β€” and the TileJSON can be derived from them. Works when this API is down
274
+ but the file is still on a web server.
275
+ 4. **The swarm.** No HTTP at all. Slowest from cold, because BEP 9 has to
239
276
  deliver the metainfo first, and the only one that survives the server
240
277
  disappearing entirely.
241
278
 
242
- Everything those need is in the magnet: `xt` identifies the archive, `dn` names
243
- the file, `tr` finds peers, `ws` gives the HTTP fallback.
279
+ Everything those need is in the two handles: `xt` identifies the archive, `dn`
280
+ names the file, `tr` finds peers, `ws` gives the HTTP fallback, and `torrent=`
281
+ gives the metainfo without asking the swarm for it.
244
282
 
245
283
  ### One caveat on `/latest/` URLs
246
284
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.29.0",
3
+ "version": "0.31.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
@@ -66,7 +66,43 @@ function route(handler) {
66
66
  * @returns {import('express').Express} - The configured app.
67
67
  */
68
68
  /**
69
- * A TileJSON URL carrying a magnet in its fragment.
69
+ * Attaches whatever swarm handles exist to a URL, in its fragment.
70
+ *
71
+ * Two handles, and they are not a ladder from worse to better β€” they fail in
72
+ * different directions:
73
+ *
74
+ * - `torrent=` is the metainfo, and needs this host. Not redundant with the URL
75
+ * it is attached to: piece hashes reach a browser only from a peer over
76
+ * BEP 9, so this is the one handle that works from a page on a network that
77
+ * blocks the trackers, and the one that saves every client the metadata round
78
+ * trip that a magnet costs.
79
+ * - `magnet=` needs no host at all, and needs a peer. The better handle for a
80
+ * client with a DHT, the weaker one for a browser, which has neither DHT nor
81
+ * UDP.
82
+ *
83
+ * Ordered by what a client should reach for first when it can fetch.
84
+ *
85
+ * Both are percent-encoded, which is a change from the bare `#magnet:?…` this
86
+ * used to emit: `&` separates the handles now, and a magnet is full of them.
87
+ * That does break a reader that took the whole fragment for a magnet, and the
88
+ * alternative -- keeping the bare form when there is only one handle -- is
89
+ * worse, because it makes the shape of the fragment depend on what happened to
90
+ * be available, so every reader has to handle both anyway. One shape,
91
+ * `URLSearchParams` reads it, and a magnet survives the round trip exactly.
92
+ *
93
+ * @param {string} url - The URL a style points at.
94
+ * @param {object} handles - `{torrent, magnet}`, either of which may be absent.
95
+ * @returns {string} - The URL, with a fragment if there is anything to put in one.
96
+ */
97
+ function withSwarmHandles(url, { torrent, magnet }) {
98
+ const parts = [];
99
+ if (torrent) parts.push(`torrent=${encodeURIComponent(torrent)}`);
100
+ if (magnet) parts.push(`magnet=${encodeURIComponent(magnet)}`);
101
+ return parts.length > 0 ? `${url}#${parts.join('&')}` : url;
102
+ }
103
+
104
+ /**
105
+ * A TileJSON URL carrying the ways into the swarm in its fragment.
70
106
  * @param {string} category - Which category.
71
107
  * @param {object} newest - Its newest entry.
72
108
  * @param {string} base - Public base URL.
@@ -90,7 +126,14 @@ function styleUrlFor(category, newest, base) {
90
126
  webSeeds: newest.webSeeds,
91
127
  })
92
128
  : newest?.magnet;
93
- return magnet ? `${url}#${magnet}` : url;
129
+ // Category-scoped rather than the newest build's immutable URL, for the same
130
+ // reason the TileJSON in front of it is: it redirects to whatever is current,
131
+ // so a style holding this keeps working across rebuilds. The magnet beside it
132
+ // only manages that when the node publishes a BEP 46 key; this handle manages
133
+ // it always, which makes it the more durable half of the pair as well as the
134
+ // faster one.
135
+ const torrent = `${base}/latest/${category}/archive.torrent`;
136
+ return withSwarmHandles(url, { torrent, magnet });
94
137
  }
95
138
 
96
139
  export function createApp({
@@ -997,6 +1040,21 @@ export function createApp({
997
1040
  }),
998
1041
  );
999
1042
 
1043
+ // When the record and the disk disagree, this is the only thing that goes and
1044
+ // looks. Answers as soon as the check is under way -- hashing a planet build
1045
+ // is tens of minutes, and no request should be held open for it.
1046
+ app.post(
1047
+ '/api/torrents/:infoHash/recheck',
1048
+ route(async (req, res) => {
1049
+ try {
1050
+ const result = await library.recheck(req.params.infoHash);
1051
+ res.status(202).json(result);
1052
+ } catch (error) {
1053
+ res.status(error.status ?? 500).json({ error: error.message });
1054
+ }
1055
+ }),
1056
+ );
1057
+
1000
1058
  // Joining defaults to cache, deliberately. This is how that is changed
1001
1059
  // afterwards, without re-adding the archive by hand.
1002
1060
  app.patch(
@@ -1879,11 +1937,12 @@ export function createApp({
1879
1937
  servable,
1880
1938
  endpoints: {
1881
1939
  tileJson: servable ? `${base}/latest/${category}/tiles.json` : null,
1882
- // The same URL with a magnet in the fragment, which is what a
1883
- // style should carry. A fragment is never sent in a request, so
1884
- // an ordinary client fetches the TileJSON and ignores it, while
1885
- // a swarm-aware one has the magnet before it makes any call --
1886
- // and can therefore still start when this server cannot answer.
1940
+ // The same URL with the ways into the swarm in the fragment,
1941
+ // which is what a style should carry. A fragment is never sent in
1942
+ // a request, so an ordinary client fetches the TileJSON and
1943
+ // ignores it, while a swarm-aware one has somewhere to join before
1944
+ // it makes any call -- and can therefore still start when this
1945
+ // server cannot answer.
1887
1946
  //
1888
1947
  // The mutable magnet where there is one, because a category is
1889
1948
  // precisely where an infohash goes stale: it names the category
@@ -549,6 +549,50 @@ export class CompositeEngine {
549
549
  return this.#primary.resume ? this.#primary.resume(infoHash) : false;
550
550
  }
551
551
 
552
+ /**
553
+ * Hashes what is on disk again, on every engine that can.
554
+ *
555
+ * Each engine keeps its own belief about the same file, so a stale one on the
556
+ * secondary is the same fault as a stale one on the primary -- it is why a
557
+ * browser peer would find nothing while the primary seeds happily. Both are
558
+ * asked.
559
+ *
560
+ * They hash concurrently, which sounds worse than it is: the second pass over
561
+ * a file the first just read is mostly page cache, and a recheck is a rare
562
+ * thing somebody asked for rather than something that runs on a timer.
563
+ *
564
+ * An engine without the operation is skipped rather than worked around. The
565
+ * re-add that WebTorrent needs instead is the library's to do, because it
566
+ * takes the catalog entry -- see Library.recheck.
567
+ * @param {string} infoHash - The archive to verify.
568
+ * @returns {Promise<object>} - The primary's answer, plus one row per engine.
569
+ */
570
+ async recheck(infoHash) {
571
+ const engines = [];
572
+ for (const engine of [this.#primary, ...this.#secondaries]) {
573
+ if (!engine.recheck) {
574
+ engines.push({ engine: engine.name, rechecking: false, skipped: true });
575
+ continue;
576
+ }
577
+ try {
578
+ const result = await engine.recheck(infoHash);
579
+ engines.push({ engine: engine.name, ...result });
580
+ } catch (error) {
581
+ // A secondary that cannot verify must not fail the primary's check.
582
+ // The primary holds the data the tiles are served from.
583
+ engines.push({
584
+ engine: engine.name,
585
+ rechecking: false,
586
+ error: error.message,
587
+ });
588
+ }
589
+ }
590
+
591
+ const primary = engines[0];
592
+ if (primary.error) throw new Error(primary.error);
593
+ return { ...primary, engines };
594
+ }
595
+
552
596
  /**
553
597
  * Tells every engine about a web seed.
554
598
  * @param {string} infoHash - The archive.
@@ -303,6 +303,33 @@ export class LibtorrentEngine {
303
303
  }
304
304
  }
305
305
 
306
+ /**
307
+ * Hashes what is on disk again and believes the result over the record.
308
+ *
309
+ * Returns as soon as the check is under way, not when it finishes: a planet
310
+ * archive is tens of minutes of disk. The torrent reports state `checking`
311
+ * while it runs, with progress as the fraction hashed.
312
+ * @param {string} infoHash - The archive to verify.
313
+ * @returns {Promise<object>} - `{rechecking, wasPaused}`.
314
+ */
315
+ async recheck(infoHash) {
316
+ try {
317
+ return await this.#call('recheck', { infoHash });
318
+ } catch (error) {
319
+ // An older sidecar answers "unknown op", which is true and useless: it
320
+ // reads as a bug in the request rather than as a package that needs
321
+ // updating. Said plainly instead, because this is a button somebody just
322
+ // pressed and the next thing they do depends on which it is.
323
+ if (/unknown op/i.test(error.message)) {
324
+ throw new Error(
325
+ 'this sidecar cannot recheck; pmtiles-torrent 0.5.1 or newer is needed',
326
+ { cause: error },
327
+ );
328
+ }
329
+ throw error;
330
+ }
331
+ }
332
+
306
333
  async list() {
307
334
  // A node that is shutting down still has a console polling it and a sweep
308
335
  // or two in flight. Answering "the sidecar exited" to each of them fills
@@ -211,6 +211,20 @@ export class QBittorrentEngine {
211
211
  await this.#request('/api/v2/torrents/delete', { method: 'POST', body });
212
212
  }
213
213
 
214
+ /**
215
+ * Hashes what is on disk again and believes the result over the record.
216
+ *
217
+ * qBittorrent starts the check and answers immediately, the same as the
218
+ * libtorrent engine does -- which is the same library underneath.
219
+ * @param {string} infoHash - The archive to verify.
220
+ * @returns {Promise<object>} - `{rechecking: true}`.
221
+ */
222
+ async recheck(infoHash) {
223
+ const body = new URLSearchParams({ hashes: infoHash.toLowerCase() });
224
+ await this.#request('/api/v2/torrents/recheck', { method: 'POST', body });
225
+ return { rechecking: true };
226
+ }
227
+
214
228
  /**
215
229
  * Lists every torrent qBittorrent holds.
216
230
  * @returns {Promise<import('./types.js').TorrentStatus[]>} - Normalised statuses.
@@ -60,6 +60,7 @@
60
60
  * @property {(filePath: string, options?: object) => Promise<object>} [createTorrent] - Builds a torrent from a local file, where the engine can do it better than the default β€” libtorrent produces hybrid v1+v2, which create-torrent cannot.
61
61
  * @property {(infoHash: string) => Promise<object[]>} [trackerStatus] - Per-tracker announce results, where the engine keeps them.
62
62
  * @property {(infoHash: string) => Promise<Uint8Array | null>} [metadata] - The torrent's metainfo once known, so an archive joined by magnet can be written down rather than re-fetched over BEP 9 on every start.
63
+ * @property {(infoHash: string) => Promise<object>} [recheck] - Hash what is on disk again and believe the result over the stored state -- resume data, or the `seedOnly` claim made when the torrent was added. Returns once the check is under way, not when it finishes: a planet archive is tens of minutes of disk. An engine without this is rechecked by re-adding it instead, which the library does.
63
64
  * @property {() => Promise<object | null>} [reachability] - Whether peers can open a connection to this node, or only the reverse. Three states: `open` (something has connected inward), `unproven` (listening, but nothing ever has) and `offline` (not listening at all). The middle is deliberately not called firewalled -- on a node with no peers, blocked and untried are the same observation.
64
65
  * @property {() => Promise<void>} destroy - Releases resources.
65
66
  */
package/src/library.js CHANGED
@@ -1657,6 +1657,68 @@ export class Library {
1657
1657
  return this.#catalog.put({ infoHash, paused: true });
1658
1658
  }
1659
1659
 
1660
+ /**
1661
+ * Hashes what is on disk again and believes the result over the record.
1662
+ *
1663
+ * Every other answer about how much of an archive is here comes from
1664
+ * something written down earlier -- the catalog's `complete` flag, resume
1665
+ * data, the `seedOnly` claim made when it was added. When one of those is
1666
+ * wrong there is no path back on its own: an archive built here whose entry
1667
+ * says `complete: false` is re-added without `seedOnly`, so the engine goes
1668
+ * looking for bytes that are already under its nose and sits at 0% beside a
1669
+ * finished file. This is the way out.
1670
+ *
1671
+ * Nothing is written to the catalog here. The check runs for as long as it
1672
+ * takes to hash the archive -- tens of minutes for a planet build -- so the
1673
+ * answer arrives long after this returns, and it arrives where it always
1674
+ * does: the engine's own progress, which the completion sweep already reads
1675
+ * and acts on. Recording a guess now would only have to be corrected later.
1676
+ *
1677
+ * Note what this cannot fix on its own. The sweep promotes, it never demotes,
1678
+ * so a recheck that finds *less* than the record claims shows the truth in
1679
+ * the console but leaves `complete: true` in place. That is deliberate: a
1680
+ * torrent reports progress below 1 for perfectly ordinary reasons while it is
1681
+ * checking, and demoting on that would strip the finished name off an archive
1682
+ * that is merely being verified.
1683
+ * @param {string} infoHash - The archive to verify.
1684
+ * @returns {Promise<object>} - `{rechecking, method}`.
1685
+ */
1686
+ async recheck(infoHash) {
1687
+ const entry = this.#catalog.get(infoHash);
1688
+ if (!entry) {
1689
+ const error = new Error('unknown archive');
1690
+ error.status = 404;
1691
+ throw error;
1692
+ }
1693
+
1694
+ if (this.#engine.recheck) {
1695
+ const result = await this.#engine.recheck(infoHash);
1696
+ return { ...result, rechecking: true, method: 'recheck' };
1697
+ }
1698
+
1699
+ // WebTorrent has no recheck at all: it verifies on add and never again. So
1700
+ // the only way to make it look is to make it add the torrent again, with
1701
+ // the claim that the data is already there withheld -- `seedOnly` is
1702
+ // precisely "do not verify this", and it is computed from `complete`, so a
1703
+ // copy of the entry saying otherwise is what turns the check on.
1704
+ //
1705
+ // Nothing is deleted and the catalog is not touched; this re-adds the same
1706
+ // torrent against the same files. It is a slower and cruder mechanism than
1707
+ // force_recheck, and it is reported as a different one rather than dressed
1708
+ // up as the same thing.
1709
+ if (entry.paused) {
1710
+ const error = new Error(
1711
+ 'this engine rechecks by re-adding the archive, which a paused ' +
1712
+ 'archive cannot do. Resume it first.',
1713
+ );
1714
+ error.status = 409;
1715
+ throw error;
1716
+ }
1717
+ await this.#engine.remove(infoHash, { deleteData: false }).catch(() => {});
1718
+ await this.#readd({ ...entry, complete: false });
1719
+ return { rechecking: true, method: 'readd' };
1720
+ }
1721
+
1660
1722
  /**
1661
1723
  * Starts offering a paused archive again.
1662
1724
  * @param {string} infoHash - The archive.
@@ -749,11 +749,14 @@
749
749
  const pct = (n) => `${Math.round((n ?? 0) * 100)}%`;
750
750
 
751
751
  /**
752
- * When an archive was added, as something short enough for a column.
752
+ * When an archive was added: the same date and time on every row.
753
753
  *
754
- * A date on its own for anything older than today, and a time for
755
- * today: the question a list answers is "which of these is recent", and
756
- * a full timestamp on every row is harder to scan than either.
754
+ * It used to shorten today's rows to a time and older ones to a date,
755
+ * on the theory that a column is easier to scan that way. It is not:
756
+ * a column of `8/13/2026` with an `08:14 AM` in the middle of it reads
757
+ * as two different quantities, and the eye has to stop and work out
758
+ * which. Seconds are dropped rather than the date, since nothing here
759
+ * is sorted finely enough for them to matter; the title keeps them.
757
760
  * @param {string} value - ISO timestamp.
758
761
  * @returns {string} - Markup for the cell.
759
762
  */
@@ -761,12 +764,15 @@
761
764
  if (!value) return '<span class="sub">β€”</span>';
762
765
  const when = new Date(value);
763
766
  if (Number.isNaN(when.getTime())) return '<span class="sub">β€”</span>';
764
- const today = new Date().toDateString() === when.toDateString();
765
- return `<span title="${escapeHtml(when.toLocaleString())}">${
766
- today
767
- ? when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
768
- : when.toLocaleDateString()
769
- }</span>`;
767
+ return `<span title="${escapeHtml(when.toLocaleString())}">${escapeHtml(
768
+ when.toLocaleString([], {
769
+ year: 'numeric',
770
+ month: 'numeric',
771
+ day: 'numeric',
772
+ hour: '2-digit',
773
+ minute: '2-digit',
774
+ }),
775
+ )}</span>`;
770
776
  };
771
777
 
772
778
  // The same three questions the public page asks, against an admin row.
@@ -1563,7 +1569,7 @@
1563
1569
  <button id="copy-magnet">Copy magnet</button>
1564
1570
  <button id="copy-hash">Copy infohash</button>
1565
1571
  ${servable ? `<button id="copy-tilejson">Copy TileJSON URL</button>
1566
- <button id="copy-tilejson-swarm" title="The same URL with the magnet in the fragment. A fragment is never sent to the server, so ordinary clients fetch the TileJSON exactly as before; a torrent-aware client reads the magnet from it and can still join the swarm when this server is unreachable.">Copy TileJSON URL + magnet</button>
1572
+ <button id="copy-tilejson-swarm" title="The same URL with the .torrent and the magnet in the fragment. A fragment is never sent to the server, so ordinary clients fetch the TileJSON exactly as before; a torrent-aware client reads them from it, and a browser can fetch the metainfo over HTTP instead of waiting on a peer for it.">Copy TileJSON URL + swarm</button>
1567
1573
  <a href="${tileJson}" target="_blank" rel="noreferrer"><button type="button">Open TileJSON</button></a>
1568
1574
  <a href="${base}/archives/${entry.infoHash}/preview" target="_blank" rel="noreferrer"><button type="button">${
1569
1575
  summary.format === 'pbf' ? 'Inspect' : 'Preview'
@@ -1635,6 +1641,7 @@
1635
1641
  <button id="set-location">Set location…</button>
1636
1642
  <button id="add-seed">Add web seed</button>
1637
1643
  <button id="clear-cache" ${mode === 'cache' ? '' : 'disabled title="only cache-mode archives have a cache to clear"'}>Clear cache</button>
1644
+ <button id="recheck" title="Hash the files on disk again and believe the result. For an archive that reads 0% next to a file that is plainly there, or one that claims to be complete and is not β€” every other figure comes from something written down earlier, and this is the only thing that goes and looks.">Recheck files</button>
1638
1645
  <button id="pause">${entry.paused ? 'Resume' : 'Pause'}</button>
1639
1646
  <button id="remove" class="danger">Remove</button>
1640
1647
  </div>
@@ -1835,20 +1842,30 @@
1835
1842
  $('copy-magnet').onclick = () => copy(entry.magnet ?? '', 'Magnet link');
1836
1843
  if (servable) {
1837
1844
  $('copy-tilejson').onclick = () => copy(tileJson, 'TileJSON URL');
1838
- // The magnet rides in the fragment, which is client-side only and
1845
+ // The handles ride in the fragment, which is client-side only and
1839
1846
  // never sent in the request. So the same string serves both: an
1840
1847
  // ordinary client fetches the TileJSON and ignores the fragment, and
1841
- // a torrent-aware one reads the magnet before making any call at all
1842
- // β€” which is what lets it start when this server is down.
1848
+ // a torrent-aware one has somewhere to join before making any call at
1849
+ // all β€” which is what lets it start when this server is down.
1843
1850
  //
1844
- // A magnet is legal in a fragment unencoded (RFC 3986 allows ?, &, =
1845
- // and : there), and leaving it readable matters for something people
1846
- // paste into a style file by hand.
1847
- $('copy-tilejson-swarm').onclick = () =>
1851
+ // The .torrent URL is there because a browser cannot obtain piece
1852
+ // hashes any other way: they come only from a peer over BEP 9, so a
1853
+ // magnet alone leaves a page waiting on a WebRTC handshake before it
1854
+ // can read a byte. Same fragment, same string, both handles.
1855
+ //
1856
+ // A lone magnet keeps the bare unencoded form β€” legal in a fragment
1857
+ // (RFC 3986 allows ?, &, = and : there) and readable, which matters
1858
+ // for something people paste into a style file by hand.
1859
+ $('copy-tilejson-swarm').onclick = () => {
1860
+ const parts = [`torrent=${encodeURIComponent(torrentUrl)}`];
1861
+ if (entry.magnet) parts.push(`magnet=${encodeURIComponent(entry.magnet)}`);
1848
1862
  copy(
1849
- entry.magnet ? `${tileJson}#${entry.magnet}` : tileJson,
1850
- entry.magnet ? 'TileJSON URL with magnet' : 'TileJSON URL (no magnet on this archive)',
1863
+ `${tileJson}#${parts.join('&')}`,
1864
+ entry.magnet
1865
+ ? 'TileJSON URL with torrent and magnet'
1866
+ : 'TileJSON URL with torrent (no magnet on this archive)',
1851
1867
  );
1868
+ };
1852
1869
  }
1853
1870
  $('copy-hash').onclick = () => copy(entry.infoHash, 'Infohash');
1854
1871
 
@@ -1933,6 +1950,38 @@
1933
1950
  }
1934
1951
  };
1935
1952
 
1953
+ $('recheck').onclick = async () => {
1954
+ if (
1955
+ !window.confirm(
1956
+ `Recheck ${entry.name}.
1957
+
1958
+ Every piece is hashed against the ` +
1959
+ 'torrent, which for a large archive is minutes to tens of ' +
1960
+ 'minutes of disk. Nothing is deleted, and the archive keeps ' +
1961
+ 'serving whatever it can while the check runs.',
1962
+ )
1963
+ ) {
1964
+ return;
1965
+ }
1966
+ try {
1967
+ const result = await api(`/api/torrents/${infoHash}/recheck`, {
1968
+ method: 'POST',
1969
+ });
1970
+ // Said rather than left to be inferred: this returns as soon as the
1971
+ // check is under way, and the progress bar going backwards for the
1972
+ // next twenty minutes is the operation working, not a fault.
1973
+ toast(
1974
+ result.method === 'readd'
1975
+ ? 'rechecking β€” this engine verifies by re-adding, so it will start from 0%'
1976
+ : 'rechecking β€” progress shows the fraction hashed',
1977
+ );
1978
+ refresh();
1979
+ renderDetail(infoHash);
1980
+ } catch (error) {
1981
+ toast(error.message);
1982
+ }
1983
+ };
1984
+
1936
1985
  $('pause').onclick = async () => {
1937
1986
  const action = entry.paused ? 'resume' : 'pause';
1938
1987
  try {
@@ -2572,12 +2621,15 @@
2572
2621
  url.length > 96 ? `${url.slice(0, 96)}…` : url,
2573
2622
  )}</code>${
2574
2623
  label === 'For a style'
2575
- ? `<div class="sub">the magnet rides in the fragment, which is
2576
- never sent to the server β€” an ordinary client fetches the
2577
- TileJSON and ignores it, a swarm-aware one reads it before
2578
- making any call${
2579
- url.includes('xs=urn:btpk')
2580
- ? ' and follows the category rather than this build'
2624
+ ? `<div class="sub">the .torrent URL and the magnet ride in the
2625
+ fragment, which is never sent to the server β€” an ordinary
2626
+ client fetches the TileJSON and ignores them, a swarm-aware
2627
+ one reads them before making any call${
2628
+ // Matched unanchored because the magnet is
2629
+ // percent-encoded in the fragment now that it
2630
+ // shares one with the .torrent URL.
2631
+ url.includes('btpk')
2632
+ ? ', and both follow the category rather than this build'
2581
2633
  : ''
2582
2634
  }</div>`
2583
2635
  : ''
@@ -401,9 +401,9 @@
401
401
  card.append(links);
402
402
 
403
403
  const urls = el('div', 'urls');
404
- // The one worth copying: the TileJSON URL with the magnet in its
405
- // fragment, which a plain client fetches over HTTP and a swarm-aware
406
- // one joins directly.
404
+ // The one worth copying: the TileJSON URL with the .torrent URL and
405
+ // the magnet in its fragment, which a plain client fetches over HTTP
406
+ // and a swarm-aware one joins directly.
407
407
  if (ends.styleUrl) {
408
408
  urls.append(
409
409
  urlRow('style URL', new URL(ends.styleUrl, location.href).href),