pmtiles-swarm 0.37.0 → 0.37.3

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,59 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.37.3
11
+ ### ✨ Features and improvements
12
+
13
+ ### 🐞 Bug fixes
14
+ - **An archive fetched from a URL is now hashed the same way one already on disk is.** `creator` was
15
+ passed on the local path and nowhere else, so every archive a schedule ever built was hashed
16
+ inside this process — the one serving tiles and the console — rather than in the sidecar's
17
+ one-shot hasher. Four things followed from that one omission: the hash competed with serving,
18
+ it could not be cancelled (`create-torrent` takes no signal, so the add's AbortController reached
19
+ the download and stopped there), it reported nothing while it ran, and it produced a v1 torrent
20
+ rather than a hybrid. An archive arriving from a feed got a lesser torrent, built the slower way,
21
+ than the same file added by path.
22
+ - **A fetched archive now says when it has stopped downloading and started hashing.** The remote
23
+ add's progress callback dropped the `phase` the hasher reports, and its entry carried no phase at
24
+ all, so `runningAdds()` called it `fetching` from beginning to end. With the byte counts equal at
25
+ that point, the row sat at 100% "fetching" for the whole hash — which reads as a transfer that
26
+ completed and then hung, and was reported as exactly that. The bar now hands over to the hash and
27
+ fills again as pieces are read.
28
+
29
+ ## 0.37.2
30
+ ### ✨ Features and improvements
31
+
32
+ ### 🐞 Bug fixes
33
+ - **Pausing an archive now stops it.** Reported from the field: an archive was paused, the row read
34
+ `paused`, and it went on downloading at 8.4 MiB/s. Nothing in the chain refused — the pause was
35
+ asked for, reported as done, and never happened.
36
+
37
+ Three faults in a line. `LibtorrentEngine` had no `pause` or `resume` at all, so there was no way
38
+ to stop a libtorrent torrent from here. `CompositeEngine.pause` answered for its primary alone,
39
+ so an archive held by a secondary reported as not stopped when it was. And `Library.pause` tested
40
+ only that the engine *had* a pause method and threw away what it answered — a composite has one
41
+ whatever its engines can do, so the `false` went into a void, the fallback never ran, and
42
+ `paused: true` went into the catalog regardless. The console prefers that flag to the engine's
43
+ live state, which is why the row said `paused` while Down and Up kept moving.
44
+
45
+ Requires pmtiles-torrent 0.9.0, which adds the `pause` and `resume` the sidecar never had — and
46
+ makes them stick. `handle.pause()` alone is not a stop: libtorrent's auto-manager clears the
47
+ paused flag again within about a second, so pausing that way produces a torrent that describes
48
+ itself as paused while it transfers. That would have reproduced this exact symptom one layer
49
+ deeper.
50
+
51
+ Nothing was left in a bad state by this: because the pause never took effect, no archive was
52
+ half-stopped and no resume data is wrong. They were seeding and downloading throughout.
53
+
54
+ ## 0.37.1
55
+ ### ✨ Features and improvements
56
+ - **Cancel now sits in the row it cancels.** Collected into a bar underneath the list, each button
57
+ had to repeat the whole filename to say which add it stopped — two of those filled a line, and
58
+ pressing the right one meant matching a long name against the list above it. The rows carry their
59
+ own, and an add that cannot be cancelled keeps an empty cell so the columns stay lined up.
60
+
61
+ ### 🐞 Bug fixes
62
+
10
63
  ## 0.37.0
11
64
  ### ✨ Features and improvements
12
65
  - **An archive being hashed can now be cancelled, and says how far through it is.** 0.36.0 moved
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.37.0",
3
+ "version": "0.37.3",
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",
@@ -46,7 +46,7 @@
46
46
  "maplibre-gl": "^6.2.0",
47
47
  "parse-torrent": "^11.0.24",
48
48
  "pmtiles": "^4.4.1",
49
- "pmtiles-torrent": "^0.8.0",
49
+ "pmtiles-torrent": "^0.9.0",
50
50
  "webtorrent": "^3.0.21"
51
51
  },
52
52
  "engines": {
@@ -543,26 +543,40 @@ export class CompositeEngine {
543
543
 
544
544
  /**
545
545
  * Stops offering an archive, everywhere.
546
+ *
547
+ * Answers for the whole composite rather than for the primary alone. An
548
+ * archive can be held by a secondary and not by the primary, and reporting
549
+ * the primary's "no" for it told the caller nothing was stopped when
550
+ * something was -- which is a false negative that costs data, since the
551
+ * caller's answer to a pause it cannot get is to remove the torrent instead.
546
552
  * @param {string} infoHash - The archive.
547
- * @returns {Promise<boolean>} - Whether the primary paused it.
553
+ * @returns {Promise<boolean>} - Whether any engine stopped it.
548
554
  */
549
555
  async pause(infoHash) {
556
+ let stopped = false;
550
557
  for (const engine of this.#secondaries) {
551
- await engine.pause?.(infoHash).catch(() => {});
558
+ stopped = (await engine.pause?.(infoHash).catch(() => false)) || stopped;
552
559
  }
553
- return this.#primary.pause ? this.#primary.pause(infoHash) : false;
560
+ const primary = this.#primary.pause
561
+ ? await this.#primary.pause(infoHash)
562
+ : false;
563
+ return primary || stopped;
554
564
  }
555
565
 
556
566
  /**
557
567
  * Offers it again, everywhere.
558
568
  * @param {string} infoHash - The archive.
559
- * @returns {Promise<boolean>} - Whether the primary resumed it.
569
+ * @returns {Promise<boolean>} - Whether any engine started it.
560
570
  */
561
571
  async resume(infoHash) {
572
+ let started = false;
562
573
  for (const engine of this.#secondaries) {
563
- await engine.resume?.(infoHash).catch(() => {});
574
+ started = (await engine.resume?.(infoHash).catch(() => false)) || started;
564
575
  }
565
- return this.#primary.resume ? this.#primary.resume(infoHash) : false;
576
+ const primary = this.#primary.resume
577
+ ? await this.#primary.resume(infoHash)
578
+ : false;
579
+ return primary || started;
566
580
  }
567
581
 
568
582
  /**
@@ -415,6 +415,57 @@ export class LibtorrentEngine {
415
415
  }
416
416
  }
417
417
 
418
+ /**
419
+ * Stops a torrent, leaving its data and its place in the session alone.
420
+ *
421
+ * Not removal. The bytes stay, the resume data stays, and starting it again
422
+ * costs nothing -- which is the whole reason this exists rather than the
423
+ * remove-and-re-add a missing pause used to fall back to. Re-adding a
424
+ * 698 GiB archive means hashing the store again to arrive where it already
425
+ * was.
426
+ * @param {string} infoHash - The archive to stop.
427
+ * @returns {Promise<boolean>} - Whether it was stopped.
428
+ */
429
+ async pause(infoHash) {
430
+ await this.#stopStart('pause', infoHash);
431
+ return true;
432
+ }
433
+
434
+ /**
435
+ * Offers a stopped torrent again.
436
+ * @param {string} infoHash - The archive to start.
437
+ * @returns {Promise<boolean>} - Whether it was started.
438
+ */
439
+ async resume(infoHash) {
440
+ await this.#stopStart('resume', infoHash);
441
+ return true;
442
+ }
443
+
444
+ /**
445
+ * Pause and resume differ only in the word, including how they fail.
446
+ * @param {string} op - 'pause' or 'resume'.
447
+ * @param {string} infoHash - The archive.
448
+ * @returns {Promise<object>} - The sidecar's answer.
449
+ */
450
+ async #stopStart(op, infoHash) {
451
+ try {
452
+ return await this.#call(op, { infoHash });
453
+ } catch (error) {
454
+ // An older sidecar answers "unknown op", which is true and useless: it
455
+ // reads as a bug in the request rather than as a package that needs
456
+ // updating. Worth saying plainly, because before 0.9.0 there was no
457
+ // pause here at all -- the request reached the catalog and stopped, so
458
+ // the console showed `paused` beside an archive still transferring.
459
+ if (/unknown op/i.test(error.message)) {
460
+ throw new Error(
461
+ `this sidecar cannot ${op}; pmtiles-torrent 0.9.0 or newer is needed`,
462
+ { cause: error },
463
+ );
464
+ }
465
+ throw error;
466
+ }
467
+ }
468
+
418
469
  async list() {
419
470
  // A node that is shutting down still has a console polling it and a sweep
420
471
  // or two in flight. Answering "the sidecar exited" to each of them fills
package/src/library.js CHANGED
@@ -311,20 +311,7 @@ export class Library {
311
311
  comment: options.comment,
312
312
  md5: options.md5 ?? this.#config.md5,
313
313
  signal: controller.signal,
314
- // Pieces in, bytes out. The console draws one progress bar for adds and
315
- // labels it in bytes, and a piece count means nothing beside a download
316
- // measured in gigabytes — so it is converted here, where the file size
317
- // is known, rather than teaching the console a second unit. Accurate to
318
- // within one piece, which at 4 MiB against a planet archive is four
319
- // parts in a million.
320
- onHashProgress: ({ piece, pieces }) => {
321
- const state = this.#running.get(requested);
322
- if (!state || !pieces || !size) return;
323
- state.received = Math.min(
324
- size,
325
- Math.round(((piece + 1) / pieces) * size),
326
- );
327
- },
314
+ onHashProgress: this.#hashProgress(requested),
328
315
  });
329
316
 
330
317
  return await this.#register(created, {
@@ -352,6 +339,28 @@ export class Library {
352
339
  }
353
340
  }
354
341
 
342
+ /**
343
+ * Reports a hash's progress as bytes, which is what the console draws.
344
+ *
345
+ * Pieces are what a hasher counts; the adds list has one bar and labels it in
346
+ * bytes, and a piece count means nothing beside a download measured in
347
+ * gigabytes. Converted here, where the size is known, rather than teaching
348
+ * the console a second unit. Accurate to within one piece — at 4 MiB against
349
+ * a planet archive, four parts in a million.
350
+ * @param {string} key - How the add is registered in #running.
351
+ * @returns {Function} - An onHashProgress callback.
352
+ */
353
+ #hashProgress(key) {
354
+ return ({ piece, pieces }) => {
355
+ const state = this.#running.get(key);
356
+ if (!state || !pieces || !state.total) return;
357
+ state.received = Math.min(
358
+ state.total,
359
+ Math.round(((piece + 1) / pieces) * state.total),
360
+ );
361
+ };
362
+ }
363
+
355
364
  /**
356
365
  * Turns a caller's choice of location into a directory, and checks it.
357
366
  *
@@ -884,8 +893,33 @@ export class Library {
884
893
  fetchAttempts: this.#config.fetchAttempts,
885
894
  fetchRetryDelayMs: (this.#config.fetchRetrySeconds ?? 5) * 1000,
886
895
  signal: controller.signal,
887
- onProgress: ({ received, total, done }) => {
896
+ // The same hasher a local add gets, which this route never asked for.
897
+ //
898
+ // Without it the hash ran in this process -- the one serving tiles and
899
+ // the console -- for every archive a schedule ever built, could not be
900
+ // cancelled because create-torrent takes no signal, reported nothing
901
+ // while it ran, and produced a v1 torrent rather than a hybrid. So an
902
+ // archive arriving from a feed got a lesser torrent, built the slower
903
+ // way, than the same file added by path.
904
+ creator: this.#creator(),
905
+ onHashProgress: this.#hashProgress(url),
906
+ onProgress: ({ phase, received, total, done }) => {
888
907
  const state = this.#running.get(url);
908
+ if (phase === 'hashing') {
909
+ // The transfer is over and a different job has started, measured
910
+ // in the same units. Handing the bar over rather than leaving it
911
+ // at 100%: a finished download that sits at 100% for the length of
912
+ // a 128 GiB hash reads as one that completed and then hung, which
913
+ // is how it was reported.
914
+ if (state) {
915
+ Object.assign(state, {
916
+ phase: 'hashing',
917
+ received: undefined,
918
+ total,
919
+ });
920
+ }
921
+ return;
922
+ }
889
923
  if (state) Object.assign(state, { received, total });
890
924
  const pct = total ? ((received / total) * 100).toFixed(1) : '?';
891
925
  console.log(
@@ -1866,11 +1900,24 @@ export class Library {
1866
1900
  throw error;
1867
1901
  }
1868
1902
 
1869
- if (this.#engine.pause) {
1870
- await this.#engine.pause(infoHash);
1871
- } else {
1903
+ // Whether it actually stopped, not whether something was asked.
1904
+ //
1905
+ // This tested only that the engine *had* a pause method and threw the
1906
+ // answer away. A composite has one whatever its engines can do, so a
1907
+ // primary with no pause of its own returned false into a void: the
1908
+ // fallback below never ran, `paused: true` went into the catalog, and the
1909
+ // console -- which prefers that flag to the engine's live state -- showed
1910
+ // `paused` beside an archive still transferring at 8 MiB/s. The button did
1911
+ // nothing and said it had worked.
1912
+ const stopped = this.#engine.pause
1913
+ ? await this.#engine.pause(infoHash)
1914
+ : false;
1915
+ if (!stopped) {
1872
1916
  // Removing without its data is a pause an engine cannot refuse; resume
1873
- // adds it back and it rechecks what is already on disk.
1917
+ // adds it back and it rechecks what is already on disk. A last resort,
1918
+ // because that recheck is the whole store -- tens of minutes for a
1919
+ // planet archive -- which is why an engine that can really pause is
1920
+ // worth the two operations it takes.
1874
1921
  await this.#engine
1875
1922
  .remove(infoHash, { deleteData: false })
1876
1923
  .catch(() => {});
@@ -1954,9 +2001,14 @@ export class Library {
1954
2001
  throw error;
1955
2002
  }
1956
2003
 
1957
- if (this.#engine.resume) {
1958
- await this.#engine.resume(infoHash);
1959
- } else {
2004
+ // Same as pause: the answer decides, not the presence of a method. An
2005
+ // archive stopped by the fallback above is not in the engine at all, so a
2006
+ // resume it merely claimed would leave the catalog saying the archive was
2007
+ // running while nothing held it.
2008
+ const started = this.#engine.resume
2009
+ ? await this.#engine.resume(infoHash)
2010
+ : false;
2011
+ if (!started) {
1960
2012
  await this.#readd({ ...entry, paused: false });
1961
2013
  }
1962
2014
  await this.#tiles?.invalidate(infoHash).catch(() => {});
@@ -309,6 +309,16 @@
309
309
  .piecerow { display: grid; grid-template-columns: 7rem 1fr 4rem; gap: 0.6rem; align-items: center; margin-bottom: 0.5rem; }
310
310
  .piecerow > .label { font-size: 0.8rem; color: var(--muted); }
311
311
  .piecerow > .value { font-size: 0.8rem; color: var(--muted); text-align: right; }
312
+ /* Like a piecerow, but the label is a filename rather than a fixed word,
313
+ and each row carries its own Cancel. A separate class because the
314
+ piecerow's 7rem label and 4rem value are sized for "downloaded" and
315
+ "41%": an archive name in 7rem wraps onto three lines, and the naming
316
+ of the buttons had to repeat the whole filename to say which was
317
+ which. */
318
+ .addrow { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 0.6rem; align-items: center; margin-bottom: 0.5rem; }
319
+ .addrow > .label { font-size: 0.8rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
320
+ .addrow > .value { font-size: 0.8rem; color: var(--muted); text-align: right; white-space: nowrap; }
321
+ .addrow > button { padding: 0.15rem 0.55rem; font-size: 0.75rem; }
312
322
 
313
323
  button.speed { border-color: var(--line); color: var(--muted); }
314
324
  button.speed.on { border-color: var(--warn); color: var(--warn); }
@@ -923,7 +933,6 @@
923
933
  box.innerHTML = '';
924
934
  return;
925
935
  }
926
- const cancellable = running.filter((add) => add.cancellable !== false);
927
936
  box.innerHTML = `
928
937
  <h3 style="margin:1.2rem 0 0.5rem">Being added, before a torrent exists</h3>
929
938
  <div class="sub" style="margin-bottom:0.6rem">
@@ -958,26 +967,29 @@
958
967
  : pct == null
959
968
  ? bytes(add.received)
960
969
  : `${pct.toFixed(1)}%`;
970
+ const name = add.name ?? add.url.split('/').pop() ?? add.url;
971
+ // Cancel sits in the row it cancels, so the button does not have
972
+ // to name the archive to say which one it is. Named, they were as
973
+ // wide as the filename — two of them filled a line, and reading
974
+ // one meant matching a long name against the list above it.
975
+ //
976
+ // An add with no controller keeps an empty cell rather than
977
+ // losing one, so the rows above and below it stay lined up.
978
+ const stop =
979
+ add.cancellable === false
980
+ ? '<span></span>'
981
+ : `<button data-cancel="${escapeHtml(add.url)}" title="Cancel ${escapeHtml(
982
+ name,
983
+ )}" aria-label="Cancel ${escapeHtml(name)}">Cancel</button>`;
961
984
  return `
962
- <div class="piecerow">
963
- <span class="label" title="${escapeHtml(add.url)}">${escapeHtml(
964
- add.name ?? add.url.split('/').pop() ?? add.url,
965
- )}</span>
985
+ <div class="addrow">
986
+ <span class="label" title="${escapeHtml(add.url)}">${escapeHtml(name)}</span>
966
987
  <span class="track"><i style="width:${pct == null ? 0 : pct.toFixed(1)}%"></i></span>
967
988
  <span class="value">${value}</span>
989
+ ${stop}
968
990
  </div>`;
969
991
  })
970
- .join('')}
971
- <div class="bar" style="margin-top:0.5rem">
972
- ${cancellable
973
- .map(
974
- (add) =>
975
- `<button data-cancel="${escapeHtml(add.url)}">Cancel ${escapeHtml(
976
- add.name ?? add.url.split('/').pop() ?? '',
977
- )}</button>`,
978
- )
979
- .join('')}
980
- </div>`;
992
+ .join('')}`;
981
993
 
982
994
  for (const button of box.querySelectorAll('[data-cancel]')) {
983
995
  button.onclick = async () => {