Compare commits

..
2 Commits
Author SHA1 Message Date
ztimson 3c80bfae93 Track download progress
Publish Library / Build NPM Project (push) Successful in 1m50s
Publish Library / Tag Version (push) Successful in 7s
2026-10-05 15:08:09 -04:00
ztimson ba4bdceab1 Track download progress
Publish Library / Build NPM Project (push) Successful in 1m47s
Publish Library / Tag Version (push) Successful in 21s
2026-10-05 15:00:09 -04:00
4 changed files with 69 additions and 28 deletions
+3
View File
@@ -146,6 +146,9 @@ function resolveArchiveName() {
} }
async function install() { async function install() {
if (fs.existsSync(BIN_DIR) && (await fs.promises.readdir(BIN_DIR)).some(f => f.startsWith('kiwix-')))
return console.log(`Already installed: ${BIN_DIR}`);
const {archiveName, ext} = resolveArchiveName(); const {archiveName, ext} = resolveArchiveName();
const url = `${BASE_URL}/${archiveName}`; const url = `${BASE_URL}/${archiveName}`;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiwix-install-')); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kiwix-install-'));
+7 -7
View File
@@ -1,17 +1,17 @@
{ {
"name": "@ztimson/zim-utils", "name": "@ztimson/zim-utils",
"version": "0.3.6", "version": "0.4.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@ztimson/zim-utils", "name": "@ztimson/zim-utils",
"version": "0.3.6", "version": "0.4.8",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ztimson/utils": "^0.30.8", "@ztimson/utils": "^0.30.9",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.18",
"lzma1": "^0.3.0", "lzma1": "^0.3.0",
"tar": "^7.4.3", "tar": "^7.4.3",
"zstd-codec": "^0.1.5" "zstd-codec": "^0.1.5"
@@ -30,9 +30,9 @@
} }
}, },
"node_modules/@ztimson/utils": { "node_modules/@ztimson/utils": {
"version": "0.30.8", "version": "0.30.9",
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.30.8.tgz", "resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.30.9.tgz",
"integrity": "sha512-+vBjcinqckqMHkP95xWiQeQz2E7Q1oS0b+Odjp+F9rvQ4z0US4JdodjqhEaqh+RAO/yP77x4Eu0a04yBB4HNdw==", "integrity": "sha512-th/6qBaRJFnxoQk+01ESXjFqhYzsiXziOzd3gPgFG6XrusA+oHtfgxBKJ77ygPq//0cI85srWgAzvQcAmKUAQw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"var-persist": "^1.0.1" "var-persist": "^1.0.1"
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/zim-utils", "name": "@ztimson/zim-utils",
"version": "0.4.6", "version": "0.4.8",
"description": "Native, dependency-light ZIM archive reader/searcher and Kiwix catalog downloader for Node.js", "description": "Native, dependency-light ZIM archive reader/searcher and Kiwix catalog downloader for Node.js",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",
@@ -15,10 +15,10 @@
"postinstall": "node ./bin/install-kwix.js" "postinstall": "node ./bin/install-kwix.js"
}, },
"dependencies": { "dependencies": {
"@ztimson/utils": "^0.30.8", "@ztimson/utils": "^0.30.9",
"lzma1": "^0.3.0", "lzma1": "^0.3.0",
"tar": "^7.4.3", "tar": "^7.4.3",
"zstd-codec": "^0.1.5", "zstd-codec": "^0.1.5",
"adm-zip": "^0.5.16" "adm-zip": "^0.5.18"
} }
} }
+48 -10
View File
@@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import {pipeline} from 'node:stream/promises'; import {pipeline} from 'node:stream/promises';
import {Readable} from 'node:stream'; import {Readable} from 'node:stream';
import {PromiseProgress} from '@ztimson/utils';
import {KiwixServer} from './server.js'; import {KiwixServer} from './server.js';
import {zimCatalog, zimCatalogInfo, CATALOG_URL} from './catalog.js'; import {zimCatalog, zimCatalogInfo, CATALOG_URL} from './catalog.js';
@@ -17,6 +18,7 @@ export class ZimManager {
#dir; #dir;
#server; #server;
#ownsServer; #ownsServer;
#active = {}; // name -> in-flight PromiseProgress, for on-demand status lookups
/** @param {{catalog?: string, server?: KiwixServer, port?: number, host?: string, binDir?: string, url?: string}} [opts] /** @param {{catalog?: string, server?: KiwixServer, port?: number, host?: string, binDir?: string, url?: string}} [opts]
* server: reuse an existing KiwixServer instance directly. * server: reuse an existing KiwixServer instance directly.
@@ -41,11 +43,16 @@ export class ZimManager {
return this.#server; return this.#server;
} }
async #download(url, destPath) { /** Streams `url` to `destPath`, reporting raw bytes/total via `onBytes(size, total)` as chunks arrive. */
async #download(url, destPath, onBytes) {
const {res} = await this.#resolveUrl(url); const {res} = await this.#resolveUrl(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const total = Number(res.headers.get('content-length')) || 0;
const tmpPath = `${destPath}.part`; const tmpPath = `${destPath}.part`;
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(tmpPath)); let size = 0;
const readable = Readable.fromWeb(res.body);
if (onBytes) readable.on('data', chunk => onBytes(size += chunk.length, total));
await pipeline(readable, fs.createWriteStream(tmpPath));
await fs.promises.rename(tmpPath, destPath); await fs.promises.rename(tmpPath, destPath);
} }
@@ -75,7 +82,8 @@ export class ZimManager {
return false; return false;
} }
async #update(name, catalogEntry, localMatch, force) { /** Performs the actual download + post-processing for a resolved catalog entry, forwarding byte progress via `onBytes`. */
async #update(name, catalogEntry, localMatch, force, onBytes) {
const remoteDate = catalogEntry.updated ? new Date(catalogEntry.updated) : null; const remoteDate = catalogEntry.updated ? new Date(catalogEntry.updated) : null;
const localDate = localMatch?.updated ?? null; const localDate = localMatch?.updated ?? null;
if (!force && localMatch && remoteDate && localDate && remoteDate <= localDate) if (!force && localMatch && remoteDate && localDate && remoteDate <= localDate)
@@ -84,13 +92,22 @@ export class ZimManager {
const filename = path.basename(new URL(catalogEntry.href).pathname).replace(/\.meta4$/i, ''); const filename = path.basename(new URL(catalogEntry.href).pathname).replace(/\.meta4$/i, '');
const destPath = path.join(this.#dir, filename); const destPath = path.join(this.#dir, filename);
await this.#download(catalogEntry.href, destPath); await this.#download(catalogEntry.href, destPath, onBytes);
if (localMatch && localMatch.file !== filename) await fs.promises.rm(path.join(this.#dir, localMatch.file), {force: true}); if (localMatch && localMatch.file !== filename) await fs.promises.rm(path.join(this.#dir, localMatch.file), {force: true});
await this.#ensureServer(); await this.#ensureServer();
await this.#waitUntilVisible(filename, {expect: true}); await this.#waitUntilVisible(filename, {expect: true});
return {name, status: 'updated', file: filename}; return {name, status: 'updated', file: filename};
} }
/** Wraps `#update` in a tracked `PromiseProgress`, registered in `#active` under `name` until it settles. */
#trackedUpdate(name, catalogEntry, localMatch, force) {
const dl = new PromiseProgress((resolve, reject, prog) =>
this.#update(name, catalogEntry, localMatch, force, prog).then(resolve, reject));
this.#active[name] = dl;
dl.catch(() => {}).finally(() => { if (this.#active[name] === dl) delete this.#active[name]; });
return dl;
}
catalog(search, opts = {}) { catalog(search, opts = {}) {
return zimCatalog(search, {url: this.#catalogUrl, ...opts}); return zimCatalog(search, {url: this.#catalogUrl, ...opts});
} }
@@ -111,18 +128,28 @@ export class ZimManager {
return {file: match.file, status: 'deleted'}; return {file: match.file, status: 'deleted'};
} }
/** Downloads/updates a single ZIM by direct href, matching against any existing local copy by name. */ /** Downloads/updates a single ZIM by direct href, matching against any existing local copy by name.
async download(href, {force = false} = {}) { * Returns a `PromiseProgress` with live `.progress`/`.speed`/`.eta`/etc getters & `onProgress` callback,
* while also tracking it internally (see `progress()`/`downloads()`) in case the reference is dropped. */
download(href, {force = false} = {}) {
let dl;
dl = new PromiseProgress(async (resolve, reject, prog) => {
try {
const {url: finalUrl} = await this.#resolveUrl(href); const {url: finalUrl} = await this.#resolveUrl(href);
const filename = path.basename(new URL(finalUrl).pathname).replace(/\.meta4$/i, ''); const filename = path.basename(new URL(finalUrl).pathname).replace(/\.meta4$/i, '');
await fs.promises.mkdir(this.#dir, {recursive: true}); await fs.promises.mkdir(this.#dir, {recursive: true});
const local = await this.list(); const local = await this.list();
const exact = local.find(l => l.file === filename); const exact = local.find(l => l.file === filename);
if (exact && !force) return {name: exact.name ?? this.#stripDate(filename), status: 'skipped', reason: 'already downloaded', file: filename}; if (exact && !force) return resolve({name: exact.name ?? this.#stripDate(filename), status: 'skipped', reason: 'already downloaded', file: filename});
const name = this.#stripDate(filename); const name = this.#stripDate(filename);
const localMatch = local.find(l => l.name === name) ?? null; const localMatch = local.find(l => l.name === name) ?? null;
const catalogEntry = await zimCatalogInfo(name, this.#catalogUrl) || {name, updated: null, href}; const catalogEntry = await zimCatalogInfo(name, this.#catalogUrl) || {name, updated: null, href};
return this.#update(name, catalogEntry, localMatch, force); this.#active[name] = dl;
dl.catch(() => {}).finally(() => { if (this.#active[name] === dl) delete this.#active[name]; });
resolve(await this.#update(name, catalogEntry, localMatch, force, prog));
} catch (e) { reject(e); }
});
return dl;
} }
async link(href) { async link(href) {
@@ -159,7 +186,8 @@ export class ZimManager {
return server.search(terms, {limit, sources}); return server.search(terms, {limit, sources});
} }
/** Checks all local ZIMs against the catalog and updates any that are outdated. */ /** Checks all local ZIMs against the catalog and updates any that are outdated.
* Each in-flight update is individually tracked in `#active` - use `progress(name)`/`downloads()` to poll them mid-run. */
async updateAll({force = false} = {}) { async updateAll({force = false} = {}) {
const local = await this.list(); const local = await this.list();
const results = []; const results = [];
@@ -168,11 +196,21 @@ export class ZimManager {
const catalogEntry = await zimCatalogInfo(entry.name, this.#catalogUrl); const catalogEntry = await zimCatalogInfo(entry.name, this.#catalogUrl);
if (!catalogEntry) { results.push({name: entry.name, status: 'skipped', reason: 'missing from catalog'}); continue; } if (!catalogEntry) { results.push({name: entry.name, status: 'skipped', reason: 'missing from catalog'}); continue; }
try { try {
results.push(await this.#update(entry.name, catalogEntry, entry, force)); results.push(await this.#trackedUpdate(entry.name, catalogEntry, entry, force));
} catch (e) { } catch (e) {
results.push({name: entry.name, status: 'error', reason: e.message}); results.push({name: entry.name, status: 'error', reason: e.message});
} }
} }
return results; return results;
} }
/** Live progress snapshot (speed/eta/size/total/...) for a single named download, or `null` if nothing's in flight. */
progress(name) {
return this.#active[name] ? {name, ...this.#active[name].meta} : null;
}
/** Live progress snapshots for every currently active download. */
downloads() {
return Object.entries(this.#active).map(([name, dl]) => ({name, ...dl.meta}));
}
} }