169 lines
7.6 KiB
JavaScript
169 lines
7.6 KiB
JavaScript
'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');
|
|
const { resolveWorkIdentity, sameWorkIdentity } = require('./work-identity');
|
|
|
|
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 parsed = new URL(String(url));
|
|
const host = parsed.hostname.replace(/^www\./, '').toLowerCase();
|
|
if (parsed.protocol === 'peer:' && /^p[0-9a-f]{24}$/.test(host)) return `peer-${host}`;
|
|
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, bookFolder: bookEntry.name, 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 (identity.workIdentity) {
|
|
const matches = inventory.mangas.filter(manga => sameWorkIdentity(
|
|
resolveWorkIdentity(manga.meta, manga.id, identity.localServerId), identity.workIdentity));
|
|
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];
|
|
}
|
|
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,
|
|
};
|