'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'; if (host === 'mangapill.com') return 'mangapill'; 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), releaseKind: chapterMeta.releaseKind || null, }); } } 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); if (ref.releaseKind === 'volume') return manga.chapters.filter(item => item.book === book && item.releaseKind === 'volume'); return manga.chapters.filter(item => item.book === book && item.number === number); } function shouldDownload(manga, ref, source, existingPolicy = 'skip-any') { const versions = existingVersions(manga, ref); // A whole-volume archive can overlap every numbered chapter in that book. // Require an explicit add-source choice before storing both representations. if (ref.releaseKind === 'volume' && existingPolicy === 'skip-any' && manga?.chapters.some(item => item.book === chapterBook(ref, manga) && item.releaseKind !== 'volume')) return false; 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, };