Consolidate MangaCircus app, scraper, and Compose baseline
This commit is contained in:
1 parent
cfb2d79103
commit
eef4ffa07c
103 files changed
+9316
-172
No files matched your search
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user