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:
1 parent
cd6a30a76b
commit
e9da27e688
16 files changed
+277
-11
No files matched your search
@@ -0,0 +1,75 @@
|
||||
'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);
|
||||
});
|
||||
Reference in new issue
Block a user