pmtiles-swarm 0.30.0 β†’ 0.32.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,66 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.32.0
11
+ ### ✨ Features and improvements
12
+ - _...Add new stuff here..._
13
+
14
+ ### 🐞 Bug fixes
15
+ - **A mutable magnet no longer carries a web seed.** A `ws=` URL names one build; a BEP 46 magnet
16
+ names a series. They disagree the moment the next build is published β€” and not harmlessly, which
17
+ was the part that was easy to miss. `tr=` and `ws=` live outside the info dictionary, so BEP 9
18
+ never replaces them: a client keeps the magnet's copies and merges them into whatever torrent it
19
+ resolved to. So the client that did exactly what the magnet asked β€” resolved the key, landed on
20
+ the current build β€” was handed a web seed for a previous one, and every piece it fetched from
21
+ there failed hash verification until the peer banned it. A client that *ignored* the key and
22
+ joined the pinned `xt=` was fine, because there the infohash and the web seed named the same
23
+ build.
24
+
25
+ Nothing is lost. The metainfo carries the right web seed for whichever build was actually
26
+ resolved, written into `url-list` when that torrent was created, and there are two ways to reach
27
+ it: the `torrent=` handle added to style URLs in 0.30.0, or BEP 9 from any peer. The magnet was
28
+ the third route and the only one that could be wrong.
29
+
30
+ Affects the category style URL, the feed's `pmtiles:mutable`, the TileJSON `torrent.mutable
31
+ .magnet` and the publisher's own magnet. Archives' immutable magnets still carry their web seeds,
32
+ since there `xt` and `ws` cannot drift apart. The parameter was removed from `mutableMagnet`
33
+ outright rather than dropped at each call site, so it cannot be reintroduced by a future caller.
34
+
35
+ **Restyle anything holding one.** A style carrying an older mutable magnet keeps working, but
36
+ carries the stale web seed until it is regenerated.
37
+
38
+ - _...Add new stuff here..._
39
+
40
+ ## 0.31.0
41
+ ### ✨ Features and improvements
42
+ - **A Recheck files button, for when an archive's progress and its files disagree.** Every figure a
43
+ node can give you about how much of an archive is present is derived from something written down
44
+ earlier: the catalog's `complete` flag, the engine's resume data, the `seedOnly` claim made when
45
+ the torrent was added. When one of those is wrong there is no path back on its own β€” an archive
46
+ built on this node whose entry says `complete: false` is re-added without `seedOnly`, so the
47
+ engine goes looking for bytes that are already under its nose and sits at 0% beside a finished
48
+ file. Every restart reaches the same conclusion.
49
+
50
+ `POST /api/torrents/<infohash>/recheck` hashes every piece against the torrent and the result
51
+ wins. libtorrent does it with `force_recheck` through the sidecar (pmtiles-torrent 0.5.1 or
52
+ newer); qBittorrent has the same operation in its WebUI API. WebTorrent has none β€” it verifies on
53
+ add and never again β€” so it is re-added with the "the data is already here" claim withheld, which
54
+ is reported as `method: "readd"` rather than dressed up as the same mechanism.
55
+
56
+ Answers as soon as the check is under way, because hashing a planet build is tens of minutes and
57
+ no request should be held open for it. The archive reports state `checking` with progress as the
58
+ fraction hashed; progress running backwards during that is the operation working. Nothing is
59
+ deleted, and nothing is written to the catalog β€” the answer arrives where progress always does,
60
+ and the completion sweep already acts on it.
61
+
62
+ With two engines both are asked, since each keeps its own belief about the same file and a stale
63
+ one on the secondary is why a browser peer would find nothing while the primary seeds happily.
64
+
65
+ - _...Add new stuff here..._
66
+
67
+ ### 🐞 Bug fixes
68
+ - _...Add new stuff here..._
69
+
10
70
  ## 0.30.0
11
71
  ### ✨ Features and improvements
12
72
  - **The style URL now carries the `.torrent` URL as well as the magnet.** Piece hashes reach a
package/README.md CHANGED
@@ -753,6 +753,7 @@ which the endpoint answers 501.
753
753
  | `PATCH` | `/api/torrents/:infoHash/categories` | Set, add or remove categories |
754
754
  | `PATCH` | `/api/torrents/:infoHash/seeding` | Per-archive seeding limit, or "use the global one" |
755
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 |
756
757
  | `POST` | `/api/torrents/:infoHash/pause`, `/resume` | Stop offering it, without forgetting it |
757
758
  | `POST` | `/api/torrents/:infoHash/webseeds` | Add web seeds β€” does not change the infohash |
758
759
  | `POST` | `/api/torrents/:infoHash/warm` | Pre-fetch a region (`GET` for progress, `DELETE` to cancel) |
package/docs/engines.md CHANGED
@@ -344,6 +344,43 @@ Trackers live outside the torrent's `info` dictionary, so adding them **does not
344
344
  infohash** β€” but it only applies to torrents created after the change. An existing archive keeps
345
345
  announcing where its own torrent says to.
346
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
+
347
384
  ## Marking incomplete files
348
385
 
349
386
  An archive that is not whole yet is written under a marked name and renamed when
@@ -375,9 +412,10 @@ checked and the unreadable ones are joined by magnet instead.
375
412
 
376
413
  ## Writing another engine
377
414
 
378
- Implement `connect`, `add`, `remove`, `list`, `get`, `destroy`, and optionally `peers` and
379
- `reachability`. Anything optional that is missing is simply not offered β€” an engine without
380
- `reachability` hides the indicator rather than reporting a node unreachable.
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.
381
419
  The interface is deliberately small; see
382
420
  [`src/engines/types.js`](../src/engines/types.js) for the contract and
383
421
  [`src/engines/webtorrent.js`](../src/engines/webtorrent.js) for the shortest example.
@@ -303,7 +303,7 @@ that publishes signs a DHT record naming whichever infohash is current, and the
303
303
  magnet then names the **category** rather than a build:
304
304
 
305
305
  ```
306
- magnet:?xs=urn:btpk:<public key>&s=openmaptiles&dn=…&ws=…
306
+ magnet:?xs=urn:btpk:<public key>&s=openmaptiles&dn=…
307
307
  ```
308
308
 
309
309
  No infohash anywhere, so nothing to go stale. A client resolves the record over
@@ -368,7 +368,7 @@ TileJSON documents from this server carry a non-standard `torrent` member:
368
368
  "publicKey": "7680dc95248eb807…",
369
369
  "salt": "openmaptiles",
370
370
  "seq": 1786108931,
371
- "magnet": "magnet:?xs=urn:btpk:7680dc95248eb807…&s=openmaptiles&ws=…"
371
+ "magnet": "magnet:?xs=urn:btpk:7680dc95248eb807…&s=openmaptiles"
372
372
  }
373
373
  }
374
374
  }
package/docs/tilejson.md CHANGED
@@ -77,13 +77,28 @@ serve this block β€” publishing is the only thing the secret is used for.
77
77
  `mutable.magnet` carries **both** identifiers:
78
78
 
79
79
  ```
80
- magnet:?xt=urn:btih:<current build>&xs=urn:btpk:<public key>&dn=…&s=…&ws=…
80
+ magnet:?xt=urn:btih:<current build>&xs=urn:btpk:<public key>&dn=…&s=…
81
81
  ```
82
82
 
83
83
  - `xs=urn:btpk:` is the public key. Resolving it through the DHT gives whichever
84
84
  infohash is current, now and after every future rebuild.
85
85
  - `xt=urn:btih:` is the build that was current when the document was generated.
86
86
 
87
+ **No `ws=`, and the omission is deliberate.** A web seed URL names one build;
88
+ this magnet names a series. They disagree the moment the next build is
89
+ published, and not harmlessly: `tr=` and `ws=` sit outside the info dictionary,
90
+ so BEP 9 never replaces them β€” a client keeps the magnet's copies and merges
91
+ them into whatever torrent it resolved to. A web seed attached to a build it
92
+ does not describe fails hash verification on every piece it serves, until the
93
+ peer banning it stops the waste.
94
+
95
+ Nothing is lost by leaving it out. `webseeds` above is the same list for the
96
+ build this document describes, and the metainfo for whichever build a client
97
+ resolves to carries its own β€” written into `url-list` when that torrent was
98
+ created, and reachable either from the `torrent=` handle in a style fragment or
99
+ over BEP 9 from any peer. The archive's own immutable magnet still carries web
100
+ seeds, because there `xt` and `ws` name the same build and cannot drift apart.
101
+
87
102
  A client reads whichever it understands. A DHT-capable one resolves the key and
88
103
  follows the series indefinitely; one without a DHT joins the infohash and gets a
89
104
  working archive immediately.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.30.0",
3
+ "version": "0.32.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
@@ -123,7 +123,6 @@ function styleUrlFor(category, newest, base) {
123
123
  salt: newest.mutable.salt ?? category,
124
124
  // The category, since that is what this magnet resolves to.
125
125
  name: newest.mutable.salt ?? category,
126
- webSeeds: newest.webSeeds,
127
126
  })
128
127
  : newest?.magnet;
129
128
  // Category-scoped rather than the newest build's immutable URL, for the same
@@ -1040,6 +1039,21 @@ export function createApp({
1040
1039
  }),
1041
1040
  );
1042
1041
 
1042
+ // When the record and the disk disagree, this is the only thing that goes and
1043
+ // looks. Answers as soon as the check is under way -- hashing a planet build
1044
+ // is tens of minutes, and no request should be held open for it.
1045
+ app.post(
1046
+ '/api/torrents/:infoHash/recheck',
1047
+ route(async (req, res) => {
1048
+ try {
1049
+ const result = await library.recheck(req.params.infoHash);
1050
+ res.status(202).json(result);
1051
+ } catch (error) {
1052
+ res.status(error.status ?? 500).json({ error: error.message });
1053
+ }
1054
+ }),
1055
+ );
1056
+
1043
1057
  // Joining defaults to cache, deliberately. This is how that is changed
1044
1058
  // afterwards, without re-adding the archive by hand.
1045
1059
  app.patch(
@@ -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/feed.js CHANGED
@@ -147,7 +147,6 @@ ${
147
147
  infoHash: entry.infoHash,
148
148
  salt: entry.mutable.salt,
149
149
  name: entry.mutable.salt ?? entry.name,
150
- webSeeds: entry.webSeeds,
151
150
  }),
152
151
  )}</pmtiles:mutable>`
153
152
  : ''
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/mutable.js CHANGED
@@ -86,7 +86,6 @@ function rawPublicKey(publicKey) {
86
86
  * @param {string} [options.infoHash] - The build that is current, so a client with no DHT has somewhere to start.
87
87
  * @param {string} [options.name] - Display name for the archive.
88
88
  * @param {string[]} [options.trackers] - Tracker announce URLs.
89
- * @param {string[]} [options.webSeeds] - BEP 19 web seeds.
90
89
  * @param {string} [options.salt] - Salt, when one key publishes several archives.
91
90
  * @returns {string} - A BEP 46 magnet URI.
92
91
  */
@@ -129,12 +128,25 @@ export function mutableMagnet(publicKey, options = {}) {
129
128
  for (const tracker of options.trackers ?? []) {
130
129
  parts.push(`tr=${encodeURIComponent(tracker)}`);
131
130
  }
132
- // Carried because it is what makes a magnet useful with no peers at all: a
133
- // client can range-read the archive over HTTP and still be correct, which is
134
- // the difference between a slow first paint and a blank map.
135
- for (const seed of options.webSeeds ?? []) {
136
- parts.push(`ws=${encodeURIComponent(seed)}`);
137
- }
131
+ // No `ws=`, and the omission is the point.
132
+ //
133
+ // A web seed URL names one build. This magnet names a series, and a client
134
+ // that resolves the key lands on whatever build is current -- so the two
135
+ // disagree the moment the next one is published. They do not disagree
136
+ // harmlessly: `tr=` and `ws=` live outside the info dictionary, so BEP 9
137
+ // never replaces them, and a client keeps the magnet's copies and merges
138
+ // them into whatever torrent it ends up with. The result is a web seed
139
+ // attached to a build it does not describe, failing hash verification on
140
+ // every piece it serves until the peer bans it.
141
+ //
142
+ // Nothing is lost. The metainfo carries the right web seed for whichever
143
+ // build the client actually resolved -- it is written into `url-list` when
144
+ // that build's torrent is created -- and there are two ways to reach it: the
145
+ // `torrent=` handle in the style fragment, or BEP 9 from any peer. This was
146
+ // the third route, and the only one that could be wrong.
147
+ //
148
+ // An immutable magnet is a different case and still carries its web seeds:
149
+ // there, `xt` and `ws` name the same build and cannot drift apart.
138
150
  return parts.join('&');
139
151
  }
140
152
 
package/src/publisher.js CHANGED
@@ -211,7 +211,6 @@ export class MutablePublisher {
211
211
  // with the metadata and overrides it -- so nothing depends on this
212
212
  // beyond being honest about what the magnet identifies.
213
213
  name: category,
214
- webSeeds: entry?.webSeeds,
215
214
  });
216
215
  }
217
216
 
package/src/tilejson.js CHANGED
@@ -113,7 +113,6 @@ function buildTorrentBlock(entry, root) {
113
113
  // The category, not this build: the record resolves to whichever
114
114
  // archive is current, and `dn` is a label the metadata replaces.
115
115
  name: entry.mutable.salt,
116
- webSeeds: entry.webSeeds,
117
116
  }),
118
117
  };
119
118
  }
@@ -1641,6 +1641,7 @@
1641
1641
  <button id="set-location">Set location…</button>
1642
1642
  <button id="add-seed">Add web seed</button>
1643
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>
1644
1645
  <button id="pause">${entry.paused ? 'Resume' : 'Pause'}</button>
1645
1646
  <button id="remove" class="danger">Remove</button>
1646
1647
  </div>
@@ -1949,6 +1950,38 @@
1949
1950
  }
1950
1951
  };
1951
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
+
1952
1985
  $('pause').onclick = async () => {
1953
1986
  const action = entry.paused ? 'resume' : 'pause';
1954
1987
  try {