168 lines
8.1 KiB
JavaScript
168 lines
8.1 KiB
JavaScript
'use strict';
|
||
/*
|
||
* Library scanner: turns <library>/<Manga>/<Book>/<Chapter>/<page images>
|
||
* into an in-memory index. The folder format is documented in README.md.
|
||
*
|
||
* Only folder names, small JSON files and the first bytes of each image (for its size) are read.
|
||
* Image sizes are cached per chapter folder and only re-read when the folder's mtime changes.
|
||
*/
|
||
const fsp = require('fs').promises, path = require('path');
|
||
const { parseSpecialFolder, parseKey } = require('./chapter-identity');
|
||
const { resolveReadingDirection } = require('./reading-direction');
|
||
|
||
const IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i;
|
||
const COVER = /^cover\.(jpe?g|png|webp|gif|avif|bmp)$/i;
|
||
const cmp = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }).compare;
|
||
const str = x => (typeof x === 'string' ? x.trim() : '');
|
||
const order = (a, b) => Number(!!a.special) - Number(!!b.special) || a.x - b.x || a.num - b.num || cmp(a.key, b.key);
|
||
|
||
async function readJson(file) {
|
||
try { return JSON.parse((await fsp.readFile(file, 'utf8')).replace(/^/, '')); }
|
||
catch (e) { if (e.code !== 'ENOENT') console.warn(`[scan] ignoring broken ${file}: ${e.message}`); return {}; }
|
||
}
|
||
|
||
async function list(dir) {
|
||
const dirs = [], files = [];
|
||
for (const e of await fsp.readdir(dir, { withFileTypes: true })) {
|
||
if (e.name.startsWith('.')) continue;
|
||
let isDir = e.isDirectory();
|
||
if (e.isSymbolicLink()) isDir = await fsp.stat(path.join(dir, e.name)).then(s => s.isDirectory(), () => false);
|
||
(isDir ? dirs : files).push(e.name);
|
||
}
|
||
return { dirs: dirs.sort(cmp), files: files.sort(cmp) };
|
||
}
|
||
|
||
// Width/height from the image header (PNG, GIF, BMP, WebP, JPEG, AVIF/HEIF).
|
||
function sizeFromBuffer(b) {
|
||
try {
|
||
if (b.readUInt32BE(0) === 0x89504e47) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
|
||
if (b.toString('latin1', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) };
|
||
if (b.toString('latin1', 0, 2) === 'BM') return { w: b.readInt32LE(18), h: Math.abs(b.readInt32LE(22)) };
|
||
if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
|
||
const f = b.toString('latin1', 12, 16);
|
||
if (f === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff };
|
||
if (f === 'VP8L') { const v = b.readUInt32LE(21); return { w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1 }; }
|
||
if (f === 'VP8X') return { w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 };
|
||
}
|
||
if (b[0] === 0xff && b[1] === 0xd8) {
|
||
for (let i = 2; i + 9 < b.length;) {
|
||
if (b[i] !== 0xff) { i++; continue; }
|
||
const mk = b[i + 1];
|
||
if (mk === 0xff) { i++; continue; }
|
||
if (mk === 0x01 || (mk >= 0xd0 && mk <= 0xd8)) { i += 2; continue; }
|
||
if (mk >= 0xc0 && mk <= 0xcf && mk !== 0xc4 && mk !== 0xc8 && mk !== 0xcc) return { w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5) };
|
||
i += 2 + b.readUInt16BE(i + 2);
|
||
}
|
||
}
|
||
const k = b.indexOf('ispe');
|
||
if (k > 0 && b.toString('latin1', 4, 8) === 'ftyp') return { w: b.readUInt32BE(k + 8), h: b.readUInt32BE(k + 12) };
|
||
} catch { /* truncated or unknown header */ }
|
||
return null;
|
||
}
|
||
|
||
async function imageSize(file) {
|
||
const fh = await fsp.open(file, 'r');
|
||
let head;
|
||
try {
|
||
const buf = Buffer.alloc(256 * 1024);
|
||
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
||
head = buf.subarray(0, bytesRead);
|
||
} finally { await fh.close(); }
|
||
// JPEGs with big EXIF/ICC blocks can have their size marker further in.
|
||
return sizeFromBuffer(head) || (head.length === 256 * 1024 ? sizeFromBuffer(await fsp.readFile(file)) : null);
|
||
}
|
||
|
||
function pageInfo([f, w, h]) {
|
||
const t = f.slice(0, f.lastIndexOf('.')).toLowerCase().split('_').slice(1);
|
||
const rot = t.find(x => /^rot(90|180|270)$/.test(x));
|
||
return { f, w, h, t, r: rot ? +rot.slice(3) : 0 };
|
||
}
|
||
|
||
async function scanVersion(root, rel, ck, oldCache, cache) {
|
||
const dir = path.join(root, rel), st = await fsp.stat(dir);
|
||
const meta = await readJson(path.join(dir, 'chapter.json'));
|
||
let c = oldCache[rel];
|
||
if (!c || c.m !== st.mtimeMs) {
|
||
const p = [];
|
||
for (const f of (await list(dir)).files) {
|
||
if (!IMG.test(f)) continue;
|
||
const s = await imageSize(path.join(dir, f)).catch(() => null);
|
||
p.push([f, s ? s.w : 0, s ? s.h : 0]);
|
||
}
|
||
c = { m: st.mtimeMs, p };
|
||
}
|
||
cache[rel] = c;
|
||
const src = meta.source && typeof meta.source === 'object' ? meta.source : { name: meta.source };
|
||
return {
|
||
id: rel.slice(rel.lastIndexOf('/') + 1), rel, meta,
|
||
source: str(src.name) || ck.src, sourceUrl: str(src.url),
|
||
added: Date.parse(meta.added) || st.mtimeMs,
|
||
pages: c.p.map(pageInfo),
|
||
};
|
||
}
|
||
|
||
const thumbOf = v => { const p = v.pages.find(p => p.t.includes('cover')) || v.pages[0]; return v.rel + '/' + p.f; };
|
||
const coverIn = (files, rel) => { const f = files.find(f => COVER.test(f)); return f ? rel + '/' + f : null; };
|
||
|
||
async function scanManga(root, name, oldCache, cache) {
|
||
const { dirs, files } = await list(path.join(root, name));
|
||
const meta = await readJson(path.join(root, name, 'manga.json'));
|
||
const m = { id: name, title: str(meta.title) || name, meta, direction: resolveReadingDirection([meta]).value, cover: coverIn(files, name), books: [], chapters: [], chapterById: new Map(), updated: 0 };
|
||
const bookKeys = new Set();
|
||
for (const bName of dirs) {
|
||
const bk = parseKey(bName);
|
||
if (!bk) { console.warn(`[scan] ignoring "${name}/${bName}": book folders must be named <letters><number>[ title], e.g. "v01 Spring"`); continue; }
|
||
if (bookKeys.has(bk.key)) { console.warn(`[scan] ignoring "${name}/${bName}": another book folder already uses number ${bk.key}`); continue; }
|
||
bookKeys.add(bk.key);
|
||
const bRel = name + '/' + bName, bl = await list(path.join(root, bRel));
|
||
const bMeta = await readJson(path.join(root, bRel, 'book.json'));
|
||
const book = {
|
||
key: bk.key, num: bk.num, x: bk.x, extra: bk.x || bMeta.extra === true,
|
||
label: (bk.x ? 'Extra book ' : 'Book ') + bk.num, title: str(bMeta.title) || bk.title, cover: coverIn(bl.files, bRel), chapters: [],
|
||
};
|
||
const byKey = new Map();
|
||
for (const cName of bl.dirs) {
|
||
const ck = parseSpecialFolder(cName) || parseKey(cName);
|
||
if (!ck) { console.warn(`[scan] ignoring "${bRel}/${cName}": chapter folders must be named <letters><number>[ title], e.g. "c0001 The Seed"`); continue; }
|
||
const v = await scanVersion(root, bRel + '/' + cName, ck, oldCache, cache);
|
||
if (!v.pages.length) continue;
|
||
v.directionInfo = resolveReadingDirection([v.meta, bMeta, meta], v.pages);
|
||
v.direction = v.directionInfo.value;
|
||
let ch = byKey.get(ck.key);
|
||
if (!ch) {
|
||
ch = { id: book.key + '/' + ck.key, book: book.key, key: ck.key, num: ck.num, x: ck.x, special: !!ck.special, extra: book.extra || ck.x, label: ck.special ? str(v.meta.label) || 'Special' : (ck.x ? 'Extra ' : 'Ch. ') + ck.num, title: '', versions: [] };
|
||
byKey.set(ck.key, ch);
|
||
}
|
||
if (v.meta.extra === true) ch.extra = true;
|
||
ch.title = ch.title || str(v.meta.title) || ck.title;
|
||
ch.versions.push(v);
|
||
m.updated = Math.max(m.updated, v.added);
|
||
}
|
||
book.chapters = [...byKey.values()].sort(order);
|
||
if (book.chapters.length) m.books.push(book);
|
||
}
|
||
m.books.sort(order);
|
||
for (const b of m.books) {
|
||
b.cover = b.cover || thumbOf(b.chapters[0].versions[0]);
|
||
for (const ch of b.chapters) { m.chapters.push(ch); m.chapterById.set(ch.id, ch); }
|
||
}
|
||
m.cover = m.cover || (m.books[0] && m.books[0].cover) || null;
|
||
return m;
|
||
}
|
||
|
||
// Returns { index, cache }. Pass the previous cache to skip re-reading unchanged chapter folders.
|
||
async function scanLibrary(root, oldCache = {}) {
|
||
const cache = {}, mangas = [];
|
||
for (const name of (await list(root)).dirs) {
|
||
try {
|
||
const m = await scanManga(root, name, oldCache, cache);
|
||
if (m.chapters.length) mangas.push(m);
|
||
else console.warn(`[scan] "${name}" has no chapters (expected <manga>/<book>/<chapter>/<images>)`);
|
||
} catch (e) { console.warn(`[scan] skipped "${name}": ${e.message}`); }
|
||
}
|
||
mangas.sort((a, b) => cmp(a.title, b.title));
|
||
return { index: { mangas, byId: new Map(mangas.map(m => [m.id, m])) }, cache };
|
||
}
|
||
|
||
module.exports = { scanLibrary, parseKey, sizeFromBuffer, imageSize };
|