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>
85 lines
4.5 KiB
JavaScript
85 lines
4.5 KiB
JavaScript
'use strict';
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { GreatestCovers } = require('../greatest/covers');
|
|
|
|
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
|
const image = (body = PNG, type = 'image/png') => new Response(body, { status: 200, headers: { 'content-type': type } });
|
|
|
|
function setup(t, responses, options = {}) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-greatest-covers-'));
|
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
const calls = [];
|
|
const fetchImpl = async (url, init) => {
|
|
calls.push({ url, init });
|
|
const next = responses[url];
|
|
if (next instanceof Error) throw next;
|
|
return typeof next === 'function' ? next() : next || new Response('missing', { status: 404 });
|
|
};
|
|
const sleeps = [];
|
|
const covers = new GreatestCovers(dir, { fetchImpl, sleep: async ms => { sleeps.push(ms); }, ...options });
|
|
return { dir, calls, sleeps, covers };
|
|
}
|
|
|
|
test('warm downloads each cover once and serves it from disk afterwards', async t => {
|
|
const a = 'https://img.example/a.png', b = 'https://img.example/b.jpg';
|
|
const { dir, calls, sleeps, covers } = setup(t, { [a]: () => image(), [b]: () => image(PNG, 'image/jpeg') });
|
|
const result = await covers.warm([a, b, a]);
|
|
assert.deepEqual(result, { fetched: 2, skipped: 1, failed: 0, stopped: null });
|
|
assert.equal(calls.length, 2);
|
|
assert.deepEqual(sleeps, [1000], 'one pause between two downloads, none before the first');
|
|
assert.ok(covers.file(a).endsWith('.png') && fs.existsSync(covers.file(a)));
|
|
assert.ok(covers.file(b).endsWith('.jpg'));
|
|
assert.equal(covers.file('https://img.example/other.png'), null);
|
|
|
|
// A restarted reader finds the stored files and asks nobody.
|
|
const reopened = new GreatestCovers(dir, { fetchImpl: async () => { throw new Error('must not fetch'); } });
|
|
assert.deepEqual(await reopened.warm([a, b]), { fetched: 0, skipped: 2, failed: 0, stopped: null });
|
|
assert.equal(reopened.file(a), covers.file(a));
|
|
});
|
|
|
|
test('a changed cover URL is a new file', async t => {
|
|
const { covers } = setup(t, { 'https://img.example/v1.png': () => image(), 'https://img.example/v2.png': () => image() });
|
|
await covers.warm(['https://img.example/v1.png', 'https://img.example/v2.png']);
|
|
assert.notEqual(covers.file('https://img.example/v1.png'), covers.file('https://img.example/v2.png'));
|
|
assert.notEqual(covers.version('https://img.example/v1.png'), covers.version('https://img.example/v2.png'));
|
|
});
|
|
|
|
test('non-images, oversized files, plain http and errors are skipped', async t => {
|
|
const html = 'https://img.example/page', big = 'https://img.example/big.png', declaredBig = 'https://img.example/declared.png';
|
|
const broken = 'https://img.example/broken.png', gone = 'https://img.example/gone.png';
|
|
const { covers, calls } = setup(t, {
|
|
[html]: () => new Response('<html>', { status: 200, headers: { 'content-type': 'text/html' } }),
|
|
[big]: () => image(Buffer.alloc(64)),
|
|
[declaredBig]: () => new Response(PNG, { status: 200, headers: { 'content-type': 'image/png', 'content-length': '999999' } }),
|
|
[broken]: new TypeError('fetch failed'),
|
|
}, { maxBytes: 32 });
|
|
const result = await covers.warm([html, big, declaredBig, broken, gone, 'http://img.example/plain.png', '', null]);
|
|
assert.deepEqual(result, { fetched: 0, skipped: 0, failed: 5, stopped: null });
|
|
assert.equal(calls.length, 5, 'plain http and empty URLs are never requested');
|
|
for (const url of [html, big, declaredBig, broken, gone]) assert.equal(covers.file(url), null);
|
|
assert.deepEqual(fs.readdirSync(covers.dir).filter(name => !name.startsWith('.')), []);
|
|
});
|
|
|
|
test('429 and 403 stop the run at once', async t => {
|
|
for (const status of [429, 403]) {
|
|
const first = 'https://img.example/1.png', second = 'https://img.example/2.png';
|
|
const { covers, calls } = setup(t, { [first]: () => new Response('', { status }), [second]: () => image() });
|
|
const result = await covers.warm([first, second]);
|
|
assert.equal(result.stopped, `HTTP ${status}`);
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(covers.file(second), null);
|
|
}
|
|
});
|
|
|
|
test('requests carry a timeout and a fixed user agent', async t => {
|
|
const a = 'https://img.example/a.png';
|
|
const { covers, calls } = setup(t, { [a]: () => image() });
|
|
await covers.warm([a]);
|
|
assert.ok(calls[0].init.signal instanceof AbortSignal);
|
|
assert.match(calls[0].init.headers['user-agent'], /^MangaCircus/);
|
|
});
|