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>
76 lines
4.2 KiB
JavaScript
76 lines
4.2 KiB
JavaScript
'use strict';
|
|
// BL-0053: Greatest covers are served from the reader's own copy, with a redirect until one exists.
|
|
const test = require('node:test'), assert = require('node:assert/strict');
|
|
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net'), crypto = require('crypto');
|
|
const { spawn } = require('child_process');
|
|
const { loadCatalog } = require('../greatest');
|
|
const { GreatestCovers } = require('../greatest/covers');
|
|
|
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
const hashPw = pw => { const salt = crypto.randomBytes(16).toString('hex'); return salt + ':' + crypto.scryptSync(pw, salt, 32).toString('hex'); };
|
|
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
|
|
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
|
|
|
async function freePort() {
|
|
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));
|
|
return port;
|
|
}
|
|
|
|
test('Greatest covers come from the local copy, else redirect to the catalog URL', async t => {
|
|
const entries = loadCatalog().entries.filter(entry => entry.cover);
|
|
const [cached, missing] = entries;
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-greatest-cover-'));
|
|
const library = path.join(root, 'library'), data = path.join(root, 'data');
|
|
fs.mkdirSync(library, { recursive: true });
|
|
fs.mkdirSync(path.join(data, 'greatest-covers'), { recursive: true });
|
|
const version = new GreatestCovers(path.join(data, 'greatest-covers')).version(cached.cover);
|
|
fs.writeFileSync(path.join(data, 'greatest-covers', `${version}.png`), PNG);
|
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
fs.writeFileSync(path.join(data, 'db.json'), JSON.stringify({
|
|
users: { reader: { name: 'reader', hash: hashPw('reader-password'), admin: false, created: Date.now(), settings: {} } },
|
|
sessions: { [sha(token)]: { u: 'reader', t: Date.now() } } }));
|
|
|
|
const port = await freePort(), output = [];
|
|
const 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: '', BACKUP: '',
|
|
GREATEST_COVER_WARM: '0' } });
|
|
child.stdout.on('data', d => output.push(String(d)));
|
|
child.stderr.on('data', d => output.push(String(d)));
|
|
t.after(async () => {
|
|
await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); });
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
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 sleep(50);
|
|
}
|
|
assert.ok(ready, output.join(''));
|
|
const get = (url, cookie = `sid=${token}`) => fetch(base + '/' + url, { headers: cookie ? { cookie } : {}, redirect: 'manual' });
|
|
|
|
const listing = await (await get('api/greatest')).json();
|
|
const coverOf = id => listing.entries.find(entry => entry.id === id).cover;
|
|
assert.equal(coverOf(cached.id), `api/greatest-cover?id=${encodeURIComponent(cached.id)}&v=${version}`);
|
|
assert.match(coverOf(missing.id), /^api\/greatest-cover\?id=.+&v=[0-9a-f]{16}$/);
|
|
|
|
const hit = await get(coverOf(cached.id));
|
|
assert.equal(hit.status, 200);
|
|
assert.equal(hit.headers.get('content-type'), 'image/png');
|
|
assert.equal(hit.headers.get('cache-control'), 'private, max-age=31536000, immutable');
|
|
assert.deepEqual(Buffer.from(await hit.arrayBuffer()), PNG);
|
|
|
|
const miss = await get(coverOf(missing.id));
|
|
assert.equal(miss.status, 302);
|
|
assert.equal(miss.headers.get('location'), missing.cover);
|
|
assert.equal(miss.headers.get('cache-control'), 'no-store');
|
|
|
|
assert.equal((await get(coverOf(cached.id), null)).status, 401);
|
|
assert.equal((await get('api/greatest-cover?id=not-in-the-catalog')).status, 404);
|
|
assert.equal((await get('api/greatest-cover?id=../../db.json')).status, 404);
|
|
});
|