Checkpoint peering identity and opt-in discovery
This commit is contained in:
1 parent
eba9b50e30
commit
47de081721
44 files changed
+1557
-55
No files matched your search
+54
-4
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user