pmtiles-swarm 0.25.0 → 0.26.1
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 +42 -0
- package/package.json +1 -1
- package/src/api.js +34 -1
- package/src/library.js +21 -1
- package/src/torrent-create.js +106 -15
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,48 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.26.1
|
|
11
|
+
### 🐞 Bug fixes
|
|
12
|
+
- **A download that finished was fetched all over again.** Resuming looked only at the
|
|
13
|
+
`.incomplete` path. A run that transferred the whole archive, had the marker removed, and then
|
|
14
|
+
stopped during the hashing left the file under its final name — so the restart found nothing to
|
|
15
|
+
resume and re-fetched every byte, with the finished copy sitting beside it the entire time.
|
|
16
|
+
Reported after 700 GB was transferred twice.
|
|
17
|
+
|
|
18
|
+
A file already under the final name is now checked against the length the source reports, and
|
|
19
|
+
hashed where it matches. Where it does not match it is not the archive being asked for, whatever
|
|
20
|
+
its name says, and the fetch proceeds — hashing it would publish the wrong bytes under the right
|
|
21
|
+
name, which is worse than transferring it again.
|
|
22
|
+
- **Hashing looked like a hang.** Progress was reported for the download and then nothing at all,
|
|
23
|
+
while `createTorrentFromFile` read the whole archive to build the piece hashes — twice, with
|
|
24
|
+
`md5` on. For a planet archive that is the longer half of the work and it was completely silent,
|
|
25
|
+
so a fetch reaching 100% and going quiet read as a stall. Reported as "it completed and never
|
|
26
|
+
started making the torrent", when it had been making it for some time. It now says what it is
|
|
27
|
+
doing when it starts, reports a heartbeat every minute while it runs, and says how long it took.
|
|
28
|
+
|
|
29
|
+
## 0.26.0
|
|
30
|
+
### 🐞 Bug fixes
|
|
31
|
+
- **The add dialog stayed on screen for the length of a download.** `POST /api/torrents` awaited
|
|
32
|
+
the entire transfer before answering, so for a URL the response arrived hours after the request
|
|
33
|
+
— and the console, which closes the dialog when the response lands, sat there over an archive
|
|
34
|
+
visibly appearing behind it.
|
|
35
|
+
|
|
36
|
+
The console was always written for the other arrangement: it says "fetching — watch the log" as
|
|
37
|
+
it closes, polls `/api/adds` for progress, and offers `DELETE /api/adds` to cancel. Only the
|
|
38
|
+
route was missing. It now answers **202** as soon as the URL has been checked — it answers, it is
|
|
39
|
+
an archive of a publishable kind, it is not a credential about to be broadcast — and lets the
|
|
40
|
+
transfer run behind it. Everything a person can correct is still reported in the dialog, because
|
|
41
|
+
all of it is found before the first byte moves.
|
|
42
|
+
|
|
43
|
+
Both shortcuts inside `addRemoteArchive` had to be taught the same signal. A URL already in the
|
|
44
|
+
catalog, or one already being fetched by somebody else, returns without ever reaching the checks
|
|
45
|
+
— so a response waiting on them would have waited for something that had already happened, or
|
|
46
|
+
for the whole of a download another caller had started.
|
|
47
|
+
|
|
48
|
+
`url` bodies now answer 202 with an acknowledgement rather than 201 with the finished entry.
|
|
49
|
+
Paths, magnets and `.torrent` URLs are unchanged: they were always fast, and still answer 201
|
|
50
|
+
with the entry.
|
|
51
|
+
|
|
10
52
|
## 0.25.0
|
|
11
53
|
### ✨ Features and improvements
|
|
12
54
|
- **A stopped download is kept, and adding the same URL again resumes it.** Staging directories
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.1",
|
|
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
|
@@ -1387,7 +1387,40 @@ export function createApp({
|
|
|
1387
1387
|
if (body.path) {
|
|
1388
1388
|
entry = await library.addLocalArchive(body.path, options);
|
|
1389
1389
|
} else if (body.url) {
|
|
1390
|
-
|
|
1390
|
+
// Answered as soon as the URL has been checked, not when the transfer
|
|
1391
|
+
// finishes. Awaiting the whole thing held the response open for the
|
|
1392
|
+
// length of the download -- hours for a planet archive -- so the
|
|
1393
|
+
// console's add dialog stayed on screen throughout, over an archive
|
|
1394
|
+
// that was visibly appearing behind it.
|
|
1395
|
+
//
|
|
1396
|
+
// Progress has its own route already: runningAdds() feeds /api/adds,
|
|
1397
|
+
// the console polls it, and DELETE /api/adds cancels one. This is the
|
|
1398
|
+
// piece that was missing rather than a new mechanism.
|
|
1399
|
+
const validated = Promise.withResolvers();
|
|
1400
|
+
const running = library
|
|
1401
|
+
.addRemoteArchive(body.url, {
|
|
1402
|
+
...options,
|
|
1403
|
+
onValidated: validated.resolve,
|
|
1404
|
+
})
|
|
1405
|
+
// A failure after validation has nowhere to be reported: the
|
|
1406
|
+
// response has gone. It is logged where the rest of the fetch is,
|
|
1407
|
+
// and swallowed here so it cannot take the process down as an
|
|
1408
|
+
// unhandled rejection.
|
|
1409
|
+
.catch((error) => {
|
|
1410
|
+
validated.reject(error);
|
|
1411
|
+
console.error(`[fetch] ${body.url}: ${error.message}`);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
// Whichever comes first: the checks passing, or the whole attempt
|
|
1415
|
+
// failing. A URL that does not answer, or is not an archive, still
|
|
1416
|
+
// reports itself in the dialog where somebody can correct it.
|
|
1417
|
+
await validated.promise;
|
|
1418
|
+
void running;
|
|
1419
|
+
return res.status(202).json({
|
|
1420
|
+
accepted: true,
|
|
1421
|
+
url: body.url,
|
|
1422
|
+
message: 'fetching; progress is reported by /api/adds',
|
|
1423
|
+
});
|
|
1391
1424
|
} else if (body.magnet) {
|
|
1392
1425
|
entry = await library.addExistingTorrent(
|
|
1393
1426
|
{ magnet: body.magnet },
|
package/src/library.js
CHANGED
|
@@ -541,8 +541,15 @@ export class Library {
|
|
|
541
541
|
* @returns {Promise<object>} - The catalog entry.
|
|
542
542
|
*/
|
|
543
543
|
async addRemoteArchive(url, options = {}) {
|
|
544
|
+
// Both shortcuts below have to fire onValidated before returning, and for
|
|
545
|
+
// the same reason: a caller waiting on it to answer a request would
|
|
546
|
+
// otherwise wait for something that has already happened, or -- worse --
|
|
547
|
+
// for the whole of a download somebody else started.
|
|
544
548
|
const existing = this.#catalog.findBySource(url);
|
|
545
|
-
if (existing)
|
|
549
|
+
if (existing) {
|
|
550
|
+
options.onValidated?.({ url, kind: existing.kind, held: true });
|
|
551
|
+
return existing;
|
|
552
|
+
}
|
|
546
553
|
|
|
547
554
|
// A second request for a URL already being fetched joins the first; the
|
|
548
555
|
// catalog cannot answer this, since an entry exists only once the download
|
|
@@ -550,6 +557,7 @@ export class Library {
|
|
|
550
557
|
const inFlight = this.#inFlight.get(url);
|
|
551
558
|
if (inFlight) {
|
|
552
559
|
console.log(`[fetch] ${url} is already being fetched; joining that one`);
|
|
560
|
+
options.onValidated?.({ url, joined: true });
|
|
553
561
|
return inFlight;
|
|
554
562
|
}
|
|
555
563
|
|
|
@@ -590,6 +598,18 @@ export class Library {
|
|
|
590
598
|
allowUnknown: options.allowUnknown ?? this.#config.allowUnknownArchives,
|
|
591
599
|
});
|
|
592
600
|
|
|
601
|
+
// Everything a caller can do something about has now been checked: the URL
|
|
602
|
+
// answers, it is an archive of a kind this will publish, and it is not a
|
|
603
|
+
// credential being broadcast to a swarm. What remains is the transfer,
|
|
604
|
+
// which for a planet archive is hours.
|
|
605
|
+
//
|
|
606
|
+
// A caller that wants to stop waiting there says so with onValidated. That
|
|
607
|
+
// is the difference between a dialog that closes on a bad URL with the
|
|
608
|
+
// reason in it, and one that sits open for the length of the download --
|
|
609
|
+
// and the console was always written for the former, since it reports
|
|
610
|
+
// progress through runningAdds() and says "watch the log" as it closes.
|
|
611
|
+
options.onValidated?.({ url, kind: identified.kind });
|
|
612
|
+
|
|
593
613
|
const summary =
|
|
594
614
|
identified.kind === 'pmtiles'
|
|
595
615
|
? await probePMTiles(url).catch(() => undefined)
|
package/src/torrent-create.js
CHANGED
|
@@ -49,13 +49,48 @@ export async function createTorrentFromFile(filePath, options = {}) {
|
|
|
49
49
|
if (!stat.isFile() || stat.size === 0) {
|
|
50
50
|
throw new Error(`not a usable file: ${filePath}`);
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
|
|
53
|
+
// Says that it started, and keeps saying it is going.
|
|
54
|
+
//
|
|
55
|
+
// Hashing reads the whole archive, and with md5 on it reads it twice — for a
|
|
56
|
+
// planet archive that is the longer half of the job, and it emitted nothing
|
|
57
|
+
// at all. The download reports every second and then stops dead, so the
|
|
58
|
+
// symptom is a fetch that reaches 100% and appears to hang; reported from the
|
|
59
|
+
// field as "it completed and never started making the torrent", when it had
|
|
60
|
+
// in fact been making it for some time.
|
|
61
|
+
const what = options.sourceUrl ?? filePath;
|
|
62
|
+
const passes = options.md5 ? 'twice, once of them for the MD5' : 'once';
|
|
63
|
+
console.log(
|
|
64
|
+
`[hash] ${what}: reading ${stat.size} bytes ${passes} to build the torrent`,
|
|
65
|
+
);
|
|
66
|
+
const startedAt = Date.now();
|
|
67
|
+
const heartbeat = setInterval(() => {
|
|
68
|
+
const minutes = Math.round((Date.now() - startedAt) / 60000);
|
|
69
|
+
console.log(`[hash] ${what}: still hashing after ${minutes}m`);
|
|
70
|
+
}, 60000);
|
|
71
|
+
heartbeat.unref?.();
|
|
72
|
+
options.onProgress?.({
|
|
73
|
+
phase: 'hashing',
|
|
74
|
+
received: stat.size,
|
|
75
|
+
total: stat.size,
|
|
58
76
|
});
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// A second read of the archive, which is why it is opt-in. Nothing else
|
|
80
|
+
// here touches these bytes again once the piece hashes are done.
|
|
81
|
+
const md5Digest = options.md5 ? await md5File(filePath) : undefined;
|
|
82
|
+
const built = await buildTorrent(
|
|
83
|
+
filePath,
|
|
84
|
+
path.basename(filePath),
|
|
85
|
+
stat.size,
|
|
86
|
+
{ ...options, md5Digest },
|
|
87
|
+
);
|
|
88
|
+
const seconds = Math.round((Date.now() - startedAt) / 1000);
|
|
89
|
+
console.log(`[hash] ${what}: torrent built in ${seconds}s`);
|
|
90
|
+
return built;
|
|
91
|
+
} finally {
|
|
92
|
+
clearInterval(heartbeat);
|
|
93
|
+
}
|
|
59
94
|
}
|
|
60
95
|
|
|
61
96
|
/**
|
|
@@ -91,21 +126,53 @@ export async function createTorrentFromUrl(url, options = {}) {
|
|
|
91
126
|
// verify. The marker means the URL 404s until the moment it is real.
|
|
92
127
|
const target = path.join(options.retainPath, name);
|
|
93
128
|
const marker = options.incompleteSuffix ?? DEFAULT_SUFFIX;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
129
|
+
const partial = `${target}${marker}`;
|
|
130
|
+
|
|
131
|
+
// A run that got all the way through the download and stopped during the
|
|
132
|
+
// hashing leaves the archive under its final name, the marker already
|
|
133
|
+
// removed. Resuming looked only at the marker path, so it saw nothing to
|
|
134
|
+
// continue, and fetched the whole thing again with the finished copy
|
|
135
|
+
// sitting beside it -- 700 GB re-transferred to arrive at a file that was
|
|
136
|
+
// already there.
|
|
137
|
+
//
|
|
138
|
+
// Hashing is the longer half for a large archive and reports nothing while
|
|
139
|
+
// it runs, so being interrupted in it is not unlikely.
|
|
140
|
+
const finished = await bytesOnDisk(target);
|
|
141
|
+
const resuming = await bytesOnDisk(partial);
|
|
142
|
+
let haveIt = false;
|
|
143
|
+
if (finished > 0 && resuming === 0) {
|
|
144
|
+
const expected = await remoteLength(url, options.signal);
|
|
145
|
+
if (expected && finished === expected) {
|
|
146
|
+
haveIt = true;
|
|
147
|
+
console.log(
|
|
148
|
+
`[fetch] ${url} is already downloaded (${finished} bytes); ` +
|
|
149
|
+
'hashing what is on disk rather than fetching it again',
|
|
150
|
+
);
|
|
151
|
+
} else {
|
|
152
|
+
// Same name, different length: whatever this is, it is not the archive
|
|
153
|
+
// being asked for, and hashing it would publish the wrong bytes under
|
|
154
|
+
// the right name. The download proceeds to the marker path as usual.
|
|
155
|
+
console.warn(
|
|
156
|
+
`[fetch] ${target} exists but is ${finished} bytes against ` +
|
|
157
|
+
`${expected ?? 'an unknown length'} at the source; fetching again`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!haveIt) {
|
|
163
|
+
await downloadTo(url, partial, options.onProgress, options.signal, {
|
|
100
164
|
attempts: options.fetchAttempts,
|
|
101
165
|
retryDelayMs: options.fetchRetryDelayMs,
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
166
|
+
});
|
|
167
|
+
if (marker) await fs.rename(partial, target);
|
|
168
|
+
}
|
|
169
|
+
|
|
105
170
|
const created = await createTorrentFromFile(target, {
|
|
106
171
|
...options,
|
|
107
172
|
name,
|
|
108
173
|
webSeeds,
|
|
174
|
+
onProgress: options.onProgress,
|
|
175
|
+
sourceUrl: url,
|
|
109
176
|
});
|
|
110
177
|
return { ...created, retainedAt: target };
|
|
111
178
|
}
|
|
@@ -157,6 +224,30 @@ export async function createTorrentFromUrl(url, options = {}) {
|
|
|
157
224
|
* @param {string} target - The partial file.
|
|
158
225
|
* @returns {Promise<number>} - Bytes on disk.
|
|
159
226
|
*/
|
|
227
|
+
/**
|
|
228
|
+
* What the source says the archive is, in bytes, or 0 when it will not say.
|
|
229
|
+
*
|
|
230
|
+
* Used to decide whether a file already under its final name is the archive
|
|
231
|
+
* being asked for. A HEAD is enough and costs nothing next to the alternative,
|
|
232
|
+
* which is transferring the whole thing a second time to find out.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} url - The archive's URL.
|
|
235
|
+
* @param {AbortSignal} [signal] - Cancels the probe.
|
|
236
|
+
* @returns {Promise<number>} - Length, or 0.
|
|
237
|
+
*/
|
|
238
|
+
async function remoteLength(url, signal) {
|
|
239
|
+
try {
|
|
240
|
+
const response = await fetch(url, { method: 'HEAD', signal });
|
|
241
|
+
if (!response.ok) return 0;
|
|
242
|
+
return Number(response.headers.get('content-length') ?? 0) || 0;
|
|
243
|
+
} catch {
|
|
244
|
+
// A source that refuses HEAD tells us nothing, which is not the same as
|
|
245
|
+
// telling us the file is wrong. The caller re-fetches, which is what it
|
|
246
|
+
// would have done anyway.
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
160
251
|
async function bytesOnDisk(target) {
|
|
161
252
|
const stat = await fs.stat(target).catch(() => null);
|
|
162
253
|
return stat?.isFile() ? stat.size : 0;
|