Files
MangaCircus/server.js
MaashkinoozheandClaude Opus 5.5 e3e57b60bd Reader settings per login session
Each sign-in keeps its own reader mode, zoom, page tone and skipping once
it changes one; sessions that never changed follow the latest set, and a
new sign-in starts from the session used last. Settings -> Reading shows
every session's state, lets a session follow the latest again or become
the latest, and turns the feature off for one shared set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:45:44 +01:00

1273 lines
72 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/*
* MangaCircus: a no-frills, self-hosted manga reader. No dependencies, just `node server.js`.
*
* Environment:
* PORT=8080 LIBRARY=./library DATA=./data
* SIGNUP=closed|open (the very first account can always be created and becomes admin)
* SCAN_INTERVAL=10 minutes between shallow change checks, 0 = off
* FULL_SCAN_INTERVAL=1440 minutes between full reconciliations, 0 = off
* SCAN_CONCURRENCY=8 chapter folders / images probed at once by an uncached scan (library.js)
* COOKIE_SECURE=1 mark the session cookie Secure (when served over HTTPS)
* SCRAPER_URL=http://127.0.0.1:8091 scraper daemon API
* SCRAPER_TOKEN=... shared daemon token (optional on loopback)
* BACKUP=/backup folder for built-in DATA backups (unset = backups off)
* READ_AHEAD=1 read an opened chapter's pages (then the next chapter's) into the OS cache, 0 = off
* PERF_TRACE=1 performance trace to DATA/perf/ (or touch DATA/perf/trace.on at runtime; docs/performance.md)
*/
const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto');
const { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIndex } = require('./library');
const { Catalog, CatalogError, collectionViews, TYPES, ROLES } = require('./catalog');
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');
const { DiscoveryStore } = require('./discovery/store');
const { generateAsync: generateDiscovery, mergeUserFeed } = require('./discovery/engine');
const { DiscoveryCache } = require('./discovery/cache');
const { createClient: createAniListClient } = require('./discovery/anilist');
const { anchorsFor } = require('./discovery/anchors');
const { shouldRunNightly, Harvester } = require('./discovery/harvest');
const {
applyPulse, applyVisit, chapterInsight, earliestCompletion, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
} = require('./reading-analytics');
const { chapterDates } = require('./chapter-dates');
const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access');
const { BackupService, BackupError } = require('./backup');
const moments = require('./moments');
const { Tracer, requestTrace } = require('./perf/trace');
const { ReadAhead } = require('./perf/read-ahead');
const readerSessions = require('./reader-sessions');
const PORT = +process.env.PORT || 8080;
const LIB = path.resolve(process.env.LIBRARY || 'library');
const DATA = path.resolve(process.env.DATA || 'data');
const OPEN_SIGNUP = process.env.SIGNUP === 'open';
const SCAN_MINUTES = +(process.env.SCAN_INTERVAL || 10);
const FULL_SCAN_MINUTES = +(process.env.FULL_SCAN_INTERVAL || 1440);
const SECURE_COOKIE = process.env.COOKIE_SECURE === '1';
const SCRAPER_URL = String(process.env.SCRAPER_URL || 'http://127.0.0.1:8091').replace(/\/$/, '');
const SCRAPER_TOKEN = secret('SCRAPER_TOKEN');
const PEER_DATA = path.resolve(process.env.PEER_DATA || path.join(DATA, 'peering'));
const peerStore = new PeeringState(PEER_DATA);
const discoveryStore = new DiscoveryStore(DATA);
const DISCOVERY_CACHE = path.join(DATA, 'scraper', 'metadata', 'anilist');
const DISCOVERY_HARVEST_CACHE = path.join(DATA, 'discovery-cache');
const HARVEST_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
const DISCOVERY_HARVEST_AT = HARVEST_TIME.test(process.env.DISCOVERY_HARVEST_AT || '') ? process.env.DISCOVERY_HARVEST_AT : '03:30';
let discoveryRun = null, discoveryError = null;
const PUB = path.join(__dirname, 'public');
const IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i;
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.json': 'application/json', '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.avif': 'image/avif', '.bmp': 'image/bmp',
};
// ---------- storage: one JSON file, written atomically ----------
fs.mkdirSync(DATA, { recursive: true });
const tracer = new Tracer({ dir: path.join(DATA, 'perf') }).start();
const readAhead = process.env.READ_AHEAD === '0' ? null
: new ReadAhead({ onBatch: batch => tracer.log({ k: 'ahead', ...batch }) });
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: {}, moments: {}, known: {} };
// A corrupt db.json stops the server instead of silently starting empty and overwriting it.
if (fs.existsSync(DB_FILE)) Object.assign(db, JSON.parse(fs.readFileSync(DB_FILE, 'utf8')));
function writeAtomic(file, text) { fs.writeFileSync(file + '.tmp', text); fs.renameSync(file + '.tmp', file); }
let saveTimer = null;
function save() { saveTimer = saveTimer || setTimeout(flush, 500); }
function flush() { clearTimeout(saveTimer); saveTimer = null; writeAtomic(DB_FILE, JSON.stringify(db)); }
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { flush(); process.exit(0); });
db.readingAccess ||= {};
// The reader owns backups: it alone mounts all of DATA and holds db.json in memory.
const backup = new BackupService({ dataRoot: DATA, backupRoot: process.env.BACKUP || '', snapshotDb: () => JSON.stringify(db),
scraper: { state: () => scraperRequest('/api/state'), control: action => scraperRequest('/api/control', { action }) } }).start();
const readingSchedule = parseSchedule(process.env.READING_WINDOWS_JSON || '', process.env.READING_TIMEZONE || process.env.TZ || 'Europe/Berlin');
const readingAccess = createReadingAccess({ schedule: readingSchedule,
bank: loadQuestionBank(process.env.READING_QUIZ_CATALOG_PATH || ''),
unlockMinutes: Number(process.env.READING_QUIZ_UNLOCK_MINUTES || 60), grants: db.readingAccess, save,
getProgress: (user, mangaId) => db.progress[user]?.[mangaId] || {},
getMangaTitle: mangaId => lib.byId.get(mangaId)?.title || null });
// `extra` fields are sent next to `error`, e.g. `nextAllowedAt` on a 429.
class HttpError extends Error { constructor(code, msg, extra = {}) { super(msg); this.code = code; this.extra = extra; } }
function bad(code, msg, extra) { throw new HttpError(code, msg, extra); }
function peerForRoute(id) {
try { return requirePeer(peerStore.load(), id); }
catch { bad(404, 'Peer not found'); }
}
async function peerRemote(task) {
try { return await task(); }
catch (error) { bad(502, `Peer service: ${String(error.message || error).slice(0, 300)}`); }
}
// ---------- users & sessions ----------
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
function hashPw(pw) { const salt = crypto.randomBytes(16).toString('hex'); return salt + ':' + crypto.scryptSync(pw, salt, 32).toString('hex'); }
function checkPw(pw, stored) {
const [salt, hex] = String(stored).split(':'), want = Buffer.from(hex || '', 'hex'), got = crypto.scryptSync(pw, salt || '', 32);
return want.length === got.length && crypto.timingSafeEqual(want, got);
}
const DUMMY_HASH = hashPw('x');
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true,
continueRow: true, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '', readerPerSession: true };
const userSettingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
// With a login session, its locked reader settings (reader-sessions.js) win over the user's latest set.
const settingsOf = (u, sess) => readerSessions.effective(userSettingsOf(u), sess);
function createUser(name, pw, admin, replace) {
name = String(name || '').trim().toLowerCase(); pw = String(pw || '');
if (!/^[a-z0-9_.-]{1,32}$/.test(name)) bad(400, 'User names may only use a-z, 0-9, "_", "." and "-" (max. 32).');
if (pw.length < 4) bad(400, 'The password needs at least 4 characters.');
const old = db.users[name];
if (old && !replace) bad(409, 'That user name is taken.');
db.users[name] = { settings: {}, ...old, name, hash: hashPw(pw), admin: !!admin, created: old ? old.created : Date.now() };
save();
return db.users[name];
}
function startSession(req, res, user) {
const token = crypto.randomBytes(32).toString('base64url');
db.sessions[sha(token)] = { u: user.name, t: Date.now(), agent: String(req.headers['user-agent'] || '').slice(0, 300) };
save();
res.setHeader('Set-Cookie', `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${SECURE_COOKIE ? '; Secure' : ''}`);
}
const tokenOf = req => { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || ''); return m ? m[1] : null; };
function sessionKeyOf(req) { const t = tokenOf(req); return t ? sha(t) : null; }
// Last-seen time (coarse, to keep db.json writes rare) and device label of a login session.
function touchSession(u, sess, key, req) {
let changed = false;
if (!sess.agent && req.headers['user-agent']) { sess.agent = String(req.headers['user-agent']).slice(0, 300); changed = true; }
if (Date.now() - (sess.seen || 0) > 5 * 60e3) { sess.seen = Date.now(); changed = true; }
if (changed) save();
}
function userOf(req) { const t = tokenOf(req), s = t && db.sessions[sha(t)]; return (s && db.users[s.u]) || null; }
for (const [k, s] of Object.entries(db.sessions)) if (Date.now() - s.t > 365 * 864e5 || !db.users[s.u]) delete db.sessions[k];
// ---------- library index ----------
let lib = { mangas: [], byId: new Map() }, scanning = null, pendingScan = null, scanState = {}, scanCache = {}, scanStamps = {};
let lastFullAt = 0, lastFullAttemptAt = 0;
try { scanCache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { /* first run */ }
try {
const saved = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8'));
if (saved.version !== 1 || saved.root !== LIB || !saved.cache || !saved.stamps) throw new Error('Invalid library index snapshot');
const restored = restoreIndex(saved.mangas);
// A disconnected/replaced library mount must not appear populated from cache.
const present = new Set(fs.readdirSync(LIB, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name));
if (restored.mangas.length && !restored.mangas.some(m => present.has(m.id))) throw new Error('Library mount differs from snapshot');
lib = restored; scanCache = saved.cache; scanStamps = saved.stamps;
lastFullAt = Number(saved.fullAt) || Number(saved.at) || 0;
scanState = { at: saved.at, mode: 'snapshot', stale: true, mangas: lib.mangas.length, error: null };
console.log(`[scan] restored ${lib.mangas.length} manga from saved index; verifying in background`);
} catch (e) { if (e.code !== 'ENOENT') console.warn(`[scan] ignoring saved index: ${e.message}`); }
async function performScan(mode) {
const t0 = Date.now();
try {
// Capture before the scan: a publication during the walk remains visible
// as a changed stamp on the next check, rather than being silently missed.
const stamps = await libraryStamps(LIB);
const result = mode === 'changed' && Object.keys(scanStamps).length
? await scanChangedLibrary(LIB, lib, scanCache, scanStamps, stamps)
: { ...(await scanLibrary(LIB, mode === 'deep' ? {} : scanCache)), stamps, changed: Object.keys(stamps) };
const { index, cache, changed } = result;
if (mode !== 'changed' || changed.length) {
announce(index);
lib = index; scanCache = cache; scanStamps = stamps;
if (mode !== 'changed') lastFullAt = Date.now();
writeAtomic(CACHE_FILE, JSON.stringify(cache));
writeAtomic(INDEX_FILE, JSON.stringify({ version: 1, root: LIB, at: Date.now(), fullAt: lastFullAt,
mangas: snapshotIndex(index), cache, stamps }));
}
let chapters = 0, pages = 0;
for (const m of lib.mangas) for (const ch of m.chapters) { chapters++; for (const v of ch.versions) pages += v.pages.length; }
scanState = { at: Date.now(), ms: Date.now() - t0, mode, changedMangas: changed.length,
mangas: lib.mangas.length, chapters, pages, stale: false, error: null };
console.log(`[scan] ${mode}: ${changed.length} changed manga, ${lib.mangas.length} manga, ${chapters} chapters, ${pages} pages in ${scanState.ms} ms`);
tracer.log({ k: 'scan', mode, ms: scanState.ms, changed: changed.length });
return scanState;
} catch (e) {
tracer.log({ k: 'scan', mode, ms: Date.now() - t0, error: e.message });
scanState = { ...scanState, at: Date.now(), error: e.message };
console.error('[scan] failed:', e.message);
return scanState;
}
}
function queueScan(mode) {
if (scanning) {
const priority = { changed: 1, full: 2, deep: 3 };
if (!pendingScan || priority[mode] > priority[pendingScan]) pendingScan = mode;
return scanning;
}
scanning = (async () => {
let next = mode, result;
do { result = await performScan(next); next = pendingScan; pendingScan = null; } while (next);
return result;
})().finally(() => { scanning = null; });
return scanning;
}
function rescan(full) { return queueScan(full ? 'deep' : 'full'); }
function refreshChanged() { return queueScan('changed'); }
// New chapter ids (compared with every id ever seen for that manga) notify the followers.
function announce(index) {
for (const m of index.mangas) {
const known = db.known[m.id];
if (!known) { db.known[m.id] = m.chapters.map(c => c.id); save(); continue; }
const seen = new Set(known), fresh = m.chapters.filter(c => !seen.has(c.id));
if (!fresh.length) continue;
db.known[m.id] = known.concat(fresh.map(c => c.id));
for (const [name, follows] of Object.entries(db.follows)) if (follows[m.id] && db.users[name]) notify(db.users[name], m, fresh);
save();
}
}
function notify(user, m, chapters) {
const notes = (db.notes[user.name] = db.notes[user.name] || []);
for (const ch of chapters) {
notes.push({ id: crypto.randomBytes(6).toString('hex'), t: Date.now(), m: m.id, c: ch.id, text: `${m.title} · ${ch.label}${ch.title ? ': ' + ch.title : ''}`, read: false });
}
if (notes.length > 200) notes.splice(0, notes.length - 200);
const hook = settingsOf(user).webhook;
if (hook) {
const body = `New in ${m.title}: ${chapters.map(c => c.label + (c.title ? ' ' + c.title : '')).join(', ')}`;
fetch(hook, { method: 'POST', body, headers: { 'Content-Type': 'text/plain; charset=utf-8', Title: 'MangaCircus' }, signal: AbortSignal.timeout(10000) })
.catch(e => console.warn(`[notify] webhook of ${user.name} failed: ${e.message}`));
}
}
// ---------- view helpers ----------
const url = rel => (rel ? 'lib/' + rel.split('/').map(encodeURIComponent).join('/') : null);
const mangaOf = id => lib.byId.get(String(id)) || bad(404, 'Manga not found');
const chapterOf = (m, id) => m.chapterById.get(String(id)) || bad(404, 'Chapter not found');
const progressOf = (u, m) => (db.progress[u.name] && db.progress[u.name][m.id]) || {};
const mangaReadingOf = (u, m) => (db.reading[u.name] && db.reading[u.name][m.id]) || {};
const readingOf = (u, m, ch) => (db.reading[u.name] && db.reading[u.name][m.id] && db.reading[u.name][m.id][ch.id]) || null;
const unreadOf = u => (db.notes[u.name] || []).filter(n => !n.read).length;
const momentsOf = u => (db.moments[u.name] ||= []);
const momentOf = (u, id) => momentsOf(u).find(x => x.id === String(id || '')) || bad(404, 'Moment not found');
const fileSha = async rel => sha256File(path.join(LIB, rel)).catch(() => null);
function sha256File(file) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
fs.createReadStream(file).on('error', reject).on('data', d => hash.update(d)).on('end', () => resolve(hash.digest('hex')));
});
}
const admin = u => u.admin || bad(403, 'Admins only');
const READING_LEASE_MS = 15000;
const readingLeases = new Map();
async function scraperRequest(endpoint, body) {
const headers = { accept: 'application/json' };
if (SCRAPER_TOKEN) headers.authorization = `Bearer ${SCRAPER_TOKEN}`;
const options = { headers, signal: AbortSignal.timeout(7000) };
if (body !== undefined) {
options.method = 'POST'; headers['content-type'] = 'application/json'; options.body = JSON.stringify(body);
}
let response;
const t0 = performance.now();
try { response = await fetch(SCRAPER_URL + endpoint, options); }
catch (error) {
tracer.log({ k: 'scraper', endpoint, ms: Math.round(performance.now() - t0), error: error.message });
throw new HttpError(503, `Scraper service unavailable: ${error.message}`);
}
const data = await response.json().catch(() => ({}));
tracer.log({ k: 'scraper', endpoint, ms: Math.round(performance.now() - t0), status: response.status });
if (!response.ok) throw new HttpError(response.status >= 500 ? 502 : response.status, data.error || `Scraper HTTP ${response.status}`);
return data;
}
function readDiscovery() {
try { return discoveryStore.load(); }
catch (error) { bad(503, error.message); }
}
// Scoring is local: it reads the harvest cache and never calls AniList. With `onlyUser` only that
// feed is rebuilt and the other users' feeds are carried over into the same publish.
async function rescoreNow(onlyUser) {
const startedAt = new Date().toISOString();
let queuedTitles = [], queueAvailable = true;
try { queuedTitles = (await scraperRequest('/api/state')).queue?.filter(item => !['complete', 'removed'].includes(item.status)).map(item => item.title) || []; }
catch { queueAvailable = false; }
const result = await generateDiscovery({ db, library: lib.mangas, greatest: greatestCatalog,
cacheDir: DISCOVERY_CACHE, harvestCache, queuedTitles, onlyUser });
// Read the prior store only now, right before the synchronous publish, so no other publish slips between.
const value = onlyUser === undefined ? result : { ...result, ...mergeUserFeed(discoveryStore.load(), result, onlyUser) };
return { published: discoveryStore.publish({ ...value, startedAt }), queueAvailable };
}
// One rescore at a time: an admin run and a harvest follow-up must not publish over each other.
let rescoring = Promise.resolve();
function rescore({ onlyUser } = {}) {
const run = rescoring.then(() => rescoreNow(onlyUser));
rescoring = run.catch(() => {});
return run;
}
function startDiscoveryRun() {
if (discoveryRun?.status === 'running') bad(409, 'A discovery run is already active');
readDiscovery(); // Refuse a new run if the on-disk store is corrupt.
const id = crypto.randomUUID(), startedAt = new Date().toISOString();
discoveryRun = { id, status: 'running', startedAt };
discoveryError = null;
setImmediate(async () => {
try {
const { published, queueAvailable } = await rescore();
discoveryRun = { id, status: 'success', startedAt, finishedAt: published.lastRun.finishedAt,
candidates: published.lastRun.candidateCount, queueAvailable };
} catch (error) {
discoveryError = String(error.message || error).slice(0, 300);
discoveryRun = { id, status: 'failed', startedAt, finishedAt: new Date().toISOString() };
console.error('[discovery]', discoveryError);
}
});
return discoveryRun;
}
// ---------- discovery harvest (AniList seeds, BL-0051) ----------
const harvestCache = new DiscoveryCache(DISCOVERY_HARVEST_CACHE);
const discoverySettings = state => ({ harvestEnabled: state.settings?.harvestEnabled ?? true,
harvestAt: state.settings?.harvestAt ?? DISCOVERY_HARVEST_AT });
const localDate = date => [date.getFullYear(), String(date.getMonth() + 1).padStart(2, '0'), String(date.getDate()).padStart(2, '0')].join('-');
function nextNightlyAt(state, now = new Date()) {
const { harvestEnabled, harvestAt } = discoverySettings(state);
if (!harvestEnabled) return null;
const [hours, minutes] = harvestAt.split(':').map(Number);
const slot = new Date(now);
slot.setHours(hours, minutes, 0, 0);
if (state.nightly?.lastDate === localDate(now)) slot.setDate(slot.getDate() + 1);
else if (slot < now) return now.toISOString(); // due: the next minute's check starts it
return slot.toISOString();
}
function harvestTargets(kind, userName) {
const users = kind === 'user' ? [db.users[userName]].filter(Boolean) : Object.values(db.users);
return users.filter(user => settingsOf(user).discoveryEnabled).map(user => ({ user,
anchors: anchorsFor({ user, db, library: lib.mangas, greatest: greatestCatalog }), mature: !!settingsOf(user).discoveryMature }));
}
// Queued jobs live in memory only; a run cut off by a restart is recorded as interrupted.
try {
const { harvest } = discoveryStore.load();
if (harvest?.status === 'running') discoveryStore.patch({ harvest: { ...harvest, status: 'interrupted', finishedAt: new Date().toISOString() } });
} catch (error) { console.error('[discovery]', error.message); }
const harvester = new Harvester({ cache: harvestCache, client: createAniListClient(), store: discoveryStore,
targetsFor: harvestTargets, onDone: async job => {
// A failed follow-up rescore must reach the admin panel (lastError), not only the log.
try {
await rescore(job.kind === 'user' ? { onlyUser: job.user } : {});
discoveryError = null; // a later success must not leave an old failure on the admin panel
} catch (error) { discoveryError = `Re-scoring after harvest ${job.id} failed: ${String(error.message || error)}`.slice(0, 300); throw error; }
} });
function requestHarvest(args) {
readDiscovery(); // A corrupt store fails closed before any AniList call.
try { return harvester.request(args); }
catch (error) {
if (!error.status) throw error;
const extra = {};
for (const key of ['nextAllowedAt', 'blockedReason']) if (error[key]) extra[key] = error[key];
bad(error.status, error.message, extra);
}
}
function checkNightly() {
let state;
try { state = discoveryStore.load(); } catch { return; }
const now = new Date(), { harvestEnabled, harvestAt } = discoverySettings(state);
if (!shouldRunNightly({ now, harvestAt, lastNightlyDate: state.nightly?.lastDate, enabled: harvestEnabled })) return;
if (harvester.current()) return;
// The date is written only once the run was accepted; a 409 (refresh in flight) retries next minute.
try { harvester.request({ kind: 'nightly' }); }
catch (error) { if (error.status !== 409) console.error('[discovery] nightly harvest:', error.message); return; }
try { discoveryStore.patch({ nightly: { lastDate: localDate(now) } }); }
catch (error) { console.error('[discovery]', error.message); }
}
const mangaDexSearchCache = new Map();
let lastMangaDexSearch = 0;
async function searchGreatestMangaDex(entry) {
const cached = mangaDexSearchCache.get(entry.id);
if (cached && Date.now() - cached.time < 60 * 60 * 1000) return cached.results;
if (Date.now() - lastMangaDexSearch < 2000) bad(429, 'Wait two seconds before another MangaDex lookup');
lastMangaDexSearch = Date.now();
let response;
try {
response = await fetch(`https://api.mangadex.org/manga?title=${encodeURIComponent(entry.title)}&limit=10`,
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(7000) });
} catch (error) { throw new HttpError(503, `MangaDex lookup unavailable: ${error.message}`); }
if (!response.ok) throw new HttpError(response.status === 429 ? 503 : 502, `MangaDex lookup returned HTTP ${response.status}`);
const body = await response.json();
const results = (body.data || []).filter(row => /^[0-9a-f-]{36}$/i.test(row.id)).map(row => ({
title: row.attributes?.title?.en || Object.values(row.attributes?.title || {})[0] || 'Untitled',
year: row.attributes?.year || null, url: `https://mangadex.org/title/${row.id}`,
}));
mangaDexSearchCache.set(entry.id, { time: Date.now(), results });
return results;
}
// Versions sorted by the sum of all user votes; ties go to the higher "priority" in chapter.json, then folder order.
function ranked(m, ch) {
const votes = (db.votes[m.id] && db.votes[m.id][ch.id]) || {};
return ch.versions
.map((v, i) => ({ v, i, score: Object.values(votes[v.id] || {}).reduce((a, b) => a + b, 0) }))
.sort((a, b) => b.score - a.score || (+b.v.meta.priority || 0) - (+a.v.meta.priority || 0) || a.i - b.i);
}
function versionsOf(m, ch, u) {
const votes = (db.votes[m.id] && db.votes[m.id][ch.id]) || {};
return ranked(m, ch).map(({ v, score }) => ({
id: v.id, source: v.source, sourceUrl: v.sourceUrl, pages: v.pages.length, added: v.added, score, my: (votes[v.id] && votes[v.id][u.name]) || 0,
}));
}
// Normalize the release-date shapes accepted by manga.json for library sorting.
function firstReleaseOf(meta) {
meta = meta || {};
const raw = meta.firstReleaseDate || meta.releaseDate || meta.startDate;
let year, month, day, label, time;
if (raw && typeof raw === 'object') {
year = +raw.year; month = +raw.month || 1; day = +raw.day || 1;
if (year > 0) {
label = [String(year).padStart(4, '0'), raw.month ? String(month).padStart(2, '0') : null, raw.day ? String(day).padStart(2, '0') : null].filter(Boolean).join('-');
time = Date.UTC(year, month - 1, day);
}
} else if (typeof raw === 'string' && raw.trim()) {
const parsed = Date.parse(raw);
if (Number.isFinite(parsed)) { label = raw.trim(); time = parsed; }
}
if (!Number.isFinite(time)) {
year = +meta.year;
if (year > 0) { label = String(year); time = Date.UTC(year, 0, 1); }
}
return { label: label || '', time: Number.isFinite(time) ? time : 0 };
}
// When a done chapter was completed: the progress record, or for reads saved before doneAt
// existed, the earliest completion the reading clock still remembers.
function readAtOf(progress, record) {
if (!progress || !progress.done) return null;
if (progress.doneAt) return { at: progress.doneAt, approx: false };
return earliestCompletion(record);
}
function continueOf(m, prog, s) {
const ok = c => !(s.skipExtras && c.extra);
let last = null;
for (const ch of m.chapters) { const p = prog[ch.id]; if (p && (!last || p.t > last.p.t)) last = { ch, p }; }
if (!last) { const first = m.chapters.find(ok) || m.chapters[0]; return { c: first.id, text: 'Start reading · ' + first.label }; }
if (!last.p.done) return { c: last.ch.id, v: last.p.v, text: `Continue · ${last.ch.label} · page ${last.p.p + 1}` };
const next = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).find(ok);
return next ? { c: next.id, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, text: 'Read again · ' + last.ch.label };
}
// From the saved page on, then the pages before it; afterwards the next chapter's preferred version.
function warmChapter(u, m, ch, v, progress, s) {
const from = Math.min(Math.max(0, +(progress && progress.v === v.id && progress.p) || 0), v.pages.length);
const pages = v.pages.slice(from).concat(v.pages.slice(0, from)).map(p => path.join(LIB, v.rel, p.f));
readAhead.warm(pages, `${m.id}/${ch.id}`, u.name);
const next = neighbour(m, ch, 1, s), nextCh = next && m.chapterById.get(next.id);
if (nextCh) {
const nv = ranked(m, nextCh)[0].v;
readAhead.then(nv.pages.map(p => path.join(LIB, nv.rel, p.f)), `${m.id}/${nextCh.id}`, u.name);
}
}
function neighbour(m, ch, step, s) {
for (let i = m.chapters.indexOf(ch) + step; i >= 0 && i < m.chapters.length; i += step) {
const c = m.chapters[i];
if (!(s.skipExtras && c.extra)) return { id: c.id, label: c.label, title: c.title };
}
return null;
}
// ---------- API ----------
const PUBLIC = new Set(['/api/auth', '/api/login', '/api/signup']);
const routes = {
'GET /api/health': () => ({ ok: !scanState.error, service: 'reader', uptime: Math.round(process.uptime()), scan: scanState }),
'GET /api/collections': ({ u }) => ({ collections: routes['GET /api/library']({ u }).collections }),
'GET /api/collection': ({ u, q, sess }) => {
const view = routes['GET /api/library']({ u }).collections.find(c => c.id === q.get('id')) || bad(404, 'Collection not found');
const next = view.members.find(m => m.read < m.chapters) || view.members[0];
const manga = mangaOf(next.id);
return { ...view, continue: { m: next.id, ...continueOf(manga, progressOf(u, manga), settingsOf(u, sess)) },
relations: catalog.state.relations.filter(r => r.state === 'confirmed' && view.members.some(m => m.id === r.from || m.id === r.to)) };
},
'GET /api/admin/catalog': ({ u }) => { admin(u); return { ...catalog.state, types: TYPES, roles: ROLES, mangas: lib.mangas.map(m => ({ id: m.id, title: m.title })) }; },
'POST /api/admin/catalog': ({ u, b }) => { admin(u); return catalog.update(b, new Set(lib.byId.keys()), u.name); },
'POST /api/admin/catalog/suggest': ({ u, b }) => {
admin(u);
return catalog.update({ ...catalog.state, revision: b.revision, relations: catalog.suggest(lib.mangas) }, new Set(lib.byId.keys()), u.name);
},
'POST /api/admin/catalog/research': async ({ u, b }) => {
admin(u);
const m = mangaOf(b.m), id = m.meta?.['x-anilist']?.id;
if (!id) bad(400, 'Assign a verified AniList identity first');
const { lookupAniListById } = require('./scraper/metadata/anilist');
const meta = await lookupAniListById(id, { cacheDir: path.join(DATA, 'metadata', 'anilist') });
const enriched = lib.mangas.map(item => item.id === m.id ? { ...item, meta } : item);
return catalog.update({ ...catalog.state, revision: b.revision, relations: catalog.suggest(enriched) }, new Set(lib.byId.keys()), u.name);
},
'POST /api/admin/collection-cover': ({ u, b }) => {
admin(u);
const c = catalog.state.collections.find(c => c.id === b.id) || bad(404, 'Collection not found');
if (b.revision !== catalog.state.revision) bad(409, 'Catalog changed; reload before saving');
const image = Buffer.from(String(b.base64 || ''), 'base64');
const ext = require('./scraper/lib/images').imageExtension(image);
if (!ext || image.length > 600000) bad(400, 'Use a JPG, PNG, WebP, GIF, AVIF or BMP under 600 KB');
const name = sha(image) + '.' + ext;
const dir = path.join(DATA, 'collection-covers'); fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(path.join(dir, name))) fs.writeFileSync(path.join(dir, name), image, { flag: 'wx' });
return catalog.update({ ...catalog.state, collections: catalog.state.collections.map(item => item.id === c.id ? { ...item, coverMode: 'custom', customCover: name } : item) }, new Set(lib.byId.keys()), u.name);
},
'GET /api/admin/health': async ({ u }) => {
admin(u);
return { reader: { ok: !scanState.error, uptime: Math.round(process.uptime()), scan: scanState }, backup: backup.publicState() };
},
'GET /api/admin/backup': ({ u }) => { admin(u); return backup.publicState(); },
'POST /api/admin/backup': ({ u, b }) => {
admin(u);
try {
if (b.command === 'settings') return backup.updateSettings(b.settings);
if (b.command !== 'run') bad(400, 'Unknown backup command');
if (!backup.backupRoot) bad(409, 'Backups are off: set BACKUP to a mounted folder');
if (backup.running) bad(409, 'A backup is already running');
backup.run('manual').catch(() => {}); // the result lands in last/history
return backup.publicState();
} catch (e) { if (e instanceof BackupError) bad(e.code, e.message); throw e; }
},
'GET /api/auth': () => ({ firstRun: !Object.keys(db.users).length, signup: OPEN_SIGNUP }),
'POST /api/signup': ({ req, b, res }) => {
const first = !Object.keys(db.users).length;
if (!first && !OPEN_SIGNUP) bad(403, 'Sign-up is closed. Ask an admin for an account.');
startSession(req, res, createUser(b.name, b.password, first));
return { ok: true };
},
'POST /api/login': ({ req, b, res }) => {
const user = db.users[String(b.name || '').trim().toLowerCase()], pw = String(b.password || '');
if (!checkPw(pw, user ? user.hash : DUMMY_HASH) || !user) bad(403, 'Wrong user name or password.');
startSession(req, res, user);
return { ok: true };
},
'POST /api/logout': ({ req, res }) => {
delete db.sessions[sha(tokenOf(req))];
save();
res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
return { ok: true };
},
// Every page loads /api/me, so a page view marks its session as the last active one for reader settings.
'GET /api/me': ({ u, sess, sk }) => {
if (sess && readerSessions.noteActive(u, userSettingsOf(u), sess, sk)) save();
return { name: u.name, admin: !!u.admin, settings: settingsOf(u, sess), unread: unreadOf(u) };
},
'GET /api/reader-sessions': ({ u, sk }) => readerSessions.overview(u, userSettingsOf(u), db.sessions, sk),
'POST /api/reader-sessions': ({ u, b, sk }) => {
if (!['follow', 'latest'].includes(b.action)) bad(400, 'Unknown action');
if (!readerSessions.act(u, userSettingsOf(u), db.sessions, String(b.id || ''), b.action)) bad(404, 'Session not found');
save();
return readerSessions.overview(u, userSettingsOf(u), db.sessions, sk);
},
'GET /api/reading-schedule': () => publicSchedule(readingSchedule),
'GET /api/reading-access': ({ u, q }) => { mangaOf(q.get('m')); return readingAccess.status(u.name, true); },
'POST /api/reading-quiz/start': ({ u, b }) => {
try { return readingAccess.start(u.name); }
catch (e) { bad(409, e.message); }
},
'POST /api/reading-quiz/answer': ({ u, b }) => {
try { return readingAccess.answer(u.name, String(b.challenge || ''), String(b.answer || '')); }
catch (e) { bad(409, e.message); }
},
'POST /api/reading-quiz/abandon': ({ u, b }) => {
readingAccess.abandon(u.name, String(b.challenge || ''));
return { ok: true };
},
'POST /api/settings': ({ u, b, sess, sk }) => {
if (typeof b.webhook === 'string' && b.webhook.trim() && !/^https?:\/\/\S+$/i.test(b.webhook.trim())) bad(400, 'The webhook must be an http(s) URL.');
const base = userSettingsOf(u), s = {};
if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode;
if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode;
if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone;
if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate;
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate', 'continueRow',
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature', 'readerPerSession']) if (typeof b[k] === 'boolean') s[k] = b[k];
if (b.zoom && typeof b.zoom === 'object') {
s.zoom = {};
for (const k of ['flow', 'page', 'book', 'strip']) if (+b.zoom[k] > 0) s.zoom[k] = Math.min(8, Math.max(0.1, +b.zoom[k]));
}
if (typeof b.webhook === 'string') s.webhook = b.webhook.trim().slice(0, 500);
readerSessions.applyPatch({ user: u, base, session: sess, key: sk, patch: s });
save();
return { settings: settingsOf(u, sess) };
},
'POST /api/password': ({ u, b }) => {
if (!checkPw(String(b.old || ''), u.hash)) bad(403, 'The current password is wrong.');
createUser(u.name, b.new, u.admin, true);
return { ok: true };
},
'GET /api/library': ({ u }) => {
const prog = db.progress[u.name] || {}, fol = db.follows[u.name] || {}, notes = (db.notes[u.name] || []).filter(n => !n.read);
const showStory = settingsOf(u).readingStory;
const mangas = lib.mangas.map(m => {
const p = prog[m.id] || {}, meta = m.meta || {}, release = firstReleaseOf(meta);
return {
id: m.id, title: m.title, cover: url(m.cover), direction: m.direction, chapters: m.chapters.length, updated: m.updated,
altTitles: Array.isArray(meta.altTitles) ? meta.altTitles : (meta.altTitles ? [String(meta.altTitles)] : []),
authors: Array.isArray(meta.authors) ? meta.authors : (meta.authors ? [String(meta.authors)] : []),
tags: Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [String(meta.tags)] : []), status: meta.status || '', year: meta.year || '',
firstRelease: release.label, firstReleaseAt: release.time,
read: m.chapters.filter(c => p[c.id] && p[c.id].done).length,
lastRead: Object.values(p).reduce((t, x) => Math.max(t, x.t || 0), 0),
following: !!fol[m.id], unread: notes.filter(n => n.m === m.id).length,
reading: showStory ? mangaInsight(mangaReadingOf(u, m), m.chapters.map(ch => ch.id)) : null,
};
});
return {
scanning: !!scanning && !lib.mangas.length,
reading: showStory ? libraryInsight(mangas) : null,
mangas,
collections: collectionViews(catalog.state, mangas),
};
},
'GET /api/discovery': ({ u }) => {
const state = readDiscovery(), enabled = !!settingsOf(u).discoveryEnabled;
const feed = enabled ? state.feeds[u.name] || null : null;
return { enabled, settings: { discoveryEnabled: enabled,
discoveryReadingActivity: !!settingsOf(u).discoveryReadingActivity,
discoveryMature: !!settingsOf(u).discoveryMature },
generatedAt: feed?.generatedAt || null, revision: state.revision, lastRun: state.lastRun,
refresh: harvester.state(u.name),
feedback: db.discoveryFeedback?.[u.name] || {},
items: (feed?.items || []).map(item => ({ ...item, candidate: state.candidates[item.candidateId] || null })).filter(item => item.candidate) };
},
'POST /api/discovery/feedback': ({ u, b }) => {
if (!settingsOf(u).discoveryEnabled) bad(403, 'Enable personalized discovery first');
const id = String(b.candidateId || ''), value = b.value === null ? null : String(b.value || '');
if (!readDiscovery().candidates[id]) bad(404, 'Discovery candidate not found');
if (value !== null && !['interested', 'maybe', 'dismissed', 'hidden'].includes(value)) bad(400, 'Unknown feedback');
const mine = (db.discoveryFeedback ||= {})[u.name] ||= {};
if (value === null) delete mine[id]; else mine[id] = value;
save();
return { candidateId: id, value };
},
'POST /api/discovery/refresh': ({ u }) => {
if (!settingsOf(u).discoveryEnabled) bad(403, 'Enable personalized discovery first');
return requestHarvest({ kind: 'user', user: u.name });
},
'GET /api/admin/discovery': ({ u }) => {
admin(u);
const state = readDiscovery();
return { revision: state.revision, lastRun: state.lastRun, currentRun: discoveryRun,
lastError: discoveryError, optedInUsers: Object.values(db.users).filter(user => user.settings?.discoveryEnabled).length,
candidateCount: Object.keys(state.candidates).length,
harvest: state.harvest, currentHarvest: harvester.current(), cache: harvestCache.stats(),
settings: discoverySettings(state), nextNightlyAt: nextNightlyAt(state) };
},
'POST /api/admin/discovery/run': ({ u }) => { admin(u); return startDiscoveryRun(); },
'POST /api/admin/discovery/harvest': ({ u }) => { admin(u); return requestHarvest({ kind: 'admin' }); },
'POST /api/admin/discovery/settings': ({ u, b }) => {
admin(u);
const keys = b && typeof b === 'object' && !Array.isArray(b) ? Object.keys(b).sort().join() : '';
if (keys !== 'harvestAt,harvestEnabled' || typeof b.harvestEnabled !== 'boolean' || typeof b.harvestAt !== 'string' || !HARVEST_TIME.test(b.harvestAt))
bad(400, 'Send exactly { harvestEnabled: true|false, harvestAt: "HH:MM" }');
readDiscovery();
const state = discoveryStore.patch({ settings: { harvestEnabled: b.harvestEnabled, harvestAt: b.harvestAt } });
return { settings: discoverySettings(state), nextNightlyAt: nextNightlyAt(state) };
},
'GET /api/greatest': ({ u }) => ({
title: greatestCatalog.title, updatedAt: greatestCatalog.updatedAt, periods: greatestCatalog.periods,
ratings: GREATEST_RATINGS,
entries: greatestCatalog.entries.map(entry => {
const owned = findGreatestOwned(entry, lib.mangas);
const counts = greatestCounts(entry.id, db.greatestRatings || {}, db.users);
return {
...entry,
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,
hosters: hosterLinks(entry, owned).links,
myRating: db.greatestRatings?.[u.name]?.[entry.id] || null,
votes: counts, score: greatestScore(counts),
};
}),
}),
'POST /api/greatest/rating': ({ u, b }) => {
const id = String(b.id || '');
if (!greatestById.has(id)) bad(404, 'Catalog entry not found');
const rating = b.rating === null || b.rating === '' ? null : String(b.rating || '');
if (rating !== null && !GREATEST_RATINGS.includes(rating)) bad(400, 'Unknown rating');
const all = (db.greatestRatings = db.greatestRatings || {});
const mine = (all[u.name] = all[u.name] || {});
if (rating === null) delete mine[id]; else mine[id] = rating;
save();
const votes = greatestCounts(id, all, db.users);
return { id, myRating: rating, votes, score: greatestScore(votes) };
},
'GET /api/greatest/sources': async ({ u, q }) => {
admin(u);
const entry = greatestById.get(String(q.get('id') || ''));
if (!entry) bad(404, 'Catalog entry not found');
const owned = findGreatestOwned(entry, lib.mangas);
try {
const state = await scraperRequest('/api/state');
return { available: true, ...hosterLinks(entry, owned, state.queue || []) };
} catch (error) {
return { available: false, error: error.message, ...hosterLinks(entry, owned) };
}
},
'GET /api/greatest/mangadex-search': async ({ u, q }) => {
admin(u);
const entry = greatestById.get(String(q.get('id') || ''));
if (!entry) bad(404, 'Catalog entry not found');
return { results: await searchGreatestMangaDex(entry) };
},
'GET /api/manga': ({ u, q, sess }) => {
const m = mangaOf(q.get('m')), prog = progressOf(u, m), s = settingsOf(u, sess), showStory = s.readingStory;
const records = mangaReadingOf(u, m);
return {
chapterDates: { read: s.readDate, release: s.releaseDate },
id: m.id, title: m.title, meta: m.meta, cover: url(m.cover), direction: m.direction,
collections: catalog.state.collections.filter(c => c.members.some(member => member.mangaId === m.id)).map(c => ({ id: c.id, title: c.title })),
relations: catalog.state.relations.filter(r => r.state === 'confirmed' && (r.from === m.id || r.to === m.id)).map(r => ({ ...r,
otherId: r.from === m.id ? r.to : r.from, otherTitle: lib.byId.get(r.from === m.id ? r.to : r.from)?.title || (r.from === m.id ? r.to : r.from) })),
following: !!(db.follows[u.name] && db.follows[u.name][m.id]),
books: m.books.map(b => ({ key: b.key, label: b.label, title: b.title, extra: b.extra })),
chapters: m.chapters.map(ch => ({
id: ch.id, book: ch.book, label: ch.label, title: ch.title, extra: ch.extra, progress: prog[ch.id] || null, versions: versionsOf(m, ch, u),
reading: showStory ? chapterInsight(records[ch.id]) : null,
readAt: s.readDate === 'off' ? null : readAtOf(prog[ch.id], records[ch.id]),
dates: s.releaseDate ? chapterDates(ch.versions) : null,
})),
reading: showStory ? mangaInsight(records, m.chapters.map(ch => ch.id)) : null,
continue: continueOf(m, prog, s),
};
},
'GET /api/reading/export': ({ u }) => {
const byManga = db.reading[u.name] || {};
const mangas = Object.entries(byManga).map(([id, records]) => {
const m = lib.byId.get(id), chapterIds = m ? m.chapters.map(ch => ch.id) : Object.keys(records || {});
const chapterMap = m ? m.chapterById : new Map();
return {
id, title: m ? m.title : id,
reading: mangaInsight(records, chapterIds),
chapters: chapterIds.map(chapterId => {
const chapter = chapterMap.get(chapterId), reading = chapterInsight(records && records[chapterId]);
return reading ? { id: chapterId, label: chapter ? chapter.label : chapterId, title: chapter ? chapter.title : '', reading } : null;
}).filter(Boolean),
};
}).filter(item => item.reading.timedChapters || item.reading.completedPasses);
return {
schema: 1,
exportedAt: new Date().toISOString(),
user: u.name,
reading: libraryInsight(mangas),
mangas,
};
},
'POST /api/reading/delete': ({ u, b }) => {
if (b.all) {
if (!checkPw(String(b.password || ''), u.hash)) bad(403, 'The current password is wrong.');
delete db.reading[u.name];
readingLeases.delete(u.name);
save();
return { ok: true, deleted: 'all' };
}
const m = mangaOf(b.m), all = db.reading[u.name];
if (all) delete all[m.id];
save();
return { ok: true, deleted: m.id };
},
'GET /api/chapter': ({ u, q, sess }) => {
const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u, sess), prog = progressOf(u, m);
if (!readingAccess.status(u.name).allowed) bad(423, 'Reading is outside the deployment schedule. Complete the five-question challenge to unlock it.');
const v = ch.versions.find(x => x.id === q.get('v')) || ranked(m, ch)[0].v;
// Opening a chapter clears its "new chapter" notification.
let seen = false;
for (const n of db.notes[u.name] || []) if (!n.read && n.m === m.id && n.c === ch.id) seen = n.read = true;
if (seen) save();
if (readAhead) warmChapter(u, m, ch, v, prog[ch.id], s);
return {
perf: tracer.on,
manga: { id: m.id, title: m.title },
chapter: { id: ch.id, label: ch.label, title: ch.title, extra: ch.extra },
version: v.id, versions: versionsOf(m, ch, u), direction: v.direction,
pages: v.pages.map(p => ({ u: url(v.rel + '/' + p.f), w: p.w, h: p.h, t: p.t, r: p.r })),
prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s),
progress: prog[ch.id] || null,
reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null,
// the user's moments on this version, so the reader can mark pages that already have one
moments: momentsOf(u).filter(x => x.m === m.id && x.c === ch.id && x.v === v.id)
.map(x => ({ id: x.id, cat: x.cat, pages: (moments.locate(x, m) || { pages: [] }).pages })),
// for the chapter picker: the whole manga across all books
books: m.books.map(b => ({ key: b.key, label: b.label, title: b.title })),
chapters: m.chapters.map(c => ({ id: c.id, book: c.book, label: c.label, title: c.title, extra: c.extra, done: !!(prog[c.id] && prog[c.id].done) })),
};
},
'POST /api/progress': ({ u, b }) => {
const m = mangaOf(b.m), ch = chapterOf(m, b.c);
const all = (db.progress[u.name] = db.progress[u.name] || {}), mp = (all[m.id] = all[m.id] || {}), old = mp[ch.id] || {};
const now = Date.now(), done = !!(old.done || b.done);
// doneAt: when the chapter was first completed; kept until it is marked unread.
const doneAt = old.done ? old.doneAt : done ? now : undefined;
mp[ch.id] = {
v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: now, done, ...(doneAt ? { doneAt } : {}),
shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [],
};
save();
return { ok: true };
},
// Separate endpoint: an older server on rollback cannot misread cumulative
// visit totals as legacy deltas. Old open tabs retain their original protocol.
'POST /api/reading/visit': context => routes['POST /api/reading/pulse']({ ...context, cumulative: true }),
'POST /api/reading/pulse': ({ u, b, cumulative = false }) => {
const settings = settingsOf(u);
if (!settings.readingClock) bad(403, 'The Active Reading Clock is disabled.');
const m = mangaOf(b.m), ch = chapterOf(m, b.c);
const v = ch.versions.find(x => x.id === b.v) || bad(404, 'Version not found');
const session = String(b.session || '');
const seq = Number(b.seq);
if (!/^[a-zA-Z0-9_-]{8,80}$/.test(session)) bad(400, 'Invalid reading session.');
if (!Number.isInteger(seq) || seq < 1 || seq > 1e9) bad(400, 'Invalid reading pulse sequence.');
const now = Date.now(), eligible = eligiblePages(v, settings), lease = readingLeases.get(u.name);
const previous = readingOf(u, m, ch)?.recentSessions?.find(item => item.id === session);
// A stale request must not reclaim a lease after a newer exit save. Final
// snapshots can also arrive before the initial claim: do not lose that visit.
if (cumulative && previous && seq <= previous.seq) return {
accepted: true, duplicate: true, leaseMs: 0,
reading: publicSummary(readingOf(u, m, ch), v.id, eligible),
};
const lateFinal = cumulative && b.final;
if (!lateFinal && lease && lease.session !== session && now - lease.at < READING_LEASE_MS) {
return {
accepted: false,
reason: 'other-tab',
retryAfterMs: READING_LEASE_MS - (now - lease.at),
reading: publicSummary(readingOf(u, m, ch), v.id, eligible),
};
}
if (!lateFinal) readingLeases.set(u.name, { session, at: now });
const byUser = (db.reading[u.name] = db.reading[u.name] || {});
const byManga = (byUser[m.id] = byUser[m.id] || {});
const record = (byManga[ch.id] = readingOf(u, m, ch) || emptyRecord());
const result = (cumulative ? applyVisit : applyPulse)(record, b, { version: v.id, pageCount: v.pages.length, eligible, now });
if (b.release && readingLeases.get(u.name)?.session === session) readingLeases.delete(u.name);
if (!result.duplicate) save();
return {
accepted: true,
duplicate: result.duplicate,
leaseMs: b.release ? 0 : READING_LEASE_MS,
reading: publicSummary(record, v.id, eligible),
};
},
'POST /api/unread': ({ u, b }) => {
const m = mangaOf(b.m), mp = db.progress[u.name] && db.progress[u.name][m.id];
if (mp) delete mp[chapterOf(m, b.c).id];
save();
return { ok: true };
},
'POST /api/vote': ({ u, b }) => {
const m = mangaOf(b.m), ch = chapterOf(m, b.c), v = ch.versions.find(x => x.id === b.v) || bad(404, 'Version not found');
const byChapter = (db.votes[m.id] = db.votes[m.id] || {}), byVersion = (byChapter[ch.id] = byChapter[ch.id] || {});
const votes = (byVersion[v.id] = byVersion[v.id] || {}), value = Math.sign(+b.value || 0);
if (value) votes[u.name] = value;
else delete votes[u.name];
save();
return { versions: versionsOf(m, ch, u) };
},
'POST /api/follow': ({ u, b }) => {
const m = mangaOf(b.m), f = (db.follows[u.name] = db.follows[u.name] || {});
if (b.on) f[m.id] = true;
else delete f[m.id];
save();
return { following: !!f[m.id] };
},
// Moments (BL-0055): private to their user; one category each, no uncategorised saves.
'GET /api/moments': ({ u, q }) => {
const m = mangaOf(q.get('m'));
return { moments: momentsOf(u).filter(x => x.m === m.id).map(x => moments.view(x, m, url)).sort(moments.storyCompare) };
},
'POST /api/moments': async ({ u, b }) => {
const m = mangaOf(b.m), ch = chapterOf(m, b.c), v = ch.versions.find(x => x.id === b.v) || bad(404, 'Version not found');
const pages = moments.cleanPages(b.pages, v);
const moment = { id: crypto.randomBytes(6).toString('hex'), m: m.id, c: ch.id, v: v.id, i: pages, f: pages.map(i => v.pages[i].f),
layout: moments.cleanLayout(b.layout),
region: moments.cleanRegion(b.region), cat: moments.cleanCategory(b.cat), note: moments.cleanNote(b.note), t: Date.now() };
const list = momentsOf(u);
if (list.length >= moments.MAX_PER_USER) bad(409, `You already have ${moments.MAX_PER_USER} moments.`);
moment.sha = await Promise.all(moment.f.map(f => fileSha(v.rel + '/' + f)));
list.push(moment);
save();
return { moment: moments.view(moment, m, url) };
},
'POST /api/moments/update': ({ u, b }) => {
const moment = momentOf(u, b.id);
if ('cat' in b) moment.cat = moments.cleanCategory(b.cat);
if ('region' in b) moment.region = moments.cleanRegion(b.region);
if ('note' in b) moment.note = moments.cleanNote(b.note);
moment.edited = Date.now();
save();
return { moment: moments.view(moment, lib.byId.get(moment.m), url) };
},
'POST /api/moments/delete': ({ u, b }) => {
const list = momentsOf(u), moment = momentOf(u, b.id);
list.splice(list.indexOf(moment), 1);
save();
return { ok: true };
},
// Timings the reader page measured (resource timing, decode, page-turn waits) while the trace is on.
'POST /api/perf': ({ u, b }) => {
if (!tracer.on) return { on: false };
const events = Array.isArray(b.events) ? b.events.slice(0, 300) : [];
for (const e of events) if (e && typeof e === 'object' && !Array.isArray(e)) {
const clean = {};
for (const [key, value] of Object.entries(e).slice(0, 30)) {
if (key === 't' || key === 'k') continue; // the server's clock and kind
if (typeof value === 'number' && Number.isFinite(value)) clean[key] = Math.round(value * 10) / 10;
else if (typeof value === 'string' || typeof value === 'boolean') clean[key] = typeof value === 'string' ? value.slice(0, 300) : value;
}
tracer.log({ ...clean, k: 'client', user: u.name, page: b.page ? String(b.page).slice(0, 40) : undefined });
}
return { on: true };
},
'GET /api/notifications': ({ u }) => ({ items: (db.notes[u.name] || []).slice().reverse() }),
'POST /api/notifications/read': ({ u, b }) => {
for (const n of db.notes[u.name] || []) if (b.all || n.id === b.id) n.read = true;
save();
return { unread: unreadOf(u) };
},
'GET /api/admin': ({ u }) => {
admin(u);
return { users: Object.values(db.users).map(x => ({ name: x.name, admin: !!x.admin, created: x.created })), scan: { ...scanState, running: !!scanning, library: LIB } };
},
'POST /api/admin/rescan': async ({ u, b }) => {
admin(u);
await rescan(!!b.full);
return { scan: { ...scanState, running: false, library: LIB } };
},
'POST /api/admin/user': ({ u, b }) => {
admin(u);
const name = String(b.name || '').trim().toLowerCase();
createUser(name, b.password, name === u.name || !!b.admin, true);
return { ok: true };
},
// Grant or revoke admin rights without touching the password. Admins only (checked here, not
// just hidden in the UI); nobody can revoke their own rights, so at least one admin always remains.
'POST /api/admin/user/role': ({ u, b }) => {
admin(u);
const name = String(b.name || '').trim().toLowerCase();
const target = db.users[name] || bad(404, 'No such user.');
if (typeof b.admin !== 'boolean') bad(400, 'admin must be true or false.');
if (name === u.name && !b.admin) bad(400, "You can't remove your own admin rights.");
if (!!target.admin !== b.admin) {
target.admin = b.admin;
save();
console.log(`[users] ${u.name} ${b.admin ? 'made' : 'removed'} ${name} ${b.admin ? 'an admin' : 'as admin'}`);
}
return { ok: true, name, admin: target.admin };
},
'POST /api/admin/user/delete': ({ u, b }) => {
admin(u);
const name = String(b.name || '');
if (name === u.name) bad(400, "You can't delete your own account.");
delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.follows[name]; delete db.notes[name]; delete db.moments[name];
if (db.discoveryFeedback) delete db.discoveryFeedback[name];
if (db.greatestRatings) delete db.greatestRatings[name];
for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k];
save();
return { ok: true };
},
'GET /api/admin/peering': ({ u }) => {
admin(u);
return { ...peerStore.publicState(), library: lib.mangas.map(m => ({ id: m.id, title: m.title, cover: url(m.cover),
chapters: m.chapters.length, year: Number(m.meta.year || m.meta.startDate?.year) || null,
status: String(m.meta.status || 'unknown'), description: String(m.meta.description || m.meta.synopsis || '').slice(0, 3000),
tags: Array.isArray(m.meta.tags) ? m.meta.tags.map(tag => typeof tag === 'string' ? tag : tag.name).filter(Boolean).slice(0, 20) : [] })) };
},
'POST /api/admin/peering': async ({ u, b }) => {
admin(u);
const command = String(b.command || '');
if (command === 'set-name') peerStore.setName(b.name);
else if (command === 'generate') peerStore.generateIdentity(b.name);
else if (command === 'invite') return { ...peerStore.publicState(), invitation: peerStore.createInvitation(b.url) };
else if (command === 'import') peerStore.importInvitation(b.invitation);
else if (command === 'probe') await peerClient.probe(peerStore, String(b.id || ''));
else if (command === 'set') peerStore.updatePeer(String(b.id || ''), b.patch || {});
else if (command === 'revoke-grant') peerStore.revokeGrant(String(b.id || ''));
else if (command === 'share') {
if (!lib.byId.has(String(b.mangaId || ''))) bad(404, 'Library manga not found');
peerStore.setShare(String(b.id || ''), String(b.mangaId), !!b.enabled);
}
else if (command === 'share-set') {
const ids = Array.isArray(b.mangaIds) ? [...new Set(b.mangaIds.map(String))] : null;
if (!ids) bad(400, 'Manga selection is required');
if (ids.some(id => !lib.byId.has(id))) bad(404, 'One or more library manga are no longer available');
peerStore.setShares(String(b.id || ''), ids);
}
else if (command === 'grant') return { ...peerStore.publicState(), grant: peerStore.issueGrant(String(b.id || ''), b.scopes) };
else if (command === 'accept-grant') peerStore.acceptGrant(String(b.id || ''), b.token);
else if (command === 'remove') peerStore.removePeer(String(b.id || ''));
else bad(400, 'Unknown peering command');
return { ...peerStore.publicState(), library: lib.mangas.map(m => ({ id: m.id, title: m.title })) };
},
'GET /api/peering': ({ u }) => { admin(u); return { peers: peerStore.publicState().peers
.map(p => ({ id: p.id, alias: p.alias, receive: !!p.receive, paired: !!p.paired, fingerprint: p.fingerprint,
url: p.url, grantStatus: p.grantStatus, grantScopes: p.grantScopes,
readerQueue: !!p.readerQueue, shares: p.shares, hasGrant: p.hasOutboundGrant })) }; },
'GET /api/peering/access': async ({ u, q }) => {
admin(u);
const id = String(q.get('peer') || ''), peer = peerForRoute(id);
if (!peer.paired) return { paired: false, grant: { status: 'none', scopes: [] } };
return peerRemote(() => peerClient.access(peerStore, id));
},
'GET /api/peering/catalog': async ({ u, q }) => {
admin(u);
const id = String(q.get('peer') || '');
const peer = peerForRoute(id);
if (!peer.paired) bad(403, 'Verify peer identity first');
const access = await peerRemote(() => peerClient.access(peerStore, id));
if (access.grant.status !== 'active' || !access.grant.scopes.includes('catalog')) bad(403, 'This peer has not granted active catalog access');
return { peer: { id, alias: peer.alias }, items: await peerRemote(() => peerClient.catalog(peerStore, id)) };
},
'GET /api/peering/manga': async ({ u, q }) => {
admin(u);
const id = String(q.get('peer') || ''), shareId = String(q.get('manga') || '');
const peer = peerForRoute(id);
if (!peer.paired) bad(403, 'Verify peer identity first');
return peerRemote(() => peerClient.manga(peerStore, id, shareId));
},
'POST /api/peering/queue': async ({ u, b }) => {
admin(u);
const id = String(b.peer || ''), shareId = String(b.manga || '');
const peer = peerForRoute(id);
if (!peer.paired || !peer.receive || (!u.admin && !peer.readerQueue)) bad(403, 'Peer downloads are disabled for this account');
const access = await peerRemote(() => peerClient.access(peerStore, id));
if (!access.grant.scopes.includes('download')) bad(403, 'This peer has granted catalog browsing only');
if (!/^m[0-9a-f]{24}$/.test(shareId)) bad(400, 'Invalid peer manga ID');
const remote = await peerRemote(() => peerClient.manga(peerStore, id, shareId));
const present = await peerRemote(() => peerClient.catalog(peerStore, id));
if (!present.some(entry => entry.id === shareId)) bad(404, 'Peer manga not available');
return scraperRequest('/api/queue', { url: `peer://${id}/${shareId}`, title: remote.title, workIdentity: remote.workIdentity,
scope: b.scope || { mode: 'missing' }, existingPolicy: b.existingPolicy || 'skip-any',
...(b.libraryMangaId ? { libraryMangaId: String(b.libraryMangaId) } : {}) });
},
'POST /api/peering/queue-bulk': async ({ u, b }) => {
admin(u);
const id = String(b.peer || ''), peer = peerForRoute(id);
const ids = Array.isArray(b.mangas) ? [...new Set(b.mangas.map(String))] : [];
if (!ids.length || ids.length > 50 || ids.some(value => !/^m[0-9a-f]{24}$/.test(value))) bad(400, 'Select 1–50 peer titles');
if (!peer.paired || !peer.receive) bad(403, 'Enable downloads from this peer first');
const access = await peerRemote(() => peerClient.access(peerStore, id));
if (access.grant.status !== 'active' || !access.grant.scopes.includes('download')) bad(403, 'This peer has not granted active download access');
const available = new Set(await peerRemote(() => peerClient.catalog(peerStore, id)).then(items => items.map(item => item.id)));
const results = [];
for (const mangaId of ids) {
if (!available.has(mangaId)) { results.push({ id: mangaId, status: 'unavailable' }); continue; }
try {
const remote = await peerRemote(() => peerClient.manga(peerStore, id, mangaId));
const result = await scraperRequest('/api/queue', { url: `peer://${id}/${mangaId}`, title: remote.title, workIdentity: remote.workIdentity,
scope: { mode: 'missing' }, existingPolicy: 'skip-any' });
results.push({ id: mangaId, title: remote.title, status: 'queued', result });
} catch (error) { results.push({ id: mangaId, status: 'failed', error: String(error.message || error).slice(0, 240) }); }
}
return { results, queued: results.filter(item => item.status === 'queued').length };
},
'GET /api/admin/scraper': async ({ u }) => {
admin(u);
try { return { available: true, ...(await scraperRequest('/api/state')) }; }
catch (error) { return { available: false, error: error.message, url: SCRAPER_URL }; }
},
'POST /api/admin/scraper': async ({ u, b }) => {
admin(u);
const command = String(b.command || '');
if (command === 'enqueue') return scraperRequest('/api/queue', {
url: b.url, title: b.title, metadataSearch: b.metadataSearch,
scope: b.scope, existingPolicy: b.existingPolicy, libraryMangaId: b.libraryMangaId,
inspect: b.inspect === true,
});
if (command === 'remove') return scraperRequest('/api/queue/remove', { id: b.id });
if (command === 'clear-completed') return scraperRequest('/api/queue/clear-completed', { source: b.source });
if (command === 'reorder') return scraperRequest('/api/queue/reorder', { ids: b.ids });
if (command === 'control') return scraperRequest('/api/control', { action: b.action, id: b.id });
if (command === 'settings') return scraperRequest('/api/settings', b.settings || {});
if (command === 'discover') return scraperRequest('/api/discovery/run', {});
if (command === 'auto-update') return scraperRequest('/api/library/auto-update', { id: b.id, enabled: !!b.enabled });
if (command === 'publish-legacy-ready') return scraperRequest('/api/legacy/publish-ready', {});
bad(400, 'Unknown scraper command');
},
};
// ---------- HTTP ----------
function sendJson(res, code, obj) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(obj));
}
async function readBody(req) {
let s = '';
for await (const c of req) { s += c; if (s.length > 1e6) bad(413, 'Request too large'); }
try { return s ? JSON.parse(s) : {}; } catch { bad(400, 'Invalid JSON'); }
}
async function handleApi(req, res, u, route, q) {
let b = {};
if (req.method === 'POST') {
// JSON-only + same-site: plain HTML forms from other sites can't reach the API.
if (!/^application\/json/.test(req.headers['content-type'] || '') || req.headers['sec-fetch-site'] === 'cross-site') bad(403, 'Bad request origin');
b = (await readBody(req)) || {};
}
const sk = u && sessionKeyOf(req), sess = sk && db.sessions[sk];
if (sess) touchSession(u, sess, sk, req);
sendJson(res, 200, await route({ req, res, u, b, q, sess, sk }));
}
function serveFile(req, res, root, rel, cacheControl, only, trace) {
let file;
try { file = path.resolve(root, decodeURIComponent(rel)); } catch { return sendJson(res, 400, { error: 'Bad path' }); }
if (!file.startsWith(root + path.sep) || (only && !only.test(file))) return sendJson(res, 404, { error: 'Not found' });
fs.stat(file, (err, st) => {
if (trace) trace.mark('stat');
if (err || !st.isFile()) return sendJson(res, 404, { error: 'Not found' });
const etag = `"${st.size.toString(36)}-${Math.round(st.mtimeMs).toString(36)}"`;
if (req.headers['if-none-match'] === etag) { res.writeHead(304, { ETag: etag, 'Cache-Control': cacheControl }); return res.end(); }
const headers = { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Content-Length': st.size, ETag: etag, 'Cache-Control': cacheControl };
// The browser's resource timing shows this, so a slow page can be matched to the server's share.
if (trace && trace.on) headers['Server-Timing'] = `stat;dur=${trace.elapsed()}`;
res.writeHead(200, headers);
if (req.method === 'HEAD') return res.end();
const stream = fs.createReadStream(file).on('error', () => res.destroy());
if (trace) trace.stream(stream);
stream.pipe(res);
});
}
http.createServer(async (req, res) => {
const trace = requestTrace(tracer, req, res);
try {
const { pathname, searchParams } = new URL(req.url, 'http://localhost');
const u = userOf(req);
if (pathname === '/health' && req.method === 'GET') return sendJson(res, 200, { ...routes['GET /api/health'](), ok: true, ready: !scanState.error });
if (pathname === '/api/collection-cover' && req.method === 'GET') {
if (!u) bad(401, 'Please log in');
const c = catalog.state.collections.find(c => c.id === searchParams.get('id'));
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);
const peerId = String(searchParams.get('peer') || ''), mangaId = String(searchParams.get('manga') || ''), peer = peerForRoute(peerId);
if (!peer.paired) bad(403, 'Verify peer identity first');
const access = await peerRemote(() => peerClient.access(peerStore, peerId));
if (access.grant.status !== 'active' || !access.grant.scopes.includes('catalog')) bad(403, 'This peer has not granted active catalog access');
const image = await peerRemote(() => peerClient.cover(peerStore, peerId, mangaId));
res.writeHead(200, { 'Content-Type': image.mime, 'Content-Length': image.buffer.length,
...(image.etag ? { ETag: image.etag } : {}), 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff' });
return res.end(image.buffer);
}
if (pathname.startsWith('/api/')) {
const route = routes[req.method + ' ' + pathname];
if (!route) bad(404, 'Unknown endpoint');
if (!u && !PUBLIC.has(pathname)) bad(401, 'Please log in');
return await handleApi(req, res, u, route, searchParams);
}
if (req.method !== 'GET' && req.method !== 'HEAD') bad(405, 'Method not allowed');
if (pathname.startsWith('/lib/')) {
if (!u) bad(401, 'Please log in');
let imagePath;
try { imagePath = path.resolve(LIB, decodeURIComponent(pathname.slice(5))); } catch { bad(400, 'Bad image path'); }
const relative = path.relative(LIB, imagePath);
const parts = relative.split(path.sep);
if (parts.length >= 4 && !readingAccess.status(u.name).allowed)
bad(423, 'Reading is outside the deployment schedule.');
trace.mark('access');
return serveFile(req, res, LIB, pathname.slice(5), readingSchedule && parts.length >= 4 ? 'private, no-store' : 'private, max-age=86400', IMG, trace);
}
serveFile(req, res, PUB, pathname === '/' ? 'index.html' : pathname.slice(1), 'no-cache', null, trace);
} catch (e) {
const expected = e instanceof HttpError || e instanceof CatalogError || e instanceof moments.MomentError;
if (!expected) console.error(e);
if (!res.headersSent) sendJson(res, expected ? e.code : 500, expected ? { error: e.message, ...e.extra } : { error: 'Internal error' });
}
}).listen(PORT, () => console.log(`MangaCircus on http://localhost:${PORT} (library: ${LIB}, data: ${DATA})`));
// The trace's storage probe reads a random page of the library (rarely in the OS cache).
tracer.setProbeSource(() => {
const m = lib.mangas[Math.floor(Math.random() * lib.mangas.length)];
const ch = m && m.chapters[Math.floor(Math.random() * m.chapters.length)], v = ch && ch.versions[0];
const p = v && v.pages[Math.floor(Math.random() * v.pages.length)];
return p ? path.join(LIB, v.rel, p.f) : null;
});
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(() => {
const now = Date.now();
if (now - Math.max(lastFullAt, lastFullAttemptAt) >= FULL_SCAN_MINUTES * 60000) {
lastFullAttemptAt = now;
rescan();
}
}, 60000).unref();
// A completed scraper publish increments a durable revision. Polling it here keeps the
// reader index fresh without exposing an unauthenticated rescan endpoint to the daemon.
let scraperRevision = null, revisionRoute = true;
async function pollScraper() {
try {
// /api/revision is a few bytes; the full state is ~100 KB and makes the scraper summarise the library.
let state;
if (revisionRoute) state = await scraperRequest('/api/revision').catch(error => { if (error.code !== 404) throw error; revisionRoute = false; });
if (!revisionRoute) state = await scraperRequest('/api/state'); // a scraper from before /api/revision
const revision = Number(state.publishedRevision || 0);
if (scraperRevision === null || revision > scraperRevision) {
const checked = await refreshChanged();
if (checked.error) throw new Error(checked.error);
// A publication without a visible directory change should not disappear
// because of coarse filesystem timestamps or a missed scan race.
if (scraperRevision !== null && revision > scraperRevision && !checked.changedMangas) {
const reconciled = await rescan();
if (reconciled.error) throw new Error(reconciled.error);
}
}
scraperRevision = revision;
} catch { /* the reader also works when the optional scraper daemon is offline */ }
}
pollScraper();
setInterval(pollScraper, 15000).unref();
// Nightly discovery harvest; the delayed first check also covers a slot missed while the reader was down.
setInterval(checkNightly, 60000).unref();
setTimeout(checkNightly, 30000).unref();