Files
MangaCircus/library.js
Maashkinoozhe 8dd776b7f4 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
2026-10-01 09:35:05 +02:00

183 lines
8.5 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };