Greatest: serve catalog covers from a local copy (BL-0053)

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>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-26 00:38:55 +01:00
1 parent cd6a30a76b
commit e9da27e688
16 files changed
+277 -11

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
'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/);
});