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 +74 -12
- package/README.md +1 -1
- package/docs/architecture-diagram.md +50 -12
- package/docs/configuration.md +32 -5
- package/docs/internals.md +29 -0
- package/docs/publishing.md +25 -0
- package/package.json +2 -2
- package/src/api.js +65 -33
- package/src/config.js +11 -0
- package/src/index.js +11 -3
- package/src/library.js +264 -58
- package/src/torrent-create.js +107 -3
- package/src/web/index.html +65 -14
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` |
|
|
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
|
|
166
|
-
P
|
|
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->>
|
|
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
|
-
|
|
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
|
-
- **
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -215,11 +215,13 @@ seeding.
|
|
|
215
215
|
|
|
216
216
|
## Creating torrents
|
|
217
217
|
|
|
218
|
-
| setting
|
|
219
|
-
|
|
|
220
|
-
| `pieceLength`
|
|
221
|
-
| `torrentFormat`
|
|
222
|
-
| `allowUnknownArchives`
|
|
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
|
package/docs/publishing.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
/**
|
|
110
|
+
/** Adds in flight, by URL or absolute path, so they can be watched and stopped. */
|
|
75
111
|
#running = new Map();
|
|
76
|
-
/**
|
|
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)
|
|
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
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
664
|
+
aborted.push(target);
|
|
519
665
|
}
|
|
520
|
-
return
|
|
666
|
+
return aborted;
|
|
521
667
|
}
|
|
522
668
|
|
|
523
669
|
/**
|
|
524
|
-
*
|
|
525
|
-
* @returns {
|
|
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
|
-
|
|
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
|
-
|
|
710
|
-
|
|
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
|
|
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
|
-
*
|
|
2216
|
-
*
|
|
2217
|
-
*
|
|
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
|
|
2226
|
-
const
|
|
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
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
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}
|
|
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;
|
package/src/torrent-create.js
CHANGED
|
@@ -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)
|
|
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
|
|
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)
|
|
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;
|
package/src/web/index.html
CHANGED
|
@@ -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
|
|
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">
|
|
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
|
|
924
|
-
before it can be hashed
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
-
|
|
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;
|