Add first-party MangaPill source and distinct volume releases

This commit is contained in:
Codex committed 2026-10-14 09:05:34 +02:00
1 parent a8df57f165
commit a08e847932
20 files changed
+364 -34

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
'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 { adapterFor } = require('../adapters');
const { parse } = require('../plugins/registry');
const { discoverMangaPillCatalog, discoverMangaPillChapter } = require('../adapters/mangapill');
const { validateHostRequest } = require('../plugins/broker');
const { runArchive } = require('../jobs/weebcentral-archive');
const { Canvas } = require('../../tools/png');
const { scanLibrary } = require('../lib/library-inventory');
const seriesUrl = 'https://mangapill.com/manga/552/bleach';
const chapterUrl = 'https://mangapill.com/chapters/552-10001000/bleach-chapter-1';
const imageUrl = 'https://cdn.readdetectiveconan.com/file/mangap/552/10001000/1.jpg';
const catalogHtml = `<h1 class="font-bold">Bleach</h1><div id="chapters"><a href="/chapters/552-10002000/bleach-chapter-2">Chapter 2</a><a href="/chapters/552-10001000/bleach-chapter-1">Chapter 1</a></div>`;
const chapterHtml = `<picture><img class="js-page" data-src="${imageUrl}"/></picture>`;
test('MangaPill discovery stays in its host and keeps ordered source chapter IDs', async () => {
assert.equal(adapterFor(seriesUrl).adapter.id, 'mangapill');
assert.equal(adapterFor('http://mangapill.com/manga/552/bleach'), null);
const http = { async get(url) {
assert.ok([seriesUrl, chapterUrl].includes(url));
return { async text() { return url === seriesUrl ? catalogHtml : chapterHtml; } };
} };
const catalog = await discoverMangaPillCatalog(seriesUrl, http);
assert.deepEqual(catalog.chapters.map(chapter => [chapter.id, chapter.number]),
[['552-10001000', '1'], ['552-10002000', '2']]);
const chapter = await discoverMangaPillChapter(catalog.chapters[0], http);
assert.deepEqual(chapter.pages, [{ url: imageUrl, headers: { referer: chapterUrl } }]);
assert.throws(() => parse('mangapill', 'pages', {
html: '<img class="js-page" data-src="https://other.example/file/mangap/1.jpg">', url: chapterUrl,
}), /page host/);
assert.throws(() => validateHostRequest('https://other.example/manga/552', 'mangapill.com', { accept: 'text/html' }), /left its source host/);
});
test('MangaPill uses the shared checkpoint-safe publisher and skips an existing chapter', async t => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mc-mangapill-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const png = new Canvas(10, 20, [1, 2, 3]).png();
const createHttp = () => ({ async get(url) {
if (url === seriesUrl) return { async text() { return catalogHtml.replace(/<a[^>]*>Chapter 2<\/a>/, ''); } };
if (url === chapterUrl) return { async text() { return chapterHtml; } };
if (url === imageUrl) return { async arrayBuffer() { return png; } };
throw new Error(`Unexpected source request: ${url}`);
} });
const options = { source: 'mangapill', sourceLabel: 'MangaPill', sourceId: '552',
discoverCatalog: discoverMangaPillCatalog, discoverChapter: discoverMangaPillChapter, createHttp,
seriesUrl, libraryRoot: root, scope: { mode: 'missing' },
minMs: 1000, maxMs: 1000, chapterMinMs: 5000, chapterMaxMs: 5000 };
const first = await runArchive(options);
assert.equal(first.publishedCount, 1);
const manga = (await scanLibrary(root)).mangas[0];
assert.equal(manga.ids.mangapill, '552');
assert.equal(manga.chapters[0].sourceKey, 'mangapill');
const second = await runArchive(options);
assert.equal(second.publishedCount, 0);
assert.equal(second.selectedCount, 0);
});
+1 -1
View File
@@ -50,7 +50,7 @@ test('in-process parser preserves WeebCentral catalog and page discovery', async
});
test('a new source is an explicit adapter, not another container or remote module', () => {
assert.deepEqual(adapters.map(adapter => adapter.id), ['weebcentral', 'mangadex']);
assert.deepEqual(adapters.map(adapter => adapter.id), ['weebcentral', 'mangadex', 'mangapill']);
for (const adapter of adapters) {
assert.equal(typeof adapter.match, 'function');
assert.equal(typeof adapter.discover, 'function');
+69
View File
@@ -0,0 +1,69 @@
'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 { parseChapters, discoverWeebCatalog, discoverWeebChapter } = require('../adapters/weebcentral');
const { parseSpecialFolder } = require('../../chapter-identity');
const { selectByScope } = require('../lib/scope');
const { publishManifest } = require('../lib/publisher');
const { scanLibrary, shouldDownload } = require('../lib/library-inventory');
const reader = require('../../library');
const { Canvas } = require('../../tools/png');
const ids = ['01JHVPNS8PNMSNF4H18ERX3SMK', '01JHVPNS8PNSDS66BEPVHH536F'];
const seriesUrl = 'https://weebcentral.com/series/01J76XY8CF6MA6W5VJWZDNWESZ/Akira';
test('whole-volume releases keep their source IDs and do not masquerade as chapters', async t => {
const html = `<a href="/chapters/${ids[0]}">Volume 1</a><a href="/chapters/${ids[1]}">Volume 2</a>`;
const refs = parseChapters(html, seriesUrl);
assert.equal(refs.length, 2);
assert.deepEqual(refs.map(ref => ref.volume), ['1', '2']);
assert.ok(refs.every(ref => ref.special && ref.releaseKind === 'volume'));
assert.equal(parseSpecialFolder(refs[0].number + ' [WeebCentral]').releaseKind, 'volume');
assert.equal(selectByScope(refs, { mode: 'volume', volume: '2' })[0].id, ids[1]);
assert.equal(selectByScope(refs, { mode: 'chapter', chapter: '1' }).length, 0);
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mc-volume-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const page = path.join(root, '.fixture.png');
await fs.writeFile(page, new Canvas(10, 20, [1, 2, 3]).png());
const manifest = { version: 1, series: { title: 'Volume fixture', direction: 'rtl', sources: [{ name: 'WeebCentral', url: seriesUrl }] },
chapters: [refs[0], { number: '1', source: { name: 'Other', url: 'https://example.org/1' } }].map(ref => ({
...ref, source: ref.source || { name: 'WeebCentral', url: ref.url }, pages: [{ path: page }],
})) };
await publishManifest(manifest, { libraryRoot: root });
const scanned = (await reader.scanLibrary(root)).index.mangas[0];
assert.equal(scanned.chapters.length, 2);
assert.ok(scanned.chapters.some(ch => ch.label === 'Volume 1'));
assert.ok(scanned.chapters.some(ch => ch.label === 'Ch. 1'));
const manga = (await scanLibrary(root)).mangas[0];
assert.equal(shouldDownload(manga, refs[0], 'weebcentral'), false);
assert.equal(shouldDownload(manga, refs[1], 'weebcentral'), true);
assert.equal(shouldDownload(manga, refs[0], 'weebcentral', 'add-source'), false);
const numberedOnly = { ...manga, chapters: manga.chapters.filter(chapter => chapter.releaseKind !== 'volume') };
assert.equal(shouldDownload(numberedOnly, refs[0], 'weebcentral'), false);
assert.equal(shouldDownload(numberedOnly, refs[0], 'weebcentral', 'add-source'), true);
});
test('catalog ordering and chapter discovery preserve whole-volume metadata', async () => {
const html = `<h1>Volume fixture</h1><a href="/chapters/${ids[0]}">Volume 1</a>`
+ `<a href="/chapters/${ids[1]}">Chapter 1</a>`;
const http = { async get(url) {
const body = url === seriesUrl ? html
: url === `https://weebcentral.com/chapters/${ids[0]}` ? '<time datetime="2026-01-01"></time>'
: '<img src="https://images.example/1.jpg">';
return { async text() { return body; } };
} };
const catalog = await discoverWeebCatalog(seriesUrl, http);
assert.equal(catalog.chapters.length, 2);
assert.equal(catalog.chapters[0].number, '1');
assert.equal(catalog.chapters[1].label, 'Volume 1');
const chapter = await discoverWeebChapter(catalog.chapters[1], http);
assert.equal(chapter.releaseKind, 'volume');
assert.equal(chapter.sourceId, ids[0]);
assert.equal(chapter.sourceNumber, '1');
assert.equal(chapter.book.number, 1);
assert.equal(chapter.pages.length, 1);
});