pmtiles-swarm 0.30.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,36 @@
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
+
10
40
  ## 0.30.0
11
41
  ### ✨ Features and improvements
12
42
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.30.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
@@ -1040,6 +1040,21 @@ export function createApp({
1040
1040
  }),
1041
1041
  );
1042
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
+
1043
1058
  // Joining defaults to cache, deliberately. This is how that is changed
1044
1059
  // afterwards, without re-adding the archive by hand.
1045
1060
  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/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.
@@ -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 {