Consolidate MangaCircus app, scraper, and Compose baseline

This commit is contained in:
Maashkinoozhe committed 2026-10-07 23:28:20 +02:00
1 parent cfb2d79103
commit eef4ffa07c
103 files changed
+9316 -172

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
'use strict';
const fs = require('fs').promises;
const path = require('path');
const { assertInside } = require('./names');
const { chapterNumber } = require('./scope');
const { isSpecialKey, parseSpecialFolder, parseKey } = require('../../chapter-identity');
const { writeJsonAtomic } = require('./json-file');
async function readJson(file) {
try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { return {}; }
}
async function directories(folder) {
try { return (await fs.readdir(folder, { withFileTypes: true })).filter(entry => entry.isDirectory() && !entry.name.startsWith('.')); }
catch { return []; }
}
function keyFromName(name) { return parseKey(name)?.key || null; }
function sourceKey(url, name = '') {
try {
const host = new URL(String(url)).hostname.replace(/^www\./, '').toLowerCase();
if (host === 'weebcentral.com') return 'weebcentral';
if (host === 'mangadex.org') return 'mangadex';
return host;
} catch {}
const label = String(name).toLowerCase();
if (label.startsWith('mangadex')) return 'mangadex';
if (label.startsWith('weebcentral')) return 'weebcentral';
return label.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
function autoUpdateFor(meta) {
const configured = meta?.['x-scraper']?.autoUpdate;
return configured === undefined ? String(meta?.status || '').toLowerCase() === 'ongoing' : !!configured;
}
async function scanLibrary(libraryRoot) {
const root = path.resolve(libraryRoot);
const mangas = [];
for (const mangaEntry of await directories(root)) {
const mangaPath = path.join(root, mangaEntry.name);
const meta = await readJson(path.join(mangaPath, 'manga.json'));
const chapters = [];
for (const bookEntry of await directories(mangaPath)) {
const book = keyFromName(bookEntry.name);
if (!book) continue;
const bookPath = path.join(mangaPath, bookEntry.name);
for (const versionEntry of await directories(bookPath)) {
const number = parseSpecialFolder(versionEntry.name)?.key || keyFromName(versionEntry.name);
if (!number) continue;
const chapterMeta = await readJson(path.join(bookPath, versionEntry.name, 'chapter.json'));
const source = typeof chapterMeta.source === 'string' ? { name: chapterMeta.source, url: '' } : (chapterMeta.source || {});
chapters.push({
book, number, version: versionEntry.name,
sourceName: String(source.name || ''), sourceUrl: String(source.url || ''),
sourceKey: sourceKey(source.url, source.name),
});
}
}
mangas.push({
id: mangaEntry.name, path: mangaPath, title: String(meta.title || mangaEntry.name), meta,
status: String(meta.status || 'unknown').toLowerCase(), autoUpdate: autoUpdateFor(meta),
ids: { ...(meta['x-scraper']?.ids || {}) }, sources: Array.isArray(meta.sources) ? meta.sources : [], chapters,
});
}
return { root, mangas };
}
function findManga(inventory, identity = {}) {
const source = String(identity.source || '');
const seriesId = identity.seriesId == null ? '' : String(identity.seriesId);
const sourceUrl = String(identity.sourceUrl || '');
const anilist = identity.anilist == null ? '' : String(identity.anilist);
const explicit = identity.libraryMangaId == null ? '' : String(identity.libraryMangaId);
if (explicit) return inventory.mangas.find(manga => manga.id === explicit) || null;
if (source && seriesId) {
const bySourceId = inventory.mangas.find(manga => String(manga.ids[source] ?? '') === seriesId);
if (bySourceId) return bySourceId;
}
if (sourceUrl) {
const byUrl = inventory.mangas.find(manga => manga.sources.some(item => item.url === sourceUrl));
if (byUrl) return byUrl;
}
if (anilist) {
const matches = inventory.mangas.filter(manga => String(manga.ids.anilist ?? manga.meta?.['x-anilist']?.id ?? '') === anilist);
// AniList identifies the work, not every pilot/edition on a source. Never
// silently merge a different listing from that same host into its sibling.
if (matches.length !== 1) return null;
if (source && seriesId && matches[0].ids[source] != null && String(matches[0].ids[source]) !== seriesId) return null;
return matches[0];
}
return null;
}
function chapterBook(ref, manga) {
if (manga?.meta?.['x-scraper']?.strategy === 'volumes' && ref.volume != null) return chapterNumber(ref.volume, 'volume');
return chapterNumber(ref.book?.number || 1, 'book');
}
function existingVersions(manga, ref) {
if (!manga) return [];
const book = chapterBook(ref, manga);
const number = isSpecialKey(ref.number) ? String(ref.number).toLowerCase() : chapterNumber(ref.number);
return manga.chapters.filter(item => item.book === book && item.number === number);
}
function shouldDownload(manga, ref, source, existingPolicy = 'skip-any') {
const versions = existingVersions(manga, ref);
if (!versions.length) return true;
if (existingPolicy === 'add-source') return !versions.some(item => item.sourceKey === source);
return false;
}
function isNewerThanLibrary(manga, ref) {
if (isSpecialKey(ref.number)) return !existingVersions(manga, ref).length;
if (!manga?.chapters?.length) return true;
const regular = manga.chapters.filter(item => !String(item.book).startsWith('x') && /^\d+(?:\.\d+)?$/.test(String(item.number)));
if (!regular.length) return true;
const latest = regular.reduce((best, item) => {
if (!best) return item;
return Number(item.book) > Number(best.book)
|| (Number(item.book) === Number(best.book) && Number(item.number) > Number(best.number)) ? item : best;
}, null);
const candidate = { book: chapterBook(ref, manga), number: chapterNumber(ref.number) };
return Number(candidate.book) > Number(latest.book)
|| (Number(candidate.book) === Number(latest.book) && Number(candidate.number) > Number(latest.number));
}
function summarizeInventory(inventory, supported) {
return inventory.mangas.map(manga => ({
id: manga.id, title: manga.title, status: manga.status, autoUpdate: manga.autoUpdate,
chapters: new Set(manga.chapters.map(item => `${item.book}/${item.number}`)).size,
versions: manga.chapters.length,
sources: manga.sources.map(source => {
const found = supported(source.url);
return { name: source.name, url: source.url, adapter: found?.adapter.id || null };
}),
}));
}
async function setAutoUpdate(libraryRoot, mangaId, enabled) {
const root = path.resolve(libraryRoot);
const folder = assertInside(root, path.join(root, String(mangaId)));
const stat = await fs.stat(folder).catch(() => null);
if (!stat?.isDirectory() || path.basename(folder).startsWith('.')) throw new Error('Library manga not found');
const file = path.join(folder, 'manga.json');
const meta = await readJson(file);
meta['x-scraper'] = { ...(meta['x-scraper'] || {}), autoUpdate: !!enabled };
await writeJsonAtomic(file, meta);
return { id: mangaId, autoUpdate: !!enabled };
}
module.exports = {
scanLibrary, findManga, existingVersions, shouldDownload, summarizeInventory,
isNewerThanLibrary, setAutoUpdate, sourceKey, autoUpdateFor, keyFromName,
};