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

+19
View File
@@ -87,6 +87,8 @@ test('metadata refresh preserves legacy and manual fields while updating untouch
const third = mergeImportedMetadata(second, { description: 'First', 'x-anilist': { id: 42 } }, 'Fallback');
assert.equal(mergeImportedMetadata(third, { description: 'Revised', 'x-anilist': { id: 42 } }, 'Fallback').description, 'Revised');
assert.throws(() => mergeImportedMetadata(first, { 'x-anilist': { id: 99 } }, 'Fallback'), /identity conflict/);
assert.throws(() => mergeImportedMetadata({ 'x-work-identity': { scheme: 'mangacircus-work-v1', kind: 'origin', value: 'a'.repeat(64) } },
{ 'x-work-identity': { scheme: 'mangacircus-work-v1', kind: 'origin', value: 'b'.repeat(64) } }, 'Fallback'), /work identity conflict/);
});
test('publisher rejects a conflicting work ID before fetching any pages', async t => {
@@ -107,6 +109,23 @@ test('publisher rejects a conflicting work ID before fetching any pages', async
assert.equal((await fs.readdir(manga)).includes('v01'), false);
});
test('publisher explains an unresolved same-title folder collision without touching its contents', async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mcscraper-title-collision-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const manga = path.join(root, 'Same Title');
await fs.mkdir(manga);
await fs.writeFile(path.join(manga, 'manga.json'), JSON.stringify({ title: 'Same Title' }));
await fs.writeFile(path.join(manga, 'operator-note.txt'), 'keep');
let requested = 0;
await assert.rejects(publishManifest({ version: 1,
series: { title: 'Same Title', direction: 'rtl', sources: [{ name: 'Peer', url: 'peer://p123/m123' }] },
chapters: [{ number: '1', source: { name: 'Peer', url: 'peer://p123/m123' }, pages: [{ url: 'https://example.test/page.png' }] }],
}, { libraryRoot: root, httpClient: { async get() { requested++; throw new Error('unexpected request'); } } }),
/could not be safely matched.*No files were overwritten.*Choose the existing manga/s);
assert.equal(requested, 0);
assert.equal(await fs.readFile(path.join(manga, 'operator-note.txt'), 'utf8'), 'keep');
});
test('queue scopes select exact catalog slices', () => {
const chapters = [
{ id: 'a', number: '1', volume: '1' }, { id: 'b', number: '2', volume: '1' },
+50
View File
@@ -0,0 +1,50 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { recordSource, recordFailure, ledgerRows } = require('../lib/source-ledger');
const { ScraperService } = require('../service');
test('a URL lead, a parsed catalog, and rights remain separate states', () => {
const ledger = {};
const item = { id: 'job', source: 'weebcentral', seriesId: 'series', url: 'https://weebcentral.com/series/series', title: 'Example' };
assert.equal(recordSource(ledger, item).catalog, 'unverified');
const row = recordSource(ledger, item, { catalogTotal: 0, availability: { reason: 'unrecognized-labels', coverage: 'unknown' },
observedAt: '2026-10-13T12:00:00Z' });
assert.equal(row.catalog, 'found');
assert.equal(row.adapter, 'unsupported-labels');
assert.equal(row.rights, 'unverified');
assert.equal(row.hostedCount, 0);
recordFailure(ledger, item, { kind: 'adapter', message: 'Parser could not read labels' });
assert.equal(ledger['weebcentral:series'].lastFailure.kind, 'adapter');
assert.equal(ledgerRows(ledger).length, 1);
});
test('a catalog observation survives completed queue cleanup and service restart', async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mc-source-ledger-'));
const dataRoot = path.join(root, 'data'), libraryRoot = path.join(root, 'library');
await fs.mkdir(dataRoot);
await fs.writeFile(path.join(dataRoot, 'queue.json'), JSON.stringify({ paused: true, queue: [],
settings: { autoUpdateEnabled: false } }));
const adapter = { id: 'fixture', label: 'Fixture', match: url => ({ source: 'fixture', id: url.split('/').at(-1), url, title: 'Example' }) };
const options = { dataRoot, libraryRoot, adapters: [adapter], adapterFor: url => ({ adapter, info: adapter.match(url) }) };
let service = await new ScraperService(options).init();
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
const item = await service.enqueue({ url: 'https://example.test/series', inspect: true });
assert.equal(service.state.sourceLedger['fixture:series'].catalog, 'unverified');
await service.recordCatalog(item, { title: 'Canonical Example', chapters: [{ id: 'one' }],
availability: { language: 'en', coverage: 'unknown' } });
item.kind = 'download'; item.status = 'complete';
await service.clearCompleted();
await service.close();
service = await new ScraperService(options).init();
const [row] = (await service.publicState()).sourceLedger;
assert.equal(service.state.queue.length, 0);
assert.equal(row.sourceTitle, 'Canonical Example');
assert.equal(row.adapter, 'compatible');
assert.equal(row.hostedCount, 1);
assert.equal(row.rights, 'unverified');
});
+33
View File
@@ -0,0 +1,33 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { normalizeWorkIdentity, resolveWorkIdentity, sameWorkIdentity } = require('../lib/work-identity');
const { findManga } = require('../lib/library-inventory');
test('work identity follows canonical source priority and preserves an imported lineage', () => {
const anilist = { scheme: 'mangacircus-work-v1', kind: 'anilist', value: '123' };
const meta = { 'x-anilist': { id: 123 }, 'x-scraper': { ids: { mangadex: 'md-1', weebcentral: 'wc-1' } } };
assert.deepEqual(resolveWorkIdentity(meta, 'folder', 'server'), anilist);
const imported = { scheme: 'mangacircus-work-v1', kind: 'origin', value: 'a'.repeat(64) };
assert.deepEqual(resolveWorkIdentity({ ...meta, 'x-work-identity': imported }, 'other', 'other-server'), imported);
assert.equal(normalizeWorkIdentity({ ...imported, value: '' }), null);
assert.equal(sameWorkIdentity(imported, { ...imported }), true);
assert.equal(sameWorkIdentity(imported, { ...imported, value: 'b'.repeat(64) }), false);
assert.equal(resolveWorkIdentity({ 'x-scraper': { ids: { 'peer-p123': 'edition-only' } } }, 'folder', 'server').kind,
'origin', 'A peer edition ID is not a canonical work identity');
});
test('library matching uses a peer-carried stable identity before title or peer-edition IDs', () => {
const localServerId = 'p-local-server';
const inventory = { mangas: [
{ id: 'Peer A Test Manga', meta: { title: 'Peer A Test Manga' }, ids: {}, sources: [], chapters: [] },
{ id: 'Unrelated edition', meta: { title: 'Peer A Test Manga' }, ids: { 'peer-p-other': 'p-other-manga' }, sources: [], chapters: [] },
] };
const identity = resolveWorkIdentity(inventory.mangas[0].meta, inventory.mangas[0].id, localServerId);
assert.equal(findManga(inventory, { source: 'peer-p-returning', seriesId: 'p-returning-manga',
workIdentity: identity, localServerId }).id, 'Peer A Test Manga');
assert.equal(findManga(inventory, { source: 'peer-p-returning', seriesId: 'p-returning-manga',
workIdentity: { ...identity, value: 'f'.repeat(64) }, localServerId }), null,
'A matching title alone must not merge unrelated work');
});