Checkpoint peering identity and opt-in discovery

This commit is contained in:
Codex committed 2026-10-13 22:00:54 +02:00
1 parent eba9b50e30
commit 47de081721
44 files changed
+1557 -55

No files matched your search

+54 -4
View File
@@ -11,6 +11,7 @@ const { scanLibrary, findManga, shouldDownload } = require('../scraper/lib/libra
const { publishManifest } = require('../scraper/lib/publisher');
const { writeJsonAtomic } = require('../scraper/lib/json-file');
const { sleep } = require('../scraper/lib/delay');
const { normalizeWorkIdentity, sameWorkIdentity, resolveWorkIdentity } = require('../scraper/lib/work-identity');
const dataRoot = () => path.resolve(process.env.PEER_DATA || path.join(process.env.DATA || 'data', 'peering'));
const store = () => new PeeringState(dataRoot());
@@ -28,9 +29,41 @@ function refsOf(result) {
return result.chapters.map(ref => ({ id: ref.id, number: ref.number, volume: ref.volume,
book: { number: Number(ref.volume) || 1 }, title: ref.title, pages: ref.pageCount }));
}
function sameTitle(left, right) {
return String(left || '').normalize('NFKC').trim().replace(/\s+/g, ' ').toLowerCase()
=== String(right || '').normalize('NFKC').trim().replace(/\s+/g, ' ').toLowerCase();
}
async function pageHashes(folder) {
const names = (await fs.readdir(folder, { withFileTypes: true }))
.filter(entry => entry.isFile() && /\.(?:jpe?g|png|webp|gif|avif|bmp)$/i.test(entry.name))
.map(entry => entry.name).sort();
if (!names.length) return [];
return Promise.all(names.map(async name => crypto.createHash('sha256')
.update(await fs.readFile(path.join(folder, name))).digest('hex')));
}
async function matchLegacyPeerCopy(inventory, remote, peerStore, info) {
// Pre-work-ID peer imports can be adopted only with byte-for-byte evidence;
// a matching title alone is never sufficient to merge library works.
const candidates = inventory.mangas.filter(manga => sameTitle(manga.title, remote.title));
if (candidates.length !== 1) return null;
const manga = candidates[0];
for (const ref of remote.chapters) {
const book = String(Number(ref.volume) || Number(ref.book?.number) || 1);
const local = manga.chapters.filter(chapter => Number(chapter.book) === Number(book)
&& String(chapter.number) === String(ref.number));
if (!local.length) continue;
const manifest = await client.chapter(peerStore, info.peerId, info.shareId, ref.id);
const expected = manifest.pages.map(page => page.sha256);
for (const chapter of local) {
const actual = await pageHashes(path.join(manga.path, chapter.bookFolder, chapter.version));
if (actual.length === expected.length && actual.every((hash, index) => hash === expected[index])) return manga;
}
}
return null;
}
async function discover(info) {
const result = await client.manga(store(), info.peerId, info.shareId);
return { title: result.title, chapters: refsOf(result), availability: { selectedChapters: result.chapters.length, coverage: 'shared-selection' } };
return { title: result.title, workIdentity: normalizeWorkIdentity(result.workIdentity), chapters: refsOf(result), availability: { selectedChapters: result.chapters.length, coverage: 'shared-selection' } };
}
async function run(info, item, options) {
const stateStore = store(), peer = requirePeer(stateStore.load(), info.peerId);
@@ -38,7 +71,10 @@ async function run(info, item, options) {
const stop = () => { const reason = options.shouldStop?.(); if (reason) throw new ArchiveControlError(reason === true ? 'paused' : String(reason)); };
stop();
const remote = await client.manga(stateStore, info.peerId, info.shareId);
const catalog = { title: remote.title, chapters: refsOf(remote), availability: { selectedChapters: remote.chapters.length, coverage: 'shared-selection' } };
const workIdentity = normalizeWorkIdentity(remote.workIdentity);
const queuedIdentity = normalizeWorkIdentity(item.workIdentity);
if (queuedIdentity && !sameWorkIdentity(queuedIdentity, workIdentity)) throw new Error('Shared manga identity changed after it was queued; review the title before syncing');
const catalog = { title: remote.title, workIdentity, chapters: refsOf(remote), availability: { selectedChapters: remote.chapters.length, coverage: 'shared-selection' } };
if (options.onCatalog) await options.onCatalog(catalog);
const sourceUrl = info.url, source = info.source;
const workRoot = path.join(options.libraryRoot, `.mcscraper-work-${info.id}`);
@@ -50,14 +86,28 @@ async function run(info, item, options) {
catch (e) { if (e.code !== 'ENOENT') throw e; }
const record = async () => { job.updated = new Date().toISOString(); await writeJsonAtomic(stateFile, job); };
const inventory = await scanLibrary(options.libraryRoot);
let manga = findManga(inventory, { source, seriesId: info.id, sourceUrl, libraryMangaId: item.libraryMangaId });
let manga = findManga(inventory, { source, seriesId: info.id, sourceUrl, workIdentity,
localServerId: stateStore.identity()?.id, libraryMangaId: item.libraryMangaId });
if (!manga && (!workIdentity || workIdentity.kind === 'origin')) {
manga = await matchLegacyPeerCopy(inventory, remote, stateStore, info);
}
if (!manga && inventory.mangas.some(candidate => sameTitle(candidate.title, remote.title))) {
throw new Error(`A manga titled “${remote.title}” already exists, but neither its canonical identity nor shared chapter page hashes verify that it is the same work. No files were downloaded or changed; review the identity before syncing.`);
}
if (item.libraryMangaId && !manga) throw new Error(`Library manga target not found: ${item.libraryMangaId}`);
const localIdentity = manga ? resolveWorkIdentity(manga.meta, manga.id, stateStore.identity()?.id) : null;
if (manga && localIdentity && !normalizeWorkIdentity(manga.meta['x-work-identity'])) {
manga.meta = { ...manga.meta, 'x-work-identity': localIdentity };
await writeJsonAtomic(path.join(manga.path, 'manga.json'), manga.meta);
}
const selected = selectByScope(catalog.chapters.filter(ref => shouldDownload(manga, ref, source, item.existingPolicy)), item.scope);
job.total = selected.length; job.jobCompleted = 0; job.current = null; await record();
let publishedCount = 0;
const identityToPersist = localIdentity || workIdentity;
const series = { title: remote.title, direction: ['rtl', 'ltr', 'ttb'].includes(remote.direction) ? remote.direction : 'rtl',
sources: [{ name: `Peer ${peer.alias}`, url: sourceUrl }],
metadata: { title: remote.title }, scraper: { ids: { [source]: info.id } } };
metadata: { title: remote.title, ...(identityToPersist ? { 'x-work-identity': identityToPersist } : {}) },
scraper: { ids: { [source]: info.id } } };
for (let index = 0; index < selected.length; index++) {
stop();
const ref = selected[index];