pmtiles-swarm 0.32.3 โ†’ 0.34.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,9 +7,82 @@
7
7
  ### ๐Ÿž Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.34.0
11
+ ### โœจ Features and improvements
12
+ - **`md5` is a declared setting.** It was already honoured wherever a torrent is created, but it
13
+ appeared in no defaults list and no document, so it could only be written into the config file by
14
+ hand โ€” `PATCH /api/config` refused it as an unknown setting, and nothing in the console showed
15
+ whether it was on. It now defaults to `false`, is documented, and can be changed without a
16
+ restart.
17
+ - **`incomingRetentionDays`** sets how long an unfinished download stays resumable. Defaults to 14.
18
+
19
+ ### ๐Ÿž Bug fixes
20
+ - **A large download survives a restart instead of starting again from zero.** A scheduled web
21
+ source fetching a multi-hour archive lost the whole transfer every time the node restarted, and
22
+ began again from nothing on the next poll. Three things had to hold and only one did. The bytes
23
+ were always kept โ€” the staging directory is named for a hash of its URL so the next add finds
24
+ it โ€” but **shutdown deleted them**, because it stopped in-flight adds through `cancelAdd()`, and
25
+ cancelling discards the partial on purpose: somebody said stop. A restart is not that decision,
26
+ so shutdown now uses `stopAdds()`, which the fetch can tell apart. **Startup then swept whatever
27
+ survived**, on the reasoning that a killed process leaves a partial "nothing will ever look in
28
+ again" โ€” true when staging names were random, false since they became a hash of the URL. And
29
+ **the validator did not outlive the process**: the `ETag` a resume is checked against lived in a
30
+ local, so a new process had nothing to compare and refused the resume as "the server offers no
31
+ ETag or Last-Modified", deleting the partial by the very attempt meant to continue it. It is now
32
+ written beside the bytes and removed when the download completes. A restart during a 700 GiB
33
+ transfer now costs the seconds since the last write.
34
+ - **`.incoming` is swept by age rather than emptied.** Only a staging directory nothing has
35
+ written to for `incomingRetentionDays` (default 14) is cleared, so an unfinished download stays
36
+ resumable. The sweep also looks under `cacheSavePath`, which it never did โ€” staging lands there
37
+ for cache-mode adds and under a source's own `savePath`, so the one configured `savePath` was
38
+ never the whole of where it could be.
39
+ - **Adding a local archive answers when the file has been checked, not when it has been hashed.**
40
+ `POST /api/torrents` with a `path` held the response open for the whole hash โ€” every byte of the
41
+ archive, twice with `md5` on โ€” so the console's add dialog sat there for minutes with no sign
42
+ that anything was happening. Worse than the URL case it mirrors, because nothing was downloading
43
+ either: the file was already on the disk and visibly not moving, which reads as a submit button
44
+ that did nothing. It now answers `202` once the path has been identified and accepted, and the
45
+ hash reports itself through `/api/adds` like a download does. A path that is not there or is not
46
+ an archive still fails in the response. An archive already held answers `200` with its entry,
47
+ which the URL branch now does too rather than promising work that was already done. **Scripts
48
+ reading the created entry straight back from a `path` add need `/api/adds` or `/api/torrents`
49
+ instead** โ€” magnets and `.torrent` URLs are unchanged and still answer `201`.
50
+ - **A second add of a file already being hashed joins the first rather than starting its own.**
51
+ Only reachable now that the dialog closes quickly enough to submit twice, and two passes over the
52
+ same planet archive is an hour of disk for one result.
53
+ - **The console's MD5 checkbox is now the decision it looks like.** It was only sent when ticked,
54
+ and the server reads an absent `md5` as "unspecified" and falls back to the node's configured
55
+ default โ€” so on a node with `md5` on, an unticked box still hashed one, and the log said so while
56
+ the dialog appeared to have turned it off. The value is sent either way, and the box now starts
57
+ from the node's own setting rather than always unticked โ€” otherwise making it authoritative would
58
+ have turned a configured default off for every add made from the console, the same disagreement
59
+ the other way round. Omitting `md5` from an API or CLI call still inherits the config default,
60
+ which is what that fallback is for.
61
+ - **The save-location picker is hidden when adding a local file.** It did nothing there: a local
62
+ add registers the file's own directory as the save path whatever was chosen, which is exactly
63
+ what "hashed in place, nothing is copied" says โ€” but the picker sat next to that sentence
64
+ implying otherwise, and offered no way to say "leave it where it is" because that is the only
65
+ thing it does.
66
+ - **"What a torrent-aware client does" describes what they now do.** The section predated the swarm
67
+ handles moving into the TileJSON URL's fragment and still had a client learning where to join
68
+ from a TileJSON response โ€” the one thing the fragment exists to avoid, since the swarm is the
69
+ part that depends on no server. It contradicted "bootstrapping without the server" two sections
70
+ below it.
71
+
72
+ ## 0.33.0
73
+ ### โœจ Features and improvements
74
+ - **Requires pmtiles-torrent 0.6.1, which is what finally makes a downloading archive servable.**
75
+ Two fixes there, both about the few kilobytes at the front of a PMTiles archive that say where
76
+ every other section begins. A read used to ask for its piece with a deadline and then raise on
77
+ libtorrent's immediate "I do not have that yet" โ€” abandoning the very fetch it had just
78
+ requested, so each attempt gave up within milliseconds and left nothing behind to hurry the
79
+ piece. And the head was only ever asked for by a reader, so an archive nothing happened to read
80
+ was never prioritised at all. Reads now wait out their own timeout, and the head is prioritised
81
+ when the archive is added. On a 698 GiB mirror with two complete seeds connected this was the
82
+ difference between 200 GiB downloaded with no tile servable and a head that arrives in seconds.
83
+
10
84
  ## 0.32.3
11
85
  ### โœจ Features and improvements
12
- - _...Add new stuff here..._
13
86
 
14
87
  ### ๐Ÿž Bug fixes
15
88
  - **Requires pmtiles-torrent 0.5.2, which deletes a torrent's resume file with its data.** Resume
@@ -21,11 +94,9 @@
21
94
  the data goes too: a removal that keeps the files is how a pause is expressed for an engine with
22
95
  no pause of its own, and discarding resume data there would turn every pause into a full re-hash.
23
96
 
24
- - _...Add new stuff here..._
25
97
 
26
98
  ## 0.32.2
27
99
  ### โœจ Features and improvements
28
- - _...Add new stuff here..._
29
100
 
30
101
  ### ๐Ÿž Bug fixes
31
102
  - **The head warmer no longer skips every archive it was built for.** It decided an archive was done
@@ -44,11 +115,9 @@
44
115
  header off local disk for nothing, while assuming the opposite would leave every existing
45
116
  subscription stuck exactly as it is.
46
117
 
47
- - _...Add new stuff here..._
48
118
 
49
119
  ## 0.32.1
50
120
  ### โœจ Features and improvements
51
- - _...Add new stuff here..._
52
121
 
53
122
  ### ๐Ÿž Bug fixes
54
123
  - **Requires pmtiles-torrent 0.5.1, so the connection indicator and Recheck files actually work.**
@@ -58,11 +127,9 @@
58
127
  cannot answer is deliberately not reported as unreachable, so there was nothing to see and
59
128
  nothing to explain why.
60
129
 
61
- - _...Add new stuff here..._
62
130
 
63
131
  ## 0.32.0
64
132
  ### โœจ Features and improvements
65
- - _...Add new stuff here..._
66
133
 
67
134
  ### ๐Ÿž Bug fixes
68
135
  - **A mutable magnet no longer carries a web seed.** A `ws=` URL names one build; a BEP 46 magnet
@@ -88,7 +155,6 @@
88
155
  **Restyle anything holding one.** A style carrying an older mutable magnet keeps working, but
89
156
  carries the stale web seed until it is regenerated.
90
157
 
91
- - _...Add new stuff here..._
92
158
 
93
159
  ## 0.31.0
94
160
  ### โœจ Features and improvements
@@ -115,10 +181,8 @@
115
181
  With two engines both are asked, since each keeps its own belief about the same file and a stale
116
182
  one on the secondary is why a browser peer would find nothing while the primary seeds happily.
117
183
 
118
- - _...Add new stuff here..._
119
184
 
120
185
  ### ๐Ÿž Bug fixes
121
- - _...Add new stuff here..._
122
186
 
123
187
  ## 0.30.0
124
188
  ### โœจ Features and improvements
@@ -148,10 +212,8 @@
148
212
  makes the eye stop to work out which it is looking at. Seconds are dropped rather than the date,
149
213
  since nothing here is sorted finely enough for them to matter; hovering still gives them.
150
214
 
151
- - _...Add new stuff here..._
152
215
 
153
216
  ### ๐Ÿž Bug fixes
154
- - _...Add new stuff here..._
155
217
 
156
218
  ## 0.29.0
157
219
  ### โœจ Features and improvements
package/README.md CHANGED
@@ -761,7 +761,7 @@ which the endpoint answers 501.
761
761
  | `POST` | `/api/torrents/:infoHash/check` | Has the source changed since the torrent was made? |
762
762
  | `POST` | `/api/torrents/:infoHash/rebuild` | Rebuild from the current source (mints a new infohash) |
763
763
  | `POST` | `/api/check-origins` | Check every archive with a watchable source |
764
- | `GET` `DELETE` | `/api/adds` | Downloads still in flight, and cancelling one by URL |
764
+ | `GET` `DELETE` | `/api/adds` | Adds still in flight โ€” downloads, and local files being hashed โ€” and cancelling a download by URL. A hash cannot be stopped part-way, so it is listed but not cancellable |
765
765
  | `GET` `POST` | `/api/speed` | Which speed limits are in force, and the manual switch between the two sets |
766
766
  | `GET` | `/api/categories` | Every category, with the endpoints resolving to its newest build |
767
767
  | `POST` | `/api/adopt`, `/api/adopt/candidates` | Take over what an engine or another node holds |
@@ -154,6 +154,15 @@ stops needing the tile endpoint at all.
154
154
  The important part is that it does both at once โ€” HTTP for the first paint, swarm
155
155
  in the background โ€” so there is never a blank map waiting for metadata.
156
156
 
157
+ Discovery comes first, and it comes from the style rather than from a response.
158
+ The source URL carries its handles in a fragment
159
+ (`โ€ฆ/tiles.json#torrent=โ€ฆ&magnet=โ€ฆ`, described under
160
+ [bootstrapping](#bootstrapping-without-the-server)), so a client knows there is a
161
+ swarm behind a source before it makes a single request โ€” which is the point, as
162
+ that is what still works when the server is down. The TileJSON is fetched anyway,
163
+ because it carries the tile endpoint for the first paint and a fuller `torrent`
164
+ block than a fragment can, but it is no longer how the swarm is _found_.
165
+
157
166
  ```mermaid
158
167
  sequenceDiagram
159
168
  autonumber
@@ -162,24 +171,28 @@ sequenceDiagram
162
171
  participant HTTP as pmtiles-swarm<br/>(via CDN)
163
172
  participant BT as BitTorrent swarm
164
173
 
165
- App->>P: load style โ†’ tiles.json
166
- P->>HTTP: GET /archives/{hash}/tiles.json
167
- HTTP-->>P: TileJSON + torrent block
168
-
169
- Note over P: claims the /archives/{hash}/ prefix,<br/>so only these URLs come to it
174
+ App->>P: load style
175
+ Note over P: reads the fragment on each source URL:<br/>torrent= and magnet=. No request yet.
170
176
 
171
177
  par Map is usable immediately
178
+ P->>HTTP: GET /latest/{category}/tiles.json
179
+ Note over P,HTTP: the fragment is never sent
180
+ HTTP-->>P: TileJSON + torrent block
172
181
  App->>P: tile 12/2145/1436
173
182
  P->>HTTP: GET โ€ฆ/12/2145/1436.pbf
174
183
  HTTP-->>App: tile bytes
175
184
  and Swarm warms up in the background
176
- P->>BT: join (.torrent โ€” metadata already in hand)
185
+ P->>HTTP: GET the .torrent
186
+ HTTP-->>P: metainfo (piece hashes, trackers, web seeds)
187
+ P->>BT: join โ€” metadata already in hand
177
188
  BT-->>P: connected
178
189
  P->>BT: fetch PMTiles header + root directory
179
190
  BT-->>P: those pieces
180
191
  Note over P: now able to resolve any tile<br/>to a byte range locally
181
192
  end
182
193
 
194
+ Note over P: claims the TileJSON URL's prefix (without<br/>the fragment), so only these URLs come to it
195
+
183
196
  App->>P: tile 12/2146/1436
184
197
  Note over P: tile โ†’ byte range (PMTiles directory)<br/>โ†’ piece index
185
198
  P->>BT: fetch that piece
@@ -201,18 +214,43 @@ is why the `torrent` block carries the archive's `.torrent` rather than per-tile
201
214
  URLs: **there is nothing tile-specific in the swarm.** The swarm holds one file,
202
215
  and both ends know how to read tiles out of it.
203
216
 
204
- Three consequences worth being clear about:
217
+ Consequences worth being clear about:
205
218
 
206
219
  - **HTTP is never fully abandoned.** It is the fallback for anything the swarm
207
220
  cannot answer quickly, and the only path until the swarm is connected.
208
221
  - **The client becomes a seeder.** Every piece it pulls, it serves โ€” so a popular
209
222
  region gets _faster_ as more clients view it, which is the opposite of how a
210
223
  tile server behaves under load.
211
- - **Prefer the `.torrent` over the magnet.** A magnet carries only an infohash, so
212
- the client must find peers and complete a metadata exchange before it knows
213
- anything about the archive โ€” measured at 90 to 240 seconds against a 72 GiB
214
- archive. The `.torrent` served alongside the TileJSON already contains the
215
- metadata and is ready immediately.
224
+ - **The document is preferred, the fragment is the fallback.** Where the TileJSON
225
+ is reachable and carries a `torrent` block, that block wins: it is the archive's
226
+ own account of itself, and it has web seeds, size and piece length that two
227
+ handles in a URL do not. The fragment is what answers when the document does not
228
+ โ€” unreachable, not JSON, or carrying no block โ€” which is exactly the case the
229
+ handles were put in the URL for. A client that used only one of the two would be
230
+ either poorly informed or dependent on the server it is meant to survive.
231
+ - **Prefer the `.torrent` over the magnet, and for a browser this is not a
232
+ preference.** A magnet carries only an infohash, so the client must find peers
233
+ and complete a metadata exchange before it knows anything about the archive โ€”
234
+ measured at 90 to 240 seconds against a 72 GiB archive. That is the cost for a
235
+ client with a DHT. A browser has neither DHT nor UDP, and piece hashes reach a
236
+ BitTorrent client only from a **peer**, over BEP 9 โ€” a web seed serves file
237
+ payload and never metainfo. So a page that cannot reach a peer cannot use the
238
+ web seed either, however reachable that web seed is: it has bytes it is not
239
+ allowed to trust. Fetching the `.torrent` over the same HTTPS the TileJSON came
240
+ from takes the peer off the critical path entirely, which is the difference
241
+ between working on a restricted network and not working at all.
242
+ - **A client with a DHT can join on a magnet alone**, and one without cannot. That
243
+ is the one place the two kinds of client genuinely diverge, and it is why the
244
+ convention names `torrent=` first: a browser treats a source carrying only a
245
+ magnet as no candidate at all, while a native client is happy with it.
246
+ - **Holding the metainfo is not evidence that anything will serve it.** It makes
247
+ the engine ready with no peer involved, which removes the very thing that used
248
+ to prove an archive was worth binding a source to โ€” waiting for metadata was
249
+ never only a wait. A client that registers on metainfo alone can bind a source
250
+ to a swarm with nothing behind it, and that does not fall back, it _stalls_, and
251
+ the tiles never draw. One `Range: bytes=0-0` against the web seed restores the
252
+ evidence: a 206 proves the host is reachable, serves ranges and permits the
253
+ origin, which is all the engine needs from it.
216
254
 
217
255
  ---
218
256
 
@@ -215,11 +215,13 @@ seeding.
215
215
 
216
216
  ## Creating torrents
217
217
 
218
- | setting | default | |
219
- | ---------------------- | ---------- | -------------------------------------------- |
220
- | `pieceLength` | `4194304` | 4 MiB |
221
- | `torrentFormat` | `'hybrid'` | `'hybrid'`, `'v1'` or `'v2'` |
222
- | `allowUnknownArchives` | `false` | publish files not recognised as map archives |
218
+ | setting | default | |
219
+ | ----------------------- | ---------- | ----------------------------------------------- |
220
+ | `pieceLength` | `4194304` | 4 MiB |
221
+ | `torrentFormat` | `'hybrid'` | `'hybrid'`, `'v1'` or `'v2'` |
222
+ | `allowUnknownArchives` | `false` | publish files not recognised as map archives |
223
+ | `md5` | `false` | also compute an MD5 of each archive created |
224
+ | `incomingRetentionDays` | `14` | how long an unfinished download stays resumable |
223
225
 
224
226
  ### `pieceLength`
225
227
 
@@ -260,6 +262,31 @@ reading over a swarm does not work the way it does for a flat, Hilbert-ordered
260
262
  file; a finished local copy has no such problem. See
261
263
  [serving-tiles.md](serving-tiles.md#what-can-be-served).
262
264
 
265
+ ### `incomingRetentionDays`
266
+
267
+ An unfinished download is kept in `.incoming` for this long, and adding the same
268
+ URL again resumes it โ€” which is what makes a restart during a multi-hour
269
+ transfer cost minutes rather than the whole download. A scheduled source picks
270
+ its own back up on the next poll without being asked.
271
+
272
+ Only what nothing has written to for this many days is cleared at startup, since
273
+ whether a URL is still wanted is a question about configuration that the sweep
274
+ cannot see. Set it lower on a small disk, or higher if an upstream can be
275
+ unreachable for weeks.
276
+
277
+ ### `md5`
278
+
279
+ Off by default, because it costs a second full read of the archive: with it on,
280
+ adding a 700 GiB file takes twice as long and produces one convenience digest
281
+ that nothing in BitTorrent uses. The torrent already verifies the content, and
282
+ per piece rather than as a whole โ€” this is for the manual check somebody wants to
283
+ run against a published checksum, and it is carried in the feed for them.
284
+
285
+ The console's **Also compute an MD5** box starts from this setting and is sent
286
+ with the add either way, so unticking it applies to that add alone. An API or CLI
287
+ call that omits `md5` inherits this; one that passes `true` or `false` decides
288
+ for itself.
289
+
263
290
  ## Trackers
264
291
 
265
292
  `trackers` is baked into every torrent this node creates. It defaults to the
package/docs/internals.md CHANGED
@@ -556,6 +556,35 @@ assumed:
556
556
 
557
557
  Any of those failing restarts the download rather than guessing.
558
558
 
559
+ ### Across a restart, not only across a stall
560
+
561
+ All of that worked within one process and none of it survived leaving one, which
562
+ made a restart during a long download cost the whole download. Three separate
563
+ things had to be true, and only the first was:
564
+
565
+ 1. **The bytes are kept.** They always were โ€” the staging directory is named for
566
+ `sha256(url)`, so the next add of the same URL finds it.
567
+ 2. **Nothing deletes them on the way past.** Two things did. Shutdown expressed
568
+ itself through `cancelAdd()`, and cancelling deletes the partial on purpose โ€”
569
+ somebody said stop. A restart is not that decision, so shutdown now calls
570
+ `stopAdds()`, which aborts with a reason the fetch can tell apart. Startup
571
+ then swept `.incoming` unconditionally, on the reasoning that a killed
572
+ process leaves a partial "nothing will ever look in again" โ€” true when
573
+ staging names were random, false since they became a hash of the URL. It now
574
+ reaps only what nothing has written to for `incomingRetentionDays`.
575
+ 3. **The validator outlives the process.** `stillTheSameFile` compares the
576
+ `ETag` seen when the download began against the one offered now, and that
577
+ header lived in a local. A new process had nothing to compare, so the resume
578
+ was refused for the one reason that cannot be recovered from โ€” "the server
579
+ offers no ETag or Last-Modified" โ€” and the partial was deleted by the very
580
+ attempt meant to continue it. It is now written to `<partial>.resume` beside
581
+ the bytes, carrying the URL it belongs to, and removed when the download
582
+ finishes so it cannot keep the staging directory from being cleared.
583
+
584
+ The URL is recorded in the sidecar as well as implied by the directory name, so
585
+ a staging directory that has been reused for something else is refused rather
586
+ than spliced.
587
+
559
588
  ## Retiring and pruning a subscription
560
589
 
561
590
  Two different questions, which is why an RSS feed can have the first and not the
@@ -54,6 +54,31 @@ curl -X POST localhost:8090/api/torrents \
54
54
  curl -X POST localhost:8090/api/adopt
55
55
  ```
56
56
 
57
+ The first two of those answer differently, and a script should know which it is
58
+ reading. A local path and a URL both take as long as it takes to read every byte
59
+ of the archive โ€” minutes for a local file, hours for a planet download, doubled
60
+ again if `md5` is on โ€” so they answer **`202 Accepted`** the moment the source
61
+ has been checked, and the work carries on behind it:
62
+
63
+ ```json
64
+ {
65
+ "accepted": true,
66
+ "path": "/mnt/maps/planet.pmtiles",
67
+ "message": "hashing; progress is reported by /api/adds"
68
+ }
69
+ ```
70
+
71
+ There is no infohash in that, because there is not one yet. `GET /api/adds`
72
+ lists what is still running, and the archive appears in `GET /api/torrents` once
73
+ it finishes. A source that fails its checks โ€” a path that is not there, a URL
74
+ that does not answer, a file that is not an archive โ€” fails in the response
75
+ instead, since that is what somebody can do something about. An archive already
76
+ in the catalog answers `200` with the existing entry.
77
+
78
+ A magnet, a `.torrent` URL and an uploaded `.torrent` are metadata rather than
79
+ data, so there is nothing slow to wait for: those still answer `201` with the
80
+ entry.
81
+
57
82
  ### When the source URL is not published
58
83
 
59
84
  Adding from a URL registers that URL as a web seed by default, because it is by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.32.3",
3
+ "version": "0.34.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",
@@ -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.5.2",
49
+ "pmtiles-torrent": "^0.6.1",
50
50
  "webtorrent": "^3.0.21"
51
51
  },
52
52
  "engines": {
package/src/api.js CHANGED
@@ -53,6 +53,55 @@ function route(handler) {
53
53
  return (req, res, next) => Promise.resolve(handler(req, res)).catch(next);
54
54
  }
55
55
 
56
+ /**
57
+ * Starts an add that takes minutes to hours, and answers as soon as it is safe
58
+ * to say it has been accepted.
59
+ *
60
+ * Everything a caller can do something about is known long before the work is
61
+ * finished: the source answers, and it is an archive of a kind this will
62
+ * publish. What remains is transfer and hashing. Awaiting all of it held the
63
+ * response open for the whole thing, so the console's add dialog stayed on
64
+ * screen for the duration โ€” over an archive that was visibly appearing behind
65
+ * it in the URL case, and over a file that had never moved in the local one,
66
+ * where it read as a submit button that had done nothing at all.
67
+ *
68
+ * Progress has its own route already: `runningAdds()` feeds `/api/adds`, the
69
+ * console polls it, and `DELETE /api/adds` cancels the ones that can be.
70
+ * @param {object} res - The response to answer.
71
+ * @param {Function} start - Called with `{onValidated}`; returns the add's promise.
72
+ * @param {object} accepted - Fields describing the source, for the 202 body.
73
+ * @param {string} message - What the 202 tells the caller is now happening.
74
+ * @param {string} what - Prefixed log tag and source, for a failure nobody is waiting on.
75
+ * @returns {Promise<void>} - Resolves once the response has been sent.
76
+ */
77
+ async function acceptAdd(res, { start, accepted, message, what }) {
78
+ const validated = Promise.withResolvers();
79
+ const running = start({ onValidated: validated.resolve })
80
+ // A failure after validation has nowhere to be reported: the response has
81
+ // gone. It is logged where the rest of the work is, and swallowed here so
82
+ // it cannot take the process down as an unhandled rejection.
83
+ .catch((error) => {
84
+ validated.reject(error);
85
+ console.error(`${what}: ${error.message}`);
86
+ });
87
+
88
+ // Whichever comes first: the checks passing, or the whole attempt failing. A
89
+ // source that does not answer, or is not an archive, still reports itself in
90
+ // the dialog where somebody can correct it.
91
+ const outcome = await validated.promise;
92
+
93
+ // Already held, so there is no work to wait on and the entry itself is the
94
+ // better answer. Deliberately not extended to `joined`, where the promise
95
+ // belongs to somebody else's transfer and awaiting it would hold the
96
+ // response open for exactly as long as this exists to avoid.
97
+ if (outcome?.held) {
98
+ return void res.status(200).json(await running);
99
+ }
100
+
101
+ void running;
102
+ res.status(202).json({ accepted: true, ...accepted, message });
103
+ }
104
+
56
105
  /**
57
106
  * Builds the HTTP application: JSON API, RSS feeds and the web UI.
58
107
  * @param {object} deps - Collaborators.
@@ -1481,41 +1530,24 @@ export function createApp({
1481
1530
 
1482
1531
  let entry;
1483
1532
  if (body.path) {
1484
- entry = await library.addLocalArchive(body.path, options);
1533
+ // Nothing is copied and nothing is downloaded, but every byte is still
1534
+ // read to compute the piece hashes โ€” twice with md5 on. That is the
1535
+ // whole wait for a local add, and it is no shorter for the file being
1536
+ // on this disk already. See acceptAdd().
1537
+ return await acceptAdd(res, {
1538
+ start: (hooks) =>
1539
+ library.addLocalArchive(body.path, { ...options, ...hooks }),
1540
+ accepted: { path: body.path },
1541
+ message: 'hashing; progress is reported by /api/adds',
1542
+ what: `[hash] ${body.path}`,
1543
+ });
1485
1544
  } else if (body.url) {
1486
- // Answered as soon as the URL has been checked, not when the transfer
1487
- // finishes. Awaiting the whole thing held the response open for the
1488
- // length of the download -- hours for a planet archive -- so the
1489
- // console's add dialog stayed on screen throughout, over an archive
1490
- // that was visibly appearing behind it.
1491
- //
1492
- // Progress has its own route already: runningAdds() feeds /api/adds,
1493
- // the console polls it, and DELETE /api/adds cancels one. This is the
1494
- // piece that was missing rather than a new mechanism.
1495
- const validated = Promise.withResolvers();
1496
- const running = library
1497
- .addRemoteArchive(body.url, {
1498
- ...options,
1499
- onValidated: validated.resolve,
1500
- })
1501
- // A failure after validation has nowhere to be reported: the
1502
- // response has gone. It is logged where the rest of the fetch is,
1503
- // and swallowed here so it cannot take the process down as an
1504
- // unhandled rejection.
1505
- .catch((error) => {
1506
- validated.reject(error);
1507
- console.error(`[fetch] ${body.url}: ${error.message}`);
1508
- });
1509
-
1510
- // Whichever comes first: the checks passing, or the whole attempt
1511
- // failing. A URL that does not answer, or is not an archive, still
1512
- // reports itself in the dialog where somebody can correct it.
1513
- await validated.promise;
1514
- void running;
1515
- return res.status(202).json({
1516
- accepted: true,
1517
- url: body.url,
1545
+ return await acceptAdd(res, {
1546
+ start: (hooks) =>
1547
+ library.addRemoteArchive(body.url, { ...options, ...hooks }),
1548
+ accepted: { url: body.url },
1518
1549
  message: 'fetching; progress is reported by /api/adds',
1550
+ what: `[fetch] ${body.url}`,
1519
1551
  });
1520
1552
  } else if (body.magnet) {
1521
1553
  entry = await library.addExistingTorrent(
package/src/config.js CHANGED
@@ -155,6 +155,17 @@ const DEFAULTS = {
155
155
  * publish any readable file to a public swarm.
156
156
  */
157
157
  allowUnknownArchives: false,
158
+ /**
159
+ * Also compute an MD5 of each archive created here. Costs a second full read
160
+ * of the file. Already honoured wherever a torrent is created; declared so it
161
+ * can be seen and set rather than only written into the file by hand.
162
+ */
163
+ md5: false,
164
+ /**
165
+ * How long an unfinished download is kept before startup treats it as
166
+ * abandoned. Until then, re-adding the same URL resumes it.
167
+ */
168
+ incomingRetentionDays: 14,
158
169
  /**
159
170
  * Who may administer this node. Tiles, TileJSON and the feed are always
160
171
  * public; everything under /api/ is gated whenever anything here is set. See
package/src/index.js CHANGED
@@ -237,9 +237,17 @@ PMTILES_SWARM_PUBLIC_URL
237
237
  stoppers.unshift({
238
238
  label: 'downloads in progress',
239
239
  stop: () => {
240
- const cancelled = library.cancelAdd();
241
- if (cancelled.length > 0) {
242
- console.log(`[shutdown] cancelled ${cancelled.length} download(s)`);
240
+ // stopAdds, not cancelAdd: a restart is not a decision to stop wanting
241
+ // the archive, and cancelling deletes the partial download. Through
242
+ // cancelAdd every restart threw away whatever was in flight, and the
243
+ // scheduled source that asked for it began again from zero on the next
244
+ // poll โ€” which for a planet build is hours of transfer per restart.
245
+ const stopped = library.stopAdds();
246
+ if (stopped.length > 0) {
247
+ console.log(
248
+ `[shutdown] stopped ${stopped.length} download(s); their bytes are ` +
249
+ 'kept and resume when the source is next polled',
250
+ );
243
251
  }
244
252
  },
245
253
  ms: 1000,
package/src/library.js CHANGED
@@ -40,6 +40,16 @@ import {
40
40
  */
41
41
  export const INCOMING = '.incoming';
42
42
 
43
+ /**
44
+ * Why an in-flight download was aborted, when the answer is "we are stopping".
45
+ *
46
+ * Passed as the abort reason rather than tracked alongside, so it arrives with
47
+ * the signal at the one place that has to tell the difference โ€” and cannot be
48
+ * left behind by a path that clears its bookkeeping before it handles the
49
+ * error, which is exactly what the fetch does.
50
+ */
51
+ export const STOPPING = { stopping: true };
52
+
43
53
  /**
44
54
  * Moves a finished archive out of staging and into its final directory.
45
55
  *
@@ -63,6 +73,32 @@ export async function settleFromStaging({ staging, savePath, name }) {
63
73
  return to;
64
74
  }
65
75
 
76
+ /**
77
+ * When anything inside a directory was last written, as a timestamp.
78
+ *
79
+ * One level down is enough: a staging directory holds the archive being
80
+ * written and the sidecar describing it, and nothing nests below that.
81
+ * @param {string} dir - The directory to look in.
82
+ * @returns {Promise<number | null>} - Milliseconds, or null if it cannot be read.
83
+ */
84
+ async function newestMtime(dir) {
85
+ const entries = await fs
86
+ .readdir(dir, { withFileTypes: true })
87
+ .catch(() => null);
88
+ if (!entries) return null;
89
+
90
+ let newest = await fs
91
+ .stat(dir)
92
+ .then((stat) => stat.mtimeMs)
93
+ .catch(() => null);
94
+ for (const entry of entries) {
95
+ const stat = await fs.stat(path.join(dir, entry.name)).catch(() => null);
96
+ if (stat && (newest === null || stat.mtimeMs > newest))
97
+ newest = stat.mtimeMs;
98
+ }
99
+ return newest;
100
+ }
101
+
66
102
  export class Library {
67
103
  #catalog;
68
104
  #engine;
@@ -71,9 +107,9 @@ export class Library {
71
107
  #rebuildQueue = Promise.resolve();
72
108
  /** Moves in flight, and the last outcome for each archive. */
73
109
  #moves = new Map();
74
- /** In-flight remote adds, by URL, so they can be cancelled. */
110
+ /** Adds in flight, by URL or absolute path, so they can be watched and stopped. */
75
111
  #running = new Map();
76
- /** Downloads in progress, by URL, so a second request joins the first. */
112
+ /** The same adds as promises, so a second request for one joins the first. */
77
113
  #inFlight = new Map();
78
114
  /** The tile reader, told to forget an archive whose source may have changed. */
79
115
  #tiles;
@@ -144,16 +180,60 @@ export class Library {
144
180
  * Adds a local PMTiles archive, creating a torrent for it.
145
181
  *
146
182
  * The data is left where it is and the torrent points at it, so publishing a
147
- * 700 GiB archive copies nothing.
183
+ * 700 GiB archive copies nothing. Reading it is another matter: every byte
184
+ * goes past the hasher, which is why this reports progress and why a caller
185
+ * can stop waiting on it. See `onValidated`.
148
186
  * @param {string} filePath - Path to the .pmtiles file.
149
187
  * @param {object} [options] - Category, trackers, web seeds, piece length.
150
188
  * @returns {Promise<object>} - The catalog entry.
151
189
  */
152
190
  async addLocalArchive(filePath, options = {}) {
153
191
  const requested = path.resolve(filePath);
192
+
193
+ // Both shortcuts here have to fire onValidated before returning, and for
194
+ // the same reason as the remote ones: a caller waiting on it to answer a
195
+ // request would otherwise wait for something that has already happened.
154
196
  const existing = this.#catalog.findBySource(requested);
155
- if (existing) return existing;
197
+ if (existing) {
198
+ options.onValidated?.({
199
+ path: requested,
200
+ kind: existing.kind,
201
+ held: true,
202
+ });
203
+ return existing;
204
+ }
205
+
206
+ // A second request for a file already being hashed joins the first. The
207
+ // catalog cannot answer this, since the entry exists only once hashing has
208
+ // finished โ€” and until this returned early, nothing was quick enough to
209
+ // double-submit. Now that the dialog closes on validation it is, and two
210
+ // passes over the same planet archive is an hour of disk for one result.
211
+ const inFlight = this.#inFlight.get(requested);
212
+ if (inFlight) {
213
+ console.log(
214
+ `[hash] ${requested} is already being hashed; joining that one`,
215
+ );
216
+ options.onValidated?.({ path: requested, joined: true });
217
+ return inFlight;
218
+ }
219
+
220
+ const attempt = this.#hashLocalArchive(requested, options).finally(() =>
221
+ this.#inFlight.delete(requested),
222
+ );
223
+ this.#inFlight.set(requested, attempt);
224
+ return attempt;
225
+ }
156
226
 
227
+ /**
228
+ * Identifies, hashes and registers one local archive.
229
+ *
230
+ * Separate from `addLocalArchive` so that the deduplication above wraps a
231
+ * single call and cannot be bypassed by a second entry point later.
232
+ * @param {string} requested - Absolute path to the .pmtiles file.
233
+ * @param {object} [options] - Category, trackers, web seeds, piece length.
234
+ * @returns {Promise<object>} - The catalog entry.
235
+ */
236
+ async #hashLocalArchive(requested, options = {}) {
157
237
  // Move before hashing, not after. A rename on one filesystem is metadata
158
238
  // and costs nothing, while hashing the archive is minutes โ€” so the cheap
159
239
  // irreversible step goes first, and the torrent is built from where the
@@ -179,41 +259,74 @@ export class Library {
179
259
  allowUnknown: options.allowUnknown ?? this.#config.allowUnknownArchives,
180
260
  });
181
261
 
182
- // Only PMTiles can have its tiles served, so only PMTiles gets probed.
183
- const summary =
184
- identified.kind === 'pmtiles'
185
- ? await probePMTiles(absolute).catch(() => undefined)
186
- : undefined;
187
-
188
- const created = await createTorrentFromFile(absolute, {
189
- creator: this.#creator(),
190
- pieceLength: options.pieceLength ?? this.#config.pieceLength,
191
- trackers: this.#trackersFor(options),
192
- webSeeds: [...new Set(webSeeds)],
193
- comment: options.comment,
194
- md5: options.md5 ?? this.#config.md5,
262
+ // Everything a caller can do something about has now been checked: the
263
+ // path exists, it is an archive of a kind this will publish, and it is not
264
+ // something else that happened to be readable. What remains is reading it
265
+ // end to end, which for a planet archive is minutes at best โ€” twice that
266
+ // with md5 on โ€” and no amount of waiting changes the outcome.
267
+ //
268
+ // A caller that wants to stop waiting there says so with onValidated,
269
+ // exactly as the remote add does. Without it the console's add dialog sat
270
+ // open for the whole hash, over a file that was already on the disk and
271
+ // going nowhere, which read as a submit button that had done nothing.
272
+ options.onValidated?.({ path: absolute, kind: identified.kind });
273
+
274
+ // Tracked from here so the console has something to show while the hash
275
+ // runs. Registered without an AbortController, unlike a remote add: there
276
+ // is no cancelling a hash in progress, since neither libtorrent's creator
277
+ // nor create-torrent takes a signal. cancelAdd() skips entries without a
278
+ // controller, so this is inert there rather than a button that lies.
279
+ const { size } = await fs.stat(absolute).catch(() => ({ size: undefined }));
280
+ this.#running.set(requested, {
281
+ name: path.basename(absolute),
282
+ startedAt: new Date().toISOString(),
283
+ // No byte count: hashing reports nothing until it is done, so a progress
284
+ // bar here would sit at zero and then vanish. `total` is the size it is
285
+ // working through, which with the start time is enough to show it moving.
286
+ received: undefined,
287
+ total: size,
288
+ phase: 'hashing',
195
289
  });
196
290
 
197
- return this.#register(created, {
198
- categories: options.categories ?? options.category,
199
- // `watch` names the folder that imported this, where one did. Not the
200
- // same thing as the directory it sits in: with publishDir it has already
201
- // moved somewhere else, and an archive dropped into a watched folder by
202
- // hand is still that folder's to retire.
203
- source: { type: 'file', location: absolute, watch: options.watch },
204
- // The torrent names the file, so the save path is its parent directory.
205
- savePath: path.dirname(absolute),
206
- pmtiles: summary,
207
- // Read out of the archive, not taken from anybody's word for it. The
208
- // prewarmer needs the difference: a summary that arrived in a feed says
209
- // nothing about whether the header is on this disk. See prewarm.due().
210
- summarySource: 'header',
211
- kind: identified.kind,
212
- sparse: options.sparse,
213
- md5: created.md5,
214
- webSeeds: [...new Set(webSeeds)],
215
- seedOnly: true,
216
- });
291
+ try {
292
+ // Only PMTiles can have its tiles served, so only PMTiles gets probed.
293
+ const summary =
294
+ identified.kind === 'pmtiles'
295
+ ? await probePMTiles(absolute).catch(() => undefined)
296
+ : undefined;
297
+
298
+ const created = await createTorrentFromFile(absolute, {
299
+ creator: this.#creator(),
300
+ pieceLength: options.pieceLength ?? this.#config.pieceLength,
301
+ trackers: this.#trackersFor(options),
302
+ webSeeds: [...new Set(webSeeds)],
303
+ comment: options.comment,
304
+ md5: options.md5 ?? this.#config.md5,
305
+ });
306
+
307
+ return await this.#register(created, {
308
+ categories: options.categories ?? options.category,
309
+ // `watch` names the folder that imported this, where one did. Not the
310
+ // same thing as the directory it sits in: with publishDir it has
311
+ // already moved somewhere else, and an archive dropped into a watched
312
+ // folder by hand is still that folder's to retire.
313
+ source: { type: 'file', location: absolute, watch: options.watch },
314
+ // The torrent names the file, so the save path is its parent directory.
315
+ savePath: path.dirname(absolute),
316
+ pmtiles: summary,
317
+ // Read out of the archive, not taken from anybody's word for it. The
318
+ // prewarmer needs the difference: a summary that arrived in a feed says
319
+ // nothing about whether the header is on this disk. See prewarm.due().
320
+ summarySource: 'header',
321
+ kind: identified.kind,
322
+ sparse: options.sparse,
323
+ md5: created.md5,
324
+ webSeeds: [...new Set(webSeeds)],
325
+ seedOnly: true,
326
+ });
327
+ } finally {
328
+ this.#running.delete(requested);
329
+ }
217
330
  }
218
331
 
219
332
  /**
@@ -503,38 +616,77 @@ export class Library {
503
616
  }
504
617
 
505
618
  /**
506
- * Stops an in-flight remote add.
619
+ * Stops an in-flight remote add, and discards what it had downloaded.
620
+ *
621
+ * Only a remote one. A local add is listed alongside them but holds no
622
+ * controller, because a hash in progress cannot be interrupted โ€” neither
623
+ * libtorrent's creator nor create-torrent takes a signal. It is skipped
624
+ * below rather than special-cased, and reports as nothing cancelled.
507
625
  * @param {string} [url] - The source URL, or all of them when omitted.
508
626
  * @returns {string[]} - The URLs cancelled.
509
627
  */
510
628
  cancelAdd(url) {
629
+ return this.#abortAdds(url, undefined);
630
+ }
631
+
632
+ /**
633
+ * Stops in-flight adds because the process is going down, keeping the bytes.
634
+ *
635
+ * The distinction matters more than it looks. Cancelling deletes the partial
636
+ * download, deliberately: somebody said stop, and leaving a few hundred
637
+ * gigabytes behind after that is the invisible waste the cleanup exists to
638
+ * avoid. A restart is not that decision โ€” nobody stopped wanting the
639
+ * archive โ€” but shutdown used to express itself through the same call, so
640
+ * every restart deleted whatever was in flight, and the scheduled source
641
+ * that had asked for it started again from zero on the next poll. The
642
+ * staging directory is named for its URL precisely so the next attempt finds
643
+ * it; this is what lets it.
644
+ * @returns {string[]} - The URLs stopped.
645
+ */
646
+ stopAdds() {
647
+ return this.#abortAdds(undefined, STOPPING);
648
+ }
649
+
650
+ /**
651
+ * Aborts adds, saying why so the fetch can tell the two apart.
652
+ * @param {string} [url] - One source URL, or all of them when omitted.
653
+ * @param {object} [reason] - Passed to abort(); STOPPING keeps the partial.
654
+ * @returns {string[]} - The URLs aborted.
655
+ */
656
+ #abortAdds(url, reason) {
511
657
  const targets = url ? [url] : [...this.#running.keys()];
512
- const cancelled = [];
658
+ const aborted = [];
513
659
  for (const target of targets) {
514
660
  const { controller } = this.#running.get(target) ?? {};
515
661
  if (!controller) continue;
516
- controller.abort();
662
+ controller.abort(reason);
517
663
  this.#running.delete(target);
518
- cancelled.push(target);
664
+ aborted.push(target);
519
665
  }
520
- return cancelled;
666
+ return aborted;
521
667
  }
522
668
 
523
669
  /**
524
- * Remote adds currently running.
525
- * @returns {string[]} - Their source URLs.
670
+ * Adds currently running, remote and local alike.
671
+ * @returns {object[]} - One record per add, with whatever progress there is.
526
672
  */
527
673
  runningAdds() {
528
674
  // Reported with progress, not just named. An archive added from a URL has
529
675
  // to be downloaded whole before there is anything to hash a torrent out
530
676
  // of, so for hours there is no catalog entry and nothing in the list โ€”
531
- // which looks exactly like a source that silently did nothing.
677
+ // which looks exactly like a source that silently did nothing. A local add
678
+ // has the same gap for the length of the hash, without the download.
532
679
  return [...this.#running.entries()].map(([url, state]) => ({
533
680
  url,
534
681
  name: state.name,
535
- received: state.received ?? 0,
682
+ // Left absent rather than zeroed for a hash, which reports nothing until
683
+ // it finishes. A zero here is indistinguishable from a download that has
684
+ // not moved, and the console draws the two differently.
685
+ received: state.phase === 'hashing' ? undefined : (state.received ?? 0),
536
686
  total: state.total,
537
687
  startedAt: state.startedAt,
688
+ phase: state.phase ?? 'fetching',
689
+ cancellable: Boolean(state.controller),
538
690
  }));
539
691
  }
540
692
 
@@ -696,6 +848,7 @@ export class Library {
696
848
  },
697
849
  });
698
850
  } catch (error) {
851
+ const reached = this.#running.get(url)?.received ?? 0;
699
852
  this.#running.delete(url);
700
853
  // Cancelling is a decision to stop wanting this; running out of attempts
701
854
  // is not. The two used to be cleaned up identically, so a download that
@@ -706,8 +859,19 @@ export class Library {
706
859
  // A cancelled fetch is still removed. Somebody said stop, and leaving
707
860
  // gigabytes behind after that is the invisible waste this was written to
708
861
  // avoid in the first place.
709
- const cancelled = controller.signal.aborted;
710
- if (staging && cancelled) {
862
+ //
863
+ // Stopping is neither. The process going down is not a decision about
864
+ // this archive, so it keeps its bytes and says where they are; the next
865
+ // poll of whatever asked for it resumes from there. Read off the abort
866
+ // reason because #running has already been cleared by then.
867
+ const stopping = controller.signal.reason === STOPPING;
868
+ const cancelled = controller.signal.aborted && !stopping;
869
+ if (staging && stopping) {
870
+ console.log(
871
+ `[fetch] ${url} stopped at ${reached} bytes for shutdown; kept in ` +
872
+ `${staging}, and the next add of this URL resumes from there`,
873
+ );
874
+ } else if (staging && cancelled) {
711
875
  await fs.rm(staging, { recursive: true, force: true }).catch(() => {});
712
876
  } else if (staging) {
713
877
  console.warn(
@@ -2210,11 +2374,22 @@ export class Library {
2210
2374
  }
2211
2375
 
2212
2376
  /**
2213
- * Clears out staging directories left by downloads that never finished.
2377
+ * Clears out staging directories nothing is going to come back for.
2378
+ *
2379
+ * This used to remove everything it found, on the reasoning that a partial
2380
+ * archive left by a killed process sits "in a directory nothing will ever
2381
+ * look in again". That stopped being true when the staging directory was
2382
+ * named for a hash of its URL: the next add of the same URL looks in exactly
2383
+ * that directory and continues from what is in it. Sweeping unconditionally
2384
+ * therefore deleted the one thing the naming scheme exists to preserve, and
2385
+ * every restart cost a scheduled source its whole download โ€” the worst case
2386
+ * being the one this is supposed to help with, since a process killed
2387
+ * outright is precisely when hours of transfer are worth keeping.
2214
2388
  *
2215
- * The failure path removes its own, but a process killed outright cannot.
2216
- * What it leaves is a partial archive in a directory nothing will ever look
2217
- * in again โ€” invisible, and for a planet build measured in gigabytes.
2389
+ * What is genuinely abandoned is what nothing has touched for a while: a
2390
+ * source that was removed from the config, a URL that will never be asked
2391
+ * for again. Age is the only honest test available here, because whether a
2392
+ * URL is still wanted is a question about configuration this cannot see.
2218
2393
  *
2219
2394
  * Safe to run at startup precisely because nothing else may be writing here:
2220
2395
  * the data directory lock means one node owns it, and this node has not
@@ -2222,17 +2397,48 @@ export class Library {
2222
2397
  * @returns {Promise<number>} - How many were removed.
2223
2398
  */
2224
2399
  async sweepIncoming() {
2225
- const root = path.join(this.#config.savePath ?? '', INCOMING);
2226
- const stale = await fs.readdir(root).catch(() => []);
2400
+ const days = this.#config.incomingRetentionDays ?? 14;
2401
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
2402
+
2403
+ // Both roots: a source can name its own save path, and cache-mode adds go
2404
+ // to cacheSavePath, so the one configured savePath was never the whole of
2405
+ // where staging lands.
2406
+ const roots = [
2407
+ ...new Set(
2408
+ [this.#config.savePath, this.#config.cacheSavePath]
2409
+ .filter(Boolean)
2410
+ .map((root) => path.join(root, INCOMING)),
2411
+ ),
2412
+ ];
2227
2413
 
2228
2414
  let removed = 0;
2229
- for (const name of stale) {
2230
- await fs.rm(path.join(root, name), { recursive: true, force: true });
2231
- removed += 1;
2415
+ let kept = 0;
2416
+ for (const root of roots) {
2417
+ for (const name of await fs.readdir(root).catch(() => [])) {
2418
+ const at = path.join(root, name);
2419
+ // The newest thing in it, not the directory's own timestamp: on some
2420
+ // filesystems a directory's mtime does not move when a file inside it
2421
+ // is appended to, which for a download in progress is every write.
2422
+ const touched = await newestMtime(at);
2423
+ if (touched !== null && touched >= cutoff) {
2424
+ kept += 1;
2425
+ continue;
2426
+ }
2427
+ await fs.rm(at, { recursive: true, force: true });
2428
+ removed += 1;
2429
+ }
2232
2430
  }
2431
+
2233
2432
  if (removed > 0) {
2234
2433
  console.log(
2235
- `[library] cleared ${removed} unfinished download(s) from ${INCOMING}`,
2434
+ `[library] cleared ${removed} abandoned download(s) from ${INCOMING} ` +
2435
+ `(nothing written to them for ${days} days)`,
2436
+ );
2437
+ }
2438
+ if (kept > 0) {
2439
+ console.log(
2440
+ `[library] keeping ${kept} unfinished download(s) in ${INCOMING}; ` +
2441
+ 'adding the same URL again continues from where each stopped',
2236
2442
  );
2237
2443
  }
2238
2444
  return removed;
@@ -293,6 +293,85 @@ function rangeStart(contentRange) {
293
293
  return match ? Number(match[1]) : null;
294
294
  }
295
295
 
296
+ /**
297
+ * Where the validator for a partial download is remembered.
298
+ * @param {string} target - The file being written.
299
+ * @returns {string} - The sidecar path.
300
+ */
301
+ function resumePathFor(target) {
302
+ return `${target}.resume`;
303
+ }
304
+
305
+ /**
306
+ * Remembers what the source said about the file, beside the partial itself.
307
+ *
308
+ * `stillTheSameFile` compares the validator seen when the download began
309
+ * against the one offered now, and a validator held only in a local was a
310
+ * validator that died with the process. The bytes survived a restart and the
311
+ * comparison could not, so the resume was refused for the one reason that is
312
+ * not recoverable -- "the server offers no ETag or Last-Modified" -- and hours
313
+ * of transfer were deleted by the attempt that was meant to continue them.
314
+ *
315
+ * Written beside the partial rather than into the catalog because that is where
316
+ * it is true: the pair is the resumable thing, and neither half means anything
317
+ * without the other.
318
+ * @param {string} target - The file being written.
319
+ * @param {string} url - The source, so a stale sidecar cannot be mistaken for this one's.
320
+ * @param {Headers} headers - The response the download began with.
321
+ * @returns {Promise<void>} - Resolves once written.
322
+ */
323
+ async function rememberValidator(target, url, headers) {
324
+ const etag = headers?.get('etag') ?? null;
325
+ const lastModified = headers?.get('last-modified') ?? null;
326
+ // Nothing worth remembering: with neither validator a resume would be
327
+ // refused anyway, and an empty sidecar only invites believing in it.
328
+ if (!etag && !lastModified) return;
329
+ await fs
330
+ .writeFile(
331
+ resumePathFor(target),
332
+ JSON.stringify({ url, etag, lastModified, at: new Date().toISOString() }),
333
+ )
334
+ .catch((error) => {
335
+ // Not fatal. The download still works; only a restart costs more.
336
+ console.warn(
337
+ `[fetch] could not record resume data for ${url}: ${error.message}`,
338
+ );
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Reads back what a previous attempt saw, if it was for this same URL.
344
+ * @param {string} target - The file being written.
345
+ * @param {string} url - The source now being fetched.
346
+ * @returns {Promise<Headers | null>} - Headers to compare against, or null.
347
+ */
348
+ async function recallValidator(target, url) {
349
+ try {
350
+ const saved = JSON.parse(await fs.readFile(resumePathFor(target), 'utf8'));
351
+ // A staging directory is named for its URL, so a mismatch here means the
352
+ // file has been reused for something else. Refusing is the safe read.
353
+ if (saved.url !== url) return null;
354
+ const headers = new Headers();
355
+ if (saved.etag) headers.set('etag', saved.etag);
356
+ if (saved.lastModified) headers.set('last-modified', saved.lastModified);
357
+ return headers.has('etag') || headers.has('last-modified') ? headers : null;
358
+ } catch {
359
+ // No sidecar, or an unreadable one. Both mean the same thing: there is
360
+ // nothing to compare against, so this behaves as it did before.
361
+ return null;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Drops a partial download and the validator that described it.
367
+ * @param {string} target - The file being written.
368
+ * @returns {Promise<void>} - Resolves once both are gone.
369
+ */
370
+ async function discardPartial(target) {
371
+ await fs.rm(target, { force: true });
372
+ await fs.rm(resumePathFor(target), { force: true }).catch(() => {});
373
+ }
374
+
296
375
  /**
297
376
  * Streams a URL to a file, resuming where a previous attempt stopped.
298
377
  *
@@ -337,6 +416,19 @@ async function downloadTo(url, target, onProgress, signal, options = {}) {
337
416
  // is not progress.
338
417
  let consumed = 0;
339
418
  let best = await bytesOnDisk(target);
419
+
420
+ // Picked up from the last process where there was one. This is what makes a
421
+ // partial survive a restart rather than only a stall: the bytes were always
422
+ // kept, but without the validator that described them the resume below had
423
+ // nothing to compare and threw them away.
424
+ if (best > 0) {
425
+ firstHeaders = await recallValidator(target, url);
426
+ if (firstHeaders) {
427
+ console.log(
428
+ `[fetch] ${url}: continuing from ${best} bytes left by an earlier run`,
429
+ );
430
+ }
431
+ }
340
432
  // Reset does mean an unlucky download can go round more times than the
341
433
  // budget names, which is the point, so there is a ceiling as well: a source
342
434
  // dribbling a few bytes before dropping every time would otherwise retry
@@ -349,7 +441,10 @@ async function downloadTo(url, target, onProgress, signal, options = {}) {
349
441
  const from = await bytesOnDisk(target);
350
442
  // A file already at full length is one a previous attempt finished, and
351
443
  // that the caller died before renaming. Re-fetching it buys nothing.
352
- if (total && from >= total) return from;
444
+ if (total && from >= total) {
445
+ await fs.rm(resumePathFor(target), { force: true }).catch(() => {});
446
+ return from;
447
+ }
353
448
 
354
449
  let response;
355
450
  try {
@@ -403,14 +498,20 @@ async function downloadTo(url, target, onProgress, signal, options = {}) {
403
498
  // because it looks like a complete download. The next attempt sees an
404
499
  // empty target, sends no Range, and gets the whole file.
405
500
  await response.body.cancel().catch(() => {});
406
- await fs.rm(target, { force: true });
501
+ await discardPartial(target);
407
502
  total = 0;
503
+ best = 0;
408
504
  firstHeaders = null;
409
505
  continue;
410
506
  }
411
507
  }
412
508
 
413
- if (!firstHeaders) firstHeaders = response.headers;
509
+ if (!firstHeaders) {
510
+ firstHeaders = response.headers;
511
+ // Recorded before a byte is written, because the useful moment to have
512
+ // it is the one where this process is no longer running.
513
+ await rememberValidator(target, url, firstHeaders);
514
+ }
414
515
  const length = Number(response.headers.get('content-length') ?? 0);
415
516
  // On a 206 the length is what remains, not the size of the whole file.
416
517
  if (length) total = appending ? from + length : length;
@@ -437,6 +538,9 @@ async function downloadTo(url, target, onProgress, signal, options = {}) {
437
538
  { signal },
438
539
  );
439
540
  onProgress?.({ received, total, done: true });
541
+ // Nothing left to resume. Left behind it would also stop the staging
542
+ // directory being removed, since that is only unlinked once empty.
543
+ await fs.rm(resumePathFor(target), { force: true }).catch(() => {});
440
544
  return received;
441
545
  } catch (error) {
442
546
  if (signal?.aborted) throw error;
@@ -688,8 +688,10 @@
688
688
  <div class="sub">
689
689
  For a quick manual check. The torrent already verifies the
690
690
  archive, and per piece rather than as a whole โ€” this is the
691
- familiar convenience, not the integrity guarantee.
691
+ familiar convenience, not the integrity guarantee. It costs a
692
+ second read of the whole file, so the add takes twice as long.
692
693
  </div>
694
+ <div class="sub" id="md5-default"></div>
693
695
  </div>
694
696
  </fieldset>
695
697
 
@@ -902,12 +904,16 @@
902
904
  }
903
905
 
904
906
  /**
905
- * Shows downloads that have no archive yet.
907
+ * Shows adds that have no archive yet.
906
908
  *
907
909
  * An archive added from a URL is fetched whole before there is anything
908
910
  * to hash a torrent out of, so for the length of that download there is
909
911
  * no catalog entry and nothing in the table. For a planet build that is
910
912
  * hours in which a watched location looks like it silently did nothing.
913
+ *
914
+ * A local file has the same gap without the download: the bytes are
915
+ * already here, but they still all go past the hasher, and until that
916
+ * finishes there is no infohash to file it under either.
911
917
  * @param {object[]} running - From /api/adds.
912
918
  * @returns {void}
913
919
  */
@@ -917,30 +923,48 @@
917
923
  box.innerHTML = '';
918
924
  return;
919
925
  }
926
+ const cancellable = running.filter((add) => add.cancellable !== false);
920
927
  box.innerHTML = `
921
- <h3 style="margin:1.2rem 0 0.5rem">Downloading, before a torrent exists</h3>
928
+ <h3 style="margin:1.2rem 0 0.5rem">Being added, before a torrent exists</h3>
922
929
  <div class="sub" style="margin-bottom:0.6rem">
923
- These have no infohash yet โ€” an archive has to be here in full
924
- before it can be hashed. They appear in the table above once that
930
+ These have no infohash yet โ€” an archive has to be read end to end
931
+ before it can be hashed, whether it is arriving from a URL or
932
+ already on the disk. They appear in the table above once that
925
933
  finishes.
926
934
  </div>
927
935
  ${running
928
936
  .map((add) => {
929
- const pct = add.total ? (add.received / add.total) * 100 : null;
937
+ // Hashing reports nothing until it is done, so there is no
938
+ // percentage to draw โ€” only how big the file is and how long it
939
+ // has been going. A bar pinned at zero for twenty minutes says
940
+ // "stuck" when the honest answer is "no idea, still working".
941
+ const hashing = add.phase === 'hashing';
942
+ const pct =
943
+ add.total && add.received != null
944
+ ? (add.received / add.total) * 100
945
+ : null;
946
+ const elapsed = add.startedAt
947
+ ? duration(Date.now() - new Date(add.startedAt).getTime())
948
+ : null;
949
+ const value = hashing
950
+ ? `hashing${add.total ? ` ${bytes(add.total)}` : ''}${
951
+ elapsed ? ` ยท ${elapsed}` : ''
952
+ }`
953
+ : pct == null
954
+ ? bytes(add.received)
955
+ : `${pct.toFixed(1)}%`;
930
956
  return `
931
957
  <div class="piecerow">
932
958
  <span class="label" title="${escapeHtml(add.url)}">${escapeHtml(
933
959
  add.name ?? add.url.split('/').pop() ?? add.url,
934
960
  )}</span>
935
961
  <span class="track"><i style="width:${pct == null ? 0 : pct.toFixed(1)}%"></i></span>
936
- <span class="value">${
937
- pct == null ? bytes(add.received) : `${pct.toFixed(1)}%`
938
- }</span>
962
+ <span class="value">${value}</span>
939
963
  </div>`;
940
964
  })
941
965
  .join('')}
942
966
  <div class="bar" style="margin-top:0.5rem">
943
- ${running
967
+ ${cancellable
944
968
  .map(
945
969
  (add) =>
946
970
  `<button data-cancel="${escapeHtml(add.url)}">Cancel ${escapeHtml(
@@ -2032,7 +2056,7 @@ Every piece is hashed against the ` +
2032
2056
  */
2033
2057
  function locationPicker(id) {
2034
2058
  return `
2035
- <div class="field">
2059
+ <div class="field" id="${id}-field">
2036
2060
  <label for="${id}-select">Save location</label>
2037
2061
  <select id="${id}-select"></select>
2038
2062
  <input id="${id}-path" hidden placeholder="M:\\archives" style="margin-top:0.4rem" />
@@ -2202,6 +2226,11 @@ Every piece is hashed against the ` +
2202
2226
  // Only a torrent created here can carry a digest; joining one takes
2203
2227
  // whatever that torrent already says.
2204
2228
  $('md5-options').hidden = kind !== 'url' && kind !== 'path';
2229
+ // A local file is hashed where it lies and seeded from there:
2230
+ // addLocalArchive registers the file's own directory as the save path,
2231
+ // whatever was picked here. Offering the choice invited the reasonable
2232
+ // reading that the archive was about to be copied or moved somewhere.
2233
+ $('add-loc-field').hidden = kind === 'path';
2205
2234
  // Only joining has a mode to choose. Creating a torrent here always
2206
2235
  // means holding the file, so the question does not arise.
2207
2236
  $('join-options').hidden = kind !== 'magnet' && kind !== 'torrentUrl';
@@ -2238,8 +2267,13 @@ Every piece is hashed against the ` +
2238
2267
  $('extra-seeds').value = '';
2239
2268
  $('add-error').textContent = '';
2240
2269
  $('use-source-seed').checked = true;
2241
- $('want-md5').checked = false;
2242
2270
  $('extra-trackers').value = '';
2271
+ // Set from the node's own setting below, not assumed here. The box is
2272
+ // what gets sent now, so starting it at false on a node configured for
2273
+ // MD5 would quietly turn the setting off for every add made from the
2274
+ // console โ€” the same disagreement the other way round.
2275
+ $('want-md5').checked = false;
2276
+ $('md5-default').textContent = '';
2243
2277
 
2244
2278
  try {
2245
2279
  const { config } = await api('/api/config');
@@ -2247,6 +2281,10 @@ Every piece is hashed against the ` +
2247
2281
  $('default-trackers').textContent = defaults.length
2248
2282
  ? `Every torrent created here announces to: ${defaults.join(', ')}`
2249
2283
  : 'No default trackers are configured.';
2284
+ $('want-md5').checked = Boolean(config.md5);
2285
+ $('md5-default').textContent = config.md5
2286
+ ? 'This node computes one by default; unticking it applies to this add only.'
2287
+ : '';
2250
2288
  } catch {
2251
2289
  $('default-trackers').textContent =
2252
2290
  'Defaults come from the trackers setting.';
@@ -2299,7 +2337,11 @@ Every piece is hashed against the ` +
2299
2337
  .filter(Boolean);
2300
2338
  if (trackers.length > 0) body.addTrackers = trackers;
2301
2339
 
2302
- if ($('want-md5').checked) body.md5 = true;
2340
+ // Sent either way, because the server reads a missing md5 as "unspecified"
2341
+ // and falls back to the node's configured default. An unticked box was
2342
+ // therefore not a decision not to, and on a node configured for md5 the
2343
+ // log reported hashing that the dialog appeared to have turned off.
2344
+ body.md5 = $('want-md5').checked;
2303
2345
  if (kind === 'magnet' || kind === 'torrentUrl') {
2304
2346
  body.mode = document.querySelector('input[name="join-mode"]:checked').value;
2305
2347
  }
@@ -2321,7 +2363,16 @@ Every piece is hashed against the ` +
2321
2363
  body: { ...body, ...chosenLocation('add-loc') },
2322
2364
  });
2323
2365
  $('add-dialog').close();
2324
- toast(kind === 'url' ? 'fetching โ€” watch the log' : 'added');
2366
+ // The two slow kinds close on validation, not on completion, so
2367
+ // saying "added" would be claiming something that has not happened
2368
+ // yet. What has happened is that it was accepted and started.
2369
+ toast(
2370
+ kind === 'url'
2371
+ ? 'fetching โ€” watch the log'
2372
+ : kind === 'path'
2373
+ ? 'hashing โ€” watch the log'
2374
+ : 'added',
2375
+ );
2325
2376
  refresh();
2326
2377
  } catch (error) {
2327
2378
  $('add-error').textContent = error.message;