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

+28 -1
View File
@@ -19,6 +19,7 @@ const { Catalog, CatalogError, collectionViews, TYPES, ROLES } = require('./cata
const { RATINGS: GREATEST_RATINGS, loadCatalog: loadGreatestCatalog, findOwned: findGreatestOwned,
countsFor: greatestCounts, scoreFor: greatestScore } = require('./greatest');
const { hosterLinks } = require('./greatest/hosters');
const { GreatestCovers } = require('./greatest/covers');
const { secret } = require('./runtime/config');
const { PeeringState, requirePeer } = require('./peering/state');
const peerClient = require('./peering/client');
@@ -65,6 +66,10 @@ fs.mkdirSync(DATA, { recursive: true });
const catalog = new Catalog(DATA);
const greatestCatalog = loadGreatestCatalog();
const greatestById = new Map(greatestCatalog.entries.map(entry => [entry.id, entry]));
const greatestCovers = new GreatestCovers(path.join(DATA, 'greatest-covers'));
// The versioned URL lets browsers keep a cover for a year; a new catalog URL changes `v`.
const greatestCoverUrl = entry => entry.cover
? `api/greatest-cover?id=${encodeURIComponent(entry.id)}&v=${greatestCovers.version(entry.cover)}` : null;
const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'), INDEX_FILE = path.join(DATA, 'library-index.json');
const db = { users: {}, sessions: {}, progress: {}, reading: {}, readingAccess: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, known: {} };
// A corrupt db.json stops the server instead of silently starting empty and overwriting it.
@@ -620,7 +625,7 @@ const routes = {
const counts = greatestCounts(entry.id, db.greatestRatings || {}, db.users);
return {
...entry,
cover: owned?.cover ? url(owned.cover) : entry.cover,
cover: owned?.cover ? url(owned.cover) : greatestCoverUrl(entry),
chapters: owned ? owned.chapters.length : entry.chapters,
status: owned?.meta?.status || entry.status,
library: owned ? { id: owned.id, chapters: owned.chapters.length } : null,
@@ -1034,6 +1039,16 @@ http.createServer(async (req, res) => {
if (!c?.customCover) bad(404, 'Cover not found');
return serveFile(req, res, path.join(DATA, 'collection-covers'), c.customCover, 'private, no-cache', IMG);
}
if (pathname === '/api/greatest-cover' && req.method === 'GET') {
if (!u) bad(401, 'Please log in');
const entry = greatestById.get(searchParams.get('id'));
if (!entry?.cover) bad(404, 'Cover not found');
const file = greatestCovers.file(entry.cover);
if (file) return serveFile(req, res, greatestCovers.dir, path.basename(file), 'private, max-age=31536000, immutable', IMG);
// Not downloaded yet: the browser fetches the catalog's own URL this once.
res.writeHead(302, { Location: entry.cover, 'Cache-Control': 'no-store' });
return res.end();
}
if (pathname === '/api/peering/cover' && req.method === 'GET') {
if (!u) bad(401, 'Please log in');
admin(u);
@@ -1074,6 +1089,18 @@ http.createServer(async (req, res) => {
if (lib.mangas.length) refreshChanged();
else rescan();
if (SCAN_MINUTES > 0) setInterval(() => refreshChanged(), SCAN_MINUTES * 60000).unref();
// Fetch missing Greatest covers shortly after start and retry failures once a day;
// covers already on disk cost nothing.
async function warmGreatestCovers() {
try {
const result = await greatestCovers.warm(greatestCatalog.entries.map(entry => entry.cover));
if (result.fetched || result.failed || result.stopped) console.log('[greatest] covers', JSON.stringify(result));
} catch (error) { console.error('[greatest] covers', error.message); }
}
if (process.env.GREATEST_COVER_WARM !== '0') {
setTimeout(warmGreatestCovers, 10000).unref();
setInterval(warmGreatestCovers, 86400000).unref();
}
// Persist the last reconciliation time. Restarting a healthy reader should not
// trigger another full tree walk; overdue reconciliations run within a minute.
if (FULL_SCAN_MINUTES > 0) setInterval(() => {