generated from ztimson/template
Decompression caching and preview links
This commit is contained in:
@@ -4,16 +4,24 @@ import {pipeline} from 'node:stream/promises';
|
||||
import {Readable} from 'node:stream';
|
||||
import {ZimReader} from './reader.js';
|
||||
import {zimCatalog, zimCatalogInfo, CATALOG_URL} from './catalog.js';
|
||||
import {fuzzyMatch, titleFromUrl} from './utils.js';
|
||||
|
||||
const DEFAULT_READER_TTL = 60_000;
|
||||
|
||||
/** Manages a local directory of ZIM archives: listing, update checks, downloads, and reading. */
|
||||
export class ZimManager {
|
||||
#catalog;
|
||||
#dir;
|
||||
#readerTTL;
|
||||
#clusterTTL;
|
||||
#readers = new Map(); // file -> {reader, timer}
|
||||
#opening = new Map(); // file -> Promise<ZimReader>, dedupes concurrent first-open races
|
||||
|
||||
constructor(dir, catalog = CATALOG_URL) {
|
||||
/** @param {{catalog?: string, readerTTL?: number, clusterTTL?: number}} [opts] */
|
||||
constructor(dir, catalog = CATALOG_URL, {readerTTL = DEFAULT_READER_TTL, clusterTTL} = {}) {
|
||||
this.#catalog = catalog;
|
||||
this.#dir = dir;
|
||||
this.#readerTTL = readerTTL;
|
||||
this.#clusterTTL = clusterTTL; // undefined -> ZimReader's own default
|
||||
}
|
||||
|
||||
async #download(url, destPath) {
|
||||
@@ -58,13 +66,13 @@ export class ZimManager {
|
||||
const localDate = localMatch?.meta?.updated ?? null;
|
||||
if (!force && localMatch && remoteDate && localDate && remoteDate <= localDate)
|
||||
return {name, status: 'skipped', reason: 'up to date'};
|
||||
if (!catalogEntry.href) return {name, status: 'skipped', reason: 'missing download link'};
|
||||
if (!catalogEntry.download) return {name, status: 'skipped', reason: 'missing download link'};
|
||||
|
||||
const filename = path.basename(new URL(catalogEntry.href).pathname).replace(/\.meta4$/i, '');
|
||||
const filename = path.basename(new URL(catalogEntry.download).pathname).replace(/\.meta4$/i, '');
|
||||
const destPath = path.join(this.#dir, filename);
|
||||
await this.#download(catalogEntry.href, destPath);
|
||||
if (localMatch && localMatch.file !== destPath) await new ZimReader(localMatch.file).delete().catch(() => {});
|
||||
return {name, status: 'updated', file: destPath};
|
||||
await this.#download(catalogEntry.download, destPath);
|
||||
if (localMatch && localMatch.file !== filename) await this.#evict(path.join(this.#dir, localMatch.file));
|
||||
return {name, status: 'updated', file: filename};
|
||||
}
|
||||
|
||||
/** Resolves a file path or catalog `name` to a local file path. */
|
||||
@@ -75,7 +83,16 @@ export class ZimManager {
|
||||
const local = await this.list();
|
||||
const match = local.find(l => l.meta?.name === fileOrName || path.basename(l.file) === fileOrName);
|
||||
if (!match) throw new Error(`ZIM not found locally: ${fileOrName}`);
|
||||
return match.file;
|
||||
return path.join(this.#dir, match.file);
|
||||
}
|
||||
|
||||
/** Closes and drops a cached reader for `file`, if any (used before delete/replace). */
|
||||
async #evict(file) {
|
||||
const entry = this.#readers.get(file);
|
||||
if (!entry) return new ZimReader(file).delete().catch(() => {});
|
||||
clearTimeout(entry.timer);
|
||||
this.#readers.delete(file);
|
||||
await entry.reader.delete();
|
||||
}
|
||||
|
||||
catalog(search, opts) {
|
||||
@@ -84,8 +101,8 @@ export class ZimManager {
|
||||
|
||||
async delete(fileOrName) {
|
||||
const file = await this.#resolveFile(fileOrName);
|
||||
await new ZimReader(file).delete();
|
||||
return {file, status: 'deleted'};
|
||||
await this.#evict(file);
|
||||
return {file: path.basename(file), status: 'deleted'};
|
||||
}
|
||||
|
||||
/** Checks whether a local ZIM has a newer version in the catalog, without downloading. */
|
||||
@@ -105,7 +122,7 @@ export class ZimManager {
|
||||
const files = (await fs.promises.readdir(this.#dir)).filter(f => f.endsWith('.zim'));
|
||||
return Promise.all(files.map(async f => {
|
||||
const file = path.join(this.#dir, f);
|
||||
return {file, meta: await this.#readMeta(file)};
|
||||
return {file: f, ...(await this.#readMeta(file))};
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -118,7 +135,7 @@ export class ZimManager {
|
||||
|
||||
const local = await this.list();
|
||||
const localMatch = local.find(l => l.meta?.name === name) ?? null;
|
||||
const catalogEntry = await zimCatalogInfo(name, this.#catalog) || {name, updated: null, href};
|
||||
const catalogEntry = await zimCatalogInfo(name, this.#catalog) || {name, updated: null, download: href};
|
||||
return this.#update(name, catalogEntry, localMatch, force);
|
||||
}
|
||||
|
||||
@@ -141,11 +158,38 @@ export class ZimManager {
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Opens a `ZimReader` for a local ZIM, resolved by file path or catalog `name`. Caller must `.close()` it. */
|
||||
/** Opens a fresh `ZimReader` for a local ZIM, resolved by file path or catalog `name`. Caller must `.close()` it. */
|
||||
async open(fileOrName) {
|
||||
await this.#ensureDir();
|
||||
const file = await this.#resolveFile(fileOrName);
|
||||
return new ZimReader(file).open();
|
||||
return new ZimReader(file, {clusterTTL: this.#clusterTTL}).open();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cached, persistently-open `ZimReader` for serving requests — avoids
|
||||
* re-opening the file per request. Idle-evicted after `readerTTL` ms of no use.
|
||||
*/
|
||||
async getCached(fileOrName) {
|
||||
await this.#ensureDir();
|
||||
const file = await this.#resolveFile(fileOrName);
|
||||
let entry = this.#readers.get(file);
|
||||
if (!entry) {
|
||||
let pending = this.#opening.get(file);
|
||||
if (!pending) {
|
||||
pending = new ZimReader(file, {clusterTTL: this.#clusterTTL}).open();
|
||||
this.#opening.set(file, pending);
|
||||
}
|
||||
const reader = await pending;
|
||||
this.#opening.delete(file);
|
||||
entry = this.#readers.get(file) ?? {reader};
|
||||
this.#readers.set(file, entry);
|
||||
}
|
||||
clearTimeout(entry.timer);
|
||||
entry.timer = setTimeout(() => {
|
||||
this.#readers.delete(file);
|
||||
entry.reader.close();
|
||||
}, this.#readerTTL).unref();
|
||||
return entry.reader;
|
||||
}
|
||||
|
||||
/** Fuzzy-searches titles across every local ZIM in the library, merging & re-ranking hits by score. */
|
||||
@@ -154,9 +198,9 @@ export class ZimManager {
|
||||
const perZim = await Promise.all(local.map(async ({file, meta}) => {
|
||||
let reader;
|
||||
try {
|
||||
reader = await new ZimReader(file).open();
|
||||
reader = await new ZimReader(path.join(this.#dir, file)).open();
|
||||
const hits = await reader.search(terms, {limit, htmlOnly});
|
||||
return hits.map(h => ({...h, file, name: meta?.name}));
|
||||
return hits.map(h => ({...h, file}));
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user