MangaParty: self-hosted manga/comic/webtoon reader
Zero-dependency Node server + plain HTML/JS frontend. - Library is a folder tree (Manga/Book/Chapter/pages), indexed in place; page roles (cover, double, extra, rotation) are file name tags - Accounts with per-user progress, reading settings, follows and new-chapter notifications (in-app + optional webhook) - Reader with Flow, Page and Book modes, pointer-anchored page zoom, tap zones, auto-hiding controls, chapter picker, page preloading - Multiple versions per chapter with community votes - Docker image and compose file - Demo library generator (tools/make-dummy.js) - Docs: architecture, library format, import, scraper guide
This commit is contained in:
commit
8dd776b7f4
29 files changed
+3499
No files matched your search
+182
@@ -0,0 +1,182 @@
|
||||
'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 IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i;
|
||||
const COVER = /^cover\.(jpe?g|png|webp|gif|avif|bmp)$/i;
|
||||
const KEY = /^([a-z]*) ?(\d+(?:\.\d+)?)(?=$|[\s_\-[(])[\s_-]*(.*)$/i;
|
||||
const DIRECTIONS = { rtl: 'rtl', ltr: 'ltr', ttb: 'ttb', vertical: 'ttb', webtoon: 'ttb' };
|
||||
const cmp = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }).compare;
|
||||
const str = x => (typeof x === 'string' ? x.trim() : '');
|
||||
const order = (a, b) => a.x - b.x || a.num - b.num;
|
||||
|
||||
// "c0002.5 Tea Time [MangaDex]" -> { key: '2.5', x: false, num: 2.5, title: 'Tea Time', src: 'MangaDex' }
|
||||
function parseKey(name) {
|
||||
const m = KEY.exec(name);
|
||||
if (!m) return null;
|
||||
const x = m[1].toLowerCase() === 'x', num = parseFloat(m[2]);
|
||||
let title = m[3].trim(), src = '';
|
||||
const b = /\s*\[([^\]]*)\]$/.exec(title);
|
||||
if (b) { src = b[1].trim(); title = title.slice(0, b.index).trim(); }
|
||||
return { key: (x ? 'x' : '') + num, x, num, title: title.replace(/^[-:.]\s*/, ''), src };
|
||||
}
|
||||
|
||||
function direction(...metas) {
|
||||
for (const m of metas) if (DIRECTIONS[str(m.direction).toLowerCase()]) return DIRECTIONS[str(m.direction).toLowerCase()];
|
||||
return 'rtl';
|
||||
}
|
||||
|
||||
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: direction(meta), 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 = 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.direction = direction(v.meta, bMeta, meta);
|
||||
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, extra: book.extra || ck.x, label: (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 };
|
||||
Reference in new issue
Block a user