The page hotlinked ~100 AniList CDN covers per load and some failed at random. The reader now downloads each catalog cover once (sequential, ~1/s, stops on 401/403/429, images up to 2 MB) into data/greatest-covers/ and serves it from /api/greatest-cover with a year-long browser cache; until a copy exists the route redirects to the catalog URL. The page also accepts the server's relative cover URLs, so owned titles show their library cover again. GREATEST_COVER_WARM=0 turns the downloads off (tests, offline installs). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
5.9 KiB
JavaScript
103 lines
5.9 KiB
JavaScript
'use strict';
|
|
const test = require('node:test'), assert = require('node:assert/strict');
|
|
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
|
|
const { spawn } = require('child_process');
|
|
const { Canvas } = require('../tools/png');
|
|
const { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIndex } = require('../library');
|
|
|
|
const image = new Canvas(20, 40, [20, 40, 80]).png();
|
|
function chapter(root, manga, number) {
|
|
const dir = path.join(root, manga, 'v01', `c${String(number).padStart(4, '0')}`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '0001.png'), image);
|
|
fs.writeFileSync(path.join(dir, 'chapter.json'), JSON.stringify({ title: `Part ${number}` }));
|
|
}
|
|
|
|
test('saved index restores chapter maps and book references without rereading images', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-index-'));
|
|
try {
|
|
chapter(root, 'Alpha', 1); chapter(root, 'Beta', 1);
|
|
const first = await scanLibrary(root);
|
|
const stamps = await libraryStamps(root);
|
|
const restored = restoreIndex(JSON.parse(JSON.stringify(snapshotIndex(first.index))));
|
|
assert.equal(restored.byId.get('Alpha').chapterById.get('1/1').title, 'Part 1');
|
|
assert.strictEqual(restored.byId.get('Alpha').books[0].chapters[0], restored.byId.get('Alpha').chapters[0]);
|
|
const same = await scanChangedLibrary(root, restored, first.cache, stamps);
|
|
assert.deepEqual(same.changed, []);
|
|
assert.strictEqual(same.index, restored);
|
|
assert.throws(() => restoreIndex([{ id: 'Broken', books: [], chapters: [{ id: 'x', versions: [{}] }] }]), /Invalid chapter/);
|
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('shallow refresh replaces only a published manga and removes deleted cache entries', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-changed-'));
|
|
try {
|
|
chapter(root, 'Alpha', 1); chapter(root, 'Beta', 1);
|
|
const first = await scanLibrary(root), stamps = await libraryStamps(root);
|
|
const beta = first.index.byId.get('Beta');
|
|
chapter(root, 'Alpha', 2);
|
|
const next = await scanChangedLibrary(root, first.index, first.cache, stamps);
|
|
assert.deepEqual(next.changed, ['Alpha']);
|
|
assert.equal(next.index.byId.get('Alpha').chapters.length, 2);
|
|
assert.strictEqual(next.index.byId.get('Beta'), beta);
|
|
assert.equal(Object.keys(next.cache).length, 3);
|
|
fs.rmSync(path.join(root, 'Alpha'), { recursive: true });
|
|
const removed = await scanChangedLibrary(root, next.index, next.cache, next.stamps);
|
|
assert.deepEqual(removed.changed, ['Alpha']);
|
|
assert.equal(removed.index.byId.has('Alpha'), false);
|
|
assert.equal(Object.keys(removed.cache).length, 1);
|
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('in-place chapter metadata edits require a full reconciliation', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-deep-'));
|
|
try {
|
|
chapter(root, 'Alpha', 1);
|
|
const first = await scanLibrary(root), stamps = await libraryStamps(root);
|
|
fs.writeFileSync(path.join(root, 'Alpha', 'v01', 'c0001', 'chapter.json'), JSON.stringify({ title: 'Revised Part 1' }));
|
|
const shallow = await scanChangedLibrary(root, first.index, first.cache, stamps);
|
|
assert.deepEqual(shallow.changed, []);
|
|
assert.equal((await scanLibrary(root, first.cache)).index.byId.get('Alpha').chapters[0].title, 'Revised Part 1');
|
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('reader serves a saved index immediately after restart and verifies it in background', { timeout: 15000 }, async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-warm-start-'));
|
|
let child;
|
|
try {
|
|
const library = path.join(root, 'library'), data = path.join(root, 'data');
|
|
fs.mkdirSync(data);
|
|
chapter(library, 'Alpha', 1);
|
|
const scanned = await scanLibrary(library), stamps = await libraryStamps(library);
|
|
fs.writeFileSync(path.join(data, 'library-index.json'), JSON.stringify({ version: 1, root: library,
|
|
at: Date.now(), mangas: snapshotIndex(scanned.index), cache: scanned.cache, stamps }));
|
|
const listener = net.createServer();
|
|
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
|
|
const port = listener.address().port;
|
|
await new Promise(resolve => listener.close(resolve));
|
|
const output = [];
|
|
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
|
|
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
|
|
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', GREATEST_COVER_WARM: '0' } });
|
|
child.stdout.on('data', data => output.push(String(data)));
|
|
child.stderr.on('data', data => output.push(String(data)));
|
|
const base = `http://127.0.0.1:${port}`;
|
|
let ready = false;
|
|
for (let n = 0; n < 100 && !ready; n++) {
|
|
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
|
|
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
assert.ok(ready, output.join(''));
|
|
const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ name: 'fixture', password: 'fixture-password' }) });
|
|
assert.equal(signup.status, 200);
|
|
const response = await fetch(base + '/api/library', { headers: { cookie: signup.headers.get('set-cookie').split(';')[0] } });
|
|
assert.equal(response.status, 200);
|
|
assert.equal((await response.json()).mangas[0].title, 'Alpha');
|
|
assert.match(output.join(''), /restored 1 manga from saved index/);
|
|
} finally {
|
|
if (child) await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); });
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|