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
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user