pmtiles-swarm 0.4.4 → 0.4.6
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 +40 -0
- package/README.md +2 -2
- package/docs/running-as-a-service.md +142 -52
- package/docs/subscribing.md +1 -1
- package/package.json +1 -1
- package/src/api.js +2 -2
- package/src/catalog.js +2 -2
- package/src/config.js +1 -1
- package/src/hooks.js +32 -8
- package/src/subscriptions.js +1 -1
- package/src/web/index.html +46 -11
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.4.6
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **A feed's categories are a list, and are called categories.** The console offered a single
|
|
13
|
+
"Tag as" string while the configuration has always accepted `categories` as a list — one
|
|
14
|
+
concept under two names across two editors. It is now the same Categories column a watched
|
|
15
|
+
folder has. An existing `"category": "openmaptiles"` still works and needs no migration.
|
|
16
|
+
Everything user-facing now says category rather than tag, including the `%G` placeholder's
|
|
17
|
+
description and the API table.
|
|
18
|
+
|
|
19
|
+
### 🐞 Bug fixes
|
|
20
|
+
- **Saving in the console no longer deletes settings it does not show.** Each record was rebuilt
|
|
21
|
+
from the rendered columns alone, so every field without a column was dropped the first time
|
|
22
|
+
anyone pressed Save — a watched folder's `pieceLength`, `stabilitySeconds`, `trackers` and
|
|
23
|
+
`sparse`, a subscription's `savePath`. Nothing warned, because from the console's side the
|
|
24
|
+
save succeeded. Each row now remembers the entry it was rendered from and a save starts from
|
|
25
|
+
that, overlaying the columns; an emptied box still removes its field, since that is an
|
|
26
|
+
instruction rather than a gap.
|
|
27
|
+
|
|
28
|
+
## 0.4.5
|
|
29
|
+
### 🐞 Bug fixes
|
|
30
|
+
- **A hook whose command could not be started is tried again.** Completion is recorded before
|
|
31
|
+
the command runs, so that a six-hour build is not started six times over — but a command that
|
|
32
|
+
never launched has not started anything, and keeping the record meant fixing the path and
|
|
33
|
+
still never seeing it run. The archive was permanently, silently done. A failure to spawn now
|
|
34
|
+
hands the record back; a command that ran and failed keeps it, because retrying that every
|
|
35
|
+
minute is how a broken build becomes a broken loop. A spawn failure also raised two accounts
|
|
36
|
+
of itself on some platforms — the real error, then a nonsense exit code — and only the first
|
|
37
|
+
stands now.
|
|
38
|
+
|
|
39
|
+
### 📚 Documentation
|
|
40
|
+
- **The service guide is organised around the thing that actually costs an afternoon.**
|
|
41
|
+
Permissions were spread across three sections and `ReadWritePaths` was explained twice, in
|
|
42
|
+
neither place completely. There is now one **Where it writes** section built on the fact that
|
|
43
|
+
three separate things decide whether a write succeeds — the filesystem bits, the group the
|
|
44
|
+
process actually holds, and `ReadWritePaths` — that each refuse on their own and all fail
|
|
45
|
+
identically. It also covers creating the archive directory, which was never mentioned even
|
|
46
|
+
though `savePath` is the entry most often missing from `ReadWritePaths`; why `chmod -R` is
|
|
47
|
+
the wrong tool, since on a directory the execute bit is the search bit; `SupplementaryGroups=`
|
|
48
|
+
for when a group will not appear; and that `PrivateTmp=true` hides a hook's lock and log.
|
|
49
|
+
|
|
10
50
|
## 0.4.4
|
|
11
51
|
### 🐞 Bug fixes
|
|
12
52
|
- **The console no longer claims an `.incomplete` file that is not there.** The marker was a
|
package/README.md
CHANGED
|
@@ -587,7 +587,7 @@ matters there is `maxConnections`, since every peer holds a NAT table entry. See
|
|
|
587
587
|
| `GET` | `/api/torrents/:infoHash/peers`, `/trackers`, `/content` | Per-peer, per-tracker and per-file detail |
|
|
588
588
|
| `GET` | `/api/torrents/:infoHash/pieces` | Which pieces are held, how rare each is, and what peers hold |
|
|
589
589
|
| `PATCH` | `/api/torrents/:infoHash/mode` | Switch between mirror and cache |
|
|
590
|
-
| `PATCH` | `/api/torrents/:infoHash/categories` | Set, add or remove
|
|
590
|
+
| `PATCH` | `/api/torrents/:infoHash/categories` | Set, add or remove categories |
|
|
591
591
|
| `PATCH` | `/api/torrents/:infoHash/seeding` | Per-archive seeding limit, or "use the global one" |
|
|
592
592
|
| `PATCH` `GET` | `/api/torrents/:infoHash/location` | Move the data; poll the move |
|
|
593
593
|
| `POST` | `/api/torrents/:infoHash/pause`, `/resume` | Stop offering it, without forgetting it |
|
|
@@ -599,7 +599,7 @@ matters there is `maxConnections`, since every peer holds a NAT table entry. See
|
|
|
599
599
|
| `POST` | `/api/check-origins` | Check every archive with a watchable source |
|
|
600
600
|
| `GET` `DELETE` | `/api/adds` | Downloads still in flight, and cancelling one by URL |
|
|
601
601
|
| `GET` `POST` | `/api/speed` | Which speed limits are in force, and the manual switch between the two sets |
|
|
602
|
-
| `GET` | `/api/categories` | Every
|
|
602
|
+
| `GET` | `/api/categories` | Every category, with the endpoints resolving to its newest build |
|
|
603
603
|
| `POST` | `/api/adopt`, `/api/adopt/candidates` | Take over what an engine or another node holds |
|
|
604
604
|
| `POST` | `/api/sources/preview` | What a watched web location would take, without taking it |
|
|
605
605
|
| `POST` | `/api/subscriptions/preview` | Whether a peer is reachable and what it offers |
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# Running as a systemd service
|
|
2
2
|
|
|
3
|
-
Setting up the account, then the unit
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Setting up the account, then the unit, then the directories it writes to.
|
|
4
|
+
|
|
5
|
+
Two lines in that unit are not optional and one line most people copy from
|
|
6
|
+
elsewhere should be deleted, but neither is what costs the afternoon. That is
|
|
7
|
+
**permission to write**, which here means three separate things that all have to
|
|
8
|
+
agree: the filesystem bits, the group the process actually holds, and
|
|
9
|
+
`ReadWritePaths`. Any one of them says no on its own, and the failure looks the
|
|
10
|
+
same each time — so [Where it writes](#where-it-writes) is worth reading before
|
|
11
|
+
the first archive rather than after.
|
|
6
12
|
|
|
7
13
|
## An account of its own
|
|
8
14
|
|
|
@@ -46,6 +52,24 @@ Generate the key rather than inventing one:
|
|
|
46
52
|
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
|
|
47
53
|
```
|
|
48
54
|
|
|
55
|
+
### Where the archives go
|
|
56
|
+
|
|
57
|
+
Wherever `savePath` points, and it is rarely under `/var/lib` — archives are
|
|
58
|
+
measured in hundreds of gigabytes and usually live on their own mount. Make it
|
|
59
|
+
before the first download, owned by the service:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
sudo install -d -o pmtiles-swarm -g pmtiles-swarm -m 0755 /mnt/store/torrent-data
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`0755` rather than `0750` because it leaves the option of serving those files
|
|
66
|
+
over HTTP later; `0750` if you would rather they stay private. Nothing in
|
|
67
|
+
pmtiles-swarm depends on which you choose.
|
|
68
|
+
|
|
69
|
+
A directory this account owns outright needs nothing further. One that another
|
|
70
|
+
service also writes to is a different job — see
|
|
71
|
+
[a folder shared with another service](#a-folder-shared-with-another-service).
|
|
72
|
+
|
|
49
73
|
## Node, and the package
|
|
50
74
|
|
|
51
75
|
Node from your distribution or NodeSource — the package needs `^22.13.0 || 24`:
|
|
@@ -160,18 +184,27 @@ TimeoutStopSec=45
|
|
|
160
184
|
# descriptors of its own.
|
|
161
185
|
LimitNOFILE=65535
|
|
162
186
|
|
|
163
|
-
#
|
|
187
|
+
# Everything else is read-only inside this unit's namespace.
|
|
164
188
|
ProtectSystem=strict
|
|
165
189
|
ProtectHome=read-only
|
|
166
190
|
PrivateTmp=true
|
|
167
191
|
NoNewPrivileges=true
|
|
168
|
-
|
|
192
|
+
|
|
193
|
+
# The starting pair: /var/lib for the data directory, /etc because the console
|
|
194
|
+
# rewrites the configuration when a token is minted. Anywhere else the
|
|
195
|
+
# configuration points — savePath above all — has to be added here or the write
|
|
196
|
+
# is refused whatever its permissions say. See "Where it writes".
|
|
169
197
|
ReadWritePaths=/var/lib/pmtiles-swarm /etc/pmtiles-swarm
|
|
170
198
|
|
|
171
199
|
[Install]
|
|
172
200
|
WantedBy=multi-user.target
|
|
173
201
|
```
|
|
174
202
|
|
|
203
|
+
`PrivateTmp=true` is worth one more note, because it surprises people writing
|
|
204
|
+
hooks: the service gets its own `/tmp`, so a script that keeps a lock or a log
|
|
205
|
+
there is invisible from a normal shell, and a run started by hand cannot see the
|
|
206
|
+
lock a hook run holds. Have hooks log somewhere real.
|
|
207
|
+
|
|
175
208
|
## The two lines that matter
|
|
176
209
|
|
|
177
210
|
**`Restart=always`, not `on-failure`.** The console's *Save & Restart* applies
|
|
@@ -189,61 +222,95 @@ while the Python sidecar kept running and kept the data directory locked.
|
|
|
189
222
|
|
|
190
223
|
Leave `KillMode` at its default, so the sidecar goes with its parent.
|
|
191
224
|
|
|
192
|
-
##
|
|
225
|
+
## Where it writes
|
|
193
226
|
|
|
194
|
-
|
|
195
|
-
|
|
227
|
+
Three separate things decide whether the service can write to a directory, and
|
|
228
|
+
**each can refuse on its own**. A permission problem, a group problem and a
|
|
229
|
+
namespace problem all produce the same symptom — an operation that silently does
|
|
230
|
+
nothing, or a hook that exits 1 with no output — so it is worth confirming all
|
|
231
|
+
three rather than guessing between them.
|
|
232
|
+
|
|
233
|
+
### 1. The paths in the configuration
|
|
234
|
+
|
|
235
|
+
Every path resolves **relative to the configuration file**, not to the working
|
|
236
|
+
directory:
|
|
196
237
|
|
|
197
238
|
```
|
|
198
239
|
/etc/pmtiles-swarm/swarm.config.json with "dataDir": "./data"
|
|
199
240
|
-> /etc/pmtiles-swarm/data
|
|
200
241
|
```
|
|
201
242
|
|
|
202
|
-
That is
|
|
203
|
-
|
|
243
|
+
That is rarely what you want for a service. Use absolute paths for anything
|
|
244
|
+
holding data:
|
|
204
245
|
|
|
205
246
|
```json
|
|
206
247
|
{
|
|
207
248
|
"dataDir": "/var/lib/pmtiles-swarm",
|
|
208
|
-
"savePath": "/
|
|
249
|
+
"savePath": "/mnt/store/torrent-data",
|
|
209
250
|
"libtorrent": { "resumeDir": "/var/lib/pmtiles-swarm/resume" }
|
|
210
251
|
}
|
|
211
252
|
```
|
|
212
253
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
254
|
+
### 2. Every one of them in `ReadWritePaths`
|
|
255
|
+
|
|
256
|
+
`ProtectSystem=strict` presents the whole filesystem as read-only inside the
|
|
257
|
+
unit's namespace. The refusal happens **there, before any permission bit is
|
|
258
|
+
consulted** — so a directory whose ownership and mode are perfect still fails if
|
|
259
|
+
it is not named here.
|
|
260
|
+
|
|
261
|
+
List every directory the configuration points at, plus anywhere a hook writes:
|
|
262
|
+
|
|
263
|
+
```ini
|
|
264
|
+
ReadWritePaths=/var/lib/pmtiles-swarm /etc/pmtiles-swarm /mnt/store /mnt/work/planetiler
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`ReadWritePaths=` is a list: repeated assignments **merge** rather than replace,
|
|
268
|
+
whether in the unit itself or in a drop-in from `systemctl edit pmtiles-swarm`.
|
|
269
|
+
So a drop-in adds to what the unit already names. An empty assignment on its own
|
|
270
|
+
line is the only thing that resets it.
|
|
271
|
+
|
|
272
|
+
The one to forget is `savePath`, because it is usually on another mount and
|
|
273
|
+
nothing complains until a download starts — at which point a torrent that cannot
|
|
274
|
+
write fails in a way that reads like a network problem.
|
|
275
|
+
|
|
276
|
+
### 3. Permission on the directory itself
|
|
216
277
|
|
|
217
|
-
|
|
278
|
+
A directory this account owns needs nothing beyond
|
|
279
|
+
[the setup above](#where-the-archives-go). A shared one does.
|
|
280
|
+
|
|
281
|
+
### A folder shared with another service
|
|
218
282
|
|
|
219
283
|
A folder produced by something else — a generation script, or a directory a
|
|
220
|
-
torrent client already owns —
|
|
221
|
-
the first
|
|
284
|
+
torrent client already owns — takes three steps, and group membership is only
|
|
285
|
+
the first:
|
|
222
286
|
|
|
223
287
|
```sh
|
|
224
288
|
# 1. Put the service account in the owning group.
|
|
225
289
|
sudo usermod -aG qbittorrent-nox pmtiles-swarm
|
|
226
290
|
|
|
227
291
|
# 2. Give that group write, and setgid so new entries inherit it.
|
|
228
|
-
sudo find /mnt/
|
|
229
|
-
sudo find /mnt/
|
|
292
|
+
sudo find /mnt/store/generated -type d -exec chmod 2775 {} +
|
|
293
|
+
sudo find /mnt/store/generated -type f -exec chmod 664 {} +
|
|
230
294
|
|
|
231
|
-
# 3. Make what
|
|
232
|
-
sudo systemctl edit pmtiles-swarm
|
|
233
|
-
sudo systemctl restart pmtiles-swarm
|
|
295
|
+
# 3. Make what this service creates group-writable too, then restart.
|
|
296
|
+
sudo systemctl edit pmtiles-swarm # [Service] / UMask=0002
|
|
297
|
+
sudo systemctl daemon-reload && sudo systemctl restart pmtiles-swarm
|
|
234
298
|
```
|
|
235
299
|
|
|
236
|
-
|
|
300
|
+
**A folder at 0755 gives the group `r-x`.** Membership alone buys read access and
|
|
301
|
+
nothing else — enough to hash and seed an archive, not enough for anything that
|
|
302
|
+
writes. So this looks like it worked right up until the first thing that does.
|
|
237
303
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
UMask=0002
|
|
241
|
-
|
|
304
|
+
The `2` in `2775` is setgid, and it is what stops this drifting: without it a
|
|
305
|
+
file the service creates belongs to group `pmtiles-swarm`, the other service
|
|
306
|
+
cannot touch it, and you are back here in a month. `UMask=0002` is the same
|
|
307
|
+
thought for the mode — without it a new file is `0644` and the other account can
|
|
308
|
+
delete it but not modify it.
|
|
242
309
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
310
|
+
Split by type rather than using `chmod -R`. On a **directory** the execute bit
|
|
311
|
+
is the search bit: it permits resolving a path *through* the directory, so
|
|
312
|
+
removing it leaves a folder whose contents you can list and not one of which you
|
|
313
|
+
can open. Files should lose it; directories must not.
|
|
247
314
|
|
|
248
315
|
Three features want write, and it is worth knowing which, because a read-only
|
|
249
316
|
folder is a perfectly reasonable way to run:
|
|
@@ -254,33 +321,38 @@ folder is a perfectly reasonable way to run:
|
|
|
254
321
|
| `keep`, `keepDays` | Deletes retired builds |
|
|
255
322
|
| `onComplete` | Whatever the script does, since it runs as this account |
|
|
256
323
|
|
|
257
|
-
Renaming and deleting need write on the **directory**, not on the file
|
|
258
|
-
why the directory bits are the ones that matter
|
|
259
|
-
|
|
260
|
-
|
|
324
|
+
Renaming and deleting need write on the **directory**, not on the file — which is
|
|
325
|
+
why the directory bits are the ones that matter, and why a build written under a
|
|
326
|
+
temporary name and renamed into place works with directory write alone.
|
|
327
|
+
|
|
328
|
+
### Two checks that lie
|
|
329
|
+
|
|
330
|
+
**`id pmtiles-swarm`** reads `/etc/group` and shows the new group the instant
|
|
331
|
+
`usermod` returns, whether or not the running process has it. Supplementary
|
|
332
|
+
groups are read when a process starts, so the restart is not optional — and this
|
|
333
|
+
is what proves it:
|
|
334
|
+
|
|
335
|
+
```sh
|
|
336
|
+
grep -E '^(Uid|Gid|Groups)' /proc/$(systemctl show -p MainPID --value pmtiles-swarm)/status
|
|
337
|
+
getent group qbittorrent-nox # is that GID in the Groups line above?
|
|
338
|
+
```
|
|
261
339
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
of the filesystem as read-only inside the unit's namespace, and the write is
|
|
265
|
-
refused there before the permission bits are consulted. Every folder outside
|
|
266
|
-
`/var/lib/pmtiles-swarm` has to be named, in a drop-in from
|
|
267
|
-
`systemctl edit pmtiles-swarm`:
|
|
340
|
+
If it is missing even after a restart, name it outright rather than relying on
|
|
341
|
+
how systemd resolves groups when `User=` and `Group=` are both set:
|
|
268
342
|
|
|
269
343
|
```ini
|
|
270
|
-
|
|
271
|
-
ReadWritePaths=/mnt/store/generated /mnt/work/planetiler
|
|
272
|
-
UMask=0002
|
|
344
|
+
SupplementaryGroups=qbittorrent-nox
|
|
273
345
|
```
|
|
274
346
|
|
|
275
|
-
|
|
276
|
-
|
|
347
|
+
**`sudo -u pmtiles-swarm touch …`** runs outside the unit's namespace, so it
|
|
348
|
+
succeeds on permission bits alone while the service is still being refused by
|
|
349
|
+
`ProtectSystem`. It can prove a permission problem; it cannot clear one.
|
|
277
350
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
still being refused.
|
|
351
|
+
What the running service actually has:
|
|
352
|
+
|
|
353
|
+
```sh
|
|
354
|
+
systemctl show -p ReadWritePaths -p UMask -p SupplementaryGroups pmtiles-swarm
|
|
355
|
+
```
|
|
284
356
|
|
|
285
357
|
## The sidecar
|
|
286
358
|
|
|
@@ -371,3 +443,21 @@ Then check it is actually serving:
|
|
|
371
443
|
curl -fsS localhost:8090/feed.xml >/dev/null && echo "public surface ok"
|
|
372
444
|
curl -fsS localhost:8091/api/status | head -c 200
|
|
373
445
|
```
|
|
446
|
+
|
|
447
|
+
And that it can write where it is supposed to, which nothing above proves:
|
|
448
|
+
|
|
449
|
+
```sh
|
|
450
|
+
systemctl show -p ReadWritePaths -p UMask -p SupplementaryGroups pmtiles-swarm
|
|
451
|
+
grep -E '^Groups' /proc/$(systemctl show -p MainPID --value pmtiles-swarm)/status
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
Two things that go wrong quietly rather than loudly, and are worth confirming
|
|
455
|
+
once rather than diagnosing later:
|
|
456
|
+
|
|
457
|
+
* An archive that re-hashes its whole store on every start means resume data is
|
|
458
|
+
not being written. There should be one file per torrent in `resumeDir` within
|
|
459
|
+
`resumeSaveIntervalSeconds` of a start.
|
|
460
|
+
* A hook that never seems to run. It logs what it launched and why it stopped —
|
|
461
|
+
`journalctl -u pmtiles-swarm | grep -i onComplete` — and a hook redirecting its
|
|
462
|
+
own output to a file will have nothing for the journal to show, which is not
|
|
463
|
+
the same as not having run.
|
package/docs/subscribing.md
CHANGED
|
@@ -156,7 +156,7 @@ the peer's to retract:
|
|
|
156
156
|
Provenance is what makes this possible: an archive records which subscription
|
|
157
157
|
sent it, and only that subscription can ever propose removing it.
|
|
158
158
|
|
|
159
|
-
## Sharing only what you
|
|
159
|
+
## Sharing only what you categorise
|
|
160
160
|
|
|
161
161
|
Category feeds let a *subscriber* narrow what it takes. They do not narrow what
|
|
162
162
|
you publish: `/feed.xml` carries the whole catalogue, so a peer who could follow
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/api.js
CHANGED
|
@@ -843,7 +843,7 @@ export function createApp({
|
|
|
843
843
|
// Tags, after the fact. They could only be set when an archive was added,
|
|
844
844
|
// which is the wrong moment to have to know: a build becomes "weekly" when
|
|
845
845
|
// there is a second one, and an archive is marked for sharing long after it
|
|
846
|
-
// arrives. Accepts a whole list, or add/remove for one
|
|
846
|
+
// arrives. Accepts a whole list, or add/remove for one category at a time.
|
|
847
847
|
app.patch(
|
|
848
848
|
'/api/torrents/:infoHash/categories',
|
|
849
849
|
route(async (req, res) => {
|
|
@@ -1426,7 +1426,7 @@ export function createApp({
|
|
|
1426
1426
|
/**
|
|
1427
1427
|
* Whether an archive may leave this node, for this caller.
|
|
1428
1428
|
*
|
|
1429
|
-
* Any
|
|
1429
|
+
* Any category matching is enough. A category names one thing an archive is, not the
|
|
1430
1430
|
* whole of what it is, so a planet build tagged both "basemaps" and "weekly"
|
|
1431
1431
|
* belongs in a basemaps feed whether or not weekly is also published.
|
|
1432
1432
|
* @param {object} entry - Catalog entry.
|
package/src/catalog.js
CHANGED
|
@@ -34,7 +34,7 @@ import path from 'node:path';
|
|
|
34
34
|
* them. Accepts the older single `category` string so catalogues written before
|
|
35
35
|
* tagging keep working — they are read as a list of one.
|
|
36
36
|
* @param {object} source - Anything with `categories` and/or `category`.
|
|
37
|
-
* @returns {string[]} - Sorted, de-duplicated, non-empty
|
|
37
|
+
* @returns {string[]} - Sorted, de-duplicated, non-empty categories.
|
|
38
38
|
*/
|
|
39
39
|
export function normalizeCategories(source) {
|
|
40
40
|
const raw = [
|
|
@@ -117,7 +117,7 @@ export class Catalog {
|
|
|
117
117
|
* @returns {CatalogEntry[]} - Matching entries.
|
|
118
118
|
*/
|
|
119
119
|
byCategory(category) {
|
|
120
|
-
// Any match, not all: a
|
|
120
|
+
// Any match, not all: a category names one thing an archive is, and asking for
|
|
121
121
|
// "terrain" should find everything tagged terrain whatever else it is.
|
|
122
122
|
return this.list().filter((entry) =>
|
|
123
123
|
normalizeCategories(entry).includes(category),
|
package/src/config.js
CHANGED
|
@@ -643,7 +643,7 @@ const DEFAULTS = {
|
|
|
643
643
|
* }
|
|
644
644
|
*
|
|
645
645
|
* Placeholders match a torrent client's, so an existing script keeps working:
|
|
646
|
-
* %N name, %L first category, %G all
|
|
646
|
+
* %N name, %L first category, %G all categories, %F content path, %D save path,
|
|
647
647
|
* %Z size, %C file count, %I infohash.
|
|
648
648
|
*
|
|
649
649
|
* Command and arguments are separate rather than one string a shell pulls
|
package/src/hooks.js
CHANGED
|
@@ -31,7 +31,7 @@ const TAIL_LINES = 20;
|
|
|
31
31
|
* The set mirrors a torrent client's, so an existing script keeps working:
|
|
32
32
|
*
|
|
33
33
|
* %N name %I infohash %F content path
|
|
34
|
-
* %L category %G
|
|
34
|
+
* %L category %G categories %D save path
|
|
35
35
|
* %Z size %C file count
|
|
36
36
|
*
|
|
37
37
|
* @param {string} argument - An argument possibly containing placeholders.
|
|
@@ -169,9 +169,25 @@ export class ProgramHooks {
|
|
|
169
169
|
|
|
170
170
|
this.#running.add(entry.infoHash);
|
|
171
171
|
fired.push(entry);
|
|
172
|
-
this.#fire('onComplete', entry)
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
this.#fire('onComplete', entry)
|
|
173
|
+
.then(async (result) => {
|
|
174
|
+
// Recorded before running so a six-hour build is not started six
|
|
175
|
+
// times over — but a command that never launched has not started
|
|
176
|
+
// anything, and keeping the stamp would mean fixing the path and
|
|
177
|
+
// still never seeing it run. That one case is given back.
|
|
178
|
+
if (result?.started === false) {
|
|
179
|
+
await this.#library.catalog.put({
|
|
180
|
+
infoHash: entry.infoHash,
|
|
181
|
+
completedAt: null,
|
|
182
|
+
});
|
|
183
|
+
console.warn(
|
|
184
|
+
`[hook] ${entry.name}: the command never started, so this will ` +
|
|
185
|
+
'be tried again on the next sweep',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
.catch(() => {})
|
|
190
|
+
.finally(() => this.#running.delete(entry.infoHash));
|
|
175
191
|
}
|
|
176
192
|
|
|
177
193
|
return fired;
|
|
@@ -237,7 +253,13 @@ export class ProgramHooks {
|
|
|
237
253
|
stream?.on('data', collect);
|
|
238
254
|
}
|
|
239
255
|
|
|
240
|
-
|
|
256
|
+
// A failure to spawn raises 'error' and then, on some platforms, 'close'
|
|
257
|
+
// with a nonsense exit code — so the first account of what happened is
|
|
258
|
+
// the true one and the second is noise.
|
|
259
|
+
let reported = false;
|
|
260
|
+
const report = (problem, started = true) => {
|
|
261
|
+
if (reported) return;
|
|
262
|
+
reported = true;
|
|
241
263
|
if (problem) {
|
|
242
264
|
console.error(`[${label}] ${entry.name}: ${problem}`);
|
|
243
265
|
} else {
|
|
@@ -248,12 +270,14 @@ export class ProgramHooks {
|
|
|
248
270
|
for (const line of tail) {
|
|
249
271
|
if (line.trim()) console.log(`[${label}] ${line}`);
|
|
250
272
|
}
|
|
251
|
-
resolve();
|
|
273
|
+
resolve({ started });
|
|
252
274
|
};
|
|
253
275
|
|
|
254
276
|
// A command that could not be started at all — no such file, not
|
|
255
|
-
// executable — never reaches
|
|
256
|
-
|
|
277
|
+
// executable, a working directory that is not there — never reaches
|
|
278
|
+
// 'close'. Reported as not started, which is what lets the caller try
|
|
279
|
+
// again: this is a configuration to fix, not a job that ran and failed.
|
|
280
|
+
child.on('error', (error) => report(error.message, false));
|
|
257
281
|
child.on('close', (code, signal) => {
|
|
258
282
|
if (signal) return report(`killed by ${signal}`);
|
|
259
283
|
report(code === 0 ? undefined : `exited with code ${code}`);
|
package/src/subscriptions.js
CHANGED
|
@@ -346,7 +346,7 @@ export class SubscriptionManager {
|
|
|
346
346
|
*/
|
|
347
347
|
async #add(item, subscription) {
|
|
348
348
|
const options = {
|
|
349
|
-
// A subscriber may file a peer's archives under its own
|
|
349
|
+
// A subscriber may file a peer's archives under its own categories; failing
|
|
350
350
|
// that, whatever the peer tagged them with comes across.
|
|
351
351
|
categories:
|
|
352
352
|
subscription.categories ??
|
package/src/web/index.html
CHANGED
|
@@ -357,7 +357,7 @@
|
|
|
357
357
|
addressed by its infohash — which is what makes a tile immutable and
|
|
358
358
|
cacheable for a year — so a style pointed at one is pinned to that
|
|
359
359
|
build forever. These endpoints always resolve to the newest archive
|
|
360
|
-
carrying the
|
|
360
|
+
carrying the category.
|
|
361
361
|
</div>
|
|
362
362
|
<div id="category-list"></div>
|
|
363
363
|
<div class="empty" id="categories-empty" hidden>
|
|
@@ -543,8 +543,8 @@
|
|
|
543
543
|
<button type="button" id="add-category">Add</button>
|
|
544
544
|
</div>
|
|
545
545
|
<div class="sub">
|
|
546
|
-
An archive can carry several. Feeds include
|
|
547
|
-
the category asked for.
|
|
546
|
+
An archive can carry several. Feeds include every archive
|
|
547
|
+
carrying the category asked for.
|
|
548
548
|
</div>
|
|
549
549
|
</div>
|
|
550
550
|
|
|
@@ -2327,6 +2327,11 @@
|
|
|
2327
2327
|
const { into, key, title, blurb, columns, rows, restart, preview, peerPreview, footnote } =
|
|
2328
2328
|
spec;
|
|
2329
2329
|
rowEditorColumns[key] = columns;
|
|
2330
|
+
// Kept so a save can put back what this editor never showed. An entry
|
|
2331
|
+
// holds more than there are columns for it — a watch folder's
|
|
2332
|
+
// pieceLength, a subscription's savePath — and a save that rebuilt each
|
|
2333
|
+
// record from the columns alone deleted every one of them.
|
|
2334
|
+
rowEditorRows[key] = rows;
|
|
2330
2335
|
const panel = document.createElement('div');
|
|
2331
2336
|
panel.className = 'panel';
|
|
2332
2337
|
panel.style.marginBottom = '1rem';
|
|
@@ -2368,8 +2373,8 @@
|
|
|
2368
2373
|
}
|
|
2369
2374
|
</td>`;
|
|
2370
2375
|
|
|
2371
|
-
const rowHtml = (row = {}) => `
|
|
2372
|
-
<tr>
|
|
2376
|
+
const rowHtml = (row = {}, index) => `
|
|
2377
|
+
<tr${index === undefined ? '' : ` data-origin="${index}"`}>
|
|
2373
2378
|
${columns.map((column) => cell(column, row[column.field])).join('')}
|
|
2374
2379
|
<td>
|
|
2375
2380
|
${preview ? '<button type="button" data-act="preview">Preview</button>' : ''}
|
|
@@ -2392,7 +2397,9 @@
|
|
|
2392
2397
|
<th></th>
|
|
2393
2398
|
</tr>
|
|
2394
2399
|
</thead>
|
|
2395
|
-
<tbody>${[...rows, {}]
|
|
2400
|
+
<tbody>${[...rows, {}]
|
|
2401
|
+
.map((row, index) => rowHtml(row, index < rows.length ? index : undefined))
|
|
2402
|
+
.join('')}</tbody>
|
|
2396
2403
|
</table>
|
|
2397
2404
|
</div>
|
|
2398
2405
|
<div style="margin-top:0.5rem">
|
|
@@ -2492,11 +2499,19 @@
|
|
|
2492
2499
|
* @param {object[]} columns - Column definitions.
|
|
2493
2500
|
* @returns {object} - The record.
|
|
2494
2501
|
*/
|
|
2495
|
-
function readRow(row, columns) {
|
|
2496
|
-
|
|
2502
|
+
function readRow(row, columns, original = {}) {
|
|
2503
|
+
// Starts from what was there. The editor shows the fields worth
|
|
2504
|
+
// offering, not every field an entry may hold, and rebuilding from the
|
|
2505
|
+
// columns alone silently dropped the rest.
|
|
2506
|
+
const record = { ...original };
|
|
2497
2507
|
for (const column of columns) {
|
|
2498
2508
|
const raw = row.querySelector(`[data-field="${column.field}"]`)?.value.trim() ?? '';
|
|
2499
|
-
|
|
2509
|
+
// Emptied on purpose is still an instruction, so the field goes
|
|
2510
|
+
// rather than the original value surviving underneath it.
|
|
2511
|
+
if (!raw) {
|
|
2512
|
+
delete record[column.field];
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2500
2515
|
if (column.list) {
|
|
2501
2516
|
record[column.field] = raw
|
|
2502
2517
|
.split(',')
|
|
@@ -2527,8 +2542,19 @@
|
|
|
2527
2542
|
for (const [key, columns] of Object.entries(specs)) {
|
|
2528
2543
|
const panel = document.querySelector(`[data-row-editor="${key}"]`);
|
|
2529
2544
|
if (!panel) continue;
|
|
2545
|
+
const originals = rowEditorRows[key] ?? [];
|
|
2530
2546
|
const records = [...panel.querySelectorAll('tbody tr')]
|
|
2531
|
-
.map((row) =>
|
|
2547
|
+
.map((row) => {
|
|
2548
|
+
// A row added in the console has no original; one rendered from
|
|
2549
|
+
// the configuration is matched back to it, so a removed row takes
|
|
2550
|
+
// its own hidden fields with it and the others keep theirs.
|
|
2551
|
+
const origin = row.dataset.origin;
|
|
2552
|
+
return readRow(
|
|
2553
|
+
row,
|
|
2554
|
+
columns,
|
|
2555
|
+
origin === undefined ? {} : (originals[Number(origin)] ?? {}),
|
|
2556
|
+
);
|
|
2557
|
+
})
|
|
2532
2558
|
.filter((record) => record[columns[0].field]);
|
|
2533
2559
|
updates[key] = records;
|
|
2534
2560
|
}
|
|
@@ -3022,6 +3048,9 @@
|
|
|
3022
3048
|
// ── Settings ──────────────────────────────────────────────────────────
|
|
3023
3049
|
let restartKeys = new Set();
|
|
3024
3050
|
let rowEditorColumns = {};
|
|
3051
|
+
// What each editor was rendered from, so a save can put back the fields
|
|
3052
|
+
// it never showed.
|
|
3053
|
+
let rowEditorRows = {};
|
|
3025
3054
|
|
|
3026
3055
|
async function loadSettings() {
|
|
3027
3056
|
const { config, restartRequired, configPath } = await api('/api/config');
|
|
@@ -3080,6 +3109,7 @@
|
|
|
3080
3109
|
// are lists of small records, which is exactly what a torrent client
|
|
3081
3110
|
// gives a grid for, and what a textarea full of braces is worst at.
|
|
3082
3111
|
rowEditorColumns = {};
|
|
3112
|
+
rowEditorRows = {};
|
|
3083
3113
|
await renderTokenEditor(body);
|
|
3084
3114
|
renderHookEditor(body, config, restartKeys);
|
|
3085
3115
|
|
|
@@ -3352,7 +3382,12 @@
|
|
|
3352
3382
|
placeholder: '1',
|
|
3353
3383
|
number: true,
|
|
3354
3384
|
},
|
|
3355
|
-
{
|
|
3385
|
+
{
|
|
3386
|
+
field: 'categories',
|
|
3387
|
+
label: 'Categories',
|
|
3388
|
+
placeholder: 'from-peer, planet',
|
|
3389
|
+
list: true,
|
|
3390
|
+
},
|
|
3356
3391
|
{ field: 'filter', label: 'Name filter', placeholder: 'terrain' },
|
|
3357
3392
|
{ field: 'token', label: 'Token', placeholder: 'if issued one', secret: true },
|
|
3358
3393
|
{
|