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 +63 -0
- package/README.md +7 -4
- package/docs/architecture-diagram.md +10 -7
- package/docs/configuration.md +46 -2
- package/docs/engines.md +70 -1
- package/docs/publishing.md +6 -1
- package/docs/running-as-a-service.md +9 -0
- package/docs/serving-tiles.md +58 -20
- package/package.json +1 -1
- package/src/api.js +66 -7
- package/src/engines/composite.js +44 -0
- package/src/engines/libtorrent.js +27 -0
- package/src/engines/qbittorrent.js +14 -0
- package/src/engines/types.js +1 -0
- package/src/library.js +62 -0
- package/src/web/index.html +78 -26
- package/src/web/public.html +3 -3
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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_
|
|
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
|
|
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
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
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
|
---
|
package/docs/configuration.md
CHANGED
|
@@ -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` |
|
|
590
|
-
| `fetchRetrySeconds` | `
|
|
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.
|
package/docs/publishing.md
CHANGED
|
@@ -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":
|
|
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
|
package/docs/serving-tiles.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 |
|
|
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 +
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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 `
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
3. **The
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
1883
|
-
// style should carry. A fragment is never sent in
|
|
1884
|
-
// an ordinary client fetches the TileJSON and
|
|
1885
|
-
// a swarm-aware one has
|
|
1886
|
-
// and can therefore still start when this
|
|
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
|
package/src/engines/composite.js
CHANGED
|
@@ -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.
|
package/src/engines/types.js
CHANGED
|
@@ -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.
|
package/src/web/index.html
CHANGED
|
@@ -749,11 +749,14 @@
|
|
|
749
749
|
const pct = (n) => `${Math.round((n ?? 0) * 100)}%`;
|
|
750
750
|
|
|
751
751
|
/**
|
|
752
|
-
* When an archive was added
|
|
752
|
+
* When an archive was added: the same date and time on every row.
|
|
753
753
|
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
* a
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
:
|
|
769
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1845
|
-
//
|
|
1846
|
-
//
|
|
1847
|
-
|
|
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
|
-
|
|
1850
|
-
entry.magnet
|
|
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
|
|
2576
|
-
never sent to the server β an ordinary
|
|
2577
|
-
TileJSON and ignores
|
|
2578
|
-
making any call${
|
|
2579
|
-
|
|
2580
|
-
|
|
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
|
: ''
|
package/src/web/public.html
CHANGED
|
@@ -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
|
|
405
|
-
// fragment, which a plain client fetches over HTTP
|
|
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),
|