Compare commits

...
Author SHA1 Message Date
MaashkinoozheandClaude Opus 5.5 b2bb34174c Fold the sentinel into the scraper and drop the sentinel service (BL-0046)
The stall watchdog now runs inside the scraper (ScraperService.checkStalls,
every 30 s from the work loop): same rule as before, one checkpoint-safe
restart, quarantine on the second stall, missing checkpoint is no evidence,
and paused time no longer counts. Stalls are recorded as scraper incidents
(failure.kind 'stall'). The private /api/recovery endpoint is gone.

Dropped with the service: reader/scraper-unreachable incidents (supervisor
and Docker handle crashes; the health page shows "Scraper: unreachable"),
duplicate job-failure incidents and the unused /metrics endpoint.
The stack is now reader + scraper (+ optional peer-gateway).
The supervisor test moved to runtime/test (npm run test:runtime stays).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:58:03 +02:00
MaashkinoozheandClaude Opus 5.5 88555d4780 Document the measured first-scan speed on the server (BL-0043)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:07:30 +02:00
MaashkinoozheandClaude Opus 5.5 5e14e54286 Faster first library scan: bounded concurrency and a 32 KB first read
An uncached scan probed one image at a time and read 256 KB of each. On
a NAS that is bound by per-file latency (the server/Synology: ~20 ms per
image, ~100 min for 298k images). Now chapter folders of a book and
their images are probed SCAN_CONCURRENCY (default 8, 1-64) at a time
and assembled in folder order, and imageSize() reads 32 KB first, then
256 KB, then the whole file: a prefix gives either no size or the same
size, so results do not change. Tests: every format and read stage
equals the whole-file answer; concurrency 1 and 8 build the same index
and cache; the limiter keeps its bound and per-task errors. (BL-0043)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:07:29 +02:00
MaashkinoozheandClaude Opus 5.5 d6254a8be5 Admins can grant or revoke admin rights in Settings (BL-0045)
New POST /api/admin/user/role {name, admin}: admin-only (checked on the
server), keeps the password and sessions, takes effect on the target's
current session at once, logs who changed whom. Revoking your own
rights is refused, so at least one admin always remains. Settings ->
Users & access shows "Make admin" / "Remove admin" next to every other
user, with a confirmation. Test: normal users and anonymous callers are
refused, input checks, promotion and revocation apply immediately and
are saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:35:04 +02:00
MaashkinoozheandClaude Opus 5.5 f8f21ab42a Backup: probe the target with a real write instead of access(W_OK)
On the server the backup folder is a Synology NFS share with "map all
users to admin": access(W_OK) returns EACCES for uid 1000 although
writes succeed, so the first scheduled run failed with "not usable".
Create and remove a probe file instead; test that it leaves nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:12:41 +02:00
MaashkinoozheandClaude Opus 5.5 086f6353f8 Add built-in backup of the data folder (BL-0044)
The reader owns a scheduled backup into BACKUP (Compose: BACKUP_PATH mounted at
/backup, off when unset). A run pauses the scraper queue through its control
API, waits (bounded) until no job is active, writes a verified tar.gz of DATA
with db.json from memory and scraper/queue.json stored paused, excludes
runtime/lock/pid/temp files, resumes only a queue it paused, then applies
count- or day-based retention to its own archive names only. Schedule,
retention, "Back up now" and last result live in Settings > Service health.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:03:21 +02:00
46 changed files with 1363 additions and 330 deletions

No files matched your search

+1 -2
View File
@@ -6,6 +6,7 @@
!reading-direction.js
!chapter-identity.js
!catalog.js
!backup.js
!reading-analytics.js
!reading-access.js
!public/
@@ -16,8 +17,6 @@
!discovery/**
!runtime/
!runtime/**
!sentinel/
!sentinel/**
!scraper/
!scraper/**
!peering/
+3 -1
View File
@@ -7,8 +7,10 @@ READER_PORT=8080
LIBRARY_PATH=./library
DATA_PATH=./data
SCRAPER_DATA_PATH=./data/scraper
SENTINEL_DATA_PATH=./data/sentinel
SCRAPER_TOKEN_PATH=./.secrets/scraper_token
# Built-in backups of DATA_PATH (docs/backup.md): an existing folder, ideally on another
# disk/NAS, writable by uid 1000. Unset = backups off (./backup is then mounted unused).
# BACKUP_PATH=./backup
TZ=Europe/Berlin
COOKIE_SECURE=0
# Optional server-enforced reading windows. Day keys: 0=Sunday through 6=Saturday.
+2
View File
@@ -3,6 +3,8 @@
/data/
# the manga library: demo content is generated by `node tools/make-dummy.js`, a real one is yours alone
/library/
# built-in backups (BACKUP_PATH default)
/backup/
# --- Node ---
node_modules/
+1 -2
View File
@@ -3,14 +3,13 @@ RUN apk add --no-cache tzdata openssl
WORKDIR /app
ARG VCS_REF=development
LABEL org.opencontainers.image.title="MangaCircus" org.opencontainers.image.revision=$VCS_REF
COPY package.json server.js library.js reading-direction.js chapter-identity.js reading-analytics.js reading-access.js catalog.js ./
COPY package.json server.js library.js reading-direction.js chapter-identity.js reading-analytics.js reading-access.js catalog.js backup.js ./
COPY public ./public
COPY greatest ./greatest
COPY discovery ./discovery
COPY tools ./tools
COPY scraper ./scraper
COPY peering ./peering
COPY sentinel ./sentinel
COPY runtime ./runtime
RUN mkdir -p /library /data && chown node:node /data /library
ENV PORT=8080 LIBRARY=/library DATA=/data TZ=Europe/Berlin
+5 -3
View File
@@ -20,7 +20,7 @@ Open http://localhost:8080. The first account you create becomes the admin. Try
node tools/make-dummy.js library
```
To run the reader, managed scraper and Sentinel together, use `npm run start:all`. Admins can
To run the reader and managed scraper together, use `npm run start:all`. Admins can
then manage the persistent download queue, progress, pauses, retries, and rate limits
under **Settings → Admin → Scraper**. The reader continues working if the scraper is stopped.
**Settings → Admin → Collections & metadata** manages grouped tiles, reading order and relationships;
@@ -44,7 +44,7 @@ docker compose up -d --build
```
Stop existing native writers before using their data in containers. `docker-compose.yml`
runs reader, scraper and Sentinel with explicit persistent bind mounts (`library/`,
runs reader and scraper with explicit persistent bind mounts (`library/`,
`data/`), file-backed secrets and only a loopback reader port. No named data volume is
used. See [Compose deployment](docs/compose-deployment.md) for local development, VM
recommendations, cutover, image releases and backup/rollback. The local Docker stack
@@ -69,11 +69,12 @@ see administration; personal settings do not poll scraper services. See
| `SIGNUP` | `closed` | `open` lets anyone create an account. Otherwise the admin adds users under Settings |
| `SCAN_INTERVAL` | `10` | Minutes between shallow library change checks (`0` disables this timer) |
| `FULL_SCAN_INTERVAL` | `1440` | Minutes between full library reconciliations (`0` disables this timer) |
| `SCAN_CONCURRENCY` | `8` | Chapter folders and images probed at once by an uncached scan (1–64); higher helps on network storage |
| `COOKIE_SECURE` | – | `1` marks the login cookie `Secure` (use it when you serve over HTTPS) |
| `SCRAPER_URL` | `http://127.0.0.1:8091` | Private scraper daemon URL |
| `SCRAPER_TOKEN` | empty | Shared daemon token; required when it listens beyond loopback |
| `SCRAPER_TOKEN_FILE` | empty | Alternative secret file (used in Compose) |
| `SENTINEL_URL` | `http://127.0.0.1:8092` | Private observation service URL |
| `BACKUP` | – | Existing folder for built-in backups of `DATA`; unset = off. Compose: set `BACKUP_PATH`. Schedule and retention under Settings → Service health ([backup](docs/backup.md)) |
Serve it behind a reverse proxy with HTTPS if it is reachable from the internet. All pages use relative URLs, so a sub-path like `/manga/` works.
@@ -113,6 +114,7 @@ whole-series completeness. See the scraper service and library format contracts
| [docs/architecture.md](docs/architecture.md) | Components, data model, API, security, the reader's layout engine, deployment, design decisions |
| [docs/local-deployment.md](docs/local-deployment.md) | Current local Compose deployment, Tailscale access, persistent state, checks, restart, and recovery |
| [docs/settings-navigation.md](docs/settings-navigation.md) | User/Admin settings sections, scraper tabs, routes and UI checks |
| [docs/backup.md](docs/backup.md) | Built-in scheduled backup of `data/`, what it contains, retention and the restore procedure |
| [docs/reading-insights-concept.md](docs/reading-insights-concept.md) | Active-time presentation, private summaries, deletion/export controls, and the future journal design |
| [docs/scraper-guide.md](docs/scraper-guide.md) | Rule book (agent-readable) for scrapers and other tools that write into the library |
| [scraper/AGENT_RUNBOOK.md](scraper/AGENT_RUNBOOK.md) | Conservative scraper workflow and host-specific procedures for automated agents |
+402
View File
@@ -0,0 +1,402 @@
'use strict';
/*
* Built-in backup of the reader's DATA tree into BACKUP (see docs/backup.md).
*
* One run: pause the scraper queue through its control API, wait (bounded) until no
* job is active, write a gzip'd tar of DATA into BACKUP, resume the queue only if this
* run paused it, then apply retention. No container stops. db.json comes from the
* reader's in-memory state; scraper/queue.json is stored with `paused: true` so a
* restored copy never starts downloading by itself. Runtime/lock/pid/temp files are
* left out. Retention only ever deletes `circus-YYYYMMDD-HHMMSS.tar.gz` files directly
* inside BACKUP. No dependencies: the tar writer/reader below is plain ustar + PAX.
*/
const fs = require('fs'), fsp = fs.promises, path = require('path'), zlib = require('zlib');
const { Readable } = require('stream'), { pipeline } = require('stream/promises');
const ARCHIVE = /^circus-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})\.tar\.gz$/;
const PART = /^circus-\d{8}-\d{6}\.tar\.gz\.part$/;
const EXCLUDE = [/^runtime.*\.json$/, /\.lock$/, /\.pid$/, /\.tmp$/];
const DEFAULT_SETTINGS = { enabled: false, time: '03:30', retention: { mode: 'count', value: 14 } };
const RETENTION_LIMITS = { count: [1, 365], days: [1, 3650] };
const CATCH_UP_MS = 24 * 3600e3;
class BackupError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const pad = n => String(n).padStart(2, '0');
const stampOf = d => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
const archiveTime = name => { const m = ARCHIVE.exec(name); return m ? new Date(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime() : NaN; };
const message = e => String((e && e.message) || e).slice(0, 500);
function validateSettings(input, current = DEFAULT_SETTINGS) {
const next = { ...current, retention: { ...current.retention } };
if (input.enabled !== undefined) {
if (typeof input.enabled !== 'boolean') throw new BackupError(400, 'enabled must be true or false');
next.enabled = input.enabled;
}
if (input.time !== undefined) {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(input.time))) throw new BackupError(400, 'The backup time must be HH:MM');
next.time = String(input.time);
}
if (input.retention !== undefined) {
const mode = input.retention && input.retention.mode, value = Number(input.retention && input.retention.value);
const limits = RETENTION_LIMITS[mode];
if (!limits) throw new BackupError(400, 'Retention is "count" or "days"');
if (!Number.isInteger(value) || value < limits[0] || value > limits[1]) throw new BackupError(400, `Keep ${limits[0]}–${limits[1]} ${mode === 'count' ? 'backups' : 'days'}`);
next.retention = { mode, value };
}
return next;
}
// Next local occurrence of HH:MM strictly after `now` (process TZ).
function nextOccurrence(time, now) {
const [hh, mm] = time.split(':').map(Number), d = new Date(now);
const at = new Date(d.getFullYear(), d.getMonth(), d.getDate(), hh, mm, 0, 0);
if (at.getTime() <= now) at.setDate(at.getDate() + 1);
return at.getTime();
}
// ---------- tar (ustar + PAX path records), enough for `tar -xzf` everywhere ----------
function octal(buf, offset, length, value) { buf.write(value.toString(8).padStart(length - 1, '0') + '\0', offset, length, 'ascii'); }
function rawHeader(name, { size = 0, mode = 0o644, uid = 0, gid = 0, mtime = 0, type = '0' }) {
const b = Buffer.alloc(512);
b.write(name, 0, 100, 'utf8');
octal(b, 100, 8, mode & 0o7777); octal(b, 108, 8, uid & 0o7777777); octal(b, 116, 8, gid & 0o7777777);
octal(b, 124, 12, size); octal(b, 136, 12, Math.max(0, Math.floor(mtime / 1000)));
b.write(' ', 148, 8, 'ascii');
b.write(type, 156, 1, 'ascii');
b.write('ustar\0' + '00', 257, 8, 'ascii');
let sum = 0; for (const byte of b) sum += byte;
b.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
return b;
}
const padding = size => Buffer.alloc((512 - (size % 512)) % 512);
function paxRecord(key, value) {
const body = ` ${key}=${value}\n`, bytes = Buffer.byteLength(body);
let length = bytes + String(bytes).length;
if (String(length).length !== String(bytes).length) length = bytes + String(length).length;
return `${length}${body}`;
}
function tarEntry(name, data, meta) {
const parts = [];
// Names longer than the ustar field or non-ASCII go into a PAX header first.
if (Buffer.byteLength(name) > 100 || /[^\x20-\x7e]/.test(name)) {
const pax = Buffer.from(paxRecord('path', name), 'utf8');
parts.push(rawHeader('PaxHeader/' + name.replace(/[^\x20-\x7e]/g, '_').slice(-80), { size: pax.length, type: 'x', mtime: meta.mtime }), pax, padding(pax.length));
name = name.replace(/[^\x20-\x7e]/g, '_').slice(-100);
}
parts.push(rawHeader(name, { ...meta, size: data ? data.length : 0 }));
if (data && data.length) parts.push(data, padding(data.length));
return parts;
}
// Streaming reader for verification and tests: [{ name, type, size, mode, mtime, data? }].
async function readArchive(file, { contents = false } = {}) {
const entries = []; let pending = Buffer.alloc(0), current = null, paxPath = null, ended = false;
const consume = chunk => {
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
for (;;) {
if (ended) return;
if (current) {
if (pending.length < current.need) return;
const body = pending.subarray(0, current.size);
if (current.entry.type === 'x') paxPath = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(body.toString('utf8'))?.[1] ?? null;
else { if (contents) current.entry.data = Buffer.from(body); entries.push(current.entry); }
pending = pending.subarray(current.need); current = null; continue;
}
if (pending.length < 512) return;
const h = pending.subarray(0, 512); pending = pending.subarray(512);
if (h.every(byte => byte === 0)) { ended = true; return; }
let sum = 0; for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : h[i];
if (sum !== parseInt(h.toString('ascii', 148, 156), 8)) throw new Error('Corrupt tar header');
const field = (o, l) => h.toString('utf8', o, o + l).replace(/\0.*$/s, '');
const size = parseInt(field(124, 12), 8) || 0, type = field(156, 1) || '0';
const prefix = field(345, 155), name = prefix ? prefix + '/' + field(0, 100) : field(0, 100);
const entry = { name: type === 'x' ? name : (paxPath ?? name), type, size, mode: parseInt(field(100, 8), 8), mtime: parseInt(field(136, 12), 8) * 1000 };
if (type !== 'x') paxPath = null;
current = { entry, size, need: size + padding(size).length };
}
};
await pipeline(fs.createReadStream(file), zlib.createGunzip(), async function* (source) { for await (const chunk of source) consume(chunk); });
if (!ended) throw new Error('Archive ends without the tar end marker');
return entries;
}
class BackupService {
/**
* dataRoot DATA (read) backupRoot BACKUP (rw) or '' = feature off
* stateFile settings + history snapshotDb () => db.json text from memory
* scraper { state(), control(action) } or null
*/
constructor({ dataRoot, backupRoot, stateFile, snapshotDb, scraper = null, now = Date.now, log = console,
idleTimeoutMs = 15 * 60e3, pollMs = 2000, resumeRetryMs = 5000 }) {
this.dataRoot = path.resolve(dataRoot);
this.backupRoot = backupRoot ? path.resolve(backupRoot) : '';
this.stateFile = stateFile || path.join(this.dataRoot, 'backup.json');
this.snapshotDb = snapshotDb; this.scraper = scraper; this.now = now; this.log = log;
this.idleTimeoutMs = idleTimeoutMs; this.pollMs = pollMs; this.resumeRetryMs = resumeRetryMs;
this.state = { version: 1, settings: { ...DEFAULT_SETTINGS }, nextAt: null, last: null, history: [], pending: null };
this.running = null; this.archives = []; this.targetError = null; this.timer = null;
}
load() {
try { Object.assign(this.state, JSON.parse(fs.readFileSync(this.stateFile, 'utf8'))); }
catch (e) { if (e.code !== 'ENOENT') this.log.warn(`[backup] ignoring unreadable ${this.stateFile}: ${e.message}`); }
this.state.settings = validateSettings(this.state.settings || {}, DEFAULT_SETTINGS);
this.state.history = Array.isArray(this.state.history) ? this.state.history.slice(-10) : [];
return this;
}
persist() {
fs.writeFileSync(this.stateFile + '.tmp', JSON.stringify(this.state, null, 2) + '\n');
fs.renameSync(this.stateFile + '.tmp', this.stateFile);
}
// A run interrupted by a restart may have left the queue paused on its behalf.
async recoverInterrupted() {
const pending = this.state.pending;
if (!pending) return;
this.state.pending = null;
const result = { at: pending.startedAt, trigger: pending.trigger, error: 'Interrupted by a reader restart', warnings: [] };
if (pending.pausedByUs) {
try { await this.resume(); result.warnings.push('Queue resumed after the interrupted backup'); }
catch (e) { result.warnings.push(`Queue left paused: ${message(e)}`); }
}
this.record(result);
}
async refreshTarget() {
if (!this.backupRoot) { this.targetError = null; this.archives = []; return; }
try {
const st = await fsp.stat(this.backupRoot);
if (!st.isDirectory()) throw new Error('not a directory');
// Probe with a real write: access(W_OK) is unreliable on NFS with squashed ids (Synology
// "map all users to admin" denies it although writes succeed).
const probe = path.join(this.backupRoot, `.circus-backup-probe-${process.pid}`);
await fsp.writeFile(probe, '');
await fsp.rm(probe, { force: true });
const names = (await fsp.readdir(this.backupRoot)).filter(name => ARCHIVE.test(name)).sort();
this.archives = await Promise.all(names.map(async name => ({ name, size: (await fsp.stat(path.join(this.backupRoot, name))).size, at: archiveTime(name) })));
this.targetError = null;
} catch (e) { this.targetError = `Backup folder ${this.backupRoot} is not usable: ${e.code || message(e)}`; this.archives = []; }
}
publicState() {
const s = this.state;
return {
configured: !!this.backupRoot, path: this.backupRoot || null, targetError: this.targetError,
settings: s.settings, nextAt: s.settings.enabled && this.backupRoot ? s.nextAt : null,
running: this.running && { ...this.running }, last: s.last, history: s.history.slice().reverse(),
archives: { count: this.archives.length, bytes: this.archives.reduce((n, a) => n + a.size, 0),
newest: this.archives.length ? this.archives[this.archives.length - 1].name : null },
};
}
updateSettings(input) {
this.state.settings = validateSettings(input || {}, this.state.settings);
this.state.nextAt = this.state.settings.enabled ? nextOccurrence(this.state.settings.time, this.now()) : null;
this.persist();
return this.publicState();
}
start(intervalMs = 30000) {
this.load();
this.refreshTarget().then(() => this.recoverInterrupted()).catch(e => this.log.error('[backup]', message(e)));
this.timer = setInterval(() => this.tick(), intervalMs);
this.timer.unref?.();
return this;
}
stop() { clearInterval(this.timer); }
// Scheduled runs: catch up a missed slot from the last day, never older ones
// (e.g. right after restoring an old backup.json).
async tick() {
const s = this.state.settings, now = this.now();
if (!s.enabled || !this.backupRoot || this.running) return null;
if (!this.state.nextAt) { this.state.nextAt = nextOccurrence(s.time, now); this.persist(); return null; }
if (now < this.state.nextAt) return null;
const due = now - this.state.nextAt < CATCH_UP_MS;
this.state.nextAt = nextOccurrence(s.time, now); this.persist();
return due ? this.run('schedule').catch(() => null) : null;
}
/** Starts a run and resolves with its result; rejects at once when no run is possible. */
run(trigger = 'manual') {
if (!this.backupRoot) return Promise.reject(new BackupError(409, 'Backups are off: set BACKUP to a mounted folder'));
if (this.running) return Promise.reject(new BackupError(409, 'A backup is already running'));
this.running = { startedAt: this.now(), trigger, phase: 'starting' };
return this.perform(trigger).finally(() => { this.running = null; });
}
async perform(trigger) {
const started = this.now(), result = { at: started, trigger, warnings: [] };
let part = null, pausedByUs = false;
try {
await this.refreshTarget();
if (this.targetError) throw new Error(this.targetError);
await this.removeStaleParts();
// 1. Pause the queue (unless already paused) and wait for the active chapter.
let scraperState = null;
if (this.scraper) {
try { scraperState = await this.scraper.state(); }
catch (e) { result.warnings.push(`Scraper unreachable, queue not paused: ${message(e)}`); }
}
if (scraperState) {
result.queueWasPaused = !!scraperState.paused;
if (!scraperState.paused) {
this.state.pending = { startedAt: started, trigger, pausedByUs: true }; this.persist();
await this.scraper.control('pause'); pausedByUs = true;
}
this.running.phase = 'waiting for the scraper to finish its current step';
await this.waitIdle(scraperState);
}
// 2. Snapshot DATA into BACKUP/<name>.part, then rename.
this.running.phase = 'writing archive';
let stamp = started, name, file;
do { name = `circus-${stampOf(new Date(stamp))}.tar.gz`; file = path.join(this.backupRoot, name); stamp += 1000; }
while (fs.existsSync(file)); // two runs within one second must not overwrite each other
part = file + '.part';
const stats = await this.writeArchive(part);
this.running.phase = 'verifying archive';
const entries = await readArchive(part);
const files = entries.filter(e => e.type === '0').length;
if (files !== stats.files) throw new Error(`Verification read ${files} files, expected ${stats.files}`);
await fsp.rename(part, file); part = null;
Object.assign(result, { file: name, size: (await fsp.stat(file)).size, files, dataBytes: stats.bytes });
} catch (e) {
result.error = message(e);
if (part) await fsp.rm(part, { force: true }).catch(() => {});
} finally {
// 3. Resume only what this run paused.
if (pausedByUs) {
this.running.phase = 'resuming queue';
try { await this.resume(); result.queueResumed = true; }
catch (e) { result.warnings.push(`Queue could not be resumed, resume it in Settings → Scraper: ${message(e)}`); }
}
if (this.state.pending) this.state.pending = null;
}
// 4. Retention only after a successful run, so failures never thin out old copies.
if (!result.error) {
this.running.phase = 'applying retention';
try { result.deleted = await this.applyRetention(result.file); }
catch (e) { result.warnings.push(`Retention failed: ${message(e)}`); }
}
result.durationMs = this.now() - started;
await this.refreshTarget();
this.record(result);
if (result.error) this.log.error(`[backup] failed: ${result.error}`);
else this.log.log(`[backup] ${result.file} ${result.size} bytes in ${result.durationMs} ms`);
return result;
}
record(result) {
this.state.last = result;
this.state.history = [...this.state.history, result].slice(-10);
this.persist();
}
async waitIdle(initial) {
const deadline = this.now() + this.idleTimeoutMs;
let state = initial, lastError = null;
for (;;) {
if (state && !(state.active || []).length && state.status !== 'pausing') return;
if (this.now() >= deadline) {
throw new Error(`The scraper did not become idle within ${Math.round(this.idleTimeoutMs / 60000)} min${lastError ? ` (${lastError})` : ''}; no backup written`);
}
await delay(this.pollMs);
try { state = await this.scraper.state(); lastError = null; }
catch (e) { state = null; lastError = message(e); }
}
}
async resume() {
let error;
for (let attempt = 0; attempt < 3; attempt++) {
try { await this.scraper.control('resume'); return; }
catch (e) { error = e; if (attempt < 2) await delay(this.resumeRetryMs); }
}
throw error;
}
// Files of DATA in a stable order, skipping excluded names, symlinks and a nested BACKUP.
async collect(dir = this.dataRoot, rel = '') {
const out = [];
const entries = (await fsp.readdir(dir, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const entry of entries) {
const full = path.join(dir, entry.name), relative = rel ? rel + '/' + entry.name : entry.name;
if (entry.isDirectory()) {
if (full === this.backupRoot) continue;
out.push({ full, relative, dir: true });
out.push(...await this.collect(full, relative));
} else if (entry.isFile() && !EXCLUDE.some(re => re.test(entry.name))) out.push({ full, relative, dir: false });
}
return out;
}
// Contents of one file as it goes into the archive.
async contentOf(item) {
if (item.relative === 'db.json' && this.snapshotDb) return Buffer.from(this.snapshotDb());
if (item.relative === path.relative(this.dataRoot, this.stateFile).split(path.sep).join('/')) {
const { pending, ...rest } = this.state; void pending;
return Buffer.from(JSON.stringify(rest, null, 2) + '\n');
}
const data = await fsp.readFile(item.full);
if (item.relative === 'scraper/queue.json') {
const queue = JSON.parse(data.toString('utf8'));
queue.paused = true; queue.status = 'paused';
return Buffer.from(JSON.stringify(queue, null, 2) + '\n');
}
return data;
}
async writeArchive(part) {
const items = await this.collect(), stats = { files: 0, bytes: 0 };
// The in-memory db is archived even before its first debounced write.
if (this.snapshotDb && !items.some(item => item.relative === 'db.json')) items.unshift({ full: path.join(this.dataRoot, 'db.json'), relative: 'db.json', dir: false, memory: true });
const manifest = { kind: 'mangacircus-data-backup', version: 1, created: new Date(this.now()).toISOString(),
excluded: 'runtime*.json, *.lock, *.pid, *.tmp', queuePaused: true };
const self = this;
async function* tar() {
yield* tarEntry('.circus-backup.json', Buffer.from(JSON.stringify(manifest, null, 2) + '\n'), { mtime: self.now() });
for (const item of items) {
let st;
try { st = item.memory ? { mode: 0o644, uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0, mtimeMs: self.now() } : await fsp.stat(item.full); }
catch (e) { if (e.code === 'ENOENT') continue; throw e; }
const meta = { mode: st.mode, uid: st.uid, gid: st.gid, mtime: st.mtimeMs };
if (item.dir) { yield* tarEntry(item.relative + '/', null, { ...meta, type: '5' }); continue; }
let data;
try { data = await self.contentOf(item); } catch (e) { if (e.code === 'ENOENT') continue; throw new Error(`${item.relative}: ${message(e)}`); }
stats.files++; stats.bytes += data.length;
yield* tarEntry(item.relative, data, meta);
}
yield Buffer.alloc(1024);
}
await pipeline(Readable.from(tar()), zlib.createGzip(), fs.createWriteStream(part, { flags: 'wx' }));
const handle = await fsp.open(part, 'r+'); // fsync before the rename makes it visible
try { await handle.sync(); } finally { await handle.close(); }
stats.files++; // the manifest
return stats;
}
async removeStaleParts() {
for (const name of await fsp.readdir(this.backupRoot)) if (PART.test(name)) await fsp.rm(path.join(this.backupRoot, name), { force: true });
}
// Deletes only our own archive names directly inside BACKUP; never the newest one.
async applyRetention(keepName) {
const { mode, value } = this.state.settings.retention;
const names = (await fsp.readdir(this.backupRoot)).filter(name => ARCHIVE.test(name)).sort();
let doomed;
if (mode === 'count') doomed = names.slice(0, Math.max(0, names.length - value));
else { const cutoff = this.now() - value * 864e5; doomed = names.filter(name => archiveTime(name) < cutoff); }
doomed = doomed.filter(name => name !== keepName && name !== names[names.length - 1]);
for (const name of doomed) {
const target = path.join(this.backupRoot, name);
if (path.dirname(target) !== this.backupRoot) continue;
await fsp.rm(target);
}
return doomed.length;
}
}
module.exports = { BackupService, BackupError, readArchive, validateSettings, nextOccurrence, DEFAULT_SETTINGS, ARCHIVE };
-3
View File
@@ -11,6 +11,3 @@ services:
scraper:
volumes:
- ./scraper:/app/scraper:ro
sentinel:
volumes:
- ./sentinel:/app/sentinel:ro
+8 -26
View File
@@ -36,20 +36,27 @@ services:
source: ${DATA_PATH:-./data}
target: /data
bind: {create_host_path: false}
# Built-in backups (docs/backup.md). The folder must exist and be writable by
# uid 1000; backups stay off unless BACKUP_PATH is set.
- type: bind
source: ${BACKUP_PATH:-./backup}
target: /backup
bind: {create_host_path: false}
environment:
PORT: "8080"
DATA: /data
LIBRARY: /library
BACKUP: ${BACKUP_PATH:+/backup}
TZ: ${TZ:-Europe/Berlin}
SIGNUP: closed
SCAN_INTERVAL: "10"
SCAN_CONCURRENCY: ${SCAN_CONCURRENCY:-8}
COOKIE_SECURE: ${COOKIE_SECURE:-0}
READING_WINDOWS_JSON: ${READING_WINDOWS_JSON:-}
READING_TIMEZONE: ${READING_TIMEZONE:-Europe/Berlin}
READING_QUIZ_UNLOCK_MINUTES: ${READING_QUIZ_UNLOCK_MINUTES:-60}
READING_QUIZ_CATALOG_PATH: ${READING_QUIZ_CATALOG_PATH:-}
SCRAPER_URL: http://scraper:8091
SENTINEL_URL: http://sentinel:8092
SCRAPER_TOKEN_FILE: /run/secrets/scraper_token
networks: [backend, egress]
healthcheck:
@@ -93,31 +100,6 @@ services:
start_period: 60s
retries: 3
sentinel:
<<: *app
command: [node, runtime/launch.js, sentinel]
volumes:
- type: bind
source: ${SENTINEL_DATA_PATH:-./data/sentinel}
target: /data/sentinel
bind: {create_host_path: false}
environment:
DATA: /data
SENTINEL_DATA: /data/sentinel
SENTINEL_HOST: 0.0.0.0
SENTINEL_PORT: "8092"
READER_URL: http://reader:8080
SCRAPER_URL: http://scraper:8091
SCRAPER_TOKEN_FILE: /run/secrets/scraper_token
TZ: ${TZ:-Europe/Berlin}
networks: [backend]
healthcheck:
test: [CMD, node, tools/healthcheck.js, sentinel]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
peer-gateway:
<<: *app
profiles: [peering]
+19 -9
View File
@@ -2,8 +2,8 @@
MangaCircus is a self-hosted reader for manga, comics and webtoons. The reader owns
accounts/progress and a separate collection catalog. The scraper owns the persistent
queue and chapter publication. Sentinel observes health/progress and requests bounded
checkpoint recovery. Separate supervisors isolate service failures. All content remains
queue and chapter publication, and its built-in stall watchdog requeues a stuck job
once at a checkpoint. Separate supervisors isolate service failures. All content remains
a plain folder tree; there is no database server or embedded autonomous LLM.
Related documents:
@@ -18,6 +18,7 @@ Related documents:
- [peering.md](peering.md): optional private server-to-server catalog and transfer.
- [discovery.md](discovery.md): reader-owned, opt-in deterministic discovery feed and its limits.
- [source-modules.md](source-modules.md): adding and maintaining first-party hosters inside the single scraper process.
- [backup.md](backup.md): built-in scheduled backup of `data/` and the restore procedure.
## 1. Principles
@@ -47,12 +48,12 @@ Related documents:
## 3. Components
Runtime topology (2026-10-05): native `tools/start-all.js` launches reader, scraper and
Sentinel independently. In Compose, each container runs `runtime/launch.js <service>`
Runtime topology (2026-10-22): native `tools/start-all.js` launches reader and scraper
independently. In Compose, each container runs `runtime/launch.js <service>`
and exactly one service child. A failure does not stop unrelated services. Exactly one
reader and queue-owning scraper may write each shared state tree; do not scale replicas.
The catalog and relation store is reader-owned `data/catalog.json`. Sentinel writes only
`data/sentinel/`; it has no Docker socket or arbitrary code-repair authority.
The catalog and relation store is reader-owned `data/catalog.json`. No service has a
Docker socket or arbitrary code-repair authority.
The admin browser never talks to the scraper daemon directly. Admin-only reader
routes proxy commands to the daemon's private API, so its shared token stays on the
@@ -67,6 +68,7 @@ survives restarts, so restarting does not itself force another complete walk.
|------|------:|----------------|
| `server.js` | ~350 | HTTP server and router, auth and sessions, JSON storage, API handlers, scan scheduling, notifications and webhooks |
| `library.js` | ~170 | Folder scanner, name parsing, image-size reader, scan cache |
| `backup.js` | ~330 | Built-in `DATA` backup: queue pause/resume, tar.gz writer and verifier, schedule, retention |
| `public/common.js` | ~85 | Browser helpers: `h()` element builder, `api()` fetch wrapper, top navigation with the notification bell |
| `public/index.html` + `library.js` | ~60 | Library overview (cover grid, filter, sort) |
| `public/manga.html` + `manga.js` | ~110 | Manga page: info, follow, chapter list with book separators, versions and votes, read/unread toggle |
@@ -157,7 +159,11 @@ This keeps ids human-readable and derivable without a database. The price: renam
- `data/library-index.json` holds a disposable, versioned index snapshot and shallow
directory stamps. The library folders remain authoritative; a missing or invalid
snapshot triggers a full scan. Rollback-compatible `scan-cache.json` is retained.
- **Backups:** copy `data/db.json`. The atomic writes make a copy at any time consistent.
- `data/backup.json` holds the built-in backup schedule, retention and recent results.
- **Backups:** the reader's built-in backup ([backup.md](backup.md)) archives the whole
`data/` tree into `BACKUP` on a schedule, with `db.json` taken from memory and the
queue paused around the copy. A plain copy of `data/db.json` also works: atomic
writes make it consistent at any time.
## 6. Server
@@ -198,7 +204,9 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
| `GET /api/admin` | admin | Users, last scan result, library path |
| `POST /api/admin/rescan` `{full}` | admin | Scan now, and wait for the result |
| `POST /api/admin/user` `{name, password, admin}` | admin | Create a user or reset a password |
| `POST /api/admin/user/role` `{name, admin}` | admin | Grant or revoke admin rights; keeps the password and sessions. Revoking your own rights is refused, so one admin always remains |
| `POST /api/admin/user/delete` `{name}` | admin | Delete a user and their data |
| `GET /api/admin/backup` · `POST /api/admin/backup` `{command:"settings"\|"run"}` | admin | Built-in backup status, schedule/retention, "back up now" ([backup.md](backup.md)) |
### 6.3 Authentication and security
@@ -342,7 +350,8 @@ docker compose up -d --build
- **Volumes:**
- `/library`: read-only in the reader and writable in the scraper. Files must be readable—and scraper targets writable—by uid 1000.
- `/data`: reader state must be writable; the scraper also needs its writable `/data/scraper` state.
- **Configuration:** process wiring uses environment variables (`PORT`, `LIBRARY`, `DATA`, `SIGNUP`, `SCAN_INTERVAL`, `FULL_SCAN_INTERVAL`, `COOKIE_SECURE`, `SCRAPER_URL`, and `SCRAPER_TOKEN`). Queue pacing and automatic-update schedule are persisted in `data/scraper/queue.json` and edited through the authenticated admin UI.
- `/backup`: reader only, writable target of the built-in backup (`BACKUP_PATH`; off when unset).
- **Configuration:** process wiring uses environment variables (`PORT`, `LIBRARY`, `DATA`, `BACKUP`, `SIGNUP`, `SCAN_INTERVAL`, `FULL_SCAN_INTERVAL`, `COOKIE_SECURE`, `SCRAPER_URL`, and `SCRAPER_TOKEN`). Queue pacing and automatic-update schedule are persisted in `data/scraper/queue.json` and edited through the authenticated admin UI.
- **Without Docker:** `npm run start:all` with Node ≥ 18, or supervise `npm start` and `npm run start:scraper` independently. On the current Windows machine that supervision is already owned by `\MangaCircus Supervisor`.
- **Behind a reverse proxy:** terminate TLS, set `COOKIE_SECURE=1`. A sub-path works.
- **Upgrades:** replace the code and restart. Reader state has no explicit migration;
@@ -355,7 +364,7 @@ docker compose up -d --build
| Aspect | Behaviour |
|--------|-----------|
| Library size | The index keeps a small object per page (file name, size, tags), in the order of a few hundred bytes. Hundreds of thousands of pages fit in a modest container. Unchanged chapters cost one `stat` + one small read per scan |
| First scan | Reads ≤ 256 KB per image once. Later scans use the cache |
| First scan | Reads 32 KB per image once (≤ 256 KB, or the whole file for JPEGs with huge EXIF/ICC blocks), `SCAN_CONCURRENCY` (8) at a time. Later scans use the cache |
| User data | `db.json` is rewritten as a whole, at most about twice per second while people read. Comfortable up to a few MB (a household or small group, thousands of chapters read) |
| Concurrency | One process, one writer. No clustering or horizontal scaling by design |
| Images | Served as stored. No resizing or thumbnails, so large covers make the library page heavy |
@@ -366,6 +375,7 @@ docker compose up -d --build
|----------|--------------|-----|
| Index the folder tree in place | Import into a DB / managed storage | Stays readable by any viewer and writable by any tool. Nothing to keep in sync |
| No dependencies, no framework, no build | Express, React, bundler | Minimal code, maximum longevity, runs anywhere Node runs |
| Built-in backup in the reader, queue paused, no container stop | Host job that stops the stack; backup in a separate service | Only the reader sees all of `/data` and the in-memory `db.json`; the scraper's pause API gives a consistent point without downtime |
| JSON file for user data | SQLite (`node:sqlite` is still experimental in Node 22/24), Postgres | Zero dependencies, trivial backup, enough for the target size. Easy to swap: all access goes through the `db` object |
| Page roles as file-name tags | Page lists in JSON | Visible in any file browser, survives copying, no second source of truth. Unknown tags are harmless |
| Image sizes read on the server | Measure in the browser after load | Layout before load: exact scroll restore, zoom anchoring, auto-detected double pages, no layout jumps |
+116
View File
@@ -0,0 +1,116 @@
# Built-in backup
The reader can back up its own `DATA` folder on a schedule set in
**Settings → Admin → Service health → Backups**, without stopping any container.
The library is not part of it (it is plain files; back it up with the storage that
holds it). Implementation: [`backup.js`](../backup.js); tests: `test/backup.test.js`.
## Turning it on
| Where | Setting |
|---|---|
| Native | `BACKUP=/path/to/backups` for `server.js`. Unset = feature off |
| Compose | `BACKUP_PATH=/host/folder` in `.env`. The reader mounts it rw at `/backup` and gets `BACKUP=/backup`; without `BACKUP_PATH` the variable stays empty and backups are off |
The folder must already exist and be writable by uid 1000 (`create_host_path: false`:
a missing folder makes `docker compose up` fail instead of silently creating an empty
one on the wrong disk). Use another disk or a NAS share, never a folder inside `DATA`. Writability is checked by
creating and removing a probe file (`.circus-backup-probe-<pid>`), not with `access()`, which
NFS shares with squashed ids (e.g. Synology "map all users to admin") answer wrongly.
Without `BACKUP_PATH`, Compose still mounts the unused default `./backup`, so that
folder must exist too (`node tools/prepare-compose.js` creates it). Upgrading an
existing deployment therefore needs either `BACKUP_PATH` in `.env` or an empty
`backup/` beside `docker-compose.yml`.
Archives contain password hashes, sessions and the peering private key: keep the
target private.
### Why the reader owns it
The reader is the only service that mounts all of `/data` (the scraper sees only
`/data/scraper`), it holds `db.json` in memory and owns its atomic writer, it already
talks to the scraper's private control API with the shared token, and the admin
settings UI lives there. Running it in the scraper would need a full read-write `/data`
mount there (see [architecture.md §3](architecture.md#3-components)).
## Settings and status
Admin only, under Service health (`GET/POST /api/admin/backup`, also included in
`GET /api/admin/health` as `backup`):
- **Back up daily at HH:MM** (container time zone `TZ`) and an enable switch.
- **Keep the newest** N backups (`count`, 1–365) or N days (`days`, 1–3650).
- **Back up now** starts a run immediately; the page polls every 2 s while it runs.
- Status: target folder, number/size of archives, the running phase, last backup
(time, trigger, size, duration, file), its error and warnings, next scheduled run.
Settings and the last 10 results persist atomically in `data/backup.json`. A slot
missed while the reader was down runs once at the next start if it is less than a
day old; older slots (for example after restoring an old `backup.json`) are skipped.
```text
POST /api/admin/backup {command:"settings", settings:{enabled, time:"HH:MM", retention:{mode:"count"|"days", value}}}
POST /api/admin/backup {command:"run"} -> 409 when off or already running
```
## What one run does
1. Ask the scraper for its state (`GET /api/state`). If the queue runs, pause it
(`POST /api/control {action:"pause"}`) and remember that this run paused it.
2. Wait until no job is active (the worker stops at its next page/chapter boundary),
polling every 2 s for at most 15 minutes. On timeout the run fails, writes nothing
and resumes the queue if it paused it. An unreachable scraper does not block the
backup; that is recorded as a warning.
3. Write `BACKUP/circus-YYYYMMDD-HHMMSS.tar.gz.part`: a gzip'd tar of `DATA` with
paths relative to `DATA`, fsync it, read it back to verify, rename it to `.tar.gz`.
- `db.json` is serialized from the reader's in-memory state at that moment.
- `scraper/queue.json` is stored with `paused: true` (status `paused`), so a
restored copy never starts downloading by itself. The live file is not touched.
- `scan-cache.json`, `library-index.json`, `catalog.json`, peering, logs and
everything else under `DATA` is copied as found; all of them are written by
atomic rename, so each file is complete. A leftover `sentinel/` folder from
before 2026-10-22 is copied too; it is unused and can be deleted.
- Left out: `runtime*.json`, `*.lock` (`daemon.lock`, `supervisor.lock`), `*.pid`,
`*.tmp`, symlinks, and the `BACKUP` folder itself if it lies inside `DATA`.
- `.circus-backup.json` at the archive root records when and how it was made.
4. Resume the queue only if this run paused it (three attempts; a failure is shown
as a warning: resume it under Settings → Scraper).
5. After a successful run only, apply retention. It deletes only files named
`circus-YYYYMMDD-HHMMSS.tar.gz` directly inside `BACKUP` (age from the name),
never the newest one, never other files or subfolders. Stale
`circus-*.tar.gz.part` files from an interrupted run are removed at the next run.
If the reader restarts during a run, `backup.json` still carries the run's
"queue paused by backup" marker; the next start resumes the queue and records the
run as interrupted. The marker is never part of an archive.
Measured on a copy of a production data folder (54 MB, 1000 files): 5.8 MB archive in
about 5 s including verification. Pauses last until the current chapter is done
plus those seconds.
## Restore
Restoring replaces the user data with the state of the archive. Reading progress
and queue changes made after that backup are lost.
1. Stop the stack (or at least reader and scraper):
`docker compose stop reader scraper`.
2. Move the current data folder aside instead of deleting it:
`mv /srv/mangacircus/data /srv/mangacircus/data.before-restore`.
3. Unpack the archive into an empty data folder owned by uid 1000:
```bash
mkdir /srv/mangacircus/data
tar -xzf /path/to/circus-YYYYMMDD-HHMMSS.tar.gz -C /srv/mangacircus/data
chown -R 1000:1000 /srv/mangacircus/data
```
(`--same-owner` is the default for root; the archive keeps the original uid/gid.)
4. Start the reader first: `docker compose up -d --no-build reader`. Check `/health`,
log in, check progress and library. The reader rebuilds a missing or stale library
index by itself.
5. Start `scraper`. The restored queue is paused: check it under
Settings → Scraper and resume it when it looks right.
The scraper token is not in `DATA`; Compose keeps it under `.secrets/` (or the
deployment's `SCRAPER_TOKEN_PATH`). Restore tests should unpack into a separate
folder and run an isolated stack (no second writer against the live library), as
a restore-test script does for the host-level archives.
+1 -1
View File
@@ -65,7 +65,7 @@ membership edits. It should preserve rejected decisions and report uncertainty.
It must not turn a shared-universe link into an automatic collection or an unquestioned
reading order. No embedded LLM, API credentials, automatic research loop, or autonomous
catalog approval is enabled in this release. That optional capability belongs behind
this same evidence/review boundary, not inside adapters or Sentinel.
this same evidence/review boundary, not inside adapters or the scraper's stall watchdog.
## Persistence and API contract
+1 -1
View File
@@ -29,6 +29,6 @@ Public catalog chapter counts are often absent; the UI says “Chapters unknown
## Deployment and QA
The Docker image now copies `greatest/` as app code; no new Compose service, volume, port, secret, or migration is needed. Rebuild/recreate **reader only** to pick up catalog/UI changes. The scraper and sentinel do not need restarting. Existing `data/db.json` accepts the new `greatestRatings` object without migration.
The Docker image now copies `greatest/` as app code; no new Compose service, volume, port, secret, or migration is needed. Rebuild/recreate **reader only** to pick up catalog/UI changes. The scraper does not need restarting. Existing `data/db.json` accepts the new `greatestRatings` object without migration.
Run `npm run test:reader` for catalog/API tests and `node tools/test-greatest-ui.js` for disposable headless-browser QA. The UI fixture blocks remote covers and never contacts the scraper. Manual catalog edits should preserve stable IDs once anyone has voted; changing a title's ID strands its votes.
+20 -2
View File
@@ -148,7 +148,9 @@ Everything is plain data, and the whole index lives in memory.
## 3. Image sizes
The reader lays pages out before they load, so it needs every image's size. Instead of decoding images, the scanner reads the **first 256 KB** and parses the header:
The reader lays pages out before they load, so it needs every image's size. Instead of decoding images, the scanner reads the **first 32 KB** and parses the header; only if the size isn't there yet does it read on to 256 KB (and, for JPEG, the whole file). Parsing a prefix either finds nothing or the same size as the whole file, so the small first read never changes a result.
Uncached chapter folders and their images are probed **`SCAN_CONCURRENCY` at a time** (default 8, 1–64; chapters of a book in parallel, then assembled in folder order). A first scan over a NAS is bound by per-file latency, not bandwidth: on the server against a Synology (NFS) the old sequential 256 KB probe took about 20 ms per image; see §3.1 for the measurements. `SCAN_CONCURRENCY=1` restores the sequential order.
| Format | Where the size is |
|--------|-------------------|
@@ -156,9 +158,25 @@ The reader lays pages out before they load, so it needs every image's size. Inst
| GIF | logical screen (bytes 6–9) |
| BMP | DIB header (bytes 18–25) |
| WebP | `VP8 ` / `VP8L` / `VP8X` chunk |
| JPEG | first SOFn marker (the scan skips APPn/EXIF/ICC segments). If it isn't within 256 KB, the whole file is read once |
| JPEG | first SOFn marker (the scan skips APPn/EXIF/ICC segments). If it isn't within 32 KB, then 256 KB, the whole file is read once |
| AVIF / HEIF | first `ispe` box |
### 3.1 Measured first-scan speed
the server (Debian VM, 4 vCPU) reading a Synology over NFS 4.0, page cache dropped before each run, production
running alongside. Each variant scanned a different manga of ~5,000 pages (the NAS caches what it has just read).
| Variant | ms per image (round 1 / round 2) |
|---|---|
| sequential, 256 KB (before) | 7.4 / 9.7 |
| 32 KB first read, `SCAN_CONCURRENCY=1` | 3.6 / 6.0 |
| 32 KB first read, `SCAN_CONCURRENCY=8` (default) | 2.9 / 2.1 |
| 32 KB first read, `SCAN_CONCURRENCY=16` | 2.4 / 3.3 |
Whole library without cache (56 manga, 10,766 chapters, 299,467 pages) with the default: **825 s (13.8 min)**,
against an estimated 37–48 min before (about 4 h while the NAS was busy with a large copy). The page lists and
sizes of all 10,775 cached chapter folders were identical to those of the previous code.
- If the size can't be read, it is stored as `0×0`. The reader then assumes 1000×1414 (2000×1414 for `double`) and corrects the layout when the image arrives.
- EXIF orientation is **not** applied. Use `rot…` tags instead.
+1 -1
View File
@@ -58,7 +58,7 @@ past one instance for the same usage-counter directory.
Tailscale TCP forward is recommended for the pilot; the default loopback binding
is not remotely reachable. Do not expose port 8093 on the public internet merely
to make pairing work. Use a stable tailnet DNS name, not a changing IP. Keep
scraper (8091) and Sentinel (8092) private.
the scraper (8091) private.
The `PEER_BIND`, `PEER_PORT`, `PEER_DATA_PATH`, and `PEER_USAGE_PATH` Compose variables
control the bind and persistent paths. The gateway's internal port remains 8093.
+1 -1
View File
@@ -16,7 +16,7 @@ hours**; that page is read-only.
Set these deployment variables in the Compose `.env` file next to
`docker-compose.yml`, then recreate **only the reader**. The `reader` service
passes them through to the container. `READER_IMAGE` selects a reader-only
release while `APP_IMAGE` continues to select the scraper and Sentinel image.
release while `APP_IMAGE` continues to select the scraper image.
Leaving `READING_WINDOWS_JSON` empty disables the limiter. Day keys
are `0` Sunday through `6` Saturday. Each day has any number of `[start,end]`
intervals; an end earlier than its start carries into the next day. `24:00` is
+29 -29
View File
@@ -4,16 +4,15 @@
```text
Browser → Reader ──private API──→ Scraper → library (atomic chapter publication)
│ ↑
└──health──→ Sentinel ───┘ bounded checkpoint recovery only
└─ stall watchdog: bounded checkpoint recovery only
Supervisor(reader) Supervisor(scraper) Supervisor(sentinel)
Supervisor(reader) Supervisor(scraper)
↑
Coding agent: inspect incidents → reproduce → patch → test → release
```
The reader is the only writer of users/progress and the catalog. The scraper is the
only queue owner and content publisher. Sentinel owns only observations and incidents.
only queue owner and content publisher, and it records its own failures and incidents.
Each service's supervisor owns only that child process. The coding agent is an
**escalation/maintenance operator**, not a necessary part of normal downloading.
The recurring Codex check is an additional backstop, not the runtime watchdog.
@@ -28,18 +27,18 @@ The recurring Codex check is an additional backstop, not the runtime watchdog.
| Parser/local adapter failure | Quarantine job, keep partial data, continue other titles | Two different affected jobs in an hour pause source as systemic failure |
| Storage/permission failure | Pause all downloads and report | No automatic retry |
| HTTP 401/403/429 or access challenge | Pause affected source lane | No automatic retry/bypass |
| Active job with no progress for 15 minutes | Sentinel requests cooperative checkpoint-safe retry | One stall recovery; next stall quarantined |
| Active job with no progress for 15 minutes | Scraper stall watchdog stops it at its next checkpoint and requeues it | One stall recovery; next stall quarantined |
Sentinel only treats an active job as stalled when its checkpoint can be read.
The scraper resolves source-specific work folders (including MangaPill's
`mangapill-` prefix) before reporting progress; a missing checkpoint is not
evidence that downloads stopped. An operator retry resets the job's stall
recovery count without deleting its partial download.
The daemon remains authoritative: a recovery request includes the observed progress
fingerprint and is rejected if the job has moved, paused, or is no longer stale.
Sentinel also observes completed/page counts; a deliberately paused queue is not a
stall. Timeouts bound source fetches to 60 seconds and metadata calls to 30 seconds.
The stall watchdog runs inside the scraper (`ScraperService.checkStalls`, every 30 s
from the work loop). A job is stalled when its checkpoint fingerprint (completed
chapters/pages) has not moved for 15 minutes, or for one chapter delay plus three
minutes if that is longer. Time spent paused does not count, and a deliberately
paused queue is not a stall. It only treats a job as stalled when its checkpoint can
be read: the scraper resolves source-specific work folders (including MangaPill's
`mangapill-` prefix), and a missing checkpoint is not evidence that downloads stopped.
Each stall records an incident with `failure.kind: 'stall'` and an `events.log` entry.
An operator retry resets the job's stall recovery count without deleting its partial
download. Timeouts bound source fetches to 60 seconds and metadata calls to 30 seconds.
Publication remains one complete chapter at a time, on the same filesystem.
No recovery action raises pacing, creates another worker for the same source,
changes identities or deletes partial images.
@@ -62,14 +61,14 @@ use an explicit operator retry after fixing their cause.
## Observability
**Settings → Service health** shows Sentinel status, active checkpoints, failed jobs
and incident history. **Settings → Scraper** retains queue, pacing and discovery controls.
**Settings → Service health** shows scraper reachability ("Scraper: unreachable" when
the reader cannot reach it), active checkpoints, failed jobs and the scraper's incident
history. Process crashes are handled by the supervisor and Docker restart policy, not
recorded as incidents. **Settings → Scraper** retains queue, pacing and discovery controls.
Service errors are classified rather than buried in a single historical `lastError`.
`npm run check` performs read-only checks. Sentinel's private, token-protected `/metrics`
exports reader-up, scraper-up and open-incident gauges for a future Prometheus collector.
It is not a public endpoint.
`npm run check` performs read-only checks of reader and scraper.
Scraper status is independent of expensive library scans. Concurrent admin/Sentinel
Scraper status is independent of expensive library scans. Concurrent admin/health
probes share one background inventory refresh and receive the previous summary
immediately. The dashboard summary refreshes at most once per minute unless a
publication or metadata change invalidates it; duplicate-sensitive decisions still
@@ -82,22 +81,23 @@ may temporarily show no library targets, but queue and rate controls remain usab
Storage:
- `data/catalog.json`, `data/collection-covers/`: reader catalog and covers.
- `data/scraper/queue.json`, `events.log`: queue, circuits, failures and incidents.
- `data/sentinel/state.json`: Sentinel incidents and last observation.
- `data/runtime-reader.json`, `data/scraper/runtime.json`, `data/sentinel/runtime.json`:
supervisor status and persisted crash budget.
- `data/scraper/queue.json`, `events.log`: queue, circuits, failures and incidents
(including stalls).
- `data/runtime-reader.json`, `data/scraper/runtime.json`: supervisor status and
persisted crash budget.
- `data/sentinel/`: only on installs from before 2026-10-22; unused, safe to delete.
- `data/scraper/supervisor.lock`, `daemon.lock`: native process ownership; transient.
Scraper/Sentinel/supervisor emit structured logs. Queue and Sentinel histories retain
Scraper and supervisor emit structured logs. The queue history retains
200 incidents; the UI shows a bounded subset. Docker logs are rotated. Native Windows
supervisor logs remain append-only: archive them during stopped maintenance if needed.
Never export user databases, tokens, cookies, signed query strings or unredacted environments.
## What self-repair intentionally does not mean
Sentinel cannot execute a shell, patch adapters, query image sources, access a Docker
socket, or issue unrestricted restart commands. It cannot repair broken markup or
solve source challenges. The agent handles those escalations in the repository:
The stall watchdog cannot execute a shell, patch adapters, access a Docker socket,
or restart processes; it only requeues or quarantines a job. Nothing at runtime
repairs broken markup or solves source challenges. The agent handles those escalations in the repository:
1. Inspect one bounded incident and relevant checkpoint; preserve the active lane.
2. Add a fixture reproducing the failure, patch the smallest adapter/logic surface.
+1 -1
View File
@@ -32,7 +32,7 @@ npm run start:scraper # daemon only
```
These commands are for native development only. The current Windows installation
runs the three-service Docker Compose stack; the old Scheduled Task is disabled.
runs the Docker Compose stack; the old Scheduled Task is disabled.
Never start a native supervisor or daemon against its mounted state. Its observed
ports, logs, persisted settings, restart procedure, and recovery snapshot are recorded in
[`local-deployment.md`](local-deployment.md).
+4
View File
@@ -59,6 +59,10 @@ download times by manga/source, and a collapsed recent-check log. Successful
no-change nightly checks update this summary and leave the queue. Sources owns
the per-hoster request and chapter delay settings.
Service health also holds the **Backups** card: built-in backup status (last run,
size, duration, error), **Back up now**, and the daily schedule and retention form,
whose draft survives the page's polling. See [backup.md](backup.md).
Reading-time collection controls, export and deletion live under Reading time &
privacy; reader layout controls stay under Reading. Rescan controls and user
administration are separate. Server & library describes existing maintenance,
+55 -19
View File
@@ -5,6 +5,8 @@
*
* Only folder names, small JSON files and the first bytes of each image (for its size) are read.
* Image sizes are cached per chapter folder and only re-read when the folder's mtime changes.
* Uncached chapters and their images are probed SCAN_CONCURRENCY (default 8) at a time: on a NAS a
* first scan is bound by per-file latency, not bandwidth, so a few requests in flight roughly halve it.
*/
const fsp = require('fs').promises, path = require('path');
const { parseSpecialFolder, parseKey } = require('./chapter-identity');
@@ -15,6 +17,25 @@ const COVER = /^cover\.(jpe?g|png|webp|gif|avif|bmp)$/i;
const cmp = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }).compare;
const str = x => (typeof x === 'string' ? x.trim() : '');
const order = (a, b) => Number(!!a.special) - Number(!!b.special) || a.x - b.x || a.num - b.num || cmp(a.key, b.key);
const SCAN_CONCURRENCY = Math.min(64, Math.max(1, Math.floor(+process.env.SCAN_CONCURRENCY) || 8));
// Runs at most n of the given async tasks at once, in submission order.
function limiter(n) {
let active = 0;
const waiting = [];
const next = () => {
if (active >= n || !waiting.length) return;
active++;
waiting.shift()();
};
return task => new Promise((resolve, reject) => {
waiting.push(() => Promise.resolve().then(task).then(resolve, reject).finally(() => { active--; next(); }));
next();
});
}
// One pair of limits per scan: chapters (stat, chapter.json, listing) and image probes.
// Chapter tasks wait for image slots but never hold one, so the two cannot deadlock.
const scanLimits = (concurrency = SCAN_CONCURRENCY) => ({ chapters: limiter(concurrency), images: limiter(concurrency) });
async function readJson(file) {
try { return JSON.parse((await fsp.readFile(file, 'utf8')).replace(/^/, '')); }
@@ -60,16 +81,24 @@ function sizeFromBuffer(b) {
return null;
}
// Most headers fit in the first few KB, so read 32 KB first and only then up to 256 KB.
// sizeFromBuffer() on a prefix is either null or the same answer as on the whole file,
// so the smaller first read never changes a result.
const HEAD_SMALL = 32 * 1024, HEAD_LARGE = 256 * 1024;
async function imageSize(file) {
const fh = await fsp.open(file, 'r');
let head;
try {
const buf = Buffer.alloc(256 * 1024);
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
head = buf.subarray(0, bytesRead);
const buf = Buffer.alloc(HEAD_LARGE);
let n = (await fh.read(buf, 0, HEAD_SMALL, 0)).bytesRead;
head = buf.subarray(0, n);
const size = sizeFromBuffer(head);
if (size || n < HEAD_SMALL) return size;
n += (await fh.read(buf, n, HEAD_LARGE - n, n)).bytesRead;
head = buf.subarray(0, n);
} finally { await fh.close(); }
// JPEGs with big EXIF/ICC blocks can have their size marker further in.
return sizeFromBuffer(head) || (head.length === 256 * 1024 ? sizeFromBuffer(await fsp.readFile(file)) : null);
return sizeFromBuffer(head) || (head.length === HEAD_LARGE ? sizeFromBuffer(await fsp.readFile(file)) : null);
}
function pageInfo([f, w, h]) {
@@ -78,17 +107,16 @@ function pageInfo([f, w, h]) {
return { f, w, h, t, r: rot ? +rot.slice(3) : 0 };
}
async function scanVersion(root, rel, ck, oldCache, cache) {
async function scanVersion(root, rel, ck, oldCache, cache, limits) {
const dir = path.join(root, rel), st = await fsp.stat(dir);
const meta = await readJson(path.join(dir, 'chapter.json'));
let c = oldCache[rel];
if (!c || c.m !== st.mtimeMs) {
const p = [];
for (const f of (await list(dir)).files) {
if (!IMG.test(f)) continue;
const files = (await list(dir)).files.filter(f => IMG.test(f));
const p = await Promise.all(files.map(f => limits.images(async () => {
const s = await imageSize(path.join(dir, f)).catch(() => null);
p.push([f, s ? s.w : 0, s ? s.h : 0]);
}
return [f, s ? s.w : 0, s ? s.h : 0];
})));
c = { m: st.mtimeMs, p };
}
cache[rel] = c;
@@ -104,7 +132,7 @@ async function scanVersion(root, rel, ck, oldCache, cache) {
const thumbOf = v => { const p = v.pages.find(p => p.t.includes('cover')) || v.pages[0]; return v.rel + '/' + p.f; };
const coverIn = (files, rel) => { const f = files.find(f => COVER.test(f)); return f ? rel + '/' + f : null; };
async function scanManga(root, name, oldCache, cache) {
async function scanManga(root, name, oldCache, cache, limits = scanLimits()) {
const { dirs, files } = await list(path.join(root, name));
const meta = await readJson(path.join(root, name, 'manga.json'));
const m = { id: name, title: str(meta.title) || name, meta, direction: resolveReadingDirection([meta]).value, cover: coverIn(files, name), books: [], chapters: [], chapterById: new Map(), updated: 0 };
@@ -120,11 +148,17 @@ async function scanManga(root, name, oldCache, cache) {
key: bk.key, num: bk.num, x: bk.x, extra: bk.x || bMeta.extra === true,
label: (bk.x ? 'Extra book ' : 'Book ') + bk.num, title: str(bMeta.title) || bk.title, cover: coverIn(bl.files, bRel), chapters: [],
};
const byKey = new Map();
const byKey = new Map(), folders = [];
for (const cName of bl.dirs) {
const ck = parseSpecialFolder(cName) || parseKey(cName);
if (!ck) { console.warn(`[scan] ignoring "${bRel}/${cName}": chapter folders must be named <letters><number>[ title], e.g. "c0001 The Seed"`); continue; }
const v = await scanVersion(root, bRel + '/' + cName, ck, oldCache, cache);
folders.push({ cName, ck });
}
// Probe the book's chapters concurrently, then assemble them in folder order as before.
const versions = await Promise.all(folders.map(({ cName, ck }) =>
limits.chapters(() => scanVersion(root, bRel + '/' + cName, ck, oldCache, cache, limits))));
for (const [i, { ck }] of folders.entries()) {
const v = versions[i];
if (!v.pages.length) continue;
v.directionInfo = resolveReadingDirection([v.meta, bMeta, meta], v.pages);
v.direction = v.directionInfo.value;
@@ -151,11 +185,12 @@ async function scanManga(root, name, oldCache, cache) {
}
// Returns { index, cache }. Pass the previous cache to skip re-reading unchanged chapter folders.
async function scanLibrary(root, oldCache = {}) {
const cache = {}, mangas = [];
// options.concurrency overrides SCAN_CONCURRENCY (tests, benchmarks).
async function scanLibrary(root, oldCache = {}, options = {}) {
const cache = {}, mangas = [], limits = scanLimits(options.concurrency);
for (const name of (await list(root)).dirs) {
try {
const m = await scanManga(root, name, oldCache, cache);
const m = await scanManga(root, name, oldCache, cache, limits);
if (m.chapters.length) mangas.push(m);
else console.warn(`[scan] "${name}" has no chapters (expected <manga>/<book>/<chapter>/<images>)`);
} catch (e) { console.warn(`[scan] skipped "${name}": ${e.message}`); }
@@ -185,8 +220,9 @@ async function libraryStamps(root) {
return stamps;
}
async function scanChangedLibrary(root, prior, oldCache, oldStamps, stamps) {
async function scanChangedLibrary(root, prior, oldCache, oldStamps, stamps, options = {}) {
stamps = stamps || await libraryStamps(root);
const limits = scanLimits(options.concurrency);
const names = new Set([...Object.keys(oldStamps || {}), ...Object.keys(stamps)]);
const changed = [...names].filter(name => JSON.stringify(oldStamps?.[name]) !== JSON.stringify(stamps[name]));
if (!changed.length) return { index: prior, cache: oldCache, stamps, changed };
@@ -196,7 +232,7 @@ async function scanChangedLibrary(root, prior, oldCache, oldStamps, stamps) {
byId.delete(name);
for (const key of Object.keys(cache)) if (key.startsWith(name + '/')) delete cache[key];
if (!Object.hasOwn(stamps, name)) continue;
const m = await scanManga(root, name, oldCache, cache);
const m = await scanManga(root, name, oldCache, cache, limits);
if (m.chapters.length) byId.set(name, m);
}
const mangas = [...byId.values()].sort((a, b) => cmp(a.title, b.title));
@@ -232,4 +268,4 @@ function restoreIndex(mangas) {
}
module.exports = { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIndex,
parseKey, sizeFromBuffer, imageSize };
parseKey, sizeFromBuffer, imageSize, limiter };
+2 -3
View File
@@ -9,11 +9,10 @@
"start:scraper": "node scraper/daemon.js",
"start:peer": "node peering/gateway.js",
"start:all": "node tools/start-all.js",
"start:sentinel": "node sentinel/daemon.js",
"check": "node tools/check.js",
"check:compose": "node tools/compose-control.js check",
"test:runtime": "node --test sentinel/test/*.test.js",
"test:compose": "node tools/test-compose.js",
"test:runtime": "node --test runtime/test/*.test.js",
"test:compose":"node tools/test-compose.js",
"test:ui": "node tools/test-compose.js --settings-ui --queue-ui --collections-ui --reading-ui",
"test:direction-ui": "node tools/test-compose.js --direction-ui",
"test:discovery-ui": "node tools/test-compose.js --discovery-ui",
+56 -5
View File
@@ -4,14 +4,16 @@
try { me = await api('api/me'); } catch (e) { return showError(e); }
topNav(me);
if (!me.admin) return showError(new Error('Administrator access required.'));
const main = settingsShell(me, 'admin/health').content;
const shell = settingsShell(me, 'admin/health').content;
const main = h('div'), backup = backupCard();
shell.append(main, backup.card);
async function load() {
try {
const [health, scraper] = await Promise.all([api('api/admin/health'), api('api/admin/scraper')]);
backup.draw(health.backup);
main.textContent = '';
main.append(h('section', { class: 'card' },
h('p', {}, `Reader: ${health.reader.ok ? 'healthy' : 'scan error'} · Sentinel: ${health.sentinel.status} · Scraper: ${scraper.status || 'unavailable'}`),
h('p', { class: 'muted small' }, health.sentinel.checkedAt ? `Last independent check: ${new Date(health.sentinel.checkedAt).toLocaleString()}` : 'Sentinel has not reported yet.'),
h('p', {}, `Reader: ${health.reader.ok ? 'healthy' : 'scan error'} · Scraper: ${scraper.status || 'unreachable'}`),
h('a', { class: 'btn', href: 'settings.html#admin/scraper' }, 'Queue & hoster settings')));
for (const entry of scraper.active || []) main.append(h('section', { class: 'card' }, h('h2', {}, entry.source),
h('p', {}, `${entry.current.title} · ${entry.job?.completed || 0}/${entry.job?.total || '?'} chapters`),
@@ -24,11 +26,60 @@
if (item.failure?.kind === 'access' && !confirm('Has the source access problem been resolved? This manually authorizes one retry.')) return;
await api('api/admin/scraper', { command: 'control', action: item.status === 'unavailable' ? 'inspect' : 'retry', id: item.id }); load();
} }, item.status === 'unavailable' ? 'Inspect source' : 'Retry'), h('button', { class: 'btn', onclick: async () => { await api('api/admin/scraper', { command: 'control', action: 'skip', id: item.id }); load(); } }, 'Skip job')))) : h('p', { class: 'muted' }, 'No failed jobs.')));
const incidents = [...(health.sentinel.incidents || []), ...(scraper.incidents || [])];
const incidents = scraper.incidents || [];
main.append(h('details', { class: 'card', open: incidents.some(i => i.state === 'open') }, h('summary', {}, 'Incident & recovery history'), incidents.slice(-50).map(i =>
h('p', {}, `${i.at} · ${i.state} · ${i.message || i.failure?.message || ''}${i.resolution ? ' · ' + i.resolution : ''}`))));
} catch (e) { main.textContent = ''; main.append(h('p', { class: 'error', role: 'alert' }, e.message)); }
setTimeout(load, document.hidden ? 30000 : 10000);
setTimeout(load, document.hidden ? 30000 : backup.busy() ? 2000 : 10000);
}
load();
// Built-in backup: status redraws on every poll; the schedule form keeps its draft.
function backupCard() {
const mb = n => (n / 1048576).toFixed(n < 10485760 ? 1 : 0) + ' MB';
const secs = ms => ms < 60000 ? Math.round(ms / 1000) + ' s' : Math.round(ms / 60000) + ' min';
const status = h('div'), msg = h('p', { class: 'small', role: 'status' });
const enabled = h('input', { type: 'checkbox' }), time = h('input', { type: 'time', required: true });
const mode = h('select', {}, h('option', { value: 'count' }, 'backups'), h('option', { value: 'days' }, 'days'));
const keep = h('input', { type: 'number', min: '1', step: '1', style: 'width:6em' });
let state = null, filled = false;
const fill = s => { enabled.checked = s.enabled; time.value = s.time; mode.value = s.retention.mode; keep.value = s.retention.value; filled = true; };
const command = async (body, button) => {
button.disabled = true; msg.textContent = ''; msg.className = 'small';
try { const next = await api('api/admin/backup', body); if (body.command === 'settings') { fill(next.settings); msg.textContent = 'Saved.'; } draw(next); }
catch (e) { msg.textContent = e.message; msg.className = 'small error'; }
finally { button.disabled = false; }
};
const runButton = h('button', { class: 'btn primary', onclick: e => command({ command: 'run' }, e.target) }, 'Back up now');
const saveButton = h('button', { class: 'btn', onclick: e => command({ command: 'settings', settings: {
enabled: enabled.checked, time: time.value, retention: { mode: mode.value, value: Number(keep.value) } } }, e.target) }, 'Save schedule');
const form = h('div', { class: 'field' }, h('span', {}, 'Schedule'),
h('div', { class: 'row' }, h('label', { class: 'row' }, enabled, 'Back up daily at'), time),
h('div', { class: 'row', style: 'margin-top:8px' }, h('span', {}, 'Keep the newest'), keep, mode),
h('div', { class: 'row', style: 'margin-top:8px' }, saveButton, runButton), msg);
const card = h('section', { class: 'card', id: 'backup' }, h('h2', {}, 'Backups'), status, form);
function draw(next) {
if (!next) return;
state = next;
if (!filled) fill(next.settings);
const last = next.last, lines = [];
if (!next.configured) lines.push(h('p', { class: 'muted' }, 'Backups are off: mount a folder and set BACKUP_PATH (Compose) or BACKUP (native). See docs/backup.md.'));
else {
lines.push(h('p', {}, `Target: ${next.path} · ${next.archives.count} archive${next.archives.count === 1 ? '' : 's'}, ${mb(next.archives.bytes)}`));
if (next.targetError) lines.push(h('p', { class: 'error' }, next.targetError));
}
if (next.running) lines.push(h('p', { class: 'tag' }, `Running (${next.running.trigger}): ${next.running.phase}…`));
if (last) {
lines.push(h('p', {}, `Last backup: ${new Date(last.at).toLocaleString()} (${last.trigger})`,
last.error ? '' : ` · ${mb(last.size)} · ${secs(last.durationMs || 0)} · ${last.file}`));
if (last.error) lines.push(h('p', { class: 'error', role: 'alert' }, `Failed: ${last.error}`));
for (const warning of last.warnings || []) lines.push(h('p', { class: 'small error' }, warning));
} else if (next.configured) lines.push(h('p', { class: 'muted' }, 'No backup yet.'));
lines.push(h('p', { class: 'muted small' }, next.nextAt ? `Next scheduled backup: ${new Date(next.nextAt).toLocaleString()}` : 'No scheduled backup.'));
status.textContent = ''; status.append(...lines);
runButton.disabled = !next.configured || !!next.running;
form.hidden = !next.configured && !next.settings.enabled;
}
return { card, draw, busy: () => !!state?.running };
}
})();
+8 -2
View File
@@ -473,10 +473,16 @@
h('div', { class: 'field' }, h('span', {}, 'Users'),
h('div', { class: 'users' }, data.users.map(u => h('div', {},
h('span', {}, u.name, u.admin ? h('span', { class: 'tag', style: 'margin-left:8px' }, 'admin') : null),
u.name === me.name ? h('span', { class: 'muted small' }, 'you') : h('button', { class: 'btn small', onclick: async () => {
u.name === me.name ? h('span', { class: 'muted small' }, 'you') : h('span', { class: 'row' },
h('button', { class: 'btn small', onclick: async () => {
if (!confirm(u.admin ? `Remove admin rights from ${u.name}?` : `Make ${u.name} an admin? Admins can manage users, the library and the scraper.`)) return;
try { await api('api/admin/user/role', { name: u.name, admin: !u.admin }); load(); }
catch (e) { msg.textContent = e.message; msg.className = 'small error'; }
} }, u.admin ? 'Remove admin' : 'Make admin'),
h('button', { class: 'btn small', onclick: async () => {
if (!confirm(`Delete ${u.name} and their reading progress?`)) return;
await api('api/admin/user/delete', { name: u.name }); load();
} }, 'Delete'))))),
} }, 'Delete')))))),
h('div', { class: 'field' }, h('span', {}, 'Add a user or reset a password'),
h('div', { class: 'row' }, name, pw, h('label', { class: 'row' }, isAdmin, 'admin'),
h('button', { class: 'btn', onclick: async () => {
-1
View File
@@ -3,7 +3,6 @@ const fs = require('fs'), path = require('path'), { spawn } = require('child_pro
const SPECS = {
reader: { script: 'server.js', port: () => process.env.PORT || 8080, file: () => path.join(process.env.DATA || 'data', 'runtime-reader.json') },
scraper: { script: 'scraper/daemon.js', port: () => process.env.SCRAPER_PORT || 8091, file: () => path.join(process.env.SCRAPER_DATA || path.join(process.env.DATA || 'data', 'scraper'), 'runtime.json') },
sentinel: { script: 'sentinel/daemon.js', port: () => process.env.SENTINEL_PORT || 8092, file: () => path.join(process.env.SENTINEL_DATA || path.join(process.env.DATA || 'data', 'sentinel'), 'runtime.json') },
};
class Supervisor {
constructor(name, options = {}) {
@@ -1,7 +1,7 @@
'use strict';
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), { EventEmitter } = require('events');
const { Supervisor } = require('../../runtime/supervisor');
const { Supervisor } = require('../supervisor');
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
test('supervisor isolates a child crash and persists its bounded restart budget', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-supervisor-')), file = path.join(dir, 'runtime.json');
+1 -1
View File
@@ -22,7 +22,7 @@ Runtime recovery is documented in `../docs/resilience.md`. Request-level 5xx ret
remain limited to 30/60 seconds; after those are exhausted the daemon can resume the
same checkpoint once after a five-minute source cooldown. No automatic access-error
retry is permitted. Unavailable titles and parser failures are job-scoped; repeated
independent parser failures stop the source lane. Sentinel never changes adapters.
independent parser failures stop the source lane. The stall watchdog never changes adapters.
```text
public metadata -> cached series metadata
-1
View File
@@ -42,7 +42,6 @@ async function body(req) {
if (url.pathname === '/api/queue/clear-completed') { const result = await service.clearCompleted(String(input.source || '')); return json(res, 200, { ...result, state: await service.publicState() }); }
if (url.pathname === '/api/queue/reorder') { await service.reorder(input.ids); return json(res, 200, await service.publicState()); }
if (url.pathname === '/api/control') { await service.control(String(input.action || ''), input.id); return json(res, 200, await service.publicState()); }
if (url.pathname === '/api/recovery') return json(res, 200, await service.recover(input));
if (url.pathname === '/api/settings') { await service.updateSettings(input); return json(res, 200, await service.publicState()); }
if (url.pathname === '/api/discovery/run') { await service.enqueueDiscoveryChecks('manual'); return json(res, 200, await service.publicState()); }
if (url.pathname === '/api/library/auto-update') { await service.setMangaAutoUpdate(String(input.id || ''), !!input.enabled); return json(res, 200, await service.publicState()); }
+31 -14
View File
@@ -124,6 +124,9 @@ class ScraperService {
this.resolveAdapter = options.adapterFor || adapterFor;
this.adapterList = options.adapters || adapters;
this.retryDelayMs = options.retryDelayMs ?? 300000;
this.stallMs = options.stallMs ?? 900000;
this.stallObservations = new Map();
this.stallCheckedAt = 0;
this.runtimeHeartbeat = Date.now();
}
@@ -738,22 +741,32 @@ class ScraperService {
throw new Error('Unknown control action');
}
async recover(input) {
const item = this.state.queue.find(entry => entry.id === input.id);
if (!item) throw new Error('Job not found');
// Compare observations so a stale watcher cannot disturb a progressing worker.
if (Object.values(this.state.currentIds).includes(item.id)) {
// Stall watchdog: an active job whose checkpoint has not moved for stallMs (or one
// chapter delay + 3 min, if longer) is stopped at its next checkpoint and requeued
// once; a second stall quarantines it. A missing checkpoint proves nothing, and time
// spent paused does not count.
async checkStalls(now = Date.now()) {
const activeIds = new Set(Object.values(this.state.currentIds || {}));
for (const id of this.stallObservations.keys()) if (!activeIds.has(id)) this.stallObservations.delete(id);
for (const item of this.state.queue) {
if (!activeIds.has(item.id)) continue;
const job = await this.jobState(item);
if (!job) return { changed: false };
if (progressKey(job) !== input.progressKey || this.state.paused || item.stopReason) return { changed: false };
if (Date.now() - Date.parse(job?.updated || item.started) < 900000) return { changed: false };
if ((item.stallRecoveries || 0) >= 1) {
item.stopReason = 'quarantined';
} else { item.stallRecoveries = (item.stallRecoveries || 0) + 1; item.stopReason = 'recovering'; }
await this.event('warning', `Sentinel requested recovery at the next checkpoint for ${item.title}`, { id: item.id, source: item.source });
return { changed: true };
if (!job) { this.stallObservations.delete(item.id); continue; }
const key = progressKey(job), prior = this.stallObservations.get(item.id);
if (!prior || prior.key !== key) { this.stallObservations.set(item.id, { key, at: now }); continue; }
if (this.state.paused || this.state.sourcePaused[item.source] || item.stopReason) { prior.at = now; continue; }
if (now - prior.at < Math.max(this.stallMs, Number(job.rate?.chapterMaxMs || 0) + 180000)) continue;
this.stallObservations.delete(item.id);
const minutes = Math.round((now - prior.at) / 60000), quarantine = (item.stallRecoveries || 0) >= 1;
const failure = { kind: 'stall', scope: 'job', retryable: !quarantine, message: quarantine
? `No progress for ${minutes} minutes after a restart; quarantined`
: `No progress for ${minutes} minutes; restarting from the last checkpoint` };
if (quarantine) { item.stopReason = 'quarantined'; item.failure = failure; item.error = failure.message; }
else { item.stallRecoveries = (item.stallRecoveries || 0) + 1; item.stopReason = 'recovering'; }
this.state.incidents.push({ id: crypto.randomUUID(), at: new Date(now).toISOString(), jobId: item.id, source: item.source, title: item.title, failure, state: 'open' });
this.state.incidents = this.state.incidents.slice(-200);
await this.event('warning', `${item.title}: ${failure.message}`, { id: item.id, title: item.title, source: item.source });
}
return { changed: false };
}
recomputeStatus() {
@@ -876,6 +889,10 @@ class ScraperService {
const prior = this.state.status;
this.recomputeStatus();
if (prior !== this.state.status) await this.persist();
if (Date.now() - this.stallCheckedAt >= 30000) {
this.stallCheckedAt = Date.now();
await this.checkStalls().catch(error => console.error('[scraper] stall check failed:', safeMessage(error)));
}
await sleep(500);
}
}
+40 -7
View File
@@ -29,12 +29,45 @@ test('status and watchdog read the checkpoint for each source layout', async t =
}
});
test('daemon refuses recovery when the checkpoint cannot be read', async () => {
const service = Object.create(ScraperService.prototype);
service.state = { paused: false, currentIds: { mangapill: 'job' },
queue: [{ id: 'job', source: 'mangapill', status: 'downloading' }] };
service.jobState = async () => null;
const result = await service.recover({ id: 'job', progressKey: '[0,0,"",0,""]' });
assert.deepEqual(result, { changed: false });
function stallService(job) {
const service = new ScraperService({ stallMs: 100 });
service.state = { paused: false, sourcePaused: {}, currentIds: { a: 'job' }, incidents: [],
queue: [{ id: 'job', source: 'a', title: 'Manga', status: 'downloading' }] };
service.jobState = async () => job.value;
service.event = async () => {};
return service;
}
test('stall check leaves progressing and deliberately paused jobs alone', async () => {
const job = { value: { completed: 0, current: { downloadedPages: 1 } } };
const service = stallService(job), item = service.state.queue[0];
await service.checkStalls(0);
job.value = { completed: 0, current: { downloadedPages: 2 } };
await service.checkStalls(500000);
service.state.paused = true;
await service.checkStalls(1000000);
assert.equal(item.stopReason, undefined);
// Paused time does not count: the clock restarts on resume.
service.state.paused = false;
await service.checkStalls(1000050);
assert.equal(item.stopReason, undefined);
await service.checkStalls(1190000);
assert.equal(item.stopReason, 'recovering');
assert.equal(item.stallRecoveries, 1);
assert.equal(service.state.incidents[0].failure.kind, 'stall');
});
test('a second stall quarantines the job', async () => {
const service = stallService({ value: { completed: 2, total: 5 } }), item = service.state.queue[0];
item.stallRecoveries = 1;
await service.checkStalls(0); await service.checkStalls(1000000);
assert.equal(item.stopReason, 'quarantined');
assert.equal(item.failure.kind, 'stall');
});
test('missing checkpoint does not trigger a false stall recovery', async () => {
const service = stallService({ value: null });
for (const now of [0, 1000000, 2000000]) await service.checkStalls(now);
assert.equal(service.state.queue[0].stopReason, undefined);
assert.equal(service.state.incidents.length, 0);
});
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs'), path = require('path'), http = require('http');
const { secret } = require('../runtime/config');
const { Monitor } = require('./monitor');
const root = path.resolve(process.env.SENTINEL_DATA || path.join(process.env.DATA || 'data', 'sentinel'));
fs.mkdirSync(root, { recursive: true });
const token = secret('SCRAPER_TOKEN');
const host = process.env.SENTINEL_HOST || '127.0.0.1', port = +(process.env.SENTINEL_PORT || 8092);
if (!['127.0.0.1', 'localhost', '::1'].includes(host) && !token) throw new Error('SCRAPER_TOKEN is required for non-loopback Sentinel');
const reader = process.env.READER_URL || 'http://127.0.0.1:8080';
const scraper = process.env.SCRAPER_URL || 'http://127.0.0.1:8091';
const file = path.join(root, 'state.json');
let saved = {};
if (fs.existsSync(file)) saved = JSON.parse(fs.readFileSync(file, 'utf8'));
const monitor = new Monitor({ incidents: saved.incidents || [] });
const request = async (base, endpoint, body) => {
const response = await fetch(base + endpoint, { headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), 'content-type': 'application/json' },
signal: AbortSignal.timeout(8000), ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
if (!response.ok) throw new Error(`Service HTTP ${response.status}`);
return response.json();
};
let stopped = false, timer, lastTick = Date.now();
async function tick() {
try {
const results = await Promise.allSettled([request(reader, '/health'), request(scraper, '/api/state')]);
const state = await monitor.observe(results[0].status === 'fulfilled' ? results[0].value : null,
results[1].status === 'fulfilled' ? results[1].value : null, input => request(scraper, '/api/recovery', input));
fs.writeFileSync(file + '.tmp', JSON.stringify(state, null, 2) + '\n'); fs.renameSync(file + '.tmp', file);
lastTick = Date.now();
for (const incident of state.incidents.filter(i => i.state === 'open' && !i.logged)) {
console.log(JSON.stringify({ ...incident, targetService: incident.service, service: 'sentinel', level: 'warning' })); incident.logged = true;
}
} catch (error) { console.error('[sentinel]', error.message); }
if (!stopped) timer = setTimeout(tick, 30000);
}
const server = http.createServer((req, res) => {
const endpoint = new URL(req.url, 'http://localhost').pathname;
let code = 200, value;
if (endpoint === '/health') { code = Date.now() - lastTick < 120000 ? 200 : 503; value = { ok: code === 200, service: 'sentinel', checkedAt: monitor.state.checkedAt }; }
else if (token && req.headers.authorization !== `Bearer ${token}`) { code = 401; value = { error: 'Unauthorized' }; }
else if (endpoint === '/api/state' && req.method === 'GET') value = monitor.state;
else if (endpoint === '/metrics' && req.method === 'GET') {
res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4' });
return res.end(`mangacircus_reader_up ${Number(!!monitor.state.services.reader)}\nmangacircus_scraper_up ${Number(!!monitor.state.services.scraper)}\nmangacircus_open_incidents ${monitor.incidents.filter(i => i.state === 'open').length}\n`);
} else { code = 404; value = { error: 'Not found' }; }
res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value));
});
server.listen(port, host, () => console.log(JSON.stringify({ service: 'sentinel', event: 'started', port })));
tick();
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { stopped = true; clearTimeout(timer); server.close(() => process.exit(0)); });
-65
View File
@@ -1,65 +0,0 @@
'use strict';
const { progressKey } = require('../scraper/lib/failures');
// No source requests, filesystem checkpoint edits, shell execution or Docker socket.
class Monitor {
constructor(options = {}) {
this.now = options.now || Date.now;
this.stallMs = options.stallMs || 900000;
this.observations = new Map();
this.incidents = options.incidents || [];
this.state = { status: 'starting', services: {}, incidents: this.incidents };
}
incident(key, message, extra = {}) {
let incident = this.incidents.find(i => i.key === key && i.state === 'open');
if (!incident) {
incident = { key, message, state: 'open', at: new Date(this.now()).toISOString(), ...extra };
this.incidents.push(incident);
if (this.incidents.length > 200) this.incidents.splice(0, this.incidents.length - 200);
}
return incident;
}
resolve(key) {
for (const i of this.incidents) if (i.key === key && i.state === 'open') { i.state = 'resolved'; i.resolvedAt = new Date(this.now()).toISOString(); }
}
async observe(reader, scraper, recover) {
const now = this.now();
this.state.checkedAt = new Date(now).toISOString();
this.state.services = { reader: !!reader?.ok, scraper: !!scraper };
if (reader?.ready === false) this.incident('reader:scan', 'Reader is alive but its library scan failed; inspect storage and scan diagnostics', { service: 'reader' });
else if (reader?.ok) this.resolve('reader:scan');
for (const [service, healthy] of Object.entries(this.state.services)) {
if (healthy) this.resolve(`service:${service}`);
else this.incident(`service:${service}`, `${service} is unreachable or unhealthy; inspect its supervisor`, { service });
}
if (!scraper) { this.state.status = 'degraded'; return this.state; }
const ids = new Set();
for (const entry of scraper.active || []) {
const id = entry.current.id; ids.add(id);
// Without a checkpoint, a null progress key stays constant forever and
// cannot prove a stall. Do not quarantine a live worker on that basis.
if (!entry.job) { this.observations.delete(id); this.resolve(`stall:${id}`); continue; }
const key = progressKey(entry.job), prior = this.observations.get(id);
if (!prior || prior.key !== key) { this.observations.set(id, { key, at: now }); this.resolve(`stall:${id}`); continue; }
if (scraper.paused || scraper.sourcePaused?.[entry.source]) continue;
const allowance = Math.max(this.stallMs, Number(entry.job?.rate?.chapterMaxMs || 0) + 180000);
if (now - prior.at < allowance) continue;
const incident = this.incident(`stall:${id}`, 'Job progress has stopped; recovery is limited to one checkpoint-safe attempt', { jobId: id, source: entry.source, title: entry.current.title });
if (!incident.attempted && recover) {
// Persist attempted in the daemon via stopReason/stallRecoveries; monitor records audit too.
incident.attempted = true;
try { incident.recovery = await recover({ id, progressKey: key }); }
catch { incident.recovery = { changed: false, error: 'Recovery API unavailable' }; }
}
}
for (const id of this.observations.keys()) if (!ids.has(id)) { this.observations.delete(id); this.resolve(`stall:${id}`); }
const failures = (scraper.queue || []).filter(item => ['error', 'quarantined'].includes(item.status));
for (const item of failures) this.incident(`job:${item.id}`, item.failure?.message || item.error || 'Job needs attention', { jobId: item.id, source: item.source, kind: item.failure?.kind || 'unknown' });
for (const i of this.incidents) if (i.key.startsWith('job:') && !failures.some(item => i.key === `job:${item.id}`)) this.resolve(i.key);
this.state.sourcePaused = scraper.sourcePaused || {};
this.state.active = (scraper.active || []).map(e => ({ source: e.source, id: e.current.id, title: e.current.title, completed: e.job?.completed, total: e.job?.total, updated: e.job?.updated }));
this.state.status = this.incidents.some(i => i.state === 'open') ? 'attention' : scraper.paused ? 'paused' : 'healthy';
return this.state;
}
}
module.exports = { Monitor };
-38
View File
@@ -1,38 +0,0 @@
'use strict';
const test = require('node:test'), assert = require('node:assert/strict');
const { Monitor } = require('../monitor');
test('Sentinel does not disturb progressing or deliberately paused jobs', async () => {
let now = 0, recoveries = 0;
const monitor = new Monitor({ now: () => now, stallMs: 100 });
const state = { paused: false, active: [{ source: 'a', current: { id: 'job', title: 'Manga' }, job: { completed: 0, current: { downloadedPages: 1 } } }], queue: [] };
const recover = async () => { recoveries++; return { changed: true }; };
await monitor.observe({ ok: true }, state, recover);
now = 500000; state.active[0].job.current.downloadedPages = 2;
await monitor.observe({ ok: true }, state, recover);
now = 1000000; state.paused = true;
await monitor.observe({ ok: true }, state, recover);
assert.equal(recoveries, 0);
state.paused = false; await monitor.observe({ ok: true }, state, recover); await monitor.observe({ ok: true }, state, recover);
assert.equal(recoveries, 1);
state.active[0].job.completed = 1; await monitor.observe({ ok: true }, state, recover);
assert.equal(monitor.incidents.find(i => i.key === 'stall:job').state, 'resolved');
});
test('Sentinel reports service failures without restart loops and resolves recovery', async () => {
const monitor = new Monitor();
await monitor.observe(null, null); await monitor.observe(null, null);
assert.equal(monitor.incidents.length, 2);
await monitor.observe({ ok: true }, { active: [], queue: [], paused: false });
assert.equal(monitor.state.status, 'healthy'); assert.ok(monitor.incidents.every(i => i.state === 'resolved'));
});
test('missing checkpoint does not trigger a false stall recovery', async () => {
let now = 0, recoveries = 0;
const monitor = new Monitor({ now: () => now, stallMs: 100 });
const state = { paused: false, active: [{ source: 'mangapill', current: { id: 'job', title: 'Manga' }, job: null }], queue: [] };
for (const advance of [0, 1000000, 2000000]) {
now = advance;
await monitor.observe({ ok: true }, state, async () => { recoveries++; return { changed: true }; });
}
assert.equal(recoveries, 0);
assert.equal(monitor.incidents.some(item => item.key === 'stall:job'), false);
});
+34 -4
View File
@@ -7,9 +7,11 @@
* 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)
*/
const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto');
const { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIndex } = require('./library');
@@ -26,6 +28,7 @@ const {
applyPulse, applyVisit, chapterInsight, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
} = require('./reading-analytics');
const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access');
const { BackupService, BackupError } = require('./backup');
const PORT = +process.env.PORT || 8080;
const LIB = path.resolve(process.env.LIBRARY || 'library');
@@ -36,7 +39,6 @@ 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 SENTINEL_URL = String(process.env.SENTINEL_URL || 'http://127.0.0.1:8092').replace(/\/$/, '');
const PEER_DATA = path.resolve(process.env.PEER_DATA || path.join(DATA, 'peering'));
const peerStore = new PeeringState(PEER_DATA);
const discoveryStore = new DiscoveryStore(DATA);
@@ -65,6 +67,9 @@ 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 || ''),
@@ -375,9 +380,19 @@ const routes = {
},
'GET /api/admin/health': async ({ u }) => {
admin(u);
const sentinel = await fetch(SENTINEL_URL + '/api/state', { headers: SCRAPER_TOKEN ? { authorization: `Bearer ${SCRAPER_TOKEN}` } : {}, signal: AbortSignal.timeout(4000) })
.then(async r => { if (!r.ok) throw new Error(); return r.json(); }).catch(() => ({ status: 'unreachable' }));
return { reader: { ok: !scanState.error, uptime: Math.round(process.uptime()), scan: scanState }, sentinel };
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 }),
@@ -718,6 +733,21 @@ const routes = {
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 || '');
+87
View File
@@ -0,0 +1,87 @@
'use strict';
// BL-0045: admins grant or revoke admin rights in Settings -> Users & access.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
const { spawn } = require('child_process');
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('only admins can promote or demote, nobody can demote themselves, and changes apply at once', { timeout: 20000 }, async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-admin-role-'));
let child;
try {
const library = path.join(root, 'library'), data = path.join(root, 'data');
fs.mkdirSync(library); fs.mkdirSync(data);
const port = await freePort(), output = [];
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: '' } });
child.stdout.on('data', d => output.push(String(d)));
child.stderr.on('data', d => output.push(String(d)));
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 new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(ready, output.join(''));
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: JSON.stringify(body) });
const get = (cookie, url) => fetch(base + url, { headers: { cookie } });
const session = r => r.headers.get('set-cookie').split(';')[0];
const first = await post(null, '/api/signup', { name: 'boss', password: 'boss-password' });
assert.equal(first.status, 200);
const boss = session(first);
assert.equal((await post(boss, '/api/admin/user', { name: 'reader', password: 'reader-password' })).status, 200);
const login = await post(null, '/api/login', { name: 'reader', password: 'reader-password' });
assert.equal(login.status, 200);
const reader = session(login);
// a normal user can neither promote themselves nor reach admin pages
assert.equal((await post(reader, '/api/admin/user/role', { name: 'reader', admin: true })).status, 403);
assert.equal((await get(reader, '/api/admin')).status, 403);
assert.equal((await post(null, '/api/admin/user/role', { name: 'reader', admin: true })).status, 401);
// input checks
assert.equal((await post(boss, '/api/admin/user/role', { name: 'nobody', admin: true })).status, 404);
assert.equal((await post(boss, '/api/admin/user/role', { name: 'reader', admin: 'yes' })).status, 400);
assert.equal((await post(boss, '/api/admin/user/role', { name: 'boss', admin: false })).status, 400);
// promotion keeps the password and applies to the existing session immediately
const promoted = await post(boss, '/api/admin/user/role', { name: 'Reader', admin: true });
assert.equal(promoted.status, 200);
assert.deepEqual(await promoted.json(), { ok: true, name: 'reader', admin: true });
assert.equal((await (await get(reader, '/api/me')).json()).admin, true);
assert.equal((await get(reader, '/api/admin')).status, 200);
assert.equal((await post(null, '/api/login', { name: 'reader', password: 'reader-password' })).status, 200);
const users = (await (await get(boss, '/api/admin')).json()).users;
assert.deepEqual(users.map(x => [x.name, x.admin]).sort(), [['boss', true], ['reader', true]]);
// another admin may revoke; the revoked admin loses access at once
assert.equal((await post(reader, '/api/admin/user/role', { name: 'boss', admin: false })).status, 200);
assert.equal((await get(boss, '/api/admin')).status, 403);
assert.equal((await post(boss, '/api/admin/user/role', { name: 'boss', admin: true })).status, 403);
assert.match(output.join(''), /\[users\] reader removed boss as admin/);
// db.json is written shortly after a change (batched writes)
let saved = null;
for (let n = 0; n < 60; n++) {
try { saved = JSON.parse(fs.readFileSync(path.join(data, 'db.json'), 'utf8')); } catch { saved = null; }
if (saved && saved.users.boss && saved.users.boss.admin === false) break;
await new Promise(resolve => setTimeout(resolve, 100));
}
assert.equal(saved.users.boss.admin, false);
assert.equal(saved.users.reader.admin, true);
} finally {
if (child) await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); });
fs.rmSync(root, { recursive: true, force: true });
}
});
+286
View File
@@ -0,0 +1,286 @@
'use strict';
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
const { spawn, spawnSync } = require('child_process');
const { BackupService, readArchive, validateSettings, nextOccurrence } = require('../backup');
const root = path.resolve(__dirname, '..');
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const quiet = { log() {}, warn() {}, error() {} };
const LONG = 'collection-covers/' + 'a-very-long-folder-name-that-keeps-going-'.repeat(3) + '/cover-with-a-long-name.png';
function fixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-backup-'));
const data = path.join(dir, 'data'), target = path.join(dir, 'backup');
const write = (rel, text) => { fs.mkdirSync(path.dirname(path.join(data, rel)), { recursive: true }); fs.writeFileSync(path.join(data, rel), text); };
write('db.json', '{"stale":true}');
write('scan-cache.json', '{"cache":1}');
write('library-index.json', '{"version":1}');
write('catalog.json', '{"collections":[]}');
write('scraper/queue.json', JSON.stringify({ version: 3, paused: false, status: 'idle', queue: [{ id: 'a' }] }));
write('scraper/events.log', '{"level":"info"}\n');
write('sentinel/state.json', '{}');
write(LONG, 'png-bytes');
// Must not be archived.
write('runtime-reader.json', '{}'); write('scraper/runtime.json', '{}'); write('sentinel/runtime.json', '{}');
write('scraper/daemon.lock', '1'); write('scraper/supervisor.lock', '1'); write('reader.pid', '1'); write('db.json.tmp', '{}');
fs.mkdirSync(target);
return { dir, data, target, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
}
function fakeScraper({ paused = false, busyPolls = 0, failResume = false } = {}) {
const calls = [];
let polls = 0;
return {
calls,
get paused() { return paused; },
async state() { polls++; return { paused, status: paused && polls <= busyPolls ? 'pausing' : paused ? 'paused' : 'downloading', active: polls <= busyPolls ? [{ source: 'x' }] : [] }; },
async control(action) {
calls.push(action);
if (action === 'resume' && failResume) throw new Error('scraper gone');
paused = action === 'pause';
},
};
}
const service = (f, options = {}) => new BackupService({ dataRoot: f.data, backupRoot: f.target, log: quiet, pollMs: 5, resumeRetryMs: 1,
snapshotDb: () => JSON.stringify({ users: { admin: {} }, live: true }), ...options }).load();
test('settings validation and schedule computation', () => {
assert.deepEqual(validateSettings({ enabled: true, time: '04:15', retention: { mode: 'days', value: 30 } }),
{ enabled: true, time: '04:15', retention: { mode: 'days', value: 30 } });
assert.throws(() => validateSettings({ time: '24:00' }), /HH:MM/);
assert.throws(() => validateSettings({ retention: { mode: 'count', value: 0 } }), /Keep/);
assert.throws(() => validateSettings({ retention: { mode: 'weeks', value: 2 } }), /count/);
assert.throws(() => validateSettings({ enabled: 'yes' }), /enabled/);
const now = new Date(2026, 9, 22, 3, 0).getTime();
assert.equal(nextOccurrence('04:00', now), new Date(2026, 9, 22, 4, 0).getTime());
assert.equal(nextOccurrence('03:00', now), new Date(2026, 9, 23, 3, 0).getTime());
});
test('a backup pauses a running queue, waits for idle, archives a consistent DATA snapshot and resumes', async () => {
const f = fixture();
try {
const scraper = fakeScraper({ busyPolls: 3 });
const backup = service(f, { scraper });
const result = await backup.run('manual');
assert.equal(result.error, undefined, result.error);
assert.deepEqual(scraper.calls, ['pause', 'resume']);
assert.equal(result.queueWasPaused, false); assert.equal(result.queueResumed, true);
assert.match(result.file, /^circus-\d{8}-\d{6}\.tar\.gz$/);
assert.deepEqual(fs.readdirSync(f.target), [result.file]);
const entries = await readArchive(path.join(f.target, result.file), { contents: true });
const byName = new Map(entries.map(e => [e.name, e]));
const text = name => byName.get(name).data.toString('utf8');
// db.json comes from memory, not from the stale file on disk.
assert.deepEqual(JSON.parse(text('db.json')), { users: { admin: {} }, live: true });
// The archived queue is paused; the live one is untouched by the snapshot.
const queue = JSON.parse(text('scraper/queue.json'));
assert.equal(queue.paused, true); assert.deepEqual(queue.queue, [{ id: 'a' }]);
assert.equal(JSON.parse(fs.readFileSync(path.join(f.data, 'scraper/queue.json'), 'utf8')).paused, false);
for (const name of ['scan-cache.json', 'library-index.json', 'catalog.json', 'scraper/events.log', 'sentinel/state.json', LONG, '.circus-backup.json'])
assert.ok(byName.has(name), `${name} missing`);
assert.equal(text(LONG), 'png-bytes');
for (const name of ['runtime-reader.json', 'scraper/runtime.json', 'sentinel/runtime.json', 'scraper/daemon.lock', 'scraper/supervisor.lock', 'reader.pid', 'db.json.tmp'])
assert.ok(!byName.has(name), `${name} must be excluded`);
assert.ok(byName.get('scraper/').type === '5');
// The persisted state records the result but never a pending marker inside the archive.
assert.equal(JSON.parse(text('backup.json')).pending, undefined);
assert.equal(backup.publicState().last.file, result.file);
assert.equal(backup.publicState().archives.count, 1);
} finally { f.cleanup(); }
});
test('an already paused queue stays paused and is not resumed', async () => {
const f = fixture();
try {
const scraper = fakeScraper({ paused: true });
const result = await service(f, { scraper }).run('schedule');
assert.equal(result.error, undefined);
assert.deepEqual(scraper.calls, []);
assert.equal(result.queueWasPaused, true); assert.equal(scraper.paused, true);
} finally { f.cleanup(); }
});
test('a scraper that never becomes idle aborts the backup, leaves no archive and resumes the queue', async () => {
const f = fixture();
try {
const scraper = fakeScraper({ busyPolls: Infinity });
const result = await service(f, { scraper, idleTimeoutMs: 40 }).run('manual');
assert.match(result.error, /did not become idle/);
assert.deepEqual(scraper.calls, ['pause', 'resume']);
assert.deepEqual(fs.readdirSync(f.target), []);
} finally { f.cleanup(); }
});
test('an unreachable scraper does not block the backup and is reported', async () => {
const f = fixture();
try {
const scraper = { async state() { throw new Error('connect ECONNREFUSED'); }, async control() { throw new Error('unused'); } };
const result = await service(f, { scraper }).run('manual');
assert.equal(result.error, undefined);
assert.match(result.warnings[0], /unreachable/);
const queue = (await readArchive(path.join(f.target, result.file), { contents: true })).find(e => e.name === 'scraper/queue.json');
assert.equal(JSON.parse(queue.data).paused, true);
} finally { f.cleanup(); }
});
test('a failed resume is surfaced as a warning', async () => {
const f = fixture();
try {
const result = await service(f, { scraper: fakeScraper({ failResume: true }) }).run('manual');
assert.equal(result.error, undefined);
assert.match(result.warnings.join(' '), /could not be resumed/);
} finally { f.cleanup(); }
});
test('retention keeps the newest archives and never touches other files', async () => {
const f = fixture();
try {
const old = ['circus-20260101-030000.tar.gz', 'circus-20260102-030000.tar.gz', 'circus-20260103-030000.tar.gz'];
const foreign = ['circus-20250101-0300.tar.zst', 'notes.txt', 'circus-20250101-030000.tar.gz.bak'];
for (const name of [...old, ...foreign]) fs.writeFileSync(path.join(f.target, name), 'x');
fs.mkdirSync(path.join(f.target, 'circus-20250101-030000.tar.gz-dir'));
fs.writeFileSync(path.join(f.target, 'circus-20250101-030000.tar.gz.part'), 'stale');
const backup = service(f);
backup.updateSettings({ retention: { mode: 'count', value: 2 } });
const result = await backup.run('manual');
assert.equal(result.error, undefined); assert.equal(result.deleted, 2);
assert.deepEqual(fs.readdirSync(f.target).sort(), [...foreign, 'circus-20250101-030000.tar.gz-dir', old[2], result.file].sort());
// Days mode works from the timestamp in the name.
backup.updateSettings({ retention: { mode: 'days', value: 1 } });
const second = await backup.run('manual');
assert.ok(!fs.existsSync(path.join(f.target, old[2])));
assert.ok(fs.existsSync(path.join(f.target, second.file)));
for (const name of foreign) assert.ok(fs.existsSync(path.join(f.target, name)));
} finally { f.cleanup(); }
});
test('backups are off without BACKUP, and a missing folder is reported, never created', async () => {
const f = fixture();
try {
const off = service(f, { backupRoot: '' });
await assert.rejects(off.run('manual'), /Backups are off/);
assert.equal(off.publicState().configured, false);
const missing = path.join(f.dir, 'not-mounted');
const result = await service(f, { backupRoot: missing }).run('manual');
assert.match(result.error, /not usable/);
assert.ok(!fs.existsSync(missing));
} finally { f.cleanup(); }
});
test('the writability probe leaves nothing behind in the backup folder', async () => {
const f = fixture();
try {
const backup = service(f);
await backup.refreshTarget();
assert.equal(backup.publicState().targetError, null);
assert.deepEqual(fs.readdirSync(f.target).filter(name => name.startsWith('.circus-backup-probe')), []);
} finally { f.cleanup(); }
});
test('only one run at a time', async () => {
const f = fixture();
try {
const backup = service(f, { scraper: fakeScraper({ busyPolls: 5 }) });
const first = backup.run('manual');
await assert.rejects(backup.run('manual'), /already running/);
assert.equal((await first).error, undefined);
} finally { f.cleanup(); }
});
test('the schedule runs once per slot, catches up a recent miss and skips stale slots', async () => {
const f = fixture();
try {
let now = new Date(2026, 9, 22, 2, 0).getTime();
const backup = service(f, { now: () => now });
backup.updateSettings({ enabled: true, time: '03:30' });
assert.equal(backup.state.nextAt, new Date(2026, 9, 22, 3, 30).getTime());
assert.equal(await backup.tick(), null);
now = new Date(2026, 9, 22, 9, 0).getTime(); // reader was down at 03:30
const result = await backup.tick();
assert.equal(result.trigger, 'schedule'); assert.equal(result.error, undefined);
assert.equal(backup.state.nextAt, new Date(2026, 9, 23, 3, 30).getTime());
assert.equal(await backup.tick(), null);
now = new Date(2026, 9, 30, 9, 0).getTime(); // e.g. an old backup.json was restored
assert.equal(await backup.tick(), null);
assert.equal(backup.state.nextAt, new Date(2026, 9, 31, 3, 30).getTime());
assert.equal(fs.readdirSync(f.target).length, 1);
} finally { f.cleanup(); }
});
test('a run interrupted by a restart resumes the queue it paused', async () => {
const f = fixture();
try {
fs.writeFileSync(path.join(f.data, 'backup.json'), JSON.stringify({ version: 1, pending: { startedAt: 1, trigger: 'schedule', pausedByUs: true } }));
const scraper = fakeScraper({ paused: true });
const backup = service(f, { scraper });
await backup.recoverInterrupted();
assert.deepEqual(scraper.calls, ['resume']);
assert.match(backup.state.last.error, /Interrupted/);
assert.equal(JSON.parse(fs.readFileSync(path.join(f.data, 'backup.json'), 'utf8')).pending, null);
} finally { f.cleanup(); }
});
test('system tar extracts the archive', { skip: spawnSync('tar', ['--version']).error ? 'no tar binary' : false }, async () => {
const f = fixture();
try {
const result = await service(f).run('manual');
const out = path.join(f.dir, 'restored'); fs.mkdirSync(out);
const tar = spawnSync('tar', ['-xzf', path.join(f.target, result.file), '-C', out], { encoding: 'utf8' });
assert.equal(tar.status, 0, tar.stderr);
assert.equal(fs.readFileSync(path.join(out, LONG), 'utf8'), 'png-bytes');
assert.equal(JSON.parse(fs.readFileSync(path.join(out, 'scraper/queue.json'), 'utf8')).paused, true);
assert.ok(!fs.existsSync(path.join(out, 'scraper/daemon.lock')));
} finally { f.cleanup(); }
});
async function port() {
const server = net.createServer();
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const result = server.address().port; await new Promise(resolve => server.close(resolve)); return result;
}
test('reader and scraper: admin backup API pauses and resumes the real queue', { timeout: 30000 }, async () => {
const f = fixture(), children = [], output = [];
try {
const library = path.join(f.dir, 'library'); fs.mkdirSync(library);
fs.writeFileSync(path.join(f.data, 'scraper', 'queue.json'), JSON.stringify({ paused: false, queue: [], settings: { autoUpdateEnabled: false } }));
for (const name of ['runtime.json', 'daemon.lock', 'supervisor.lock']) fs.rmSync(path.join(f.data, 'scraper', name), { force: true });
for (const name of ['db.json', 'catalog.json', 'library-index.json', 'scan-cache.json']) fs.rmSync(path.join(f.data, name));
const [rp, sp] = await Promise.all([port(), port()]);
const base = `http://127.0.0.1:${rp}`, token = 'backup-test-only';
const env = { ...process.env, DATA: f.data, LIBRARY: library, SCRAPER_DATA: path.join(f.data, 'scraper'), BACKUP: f.target,
PORT: String(rp), SCRAPER_PORT: String(sp), SCRAPER_HOST: '127.0.0.1', SCRAPER_URL: `http://127.0.0.1:${sp}`, SENTINEL_URL: 'http://127.0.0.1:9',
SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0' };
for (const script of ['server.js', 'scraper/daemon.js']) {
const child = spawn(process.execPath, [script], { cwd: root, env, windowsHide: true }); children.push(child);
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => output.push(chunk.toString()));
}
for (const url of [base, `http://127.0.0.1:${sp}`]) {
let ready = false;
for (let n = 0; n < 80 && !ready; n++) { ready = await fetch(url + '/health').then(r => r.ok).catch(() => false); if (!ready) await delay(100); }
assert.ok(ready, output.join(''));
}
const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'admin', password: 'local-test-password' }) });
const cookie = signup.headers.get('set-cookie').split(';')[0];
const request = (endpoint, body) => fetch(base + endpoint, { headers: { cookie, 'content-type': 'application/json' }, ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
assert.equal((await request('/api/admin/backup', { command: 'settings', settings: { time: '25:00' } })).status, 400);
const saved = await (await request('/api/admin/backup', { command: 'settings', settings: { enabled: true, time: '04:00', retention: { mode: 'count', value: 3 } } })).json();
assert.equal(saved.settings.retention.value, 3); assert.ok(saved.nextAt > Date.now());
assert.equal((await request('/api/admin/backup', { command: 'run' })).status, 200);
let state;
for (let n = 0; n < 100; n++) { state = await (await request('/api/admin/backup')).json(); if (state.last && !state.running) break; await delay(100); }
assert.equal(state.last.error, undefined, state.last.error);
assert.equal(state.last.queueWasPaused, false); assert.equal(state.last.queueResumed, true);
const scraperState = await (await request('/api/admin/scraper')).json();
assert.equal(scraperState.paused, false);
const entries = await readArchive(path.join(f.target, state.last.file), { contents: true });
const db = JSON.parse(entries.find(e => e.name === 'db.json').data);
assert.ok(db.users.admin);
assert.equal(JSON.parse(entries.find(e => e.name === 'scraper/queue.json').data).paused, true);
assert.ok((await (await request('/api/admin/health')).json()).backup.last.file);
} finally {
await Promise.all(children.map(child => new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); })));
f.cleanup();
}
});
+106
View File
@@ -0,0 +1,106 @@
'use strict';
// First-scan speed (BL-0043): small first reads and bounded concurrency must not change any result.
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path');
const { Canvas } = require('../tools/png');
const { scanLibrary, snapshotIndex, sizeFromBuffer, imageSize, limiter } = require('../library');
const tmp = prefix => fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const u16be = n => Buffer.from([n >> 8, n & 255]);
// JPEG with `appBytes` of APP1 payload (in 64 KB segments) before the SOF0 marker.
function jpeg(w, h, appBytes) {
const parts = [Buffer.from([0xff, 0xd8])];
for (let left = appBytes; left > 0;) {
const n = Math.min(left, 65533);
parts.push(Buffer.from([0xff, 0xe1]), u16be(n + 2), Buffer.alloc(n, 0x41));
left -= n;
}
parts.push(Buffer.from([0xff, 0xc0, 0x00, 0x11, 0x08]), u16be(h), u16be(w), Buffer.alloc(12));
parts.push(Buffer.from([0xff, 0xda]), Buffer.alloc(4096, 0x55), Buffer.from([0xff, 0xd9]));
return Buffer.concat(parts);
}
function webpVp8x(w, h) {
const b = Buffer.alloc(64);
b.write('RIFF', 0, 'latin1'); b.writeUInt32LE(56, 4); b.write('WEBP', 8, 'latin1'); b.write('VP8X', 12, 'latin1');
b.writeUIntLE(w - 1, 24, 3); b.writeUIntLE(h - 1, 27, 3);
return b;
}
function gif(w, h) {
const b = Buffer.alloc(32);
b.write('GIF89a', 0, 'latin1'); b.writeUInt16LE(w, 6); b.writeUInt16LE(h, 8);
return b;
}
// AVIF-like: ftyp box, then an `ispe` box after `pad` bytes.
function avif(w, h, pad) {
const b = Buffer.alloc(32 + pad + 20);
b.writeUInt32BE(24, 0); b.write('ftypavif', 4, 'latin1');
const k = 32 + pad;
b.write('ispe', k, 'latin1'); b.writeUInt32BE(w, k + 8); b.writeUInt32BE(h, k + 12);
return b;
}
test('the 32 KB first read gives the same size as the whole file, in every read stage', async () => {
const dir = tmp('mc-probe-');
try {
const cases = {
'small.png': new Canvas(37, 53, [1, 2, 3]).png(),
'plain.jpg': jpeg(800, 1200, 0),
'exif-20k.jpg': jpeg(801, 1201, 20 * 1024), // found in the first 32 KB
'exif-100k.jpg': jpeg(802, 1202, 100 * 1024), // needs the 256 KB read
'exif-400k.jpg': jpeg(803, 1203, 400 * 1024), // needs the whole file
'tiny.webp': webpVp8x(640, 960),
'tiny.gif': gif(10, 20),
'deep.avif': avif(1024, 1536, 50 * 1024), // ispe past 32 KB
'broken.jpg': Buffer.concat([Buffer.from([0xff, 0xd8]), Buffer.alloc(40 * 1024, 0x41)]),
};
for (const [name, bytes] of Object.entries(cases)) {
fs.writeFileSync(path.join(dir, name), bytes);
assert.deepEqual(await imageSize(path.join(dir, name)), sizeFromBuffer(bytes), name);
}
assert.deepEqual(await imageSize(path.join(dir, 'exif-400k.jpg')), { w: 803, h: 1203 });
assert.deepEqual(await imageSize(path.join(dir, 'deep.avif')), { w: 1024, h: 1536 });
assert.equal(await imageSize(path.join(dir, 'broken.jpg')), null);
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
test('concurrent scanning builds the same index and cache as a sequential scan', async () => {
const root = tmp('mc-concurrency-');
try {
let n = 0;
for (const manga of ['Alpha', 'Beta']) for (const book of ['v01', 'v02']) for (let c = 1; c <= 6; c++) {
const dir = path.join(root, manga, book, `c${String(c).padStart(4, '0')} [Src]`);
fs.mkdirSync(dir, { recursive: true });
for (let p = 1; p <= 5; p++, n++) {
const file = path.join(dir, `${String(p).padStart(4, '0')}${p === 3 ? '_double' : ''}`);
if (n % 3 === 0) fs.writeFileSync(file + '.jpg', jpeg(700 + n, 1000 + n, (n % 5) * 30 * 1024));
else fs.writeFileSync(file + '.png', new Canvas(10 + n, 20 + n, [n, n, n]).png());
}
fs.writeFileSync(path.join(dir, 'chapter.json'), JSON.stringify({ title: `${manga} ${book} ${c}` }));
}
fs.mkdirSync(path.join(root, 'Alpha', 'v01', 'not a chapter'));
const one = await scanLibrary(root, {}, { concurrency: 1 });
const many = await scanLibrary(root, {}, { concurrency: 8 });
assert.deepEqual(snapshotIndex(many.index), snapshotIndex(one.index));
const sorted = c => Object.fromEntries(Object.entries(c).sort(([a], [b]) => a.localeCompare(b)));
assert.deepEqual(sorted(many.cache), sorted(one.cache));
assert.equal(Object.keys(one.cache).length, 24);
assert.equal(one.index.byId.get('Beta').chapters.length, 12);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('limiter runs at most n tasks at once and keeps results and errors per task', async () => {
const limit = limiter(3);
let active = 0, peak = 0;
const task = i => limit(async () => {
active++; peak = Math.max(peak, active);
await new Promise(r => setTimeout(r, 5 + (i % 4) * 3));
active--;
if (i === 7) throw new Error('boom');
return i * 2;
});
const results = await Promise.allSettled(Array.from({ length: 20 }, (_, i) => task(i)));
assert.equal(peak, 3);
assert.equal(results[7].status, 'rejected');
assert.deepEqual(results.filter((_, i) => i !== 7).map(r => r.value), Array.from({ length: 20 }, (_, i) => i * 2).filter((_, i) => i !== 7));
});
+8 -9
View File
@@ -24,23 +24,22 @@ test('three services integrate collections, auth, custom covers and private heal
fs.writeFileSync(path.join(library, name, 'cover.png'), png);
fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name, tags: ['Fantasy'], status: 'ongoing' }));
}
const [rp, sp, wp] = await Promise.all([port(), port(), port()]);
const base = `http://127.0.0.1:${rp}`, scraper = `http://127.0.0.1:${sp}`, sentinel = `http://127.0.0.1:${wp}`;
const [rp, sp] = await Promise.all([port(), port()]);
const base = `http://127.0.0.1:${rp}`, scraper = `http://127.0.0.1:${sp}`;
const token = 'integration-test-only';
const env = { ...process.env, DATA: data, LIBRARY: library, SCRAPER_DATA: path.join(data, 'scraper'), SENTINEL_DATA: path.join(data, 'sentinel'),
PORT: String(rp), SCRAPER_PORT: String(sp), SENTINEL_PORT: String(wp), SCRAPER_HOST: '127.0.0.1', SENTINEL_HOST: '127.0.0.1',
READER_URL: base, SCRAPER_URL: scraper, SENTINEL_URL: sentinel, SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0', COOKIE_SECURE: '0' };
for (const script of ['server.js', 'scraper/daemon.js', 'sentinel/daemon.js']) {
const env = { ...process.env, DATA: data, LIBRARY: library, SCRAPER_DATA: path.join(data, 'scraper'),
PORT: String(rp), SCRAPER_PORT: String(sp), SCRAPER_HOST: '127.0.0.1',
READER_URL: base, SCRAPER_URL: scraper, SCRAPER_TOKEN: token, SCRAPER_TOKEN_FILE: '', SCAN_INTERVAL: '0', COOKIE_SECURE: '0' };
for (const script of ['server.js', 'scraper/daemon.js']) {
const child = spawn(process.execPath, [script], { cwd: root, env, windowsHide: true }); children.push(child);
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => output.push(chunk.toString()));
}
for (const url of [base, scraper, sentinel]) {
for (const url of [base, scraper]) {
let ready = false;
for (let n = 0; n < 80 && !ready; n++) { ready = await fetch(url + '/health').then(r => r.ok).catch(() => false); if (!ready) await delay(100); }
assert.ok(ready, output.join(''));
}
assert.equal((await fetch(scraper + '/api/state')).status, 401);
assert.equal((await fetch(sentinel + '/api/state')).status, 401);
const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'tester', password: 'local-test-password' }) });
assert.equal(signup.status, 200); const cookie = signup.headers.get('set-cookie').split(';')[0];
const request = (endpoint, body) => fetch(base + endpoint, { headers: { cookie, 'content-type': 'application/json' }, ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
@@ -121,7 +120,7 @@ test('three services integrate collections, auth, custom covers and private heal
const cover = await (await request('/api/admin/collection-cover', { id: 'demo', revision, base64: png.toString('base64') })).json(); revision = cover.revision;
const image = await request('/api/collection-cover?id=demo'); assert.equal(image.status, 200); assert.deepEqual(Buffer.from(await image.arrayBuffer()), png);
}
const health = await (await request('/api/admin/health')).json(); assert.ok(health.reader); assert.ok(health.sentinel);
const health = await (await request('/api/admin/health')).json(); assert.ok(health.reader); assert.equal(health.sentinel, undefined);
assert.equal((await request('/api/admin/scraper')).status, 200);
// Exercise the actual authenticated visit route and cross-chapter lease races.
assert.equal((await request('/api/settings', { readingClock: true })).status, 200);
+3 -3
View File
@@ -11,9 +11,9 @@ for (const [index, name] of ['Moon Garden', 'Moon Garden Before the Rain', 'Iron
fs.writeFileSync(path.join(folder, '001.png'), png); fs.writeFileSync(path.join(library, name, 'cover.png'), png);
fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name, status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] }));
}
const env = { ...process.env, DATA: data, LIBRARY: library, PORT: '8180', SCRAPER_PORT: '8191', SENTINEL_PORT: '8192',
READER_URL: 'http://127.0.0.1:8180', SCRAPER_URL: 'http://127.0.0.1:8191', SENTINEL_URL: 'http://127.0.0.1:8192',
SCRAPER_DATA: path.join(data, 'scraper'), SENTINEL_DATA: path.join(data, 'sentinel'), COOKIE_SECURE: '0', SCAN_INTERVAL: '0', SCRAPER_TOKEN: 'fixture', SCRAPER_TOKEN_FILE: '' };
const env = { ...process.env, DATA: data, LIBRARY: library, PORT: '8180', SCRAPER_PORT: '8191',
READER_URL: 'http://127.0.0.1:8180', SCRAPER_URL: 'http://127.0.0.1:8191',
SCRAPER_DATA: path.join(data, 'scraper'), COOKIE_SECURE: '0', SCAN_INTERVAL: '0', SCRAPER_TOKEN: 'fixture', SCRAPER_TOKEN_FILE: '' };
const child = spawn(process.execPath, ['tools/start-all.js'], { cwd: path.resolve(__dirname, '..'), env, stdio: 'inherit', windowsHide: true });
console.log('Isolated UI fixture at http://127.0.0.1:8180 · first account is test admin');
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => child.kill('SIGTERM'));
+1 -2
View File
@@ -4,9 +4,8 @@ const { secret } = require('../runtime/config');
const token = secret('SCRAPER_TOKEN');
const root = process.env.READER_URL || 'http://127.0.0.1:8080';
const scraper = process.env.SCRAPER_URL || 'http://127.0.0.1:8091';
const sentinel = process.env.SENTINEL_URL || 'http://127.0.0.1:8092';
(async () => {
for (const [name, base, endpoint] of [['reader', root, '/health'], ['scraper', scraper, '/api/state'], ['sentinel', sentinel, '/api/state']]) {
for (const [name, base, endpoint] of [['reader', root, '/health'], ['scraper', scraper, '/api/state']]) {
try {
const response = await fetch(base + endpoint, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(10000) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
+1 -1
View File
@@ -1,5 +1,5 @@
'use strict';
const ports = { reader: process.env.PORT || 8080, scraper: process.env.SCRAPER_PORT || 8091, sentinel: process.env.SENTINEL_PORT || 8092 };
const ports = { reader: process.env.PORT || 8080, scraper: process.env.SCRAPER_PORT || 8091 };
const service = process.argv[2] || 'reader';
fetch(`http://127.0.0.1:${ports[service]}/health`, { signal: AbortSignal.timeout(4000) })
.then(async r => { const body = await r.json(); if (!r.ok || !body.ok) process.exitCode = 1; }).catch(() => { process.exitCode = 1; });
+1 -1
View File
@@ -2,7 +2,7 @@
'use strict';
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const root = path.resolve(__dirname, '..');
for (const relative of ['data', 'data/scraper', 'data/sentinel', 'library', '.secrets']) fs.mkdirSync(path.join(root, relative), { recursive: true });
for (const relative of ['data', 'data/scraper','library', 'backup', '.secrets']) fs.mkdirSync(path.join(root, relative), { recursive: true });
const file = path.join(root, '.secrets', 'scraper_token');
if (!fs.existsSync(file)) fs.writeFileSync(file, crypto.randomBytes(32).toString('hex') + '\n', { flag: 'wx', mode: 0o600 });
if (!fs.existsSync(path.join(root, '.env'))) fs.copyFileSync(path.join(root, '.env.example'), path.join(root, '.env'));
-1
View File
@@ -21,7 +21,6 @@ function copy(source, relative) {
}
for (const name of ['db.json', 'scan-cache.json', 'catalog.json', 'collection-covers', 'runtime-reader.json']) copy(path.join(data, name), path.join('data', name));
for (const name of ['queue.json', 'runtime.json', 'daemon.lock', 'supervisor.lock']) copy(path.join(data, 'scraper', name), path.join('data', 'scraper', name));
for (const name of ['state.json', 'runtime.json']) copy(path.join(data, 'sentinel', name), path.join('data', 'sentinel', name));
for (const name of fs.readdirSync(library)) {
copy(path.join(library, name, name.startsWith('.mcscraper-work-') ? 'job.json' : 'manga.json'), path.join('library', name, name.startsWith('.mcscraper-work-') ? 'job.json' : 'manga.json'));
}
+1 -1
View File
@@ -12,7 +12,7 @@ if (fs.existsSync(lock)) {
catch (e) { if (e.code !== 'ESRCH') throw e; fs.unlinkSync(lock); }
}
fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
const supervisors = ['reader', 'scraper', 'sentinel'].map(name => new Supervisor(name, { root }));
const supervisors = ['reader', 'scraper'].map(name => new Supervisor(name, { root }));
supervisors.forEach(service => service.start());
const keepalive = setInterval(() => {}, 60000);
let closing = false;
+18 -10
View File
@@ -6,10 +6,10 @@ const assert = require('assert/strict'), { spawn } = require('child_process');
const { Canvas } = require('./png');
const root = path.resolve(__dirname, '..'), dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-compose-'));
const project = 'mc-qa-' + crypto.randomBytes(3).toString('hex'), port = process.env.QA_PORT || '8180';
const library = path.join(dir, 'library'), data = path.join(dir, 'data'), tokenFile = path.join(dir, 'token');
const library = path.join(dir, 'library'), data = path.join(dir, 'data'), backupDir = path.join(dir, 'backup'), tokenFile = path.join(dir, 'token');
const directionQa = process.argv.includes('--direction-ui');
const peerGatewayQa = process.argv.includes('--peer-gateway');
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true }); fs.mkdirSync(path.join(data, 'sentinel'));
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true }); fs.mkdirSync(backupDir);
if (peerGatewayQa) {
const peerData = path.join(data, 'peering');
const peerUsage = path.join(data, 'peering-usage');
@@ -47,8 +47,8 @@ for (const [index, name] of ['Moon Garden', 'Before the Rain', 'Iron Harbor'].en
...(directionQa && index === 0 ? { 'x-reading-direction': { version: 1, value: 'rtl', source: 'fallback' } } : {}),
status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] }));
}
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'), SENTINEL_DATA_PATH: path.join(data, 'sentinel'),
SCRAPER_TOKEN_PATH: tokenFile, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'),
SCRAPER_TOKEN_PATH: tokenFile, BACKUP_PATH: backupDir, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
READER_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}',
READING_TIMEZONE: 'Europe/Berlin', READING_QUIZ_UNLOCK_MINUTES: '60', READING_QUIZ_CATALOG_PATH: '/data/reading-quiz.json' }
@@ -93,6 +93,14 @@ let keep = false;
let inventory;
await until(async () => { inventory = await (await api('/api/library')).json(); return inventory.mangas?.length === 3; }, 'Fixture inventory missing');
const byTitle = title => inventory.mangas.find(m => m.title === title).id;
// Built-in backup through the real control API: archive written, paused queue left paused.
const queueBefore = JSON.parse(fs.readFileSync(path.join(data, 'scraper', 'queue.json'), 'utf8'));
assert.equal((await api('/api/admin/backup', { command: 'run' })).status, 200);
let backupState;
await until(async () => { backupState = await (await api('/api/admin/backup')).json(); return backupState.last && !backupState.running; }, 'Backup did not finish');
assert.equal(backupState.last.error, undefined, backupState.last.error);
assert.ok(fs.existsSync(path.join(backupDir, backupState.last.file)), 'Backup archive missing on the host');
assert.equal(JSON.parse(fs.readFileSync(path.join(data, 'scraper', 'queue.json'), 'utf8')).paused, queueBefore.paused);
if (readingGateQa) {
const manga = byTitle('Moon Garden'), other = byTitle('Before the Rain');
const chapter = (await (await api('/api/manga?m=' + encodeURIComponent(manga))).json()).chapters[0].id;
@@ -193,12 +201,12 @@ let keep = false;
}
const settingsJs = await (await fetch(url + '/settings.js')).text();
assert.ok(!settingsJs.includes('Start it with: npm run start:scraper'));
await until(async () => { const h = await (await api('/api/admin/health')).json(); return h.sentinel.services?.reader && h.sentinel.services?.scraper; }, 'Internal Sentinel networking failed');
await until(async () => (await (await api('/api/admin/scraper')).json()).available, 'Internal scraper networking failed');
const mounts = JSON.parse(await docker('config', '--format', 'json'));
assert.ok(mounts.services.reader.volumes.find(v => v.target === '/library').read_only);
assert.ok(!mounts.services.scraper.ports?.length && !mounts.services.sentinel.ports?.length);
const denied = JSON.parse(await docker('exec', '-T', 'reader', 'node', '-e', "Promise.all(['http://scraper:8091/api/state','http://sentinel:8092/api/state'].map(u=>fetch(u).then(r=>r.status))).then(v=>console.log(JSON.stringify(v)))"));
assert.deepEqual(denied, [401, 401]);
assert.ok(!mounts.services.scraper.ports?.length && !mounts.services.sentinel);
const denied = JSON.parse(await docker('exec', '-T', 'reader', 'node', '-e', "fetch('http://scraper:8091/api/state').then(r=>console.log(r.status))"));
assert.equal(denied, 401);
// Deliberate fixture-only child crash: its supervisor must recover just the reader.
const before = JSON.parse(fs.readFileSync(path.join(data, 'runtime-reader.json'))).pid;
await docker('exec', '-T', 'reader', 'node', '-e', "const fs=require('fs');process.kill(JSON.parse(fs.readFileSync('/data/runtime-reader.json')).pid,'SIGKILL')");
@@ -239,11 +247,11 @@ let keep = false;
assert.equal(login.status, 200);
await require('./test-direction-ui')(url, login.headers.get('set-cookie').split(';')[0]);
}
console.log('PASS: isolated Compose auth, collections, read-only reader, private APIs, Sentinel networking and child crash recovery');
console.log('PASS: isolated Compose auth, collections, read-only reader, private APIs, scraper networking and child crash recovery');
keep = process.argv.includes('--keep');
if (keep) {
const envFile = path.join(dir, 'qa.env');
fs.writeFileSync(envFile, ['LIBRARY_PATH', 'DATA_PATH', 'SCRAPER_DATA_PATH', 'SENTINEL_DATA_PATH', 'SCRAPER_TOKEN_PATH', 'READER_PORT', 'READER_BIND', 'COOKIE_SECURE', 'APP_IMAGE',
fs.writeFileSync(envFile, ['LIBRARY_PATH', 'DATA_PATH', 'SCRAPER_DATA_PATH', 'SCRAPER_TOKEN_PATH', 'BACKUP_PATH', 'READER_PORT', 'READER_BIND', 'COOKIE_SECURE', 'APP_IMAGE',
...(readingGateQa ? ['READING_WINDOWS_JSON', 'READING_TIMEZONE', 'READING_QUIZ_UNLOCK_MINUTES', 'READING_QUIZ_CATALOG_PATH'] : [])]
.map(k => `${k}=${k === 'READING_WINDOWS_JSON' ? `'${env[k]}'` : env[k].replaceAll('\\', '/')}`).join('\n') + '\n');
console.log(JSON.stringify({ project, url, envFile, fixture: dir }));
+4 -4
View File
@@ -50,9 +50,9 @@ async function until(check, message, attempts = 120) {
}
async function setup(label, readerPort, peerPort) {
const root = path.join(TEST_ROOT, label), library = path.join(root, 'library'), data = path.join(root, 'data');
const scraper = path.join(data, 'scraper'), sentinel = path.join(data, 'sentinel'), peerData = path.join(data, 'peering');
const scraper = path.join(data, 'scraper'), peerData = path.join(data, 'peering');
const usage = path.join(data, 'peer-usage'), token = path.join(root, 'scraper-token');
for (const dir of [library, scraper, sentinel, peerData, usage]) await fsp.mkdir(dir, { recursive: true });
for (const dir of [library, scraper, peerData, usage]) await fsp.mkdir(dir, { recursive: true });
await fsp.writeFile(path.join(scraper, 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
await fsp.writeFile(token, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
const png = new Canvas(180, 270, [55, 105, 150]).png();
@@ -71,7 +71,7 @@ async function setup(label, readerPort, peerPort) {
}
new PeeringState(peerData).generateIdentity(label === 'a' ? 'PeerA' : 'PeerB');
const env = { ...process.env, APP_IMAGE, VCS_REF: 'peer-redesign-test', LIBRARY_PATH: library, DATA_PATH: data,
SCRAPER_DATA_PATH: scraper, SENTINEL_DATA_PATH: sentinel, PEER_DATA_PATH: peerData, PEER_USAGE_PATH: usage,
SCRAPER_DATA_PATH: scraper, PEER_DATA_PATH: peerData, PEER_USAGE_PATH: usage,
SCRAPER_TOKEN_PATH: token, READER_PORT: String(readerPort), READER_BIND: '127.0.0.1',
PEER_PORT: String(peerPort), PEER_BIND: '127.0.0.1', COOKIE_SECURE: '0', TZ: 'UTC' };
return { label, root, library, data, peerData, manga, readerPort, peerPort, env, url: `http://127.0.0.1:${readerPort}` };
@@ -244,7 +244,7 @@ async function main() {
assert.deepEqual(peerQueueItems[0].workIdentity, catalog.data.items[0].workIdentity,
'The resolved work identity is persisted on the queued peer transfer');
const volumes = await Promise.all(started.map(({ project, env }) => docker(project, env, 'ps', '--format', '{{.Service}}')));
for (const services of volumes) for (const name of ['reader', 'scraper', 'sentinel', 'peer-gateway']) assert.ok(services.includes(name), `${name} is running in A and B`);
for (const services of volumes) for (const name of ['reader', 'scraper', 'peer-gateway']) assert.ok(services.includes(name), `${name} is running in A and B`);
console.log('PASS: isolated Compose A↔B pairing, catalog-only access, cover proxy, grant update, browser details/bulk queue, one paused queue writer, and admin-only API/UI boundary.');
console.log(`Test Compose projects: ${projects.join(', ')}; disposable roots: ${TEST_ROOT}`);
succeeded = true;
+3 -3
View File
@@ -12,7 +12,7 @@ This is a discovery and curation feature, not a new image scraper, metadata auth
Current MangaCircus constraints remain: plain Node and browser JavaScript, Docker Compose, a folder-tree library, JSON state rather than a database, one reader owner of account state, one scraper owner of the queue and library publication, and source-specific slow request pacing. See [`docs/architecture.md`](../docs/architecture.md), [`docs/greatest.md`](../docs/greatest.md), and [`docs/scraper-service.md`](../docs/scraper-service.md).
MVP lives in the **reader service** as a bounded asynchronous job and a reader-owned JSON store. Do not add a permanently running fourth service at first. If real LLM or metadata latency later makes reader isolation necessary, move the job behind the same API to a one-off/optional worker container using the existing image; do not change the data contract or add a database merely to do that. Sentinel observes availability but does not judge recommendations, call an LLM, or repair research claims.
MVP lives in the **reader service** as a bounded asynchronous job and a reader-owned JSON store. Do not add a permanently running third service at first. If real LLM or metadata latency later makes reader isolation necessary, move the job behind the same API to a one-off/optional worker container using the existing image; do not change the data contract or add a database merely to do that. Service health reports availability but does not judge recommendations, call an LLM, or repair research claims.
Explicit non-goals for the first release:
@@ -134,7 +134,7 @@ Queueing remains through `POST /api/admin/scraper` with `command:"enqueue"` and
- One in-process run lock plus a persisted run ID prevents overlap across browser clicks and restarts. A stale interrupted run is marked interrupted on startup; keep the previous successful feed. Publish only after validation completes.
- Preserve source errors and diagnostics with bounded log size. Do not store sensitive prompts or external response bodies indefinitely. Provider blocks/challenges require user attention, not automated retries or evasions.
- Nightly scheduling comes after the manual path has passed acceptance. Make local-time schedule configurable, persist last-success time, skip duplicate same-day runs after restart, and allow a manual run without disturbing scraper nightly updates. A missed night can run once after startup, never a catch-up storm.
- The feature is optional: reader, library, Greatest, scraper, and Sentinel continue normally if metadata or LLM access is absent.
- The feature is optional: reader, library, Greatest, and scraper continue normally if metadata or LLM access is absent.
## 8. Phased implementation and acceptance
@@ -152,7 +152,7 @@ Add one configurable model adapter, schema validation, evidence-bound output, op
### Phase D — scheduling and operations
Add nightly schedule only after manual runs are stable. Expose last success/error and a `discovery_run_failed` incident to admin observability, but do not give Sentinel authority to rewrite recommendations or restart the scraper. Add backup/restore instructions for `data/discovery.json` and cache behavior. If runs measurably harm reader latency, move the job into an optional one-off Compose worker with one-writer handoff, not an always-on autonomous service.
Add nightly schedule only after manual runs are stable. Expose last success/error and a `discovery_run_failed` incident to admin observability, but do not give any watchdog authority to rewrite recommendations or restart the scraper. Add backup/restore instructions for `data/discovery.json` and cache behavior. If runs measurably harm reader latency, move the job into an optional one-off Compose worker with one-writer handoff, not an always-on autonomous service.
### Suggested implementation map
+1 -1
View File
@@ -107,7 +107,7 @@ Suggested NoDB files (names are design targets, not existing files): reader-owne
3. **Read-only publisher:** optional gateway, bounded approved-only catalog/manifest/blob endpoints, quota enforcement, read-only mounts. Test that unshared, partially downloaded, deleted, and revoked content cannot be listed or fetched—even by guessed IDs or stale manifests. Verify no user data fields or raw source URLs appear on the wire.
4. **Consumer browsing:** local-server proxy and reader UI with peer status, search, details, selection preview, and queue permission checks. No browser-to-peer credentials or direct requests.
5. **Scraper transfer lane:** chapter/page checkpoints, range resume, hashes, local throttles, source-aware deduplication, and existing atomic chapter publication. Test corruption, interrupted transfer, stale revision, out-of-space, repeated queue submission, and preservation of current jobs.
6. **Observability and operations:** per-peer health, bytes, throttling, grant expiry warnings, queue stalls, and safe retry controls. Sentinel may report a dead gateway or stuck transfer but must not renew trust, broaden a share, or bypass a quota. Document backup/restore and key-rotation runbooks.
6. **Observability and operations:** per-peer health, bytes, throttling, grant expiry warnings, queue stalls, and safe retry controls. Service health may report a dead gateway or stuck transfer but must not renew trust, broaden a share, or bypass a quota. Document backup/restore and key-rotation runbooks.
7. **Local then VM pilot:** three local Compose instances with distinct data directories; pair A↔B and A↔C, exercise every direction combination, and transfer a few admin-approved test chapters. Verify disabling B does not affect C, no account/progress exchange, and quota isolation. Only then deploy the optional service on the VM's private network. Leave existing reader/scraper ports and active downloads undisturbed during the pilot.
**Ship gate:** a fresh unpaired server sees nothing; a paired server with serving off or no granted manga sees an empty catalog; an expired/revoked peer cannot list or fetch; invitations cannot be reused; disabling one direction or peer leaves unrelated peers intact; quotas survive restart; a receiver can resume and publish one chapter without duplicates; and its browser never receives the grant token or any private key. The feature remains disabled by default until those gates pass.