Checkpoint peering identity and opt-in discovery
This commit is contained in:
1 parent
eba9b50e30
commit
47de081721
44 files changed
+1557
-55
No files matched your search
@@ -11,12 +11,16 @@
|
||||
!public/**
|
||||
!greatest/
|
||||
!greatest/**
|
||||
!discovery/
|
||||
!discovery/**
|
||||
!runtime/
|
||||
!runtime/**
|
||||
!sentinel/
|
||||
!sentinel/**
|
||||
!scraper/
|
||||
!scraper/**
|
||||
!peering/
|
||||
!peering/**
|
||||
!tools/
|
||||
!tools/**
|
||||
scraper/.cache/**
|
||||
|
||||
+3
-1
@@ -1,13 +1,15 @@
|
||||
FROM node:24-alpine
|
||||
RUN apk add --no-cache tzdata
|
||||
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 catalog.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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
reader:
|
||||
ports:
|
||||
- target: 8080
|
||||
published: "${READER_PORT:-8080}"
|
||||
host_ip: "${READER_LAN_BIND:?Set READER_LAN_BIND to the host LAN IPv4 address}"
|
||||
protocol: tcp
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// Read-only CLI. Never writes the reader's discovery.json or scraper queue.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { loadCatalog } = require('../greatest');
|
||||
const { generate } = require('./engine');
|
||||
|
||||
if (!process.argv.includes('--dry-run')) {
|
||||
console.error('Usage: node discovery/cli.js run --dry-run [--data DIR] [--catalog FILE] [--show]');
|
||||
process.exit(2);
|
||||
}
|
||||
const arg = (name, fallback) => { const i = process.argv.indexOf(name); return i < 0 ? fallback : process.argv[i + 1]; };
|
||||
const data = path.resolve(arg('--data', process.env.DATA || 'data'));
|
||||
const db = JSON.parse(fs.readFileSync(path.join(data, 'db.json'), 'utf8'));
|
||||
let library = [];
|
||||
try { library = JSON.parse(fs.readFileSync(path.join(data, 'library-index.json'), 'utf8')).mangas || []; }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
const greatest = loadCatalog(arg('--catalog', path.join(__dirname, '..', 'greatest', 'catalog.json')));
|
||||
const result = generate({ db, library, greatest, cacheDir: path.join(data, 'scraper', 'metadata', 'anilist') });
|
||||
const summary = { examined: result.examined, candidates: Object.keys(result.candidates).length,
|
||||
optedInUsers: Object.values(db.users || {}).filter(u => u.settings?.discoveryEnabled).length,
|
||||
feedSizes: Object.values(result.feeds).filter(f => !f.disabled).map(f => f.items.length) };
|
||||
if (process.argv.includes('--show')) summary.titles = Object.values(result.feeds).find(f => !f.disabled)?.items
|
||||
.map(item => result.candidates[item.candidateId]?.title) || [];
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { normalize } = require('../greatest');
|
||||
|
||||
const MAX_CANDIDATES = 50;
|
||||
const MAX_FEED = 20;
|
||||
const VALID_FORMATS = new Set(['MANGA', 'ONE_SHOT']);
|
||||
const safeUrl = value => /^https:\/\/[^\s]+$/i.test(String(value || '')) ? String(value) : null;
|
||||
const unique = values => [...new Set(values.filter(Boolean))];
|
||||
|
||||
function cachedMetadata(dir, id) {
|
||||
if (!Number.isSafeInteger(Number(id)) || Number(id) < 1) return null;
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(path.join(dir, `id-${Number(id)}.json`), 'utf8'));
|
||||
return value._cacheVersion === 4 && value.metadata?.['x-anilist']?.id === Number(id) ? value.metadata : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
function candidateFromGreatest(entry, cacheDir) {
|
||||
const id = Number(entry.metadata?.id);
|
||||
const cached = id ? cachedMetadata(cacheDir, id) : null;
|
||||
return {
|
||||
id: id ? `anilist:${id}` : `greatest:${entry.id}`,
|
||||
title: cached?.title || entry.title, year: cached?.year || entry.year || null,
|
||||
type: cached?.['x-anilist']?.format || 'MANGA', status: cached?.status || entry.status || 'unknown',
|
||||
cover: safeUrl(cached?.['x-anilist']?.coverUrl) || safeUrl(entry.cover),
|
||||
metadataUrl: safeUrl(cached?.['x-anilist']?.url) || safeUrl(entry.metadata?.url) || null,
|
||||
tags: unique(cached?.tags || []), authors: unique(cached?.authors || []),
|
||||
period: entry.period, editorialRank: entry.rank, greatestId: entry.id,
|
||||
evidence: [{ id: `greatest:${entry.id}`, kind: 'curation', label: 'Greatest of All Time selection',
|
||||
note: String(entry.note || '').slice(0, 600), url: safeUrl(entry.source?.url) || safeUrl(entry.metadata?.url) }],
|
||||
relations: cached?.['x-anilist']?.relations || [],
|
||||
};
|
||||
}
|
||||
function addRelations(map, anchor, cacheDir) {
|
||||
for (const relation of anchor.relations || []) {
|
||||
if (!VALID_FORMATS.has(relation.format) || !Number.isSafeInteger(Number(relation.id))) continue;
|
||||
const id = `anilist:${Number(relation.id)}`;
|
||||
if (map.has(id)) continue;
|
||||
const cached = cachedMetadata(cacheDir, relation.id);
|
||||
map.set(id, {
|
||||
id, title: cached?.title || relation.title || `AniList ${relation.id}`,
|
||||
year: cached?.year || null, type: relation.format, status: cached?.status || 'unknown',
|
||||
cover: safeUrl(cached?.['x-anilist']?.coverUrl),
|
||||
metadataUrl: safeUrl(cached?.['x-anilist']?.url) || safeUrl(relation.url) || `https://anilist.co/manga/${relation.id}`,
|
||||
tags: unique(cached?.tags || []), authors: unique(cached?.authors || []), period: null,
|
||||
editorialRank: null, greatestId: null,
|
||||
evidence: [{ id: `relation:${anchor.id}:${id}`, kind: 'relation', label: `${relation.type || 'RELATED'} to ${anchor.title}`,
|
||||
note: '', url: safeUrl(relation.url) || `https://anilist.co/manga/${relation.id}` }],
|
||||
relatedTo: { id: anchor.id, title: anchor.title, type: relation.type || 'RELATED' },
|
||||
relations: cached?.['x-anilist']?.relations || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
function buildCandidates({ greatest, library, cacheDir }) {
|
||||
const map = new Map();
|
||||
for (const entry of greatest.entries) {
|
||||
const candidate = candidateFromGreatest(entry, cacheDir);
|
||||
if (candidate.type === 'MANGA' || candidate.type === 'ONE_SHOT') map.set(candidate.id, candidate);
|
||||
}
|
||||
for (const manga of library) {
|
||||
const identity = manga.meta?.['x-anilist'];
|
||||
if (!identity?.id) continue;
|
||||
addRelations(map, { id: `anilist:${identity.id}`, title: manga.title, relations: identity.relations }, cacheDir);
|
||||
}
|
||||
for (const candidate of [...map.values()]) if (candidate.greatestId) addRelations(map, candidate, cacheDir);
|
||||
return [...map.values()];
|
||||
}
|
||||
function ownedKeySet(library) {
|
||||
const ids = new Set(), names = new Set();
|
||||
for (const manga of library) {
|
||||
const id = Number(manga.meta?.['x-anilist']?.id);
|
||||
if (id) ids.add(`anilist:${id}`);
|
||||
names.add(`${normalize(manga.title)}:${Number(manga.meta?.year) || '?'}`);
|
||||
}
|
||||
return { ids, names };
|
||||
}
|
||||
function labelForVote(greatest, id) { return greatest.entries.find(entry => entry.id === id)?.title || null; }
|
||||
function feedForUser({ user, db, candidates, greatest, library, queuedTitles = [] }) {
|
||||
const settings = user.settings || {};
|
||||
if (!settings.discoveryEnabled) return { generatedAt: new Date().toISOString(), items: [], disabled: true };
|
||||
const votes = db.greatestRatings?.[user.name] || {};
|
||||
const feedback = db.discoveryFeedback?.[user.name] || {};
|
||||
const owned = ownedKeySet(library);
|
||||
const queued = new Set(queuedTitles.map(normalize));
|
||||
const positive = Object.entries(votes).filter(([, vote]) => vote === 'flame' || vote === 'good')
|
||||
.map(([id, vote]) => ({ id, vote, title: labelForVote(greatest, id), entry: greatest.entries.find(e => e.id === id) })).filter(x => x.entry);
|
||||
const likedTags = new Map(), likedPeriods = new Map();
|
||||
for (const anchor of positive) {
|
||||
likedPeriods.set(anchor.entry.period, anchor);
|
||||
const candidate = candidates.find(c => c.greatestId === anchor.id);
|
||||
for (const tag of candidate?.tags || []) likedTags.set(tag.toLowerCase(), anchor);
|
||||
}
|
||||
const ownedTags = new Map();
|
||||
for (const manga of library) for (const tag of manga.meta?.tags || []) if (typeof tag === 'string') ownedTags.set(tag.toLowerCase(), manga.title);
|
||||
const readTags = new Map();
|
||||
if (settings.discoveryReadingActivity) for (const manga of library) {
|
||||
const progress = db.progress?.[user.name]?.[manga.id] || {};
|
||||
if (!Object.values(progress).some(chapter => chapter?.done)) continue;
|
||||
for (const tag of manga.meta?.tags || []) if (typeof tag === 'string') readTags.set(tag.toLowerCase(), manga.title);
|
||||
}
|
||||
const rows = [];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.greatestId && votes[candidate.greatestId]) continue;
|
||||
if (['dismissed', 'hidden'].includes(feedback[candidate.id])) continue;
|
||||
if (owned.ids.has(candidate.id) || owned.names.has(`${normalize(candidate.title)}:${candidate.year || '?'}`)) continue;
|
||||
if (queued.has(normalize(candidate.title))) continue;
|
||||
if (!settings.discoveryMature && candidate.tags.some(tag => /adult|hentai|erotica/i.test(tag))) continue;
|
||||
const matchingLikedTag = candidate.tags.find(tag => likedTags.has(tag.toLowerCase()));
|
||||
const matchingOwnedTag = candidate.tags.find(tag => ownedTags.has(tag.toLowerCase()));
|
||||
const matchingReadTag = candidate.tags.find(tag => readTags.has(tag.toLowerCase()));
|
||||
const era = likedPeriods.get(candidate.period);
|
||||
const relation = candidate.relatedTo;
|
||||
const scoreParts = { editorial: candidate.greatestId ? Math.max(0, 6 - Math.min(6, Number(candidate.editorialRank) || 6)) : 0,
|
||||
relation: relation ? 3 : 0, sharedTaste: matchingLikedTag ? 4 : 0, sharedLibrary: matchingOwnedTag ? 1 : 0,
|
||||
era: era ? 2 : 0, reading: matchingReadTag ? 2 : 0,
|
||||
interest: feedback[candidate.id] === 'interested' ? 5 : feedback[candidate.id] === 'maybe' ? 2 : 0 };
|
||||
let reason, personalEvidence = null;
|
||||
if (relation) reason = `${candidate.title} is listed as ${relation.type.toLowerCase().replaceAll('_', ' ')} to ${relation.title}. Check the edition and reading order first.`;
|
||||
else if (matchingLikedTag) {
|
||||
const anchor = likedTags.get(matchingLikedTag.toLowerCase());
|
||||
reason = `You liked ${anchor.title}; this also carries the ${matchingLikedTag} tag.`;
|
||||
personalEvidence = { id: `taste:greatest:${anchor.id}`, kind: 'personal', label: `Your Greatest vote for ${anchor.title}`, note: `Shared tag: ${matchingLikedTag}`, url: null };
|
||||
} else if (era) {
|
||||
reason = `You liked ${era.title}; this is another curated work from ${candidate.period}.`;
|
||||
personalEvidence = { id: `taste:greatest:${era.id}`, kind: 'personal', label: `Your Greatest vote for ${era.title}`, note: `Shared period: ${candidate.period}`, url: null };
|
||||
} else if (matchingReadTag) {
|
||||
const title = readTags.get(matchingReadTag.toLowerCase());
|
||||
reason = `You have read ${title}; this also carries the ${matchingReadTag} tag.`;
|
||||
personalEvidence = { id: `taste:read:${normalize(title)}`, kind: 'personal', label: `Your completed reading in ${title}`, note: `Shared tag: ${matchingReadTag}`, url: null };
|
||||
} else if (matchingOwnedTag) {
|
||||
const title = ownedTags.get(matchingOwnedTag.toLowerCase());
|
||||
reason = `This shares the ${matchingOwnedTag} tag with ${title} in your library.`;
|
||||
personalEvidence = { id: `taste:library:${normalize(title)}`, kind: 'personal', label: `${title} in your library`, note: `Shared tag: ${matchingOwnedTag}`, url: null };
|
||||
}
|
||||
else reason = candidate.evidence[0]?.note || 'A curated manga worth a closer look.';
|
||||
reason = reason.slice(0, 280);
|
||||
const evidence = [...candidate.evidence, ...(personalEvidence ? [personalEvidence] : [])];
|
||||
rows.push({ candidateId: candidate.id, reason, evidence, evidenceIds: evidence.map(e => e.id), scoreParts,
|
||||
score: Object.values(scoreParts).reduce((a, b) => a + b, 0), period: candidate.period || 'Related works' });
|
||||
}
|
||||
rows.sort((a, b) => b.score - a.score || a.candidateId.localeCompare(b.candidateId));
|
||||
const picked = [], periodCounts = new Map();
|
||||
for (const row of rows) {
|
||||
if (picked.length >= MAX_FEED) break;
|
||||
if ((periodCounts.get(row.period) || 0) >= 4) continue;
|
||||
picked.push(row); periodCounts.set(row.period, (periodCounts.get(row.period) || 0) + 1);
|
||||
}
|
||||
return { generatedAt: new Date().toISOString(), items: picked.map((row, index) => ({ ...row, rank: index + 1 })) };
|
||||
}
|
||||
function generate({ db, library, greatest, cacheDir, queuedTitles = [] }) {
|
||||
const all = buildCandidates({ greatest, library, cacheDir });
|
||||
// Sample across eras before the work cap; insertion order in the curated catalog
|
||||
// must not make recent manga invisible.
|
||||
const buckets = new Map();
|
||||
for (const candidate of all) {
|
||||
const key = candidate.period || 'Related works';
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key).push(candidate);
|
||||
}
|
||||
const candidates = [];
|
||||
while (candidates.length < MAX_CANDIDATES && [...buckets.values()].some(values => values.length)) {
|
||||
for (const values of buckets.values()) {
|
||||
if (values.length && candidates.length < MAX_CANDIDATES) candidates.push(values.shift());
|
||||
}
|
||||
}
|
||||
const feeds = {};
|
||||
for (const user of Object.values(db.users || {})) feeds[user.name] = feedForUser({ user, db, candidates, greatest, library, queuedTitles });
|
||||
return { candidates: Object.fromEntries(candidates.map(c => [c.id, c])), feeds,
|
||||
examined: all.length, clipped: Math.max(0, all.length - candidates.length) };
|
||||
}
|
||||
|
||||
module.exports = { generate, buildCandidates, feedForUser, cachedMetadata, MAX_CANDIDATES, MAX_FEED };
|
||||
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
class DiscoveryStore {
|
||||
constructor(dataDir) { this.file = path.join(path.resolve(dataDir), 'discovery.json'); }
|
||||
load() {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
||||
if (data.schema !== 1 || !Number.isSafeInteger(data.revision) || !data.candidates || !data.feeds || !data.lastRun)
|
||||
throw new Error('Invalid discovery store schema');
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return { schema: 1, revision: 0, candidates: {}, feeds: {}, lastRun: { status: 'never' } };
|
||||
throw new Error(`Discovery store unavailable; refusing to overwrite it: ${error.message}`);
|
||||
}
|
||||
}
|
||||
publish(value) {
|
||||
const prior = this.load();
|
||||
const data = { schema: 1, revision: prior.revision + 1, candidates: value.candidates, feeds: value.feeds,
|
||||
lastRun: { status: 'success', startedAt: value.startedAt, finishedAt: new Date().toISOString(),
|
||||
candidateCount: Object.keys(value.candidates).length, examined: value.examined, clipped: value.clipped } };
|
||||
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
||||
const temp = `${this.file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
||||
if (fs.existsSync(this.file)) fs.copyFileSync(this.file, `${this.file}.prev`);
|
||||
fs.renameSync(temp, this.file);
|
||||
} finally { try { fs.rmSync(temp, { force: true }); } catch {} }
|
||||
return data;
|
||||
}
|
||||
}
|
||||
module.exports = { DiscoveryStore };
|
||||
@@ -66,6 +66,10 @@ services:
|
||||
source: ${SCRAPER_DATA_PATH:-./data/scraper}
|
||||
target: /data/scraper
|
||||
bind: {create_host_path: false}
|
||||
- type: bind
|
||||
source: ${PEER_DATA_PATH:-./data/peering}
|
||||
target: /data/peering
|
||||
read_only: true
|
||||
environment:
|
||||
DATA: /data
|
||||
LIBRARY: /library
|
||||
@@ -75,6 +79,7 @@ services:
|
||||
SCRAPER_HOST: 0.0.0.0
|
||||
SCRAPER_PORT: "8091"
|
||||
SCRAPER_TOKEN_FILE: /run/secrets/scraper_token
|
||||
PEER_DATA: /data/peering
|
||||
SOURCE_PLUGIN_URL: ${SOURCE_PLUGIN_URL:-}
|
||||
MANGADEX_PLUGIN_URL: ${MANGADEX_PLUGIN_URL:-}
|
||||
networks: [backend, egress, plugin_backend]
|
||||
@@ -110,6 +115,39 @@ services:
|
||||
start_period: 60s
|
||||
retries: 3
|
||||
|
||||
peer-gateway:
|
||||
<<: *app
|
||||
profiles: [peering]
|
||||
command: [node, peering/gateway.js]
|
||||
ports: ["${PEER_BIND:-127.0.0.1}:${PEER_PORT:-8093}:8093"]
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${LIBRARY_PATH:-./library}
|
||||
target: /library
|
||||
read_only: true
|
||||
bind: {create_host_path: false}
|
||||
- type: bind
|
||||
source: ${PEER_DATA_PATH:-./data/peering}
|
||||
target: /data/peering
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ${PEER_USAGE_PATH:-./data/peering-usage}
|
||||
target: /data/peering-usage
|
||||
environment:
|
||||
LIBRARY: /library
|
||||
PEER_DATA: /data/peering
|
||||
PEER_USAGE_FILE: /data/peering-usage/usage.json
|
||||
PEER_HOST: 0.0.0.0
|
||||
PEER_PORT: "8093"
|
||||
# Docker Desktop does not publish host ports from an internal-only network.
|
||||
networks: [backend, egress]
|
||||
healthcheck:
|
||||
test: [CMD, node, tools/healthcheck-peer.js]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 3
|
||||
|
||||
source-plugin-weebcentral:
|
||||
image: ${APP_IMAGE:-mangacircus:local}
|
||||
build:
|
||||
|
||||
@@ -15,6 +15,8 @@ Related documents:
|
||||
- [resilience.md](resilience.md): supervision, recovery limits and incidents.
|
||||
- [compose-deployment.md](compose-deployment.md): local/VM deployment and release workflow.
|
||||
- [settings-navigation.md](settings-navigation.md): User/Admin workspace, scraper tabs and deep links.
|
||||
- [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.
|
||||
|
||||
## 1. Principles
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Discovery Scout (deterministic first release)
|
||||
|
||||
The reader now has a separate **Discover** page. This first slice deliberately uses
|
||||
the curated Greatest catalog, already-cached AniList identities/relations, library
|
||||
metadata, each user's Greatest votes, and explicit Discover feedback. It does not
|
||||
call an LLM, fetch new metadata, inspect chapter/image hosters, edit a collection,
|
||||
or enqueue downloads. The reader remains the only writer of account and discovery
|
||||
state; no fourth Compose service, credential, or database is required.
|
||||
|
||||
## Use
|
||||
|
||||
1. Open `discover.html`. Each account independently enables **Personalized
|
||||
discovery** (off by default). Reading activity is a separate opt-in. The
|
||||
mature-title switch only applies to candidates with a known mature tag; it is
|
||||
not a content-safety guarantee.
|
||||
2. An admin presses **Run discovery now** on the same page. Existing suggestions
|
||||
remain visible until the new feed is published. Runs are bounded to 50
|
||||
candidates and 20 suggestions per opted-in user, with no more than four from
|
||||
one period. A second concurrent run is rejected.
|
||||
3. A reader can open **Why this is here · evidence** and record Interested, Maybe,
|
||||
No thanks, or Hide. Feedback is private to the account. Dismissed/hidden
|
||||
titles disappear at once; other feedback influences the next run. The user
|
||||
can undo a choice by clicking its active button again.
|
||||
|
||||
The recommendation is an editorial lead, **not** an availability or edition
|
||||
check. The public metadata link opens the provider record; an admin still uses
|
||||
the existing source inspection and confirmation process to download anything.
|
||||
The title/year/format matching is intentionally conservative but not a verified
|
||||
identity merge. Missing year, cover, or status remains missing.
|
||||
|
||||
## Data and API
|
||||
|
||||
- `data/discovery.json`: atomic, reader-owned candidate and per-user feed
|
||||
snapshots. A previous valid version is retained as `discovery.json.prev`.
|
||||
A corrupt file makes reads/runs fail closed rather than overwrite it.
|
||||
- `data/db.json`: consent in user settings and explicit feedback under
|
||||
`discoveryFeedback`. The account-deletion route removes that feedback.
|
||||
- `GET /api/discovery`: caller's own feed, settings, evidence, and feedback only.
|
||||
- `POST /api/discovery/feedback`: caller's candidate choice; requires opt-in.
|
||||
- `GET /api/admin/discovery` and `POST /api/admin/discovery/run`: admin-only
|
||||
status and manual generation. A run keeps the last good feed on failure.
|
||||
|
||||
The job asks the private scraper state API for queued titles to avoid recommending
|
||||
them. If that API is unavailable, the run still uses only cached sources; the
|
||||
admin result records `queueAvailable: false` and queue exclusion is incomplete.
|
||||
No scraper queue edit occurs.
|
||||
|
||||
## Read-only dry run and verification
|
||||
|
||||
From the project directory:
|
||||
|
||||
```powershell
|
||||
node discovery/cli.js run --dry-run --data data --show
|
||||
npm run test:reader
|
||||
docker build -t mangacircus:discovery-test .
|
||||
$env:APP_IMAGE='mangacircus:discovery-test'; npm run test:discovery-ui
|
||||
```
|
||||
|
||||
The dry run reads `db.json`, a saved library index if present, Greatest, and
|
||||
cached AniList metadata. It writes nothing and does not contact providers. The
|
||||
Compose/browser suite uses a disposable data tree and queue, not the live one.
|
||||
|
||||
## Deliberate limits and next stage
|
||||
|
||||
This slice has no scheduled run, external metadata discovery, LLM, admin seed
|
||||
form, edition-finding action, or explicit purge control for old feed snapshots.
|
||||
Disabling consent stops feed display and future personalized generation, but
|
||||
existing snapshot/feedback is retained until account deletion or a later data
|
||||
management feature. Candidate quality is limited by the curated list and local
|
||||
metadata cache. Add external public metadata feeds and optional LLM only behind
|
||||
the existing bounded candidate/evidence contract after this deterministic path
|
||||
has been used and reviewed.
|
||||
+9
-3
@@ -81,7 +81,13 @@ require administrator-supervised re-pairing; there is no automatic key rotation
|
||||
direction needs its own approvals and grant. The server stores only a hash of a
|
||||
grant it issued; the receiver stores its imported token server-side.
|
||||
4. An admin browses the permitted catalog, selects missing/newest/last-five or an exact
|
||||
chapter, and queues it through the normal scraper. The peer adapter downloads up
|
||||
chapter, and queues it through the normal scraper. The queue carries a validated
|
||||
stable work identity: AniList first, then MangaDex/WeebCentral IDs, then another
|
||||
known hoster ID. If none exists, the owning server supplies an opaque origin ID
|
||||
that follows the work across peers without exposing its folder name. Imports retain
|
||||
that identity, so a round trip resolves to the original library title; `sync missing`
|
||||
skips chapters already present and fetches only additions. Titles alone are never
|
||||
used to merge works. The peer adapter downloads up
|
||||
to the configured pages in flight, validates size and SHA-256, resumes a partial
|
||||
page with HTTP Range, checkpoints between batches, and atomically publishes each
|
||||
complete chapter into the ordinary library. Existing-chapter policy is applied
|
||||
@@ -102,9 +108,9 @@ an admin can distinguish pairing from permission.
|
||||
| --- | --- |
|
||||
| `GET /peer/v1/hello` with one-use invitation headers | Protocol version and server identity fingerprint; no catalog |
|
||||
| `GET /peer/v1/access` | Pairing state, certificate expiry and current grant status/scopes; no catalog |
|
||||
| `GET /peer/v1/catalog?cursor=N` | Up to 50 approved opaque title IDs, safe metadata and cover availability |
|
||||
| `GET /peer/v1/catalog?cursor=N` | Up to 50 approved opaque title IDs, safe metadata, a stable work identity and cover availability |
|
||||
| `GET /peer/v1/covers/{mangaId}` | Approved cover image only; bounded to 5 MiB and charged to peer byte quota |
|
||||
| `GET /peer/v1/manga/{id}?cursor=N` | Up to 100 complete chapter IDs and public metadata |
|
||||
| `GET /peer/v1/manga/{id}?cursor=N` | Up to 100 complete chapter IDs, public metadata and the same stable work identity |
|
||||
| `GET /peer/v1/manga/{id}/chapters/{id}` | Ordered page IDs, MIME, byte size, SHA-256 and ETag |
|
||||
| `GET /peer/v1/blobs/{id}` | One image, optional `Range: bytes=N-` and `If-Match`; 206 on resume |
|
||||
|
||||
|
||||
@@ -59,6 +59,17 @@ written atomically. Individual
|
||||
archives retain their existing resumable checkpoints under
|
||||
`library/.mcscraper-work-<source-id>/job.json`. `events.log` is append-only.
|
||||
|
||||
The same state file also holds a `sourceLedger` keyed by adapter and source-series
|
||||
ID. It keeps source leads after completed queue rows are cleared. Existing queue
|
||||
URLs and recognized URLs in library metadata are backfilled without contacting a
|
||||
host; a successful catalog inspection records the selected chapter count, source
|
||||
edition, coverage signal, and observation time. Errors remain visible beside the
|
||||
last observation until a later successful inspection. **Catalog found**, **adapter
|
||||
compatible**, and **download rights** are separate states: a URL alone verifies
|
||||
none of them, and download rights remain unverified by scraper inspection. The
|
||||
admin-only **Settings → Scraper → Sources** tab shows this log. It is evidence for
|
||||
review, not an automatic source switch or a whole-series completeness claim.
|
||||
|
||||
On its first start, the daemon imports the old `.mcscraper-queue/queue.json` wishlist
|
||||
when present. Completed chapter ids in each archive checkpoint remain authoritative,
|
||||
so migration does not redownload published chapters.
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"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",
|
||||
"demo": "node tools/make-dummy.js library",
|
||||
"scrape": "node scraper/cli.js",
|
||||
"test": "npm run test:reader && npm run test:scraper && npm run test:runtime && npm run test:peer",
|
||||
|
||||
+54
-4
@@ -11,6 +11,7 @@ const { scanLibrary, findManga, shouldDownload } = require('../scraper/lib/libra
|
||||
const { publishManifest } = require('../scraper/lib/publisher');
|
||||
const { writeJsonAtomic } = require('../scraper/lib/json-file');
|
||||
const { sleep } = require('../scraper/lib/delay');
|
||||
const { normalizeWorkIdentity, sameWorkIdentity, resolveWorkIdentity } = require('../scraper/lib/work-identity');
|
||||
|
||||
const dataRoot = () => path.resolve(process.env.PEER_DATA || path.join(process.env.DATA || 'data', 'peering'));
|
||||
const store = () => new PeeringState(dataRoot());
|
||||
@@ -28,9 +29,41 @@ function refsOf(result) {
|
||||
return result.chapters.map(ref => ({ id: ref.id, number: ref.number, volume: ref.volume,
|
||||
book: { number: Number(ref.volume) || 1 }, title: ref.title, pages: ref.pageCount }));
|
||||
}
|
||||
function sameTitle(left, right) {
|
||||
return String(left || '').normalize('NFKC').trim().replace(/\s+/g, ' ').toLowerCase()
|
||||
=== String(right || '').normalize('NFKC').trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
async function pageHashes(folder) {
|
||||
const names = (await fs.readdir(folder, { withFileTypes: true }))
|
||||
.filter(entry => entry.isFile() && /\.(?:jpe?g|png|webp|gif|avif|bmp)$/i.test(entry.name))
|
||||
.map(entry => entry.name).sort();
|
||||
if (!names.length) return [];
|
||||
return Promise.all(names.map(async name => crypto.createHash('sha256')
|
||||
.update(await fs.readFile(path.join(folder, name))).digest('hex')));
|
||||
}
|
||||
async function matchLegacyPeerCopy(inventory, remote, peerStore, info) {
|
||||
// Pre-work-ID peer imports can be adopted only with byte-for-byte evidence;
|
||||
// a matching title alone is never sufficient to merge library works.
|
||||
const candidates = inventory.mangas.filter(manga => sameTitle(manga.title, remote.title));
|
||||
if (candidates.length !== 1) return null;
|
||||
const manga = candidates[0];
|
||||
for (const ref of remote.chapters) {
|
||||
const book = String(Number(ref.volume) || Number(ref.book?.number) || 1);
|
||||
const local = manga.chapters.filter(chapter => Number(chapter.book) === Number(book)
|
||||
&& String(chapter.number) === String(ref.number));
|
||||
if (!local.length) continue;
|
||||
const manifest = await client.chapter(peerStore, info.peerId, info.shareId, ref.id);
|
||||
const expected = manifest.pages.map(page => page.sha256);
|
||||
for (const chapter of local) {
|
||||
const actual = await pageHashes(path.join(manga.path, chapter.bookFolder, chapter.version));
|
||||
if (actual.length === expected.length && actual.every((hash, index) => hash === expected[index])) return manga;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function discover(info) {
|
||||
const result = await client.manga(store(), info.peerId, info.shareId);
|
||||
return { title: result.title, chapters: refsOf(result), availability: { selectedChapters: result.chapters.length, coverage: 'shared-selection' } };
|
||||
return { title: result.title, workIdentity: normalizeWorkIdentity(result.workIdentity), chapters: refsOf(result), availability: { selectedChapters: result.chapters.length, coverage: 'shared-selection' } };
|
||||
}
|
||||
async function run(info, item, options) {
|
||||
const stateStore = store(), peer = requirePeer(stateStore.load(), info.peerId);
|
||||
@@ -38,7 +71,10 @@ async function run(info, item, options) {
|
||||
const stop = () => { const reason = options.shouldStop?.(); if (reason) throw new ArchiveControlError(reason === true ? 'paused' : String(reason)); };
|
||||
stop();
|
||||
const remote = await client.manga(stateStore, info.peerId, info.shareId);
|
||||
const catalog = { title: remote.title, chapters: refsOf(remote), availability: { selectedChapters: remote.chapters.length, coverage: 'shared-selection' } };
|
||||
const workIdentity = normalizeWorkIdentity(remote.workIdentity);
|
||||
const queuedIdentity = normalizeWorkIdentity(item.workIdentity);
|
||||
if (queuedIdentity && !sameWorkIdentity(queuedIdentity, workIdentity)) throw new Error('Shared manga identity changed after it was queued; review the title before syncing');
|
||||
const catalog = { title: remote.title, workIdentity, chapters: refsOf(remote), availability: { selectedChapters: remote.chapters.length, coverage: 'shared-selection' } };
|
||||
if (options.onCatalog) await options.onCatalog(catalog);
|
||||
const sourceUrl = info.url, source = info.source;
|
||||
const workRoot = path.join(options.libraryRoot, `.mcscraper-work-${info.id}`);
|
||||
@@ -50,14 +86,28 @@ async function run(info, item, options) {
|
||||
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
const record = async () => { job.updated = new Date().toISOString(); await writeJsonAtomic(stateFile, job); };
|
||||
const inventory = await scanLibrary(options.libraryRoot);
|
||||
let manga = findManga(inventory, { source, seriesId: info.id, sourceUrl, libraryMangaId: item.libraryMangaId });
|
||||
let manga = findManga(inventory, { source, seriesId: info.id, sourceUrl, workIdentity,
|
||||
localServerId: stateStore.identity()?.id, libraryMangaId: item.libraryMangaId });
|
||||
if (!manga && (!workIdentity || workIdentity.kind === 'origin')) {
|
||||
manga = await matchLegacyPeerCopy(inventory, remote, stateStore, info);
|
||||
}
|
||||
if (!manga && inventory.mangas.some(candidate => sameTitle(candidate.title, remote.title))) {
|
||||
throw new Error(`A manga titled “${remote.title}” already exists, but neither its canonical identity nor shared chapter page hashes verify that it is the same work. No files were downloaded or changed; review the identity before syncing.`);
|
||||
}
|
||||
if (item.libraryMangaId && !manga) throw new Error(`Library manga target not found: ${item.libraryMangaId}`);
|
||||
const localIdentity = manga ? resolveWorkIdentity(manga.meta, manga.id, stateStore.identity()?.id) : null;
|
||||
if (manga && localIdentity && !normalizeWorkIdentity(manga.meta['x-work-identity'])) {
|
||||
manga.meta = { ...manga.meta, 'x-work-identity': localIdentity };
|
||||
await writeJsonAtomic(path.join(manga.path, 'manga.json'), manga.meta);
|
||||
}
|
||||
const selected = selectByScope(catalog.chapters.filter(ref => shouldDownload(manga, ref, source, item.existingPolicy)), item.scope);
|
||||
job.total = selected.length; job.jobCompleted = 0; job.current = null; await record();
|
||||
let publishedCount = 0;
|
||||
const identityToPersist = localIdentity || workIdentity;
|
||||
const series = { title: remote.title, direction: ['rtl', 'ltr', 'ttb'].includes(remote.direction) ? remote.direction : 'rtl',
|
||||
sources: [{ name: `Peer ${peer.alias}`, url: sourceUrl }],
|
||||
metadata: { title: remote.title }, scraper: { ids: { [source]: info.id } } };
|
||||
metadata: { title: remote.title, ...(identityToPersist ? { 'x-work-identity': identityToPersist } : {}) },
|
||||
scraper: { ids: { [source]: info.id } } };
|
||||
for (let index = 0; index < selected.length; index++) {
|
||||
stop();
|
||||
const ref = selected[index];
|
||||
|
||||
@@ -4,6 +4,7 @@ const https = require('node:https');
|
||||
const fs = require('node:fs');
|
||||
const fsp = fs.promises;
|
||||
const crypto = require('node:crypto');
|
||||
const { normalizeWorkIdentity } = require('../scraper/lib/work-identity');
|
||||
const { pipeline } = require('node:stream/promises');
|
||||
const { Transform } = require('node:stream');
|
||||
const { PeeringState, fp, requirePeer, grantStatus } = require('./state');
|
||||
@@ -101,6 +102,7 @@ async function catalog(store, id) {
|
||||
for (let page = 0; page < 100; page++) {
|
||||
const result = await jsonRequest(store, id, `/peer/v1/catalog${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
|
||||
if (!Array.isArray(result.items) || result.items.length > 50) throw new Error('Invalid peer catalog');
|
||||
for (const item of result.items) if (item.workIdentity != null && !normalizeWorkIdentity(item.workIdentity)) throw new Error('Invalid work identity in peer catalog');
|
||||
items.push(...result.items); cursor = result.nextCursor;
|
||||
if (!cursor) return items;
|
||||
}
|
||||
@@ -112,6 +114,7 @@ async function manga(store, id, shareId) {
|
||||
for (let page = 0; page < 100; page++) {
|
||||
const result = await jsonRequest(store, id, `/peer/v1/manga/${shareId}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
|
||||
if (result.id !== shareId || !Array.isArray(result.chapters) || result.chapters.length > 100) throw new Error('Invalid peer manga manifest');
|
||||
if (result.workIdentity != null && !normalizeWorkIdentity(result.workIdentity)) throw new Error('Invalid work identity in peer manga manifest');
|
||||
first ||= result; chapters.push(...result.chapters); cursor = result.nextCursor;
|
||||
if (!cursor) return { id: shareId, title: first.title, ...first, chapters };
|
||||
}
|
||||
|
||||
+4
-1
@@ -9,6 +9,7 @@ const crypto = require('node:crypto');
|
||||
const { once } = require('node:events');
|
||||
const { PeeringState, fp, hash, requirePeer, grantStatus } = require('./state');
|
||||
const { parseKey, parseSpecialFolder } = require('../chapter-identity');
|
||||
const { resolveWorkIdentity } = require('../scraper/lib/work-identity');
|
||||
|
||||
const DATA = path.resolve(process.env.PEER_DATA || path.join(process.env.DATA || 'data', 'peering'));
|
||||
const LIBRARY = path.resolve(process.env.LIBRARY || 'library');
|
||||
@@ -231,7 +232,8 @@ const server = https.createServer({ key: identity.key, cert: identity.cert, requ
|
||||
if (!mangaDir) continue;
|
||||
const meta = await jsonFile(path.join(mangaDir, 'manga.json'));
|
||||
items.push({ id: share.id, title: String(meta.title || share.mangaId).slice(0, 200),
|
||||
...publicMetadata(meta), coverAvailable: !!coverFile(mangaDir) });
|
||||
...publicMetadata(meta), workIdentity: resolveWorkIdentity(meta, share.mangaId, identity.id),
|
||||
coverAvailable: !!coverFile(mangaDir) });
|
||||
}
|
||||
return json(res, 200, { version: 1, items, nextCursor: cursor + 50 < shares.length ? String(cursor + 50) : null });
|
||||
}
|
||||
@@ -244,6 +246,7 @@ const server = https.createServer({ key: identity.key, cert: identity.cert, requ
|
||||
const cursor = Number(url.searchParams.get('cursor') || 0);
|
||||
if (!Number.isSafeInteger(cursor) || cursor < 0) fail(400, 'Invalid cursor');
|
||||
return json(res, 200, { id: share.id, title: String(index.meta.title || share.mangaId).slice(0, 200),
|
||||
workIdentity: resolveWorkIdentity(index.meta, share.mangaId, identity.id),
|
||||
...publicMetadata(index.meta), coverAvailable: !!coverFile(index.mangaDir),
|
||||
chapters: index.chapters.slice(cursor, cursor + 100).map(({ id, number, volume, title, pageCount }) => ({ id, number, volume, title, pageCount })),
|
||||
nextCursor: cursor + 100 < index.chapters.length ? String(cursor + 100) : null });
|
||||
|
||||
@@ -71,7 +71,7 @@ test('mTLS pairing, opt-in catalog, peer queue publication, and revocation', { t
|
||||
await fsp.writeFile(path.join(chapterDir, 'chapter.json'), JSON.stringify({ title: 'The first chapter', source: { name: 'Local' } }));
|
||||
await fsp.writeFile(path.join(chapterDir, '0001.png'), PNG);
|
||||
const ga = await gateway(dataA, libA, portA, path.join(root, 'usage-a.json'));
|
||||
const gb = await gateway(dataB, libB, portB, path.join(root, 'usage-b.json'));
|
||||
let gb = await gateway(dataB, libB, portB, path.join(root, 'usage-b.json'));
|
||||
t.after(async () => { await stop(ga); await stop(gb); });
|
||||
await client.probe(b, inB.id);
|
||||
await client.probe(a, inA.id);
|
||||
@@ -90,6 +90,7 @@ test('mTLS pairing, opt-in catalog, peer queue publication, and revocation', { t
|
||||
assert.equal(listing.length, 1);
|
||||
assert.equal(listing[0].title, 'Sample Manga');
|
||||
assert.equal(listing[0].coverAvailable, true);
|
||||
assert.deepEqual(listing[0].workIdentity, require('../../scraper/lib/work-identity').resolveWorkIdentity({}, 'Sample Manga', a.identity().id));
|
||||
assert.deepEqual(listing[0].tags, []);
|
||||
assert.equal(JSON.stringify(listing).includes('Sample Manga\\v01'), false);
|
||||
const remote = await client.manga(b, inB.id, listing[0].id);
|
||||
@@ -131,10 +132,78 @@ test('mTLS pairing, opt-in catalog, peer queue publication, and revocation', { t
|
||||
const result = await run(info, { scope: { mode: 'missing' }, existingPolicy: 'skip-any' },
|
||||
{ libraryRoot: libB, settings: { chapterMinMs: 500 }, shouldStop: () => false });
|
||||
assert.equal(result.publishedCount, 1);
|
||||
assert.deepEqual(JSON.parse(await fsp.readFile(path.join(libB, 'Sample Manga', 'manga.json'), 'utf8'))['x-work-identity'], remote.workIdentity,
|
||||
'The stable work identity is stored with the imported peer edition');
|
||||
assert.equal(fs.existsSync(path.join(libB, 'Sample Manga', 'v01', 'c0001 The first chapter [Peer ServerA ' + inB.id.slice(1, 7) + ']', '0001.png')), true);
|
||||
const second = await run(info, { scope: { mode: 'missing' }, existingPolicy: 'skip-any' },
|
||||
{ libraryRoot: libB, settings: { chapterMinMs: 500 }, shouldStop: () => false });
|
||||
assert.equal(second.publishedCount, 0);
|
||||
|
||||
// Simulate B adding one chapter to its imported edition, then sharing it back.
|
||||
// A must identify its original folder by the carried origin identity and fetch
|
||||
// only the new chapter, rather than creating a title-conflict quarantine.
|
||||
const chapterTwo = path.join(libB, 'Sample Manga', 'v01', 'c0002 Chapter from B');
|
||||
await fsp.mkdir(chapterTwo, { recursive: true });
|
||||
await fsp.writeFile(path.join(chapterTwo, 'chapter.json'), JSON.stringify({ title: 'Chapter from B', source: { name: 'Local' } }));
|
||||
await fsp.writeFile(path.join(chapterTwo, '0001.png'), PNG);
|
||||
b.setShare(inB.id, 'Sample Manga', true);
|
||||
b.updatePeer(inB.id, { serve: true });
|
||||
const reverseGrant = b.issueGrant(inB.id, ['download']);
|
||||
a.acceptGrant(inA.id, reverseGrant.token);
|
||||
a.updatePeer(inA.id, { receive: true });
|
||||
const reverseCatalog = await client.catalog(a, inA.id);
|
||||
assert.equal(reverseCatalog.length, 1);
|
||||
const reverseManga = await client.manga(a, inA.id, reverseCatalog[0].id);
|
||||
assert.deepEqual(reverseManga.workIdentity, remote.workIdentity, 'The original work identity survives a peer round trip');
|
||||
process.env.PEER_DATA = dataA;
|
||||
const reverseInfo = require('../adapter').infoFor(`peer://${inA.id}/${reverseCatalog[0].id}`);
|
||||
const returned = await require('../adapter').run(reverseInfo, { scope: { mode: 'missing' }, existingPolicy: 'skip-any', workIdentity: reverseManga.workIdentity },
|
||||
{ libraryRoot: libA, settings: { chapterMinMs: 500 }, shouldStop: () => false });
|
||||
assert.equal(returned.finalPath, path.join(libA, 'Sample Manga'), 'The round-trip publication targets the original library folder');
|
||||
assert.equal(returned.selectedCount, 1, 'Only the chapter added on B is selected for sync-missing');
|
||||
assert.equal(returned.publishedCount, 1);
|
||||
assert.equal(fs.existsSync(path.join(libA, 'Sample Manga', 'v01', 'c0002 Chapter from B [Peer ServerB ' + inA.id.slice(1, 7) + ']', '0001.png')), true);
|
||||
const noChanges = await require('../adapter').run(reverseInfo, { scope: { mode: 'missing' }, existingPolicy: 'skip-any', workIdentity: reverseManga.workIdentity },
|
||||
{ libraryRoot: libA, settings: { chapterMinMs: 500 }, shouldStop: () => false });
|
||||
assert.equal(noChanges.selectedCount, 0, 'A repeated round trip queues no duplicate chapters');
|
||||
|
||||
// Simulate an older B copy which predates persisted identities. Exact shared
|
||||
// page hashes may bridge that legacy gap, but title similarity alone may not.
|
||||
const legacyMetadataPath = path.join(libB, 'Sample Manga', 'manga.json');
|
||||
const legacyMetadata = JSON.parse(await fsp.readFile(legacyMetadataPath, 'utf8'));
|
||||
delete legacyMetadata['x-work-identity'];
|
||||
await fsp.writeFile(legacyMetadataPath, JSON.stringify(legacyMetadata));
|
||||
const chapterThree = path.join(libB, 'Sample Manga', 'v01', 'c0003 Chapter from B');
|
||||
await fsp.mkdir(chapterThree, { recursive: true });
|
||||
await fsp.writeFile(path.join(chapterThree, 'chapter.json'), JSON.stringify({ title: 'Chapter from B', source: { name: 'Local' } }));
|
||||
await fsp.writeFile(path.join(chapterThree, '0001.png'), PNG);
|
||||
await stop(gb); // Clear the gateway's short-lived manifest cache after this legacy-data migration.
|
||||
gb = await gateway(dataB, libB, portB, path.join(root, 'usage-b.json'));
|
||||
const legacyManga = await client.manga(a, inA.id, reverseCatalog[0].id);
|
||||
assert.notDeepEqual(legacyManga.workIdentity, remote.workIdentity, 'Legacy copy has no propagated identity and gets a peer-local fallback');
|
||||
const legacySync = await require('../adapter').run(reverseInfo, { scope: { mode: 'missing' }, existingPolicy: 'skip-any', workIdentity: legacyManga.workIdentity },
|
||||
{ libraryRoot: libA, settings: { chapterMinMs: 500 }, shouldStop: () => false });
|
||||
assert.equal(legacySync.finalPath, path.join(libA, 'Sample Manga'), 'Content evidence safely reuses the original folder for legacy peer copies');
|
||||
assert.equal(legacySync.selectedCount, 1, 'Legacy matching still selects only the chapter absent on A');
|
||||
assert.equal(legacySync.publishedCount, 1);
|
||||
assert.equal(fs.existsSync(path.join(libA, 'Sample Manga', 'v01', 'c0003 Chapter from B [Peer ServerB ' + inA.id.slice(1, 7) + ']', '0001.png')), true);
|
||||
for (const book of await fsp.readdir(path.join(libB, 'Sample Manga'))) {
|
||||
const bookPath = path.join(libB, 'Sample Manga', book);
|
||||
if (!(await fsp.stat(bookPath)).isDirectory()) continue;
|
||||
for (const chapter of await fsp.readdir(bookPath)) {
|
||||
const page = path.join(bookPath, chapter, '0001.png');
|
||||
if (fs.existsSync(page)) await fsp.writeFile(page, Buffer.from(`different-page-${chapter}`));
|
||||
}
|
||||
}
|
||||
await stop(gb);
|
||||
gb = await gateway(dataB, libB, portB, path.join(root, 'usage-b.json'));
|
||||
const unverifiedLegacy = await client.manga(a, inA.id, reverseCatalog[0].id);
|
||||
await assert.rejects(() => require('../adapter').run(reverseInfo,
|
||||
{ scope: { mode: 'missing' }, existingPolicy: 'skip-any', workIdentity: unverifiedLegacy.workIdentity },
|
||||
{ libraryRoot: libA, settings: { chapterMinMs: 500 }, shouldStop: () => false }),
|
||||
/neither its canonical identity nor shared chapter page hashes verify.*No files were downloaded or changed/);
|
||||
assert.equal((await require('../../scraper/lib/library-inventory').scanLibrary(libA)).mangas[0].chapters.length, 3,
|
||||
'A title collision with differing chapter bytes leaves the local library untouched');
|
||||
a.updatePeer(inA.id, { servingPaused: true });
|
||||
assert.equal((await client.access(b, inB.id)).grant.status, 'paused');
|
||||
await assert.rejects(() => client.catalog(b, inB.id), /Peer HTTP 403/);
|
||||
|
||||
+35
-9
@@ -101,7 +101,8 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.toolbar select { flex: 1; }
|
||||
.grid { grid-template-columns: repeat(2, 1fr); gap: 16px 12px; }
|
||||
.nav { padding-left: 12px; padding-right: 12px; }
|
||||
.nav { gap: 5px; }
|
||||
.nav { gap: 5px; flex-wrap: wrap; }
|
||||
.nav .grow { display: none; }
|
||||
.nav .brand { font-size: 14px; }
|
||||
.nav .btn { padding-left: 7px; padding-right: 7px; font-size: 12px; }
|
||||
.nav-greatest { font-size: 0 !important; }
|
||||
@@ -119,6 +120,11 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.chip-link { text-decoration: none; border: 1px solid transparent; }
|
||||
.chip-link:hover { border-color: var(--accent); color: #ffd8de; }
|
||||
.desc { white-space: pre-line; margin: 10px 0; }
|
||||
.coverage-indicators { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 9px; margin: 6px 0; }
|
||||
.coverage-badge { display: inline-block; border: 1px solid; border-radius: 999px; padding: 3px 9px; font-size: 12px; font-weight: 700; line-height: 1.3; }
|
||||
.coverage-complete { color: #a7f3c7; border-color: #39875d; background: #153323; }
|
||||
.coverage-partial { color: #ffd09b; border-color: #9a6630; background: #332417; }
|
||||
.coverage-unknown { color: var(--fg); border-color: var(--line); background: var(--card2); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.sources a { margin-right: 10px; }
|
||||
.metadata.card { max-width: none; }
|
||||
@@ -248,8 +254,16 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.peer-table-head { color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
||||
.peer-row-button { width: 100%; color: var(--fg); background: transparent; border: 0; border-bottom: 1px solid var(--line); text-align: left; cursor: pointer; font: inherit; }
|
||||
.peer-row-button:hover { background: #ffffff08; }
|
||||
.peer-status-ok { color: #8fe0b2; }
|
||||
.peer-status-pending { color: #efc36e; }
|
||||
.peer-status-ok { color: #a7f3c7; font-weight: 700; }
|
||||
.peer-status-pending { color: #ffe08a; font-weight: 650; }
|
||||
.peer-state { display: grid; gap: 3px; margin: 10px 0 14px; padding: 11px 13px; border: 1px solid var(--line); border-left-width: 4px; border-radius: 8px; background: var(--card2); }
|
||||
.peer-state strong { font-size: 14px; font-weight: 750; }
|
||||
.peer-state span { font-size: 13px; line-height: 1.4; }
|
||||
.peer-state-ok { color: #a7f3c7; border-color: #39875d; background: #153323; }
|
||||
.peer-state-pending { color: #ffe08a; border-color: #987631; background: #332b16; }
|
||||
.peer-state-warn { color: #ffd09b; border-color: #9a6630; background: #332417; }
|
||||
.peer-state-error { color: #ffb4b4; border-color: #9a3e48; background: #351b20; }
|
||||
.peer-state-neutral { color: var(--fg); border-color: var(--line); background: var(--card2); }
|
||||
.peer-row-action { white-space: nowrap; color: var(--accent); }
|
||||
.peer-wizard-grid, .peer-limits-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.peer-step { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--card); }
|
||||
@@ -279,6 +293,18 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.peer-catalog-toolbar > .seg .btn { flex: 1; }
|
||||
.peer-dialog-cover { max-width: 180px; }
|
||||
}
|
||||
.discovery-settings, .discovery-admin { max-width: none; }
|
||||
.discovery-hint { display: block; font-weight: 400; margin-top: 3px; }
|
||||
.discovery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: 14px; }
|
||||
.discovery-card { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px; margin: 0; max-width: none; min-width: 0; }
|
||||
.discovery-card > img, .discovery-no-cover { width: 90px; height: 130px; object-fit: cover; border-radius: 5px; background: var(--card2); }
|
||||
.discovery-no-cover { display: grid; place-items: center; text-align: center; color: var(--muted); font-size: 12px; }
|
||||
.discovery-card-body { min-width: 0; overflow-wrap: anywhere; }
|
||||
.discovery-card-body h2 { font-size: 17px; margin-bottom: 4px; }
|
||||
.discovery-card-body p { margin: 6px 0 10px; }
|
||||
.discovery-card-body details { margin: 10px 0; }
|
||||
.discovery-card-body ul { padding-left: 18px; }
|
||||
.discovery-actions { margin: 10px 0; }
|
||||
.catalog-workspace { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.catalog-workspace > .row { margin-bottom: 16px; }
|
||||
.catalog-workspace input, .catalog-workspace select, .catalog-workspace textarea { min-width: 0; max-width: 100%; }
|
||||
@@ -365,6 +391,10 @@ body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: h
|
||||
#canvas { position: relative; }
|
||||
.pg { position: absolute; overflow: hidden; background: #19191c; }
|
||||
.pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; }
|
||||
#canvas[data-page-tone="inverted"] .pg img { filter: invert(1); }
|
||||
#canvas[data-page-tone="sepia"] .pg img { filter: sepia(.8) brightness(.9); }
|
||||
#canvas[data-page-tone="dim"] .pg img { filter: brightness(.7); }
|
||||
#canvas[data-page-tone="extra-dim"] .pg img { filter: brightness(.45); }
|
||||
.pg.rot img { position: absolute; left: 50%; top: 50%; max-width: none; }
|
||||
/* still loading: a spinner that only appears after 150 ms, so fast loads never flash it */
|
||||
.pg.loading::after {
|
||||
@@ -406,6 +436,8 @@ body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none
|
||||
.seg button.meh:not(.on) { color: var(--muted); }
|
||||
.zoom-controls button { min-width: 34px; padding-left: 9px; padding-right: 9px; }
|
||||
.zoom-controls #zoom { min-width: 58px; font-variant-numeric: tabular-nums; }
|
||||
.page-tone { white-space: nowrap; }
|
||||
.page-tone:not([data-page-tone="original"]) { border-color: var(--accent); }
|
||||
.clock-chip { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.clock-chip.clock-running { border-color: #3d8b62; background: #183426; }
|
||||
.clock-chip.clock-warning { border-color: #c58a27; background: #3b2c14; }
|
||||
@@ -430,9 +462,3 @@ body.nobars #bot { transform: translateY(100%); opacity: 0; pointer-events: none
|
||||
.seg button { padding: 7px 10px; } #bot .btn { padding: 6px 10px; } .wide-only { display: none; }
|
||||
.clock-panel { bottom: calc(124px + env(safe-area-inset-bottom)); }
|
||||
}
|
||||
#canvas[data-page-tone="inverted"] .pg img { filter: invert(1); }
|
||||
#canvas[data-page-tone="sepia"] .pg img { filter: sepia(.8) brightness(.9); }
|
||||
#canvas[data-page-tone="dim"] .pg img { filter: brightness(.7); }
|
||||
#canvas[data-page-tone="extra-dim"] .pg img { filter: brightness(.45); }
|
||||
.page-tone { white-space: nowrap; }
|
||||
.page-tone:not([data-page-tone="original"]) { border-color: var(--accent); }
|
||||
+10
-2
@@ -94,15 +94,23 @@ function topNav(me) {
|
||||
const bell = h('button', { class: 'btn icon bell', title: 'Notifications', 'aria-label': 'Notifications', onclick: e => { e.stopPropagation(); open(); } }, icon(ICONS.bell), count);
|
||||
panel.addEventListener('click', e => e.stopPropagation());
|
||||
document.addEventListener('click', () => { panel.hidden = true; });
|
||||
const settingsLink = h('a', { class: 'btn', href: 'settings.html' }, 'Settings');
|
||||
const nav = h('header', { class: 'nav' },
|
||||
h('a', { class: 'brand', href: './' }, 'MangaCircus'),
|
||||
h('a', { class: 'btn nav-greatest', href: 'greatest.html', title: 'Shijō Saikō no Manga · Greatest of All Time', 'aria-label': 'Shijō Saikō no Manga' }, 'Shijō Saikō no Manga'),
|
||||
h('a', { class: 'btn', href: 'discover.html' }, 'Discover'),
|
||||
h('div', { class: 'grow' }),
|
||||
h('div', { class: 'bell-wrap' }, bell, panel),
|
||||
...(me.admin ? [h('a', { class: 'btn', href: 'peer-catalog.html' }, 'Peer catalogs')] : []),
|
||||
h('a', { class: 'btn', href: 'settings.html' }, 'Settings'),
|
||||
settingsLink,
|
||||
h('button', { class: 'btn', onclick: async () => { await api('api/logout', {}); location.href = 'login.html'; } }, 'Log out'));
|
||||
document.body.prepend(nav);
|
||||
if (me.admin) {
|
||||
api('api/admin/peering').then(state => {
|
||||
if (state.peers?.length && nav.isConnected) {
|
||||
nav.insertBefore(h('a', { class: 'btn', href: 'peer-catalog.html' }, 'Peer catalogs'), settingsLink);
|
||||
}
|
||||
}).catch(() => {}).finally(() => { nav.dataset.peerCatalogResolved = 'true'; });
|
||||
} else nav.dataset.peerCatalogResolved = 'true';
|
||||
}
|
||||
|
||||
function showError(e) {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Discover · MangaCircus</title>
|
||||
<link rel="stylesheet" href="app.css"><link rel="icon" href="icon.svg">
|
||||
<script src="common.js" defer></script><script src="discover.js" defer></script>
|
||||
</head>
|
||||
<body><main><p class="loading">Loading…</p></main></body>
|
||||
</html>
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
(async () => {
|
||||
let me, state, adminState, active = null;
|
||||
try { me = await api('api/me'); state = await api('api/discovery'); } catch (error) { return showError(error); }
|
||||
topNav(me);
|
||||
const main = $('main'), note = h('p', { role: 'status', 'aria-live': 'polite', class: 'muted small' });
|
||||
const button = (label, action, className = '') => h('button', { type: 'button', class: `btn ${className}`, onclick: action }, label);
|
||||
async function refresh() {
|
||||
try { state = await api('api/discovery'); if (me.admin) adminState = await api('api/admin/discovery'); render(); }
|
||||
catch (error) { note.textContent = error.message; note.className = 'error'; }
|
||||
}
|
||||
async function preference(key, checked) {
|
||||
try { await api('api/settings', { [key]: checked }); await refresh(); }
|
||||
catch (error) { note.textContent = error.message; note.className = 'error'; }
|
||||
}
|
||||
async function feedback(candidateId, value) {
|
||||
try {
|
||||
await api('api/discovery/feedback', { candidateId, value });
|
||||
state.feedback[candidateId] = value;
|
||||
note.textContent = value ? 'Preference saved. The next run will use it.' : 'Preference removed.';
|
||||
render();
|
||||
} catch (error) { note.textContent = error.message; note.className = 'error'; }
|
||||
}
|
||||
async function run() {
|
||||
try {
|
||||
await api('api/admin/discovery/run', {});
|
||||
note.textContent = 'Discovery running. Existing suggestions remain visible until it finishes.';
|
||||
await refresh();
|
||||
if (active) clearInterval(active);
|
||||
active = setInterval(async () => {
|
||||
await refresh();
|
||||
if (adminState?.currentRun?.status !== 'running') { clearInterval(active); active = null; }
|
||||
}, 1500);
|
||||
} catch (error) { note.textContent = error.message; note.className = 'error'; }
|
||||
}
|
||||
function toggle(label, key, hint) {
|
||||
return h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!state.settings[key],
|
||||
onchange: event => preference(key, event.target.checked) }),
|
||||
h('span', {}, label, h('small', { class: 'muted discovery-hint' }, hint)));
|
||||
}
|
||||
function preferences() {
|
||||
return h('section', { class: 'card discovery-settings' }, h('h2', {}, 'Your discovery preferences'),
|
||||
toggle('Personalized discovery', 'discoveryEnabled', 'Off by default. Your feed and feedback stay private to this server.'),
|
||||
toggle('Use reading activity', 'discoveryReadingActivity', 'Separately opt in to using completed chapters as a small taste signal.'),
|
||||
toggle('Include mature titles', 'discoveryMature', 'Only affects titles with a known mature tag.'));
|
||||
}
|
||||
function adminPanel() {
|
||||
if (!me.admin) return null;
|
||||
const runState = adminState?.currentRun?.status || adminState?.lastRun?.status || 'never';
|
||||
return h('section', { class: 'card discovery-admin' }, h('h2', {}, 'Discovery job'),
|
||||
h('p', { class: 'muted small' }, `Status: ${runState} · ${adminState?.candidateCount || 0} candidates · ${adminState?.optedInUsers || 0} opted-in users`),
|
||||
adminState?.lastError ? h('p', { class: 'error' }, adminState.lastError) : null,
|
||||
button('Run discovery now', run, 'primary'),
|
||||
h('p', { class: 'muted small' }, 'This first version uses cached public metadata and the curated catalog. It does not ask image hosters, call an LLM, or edit the scraper queue.'));
|
||||
}
|
||||
function card(item) {
|
||||
const candidate = item.candidate, vote = state.feedback[candidate.id] || null;
|
||||
const evidence = item.evidence || candidate.evidence.filter(entry => item.evidenceIds.includes(entry.id));
|
||||
const votes = [['interested', '🔥 Interested'], ['maybe', '👍 Maybe'], ['dismissed', 'No thanks'], ['hidden', 'Hide']];
|
||||
return h('article', { class: 'card discovery-card' },
|
||||
candidate.cover ? h('img', { src: candidate.cover, alt: '', loading: 'lazy', referrerpolicy: 'no-referrer' }) : h('div', { class: 'discovery-no-cover' }, 'No cover'),
|
||||
h('div', { class: 'discovery-card-body' },
|
||||
h('h2', {}, candidate.title),
|
||||
h('p', { class: 'muted small' }, [candidate.year || 'Year unknown', candidate.status || 'Status unknown', candidate.period].filter(Boolean).join(' · ')),
|
||||
h('p', {}, item.reason),
|
||||
h('details', {}, h('summary', {}, 'Why this is here · evidence'),
|
||||
h('ul', {}, evidence.map(entry => h('li', {}, entry.url ? h('a', { href: entry.url, target: '_blank', rel: 'noopener noreferrer' }, entry.label) : entry.label,
|
||||
entry.note ? h('span', { class: 'muted' }, ` — ${entry.note}`) : null))),
|
||||
h('p', { class: 'muted small' }, 'A recommendation does not verify a particular edition or English chapter availability.')),
|
||||
h('div', { class: 'row discovery-actions' }, votes.map(([value, label]) => button(label, () => feedback(candidate.id, vote === value ? null : value), vote === value ? 'on' : ''))),
|
||||
candidate.metadataUrl ? h('a', { class: 'small', href: candidate.metadataUrl, target: '_blank', rel: 'noopener noreferrer' }, 'Public metadata ↗') : null));
|
||||
}
|
||||
function render() {
|
||||
main.textContent = '';
|
||||
main.append(h('h1', {}, 'Discover'), h('p', { class: 'muted' }, 'A small, evidence-backed shelf of manga you might like. Suggestions are not downloads.'), note,
|
||||
preferences(), adminPanel());
|
||||
if (!state.enabled) return main.append(h('p', { class: 'muted' }, 'Turn on personalized discovery to see your suggestions.'));
|
||||
if (!state.items.length) return main.append(h('p', { class: 'muted' }, state.lastRun?.status === 'never'
|
||||
? 'No discovery run yet. An admin can run one above.' : 'No current suggestions passed the filters. Your previous feedback is retained.'));
|
||||
main.append(h('p', { class: 'muted small' }, `Last built ${new Date(state.generatedAt).toLocaleString()} · ${state.items.length} suggestions`),
|
||||
h('div', { class: 'discovery-grid' }, state.items.filter(item => !['hidden', 'dismissed'].includes(state.feedback[item.candidateId])).map(card)));
|
||||
}
|
||||
await refresh();
|
||||
})();
|
||||
@@ -30,6 +30,9 @@
|
||||
|
||||
const sources = arr(meta.sources).filter(source => source && (source.name || source.url));
|
||||
const tags = arr(meta.tags).filter(Boolean);
|
||||
const numberedChapters = M.chapters.map(chapter => /^Ch\. (\d+(?:\.\d+)?)/.exec(chapter.label || ''))
|
||||
.filter(Boolean).map(match => Number(match[1]));
|
||||
const firstNumber = numberedChapters.length ? Math.min(...numberedChapters) : null;
|
||||
const books = {};
|
||||
for (const book of M.books) books[book.key] = book;
|
||||
const selectedBooks = new Set();
|
||||
@@ -39,6 +42,9 @@
|
||||
h('h1', {}, M.title),
|
||||
arr(meta.altTitles).length ? h('div', { class: 'muted' }, arr(meta.altTitles).join(' · ')) : null,
|
||||
h('div', { class: 'muted' }, [arr(meta.authors).join(', '), meta.status, releaseDate(), TYPE[M.direction]].filter(Boolean).join(' · ')),
|
||||
firstNumber > 1 ? h('div', { class: 'coverage-indicators', role: 'status' },
|
||||
h('span', { class: 'coverage-badge coverage-partial' }, 'Series coverage partial'),
|
||||
h('span', { class: 'small' }, `This library starts at chapter ${firstNumber}; earlier numbered chapters are absent.`)) : null,
|
||||
h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))),
|
||||
meta.description ? h('p', { class: 'desc' }, meta.description) : null,
|
||||
h('div', { class: 'actions' },
|
||||
|
||||
+53
-16
@@ -127,11 +127,17 @@
|
||||
let pendingUrl = '';
|
||||
function connectionPanel(peer) {
|
||||
const remote = access.get(peer.id);
|
||||
const identityStatus = !peer.paired
|
||||
? peerState('pending', 'Identity not verified yet', 'Verify the other server after importing its invitation.')
|
||||
: remote?.error
|
||||
? peerState('error', 'Other server status unavailable', `This server is verified; refresh status to check the other side. ${remote.error}`)
|
||||
: remote?.paired
|
||||
? peerState('ok', 'Peering confirmed on both servers', 'Both servers have verified each other’s certificate.')
|
||||
: peerState('pending', 'Identity verified on this server', 'Waiting for the other server to verify this one.');
|
||||
const panel = h('section', { class: 'peer-step' }, h('h3', {}, 'Connection and identity'),
|
||||
h('p', { class: peer.paired ? 'peer-status-ok' : 'peer-status-pending' }, peer.paired ? 'This server has verified the peer certificate.' : 'Import the other server’s invitation before verifying it.'),
|
||||
identityStatus,
|
||||
h('p', { class: 'muted small' }, `Our fingerprint: ${state.identity?.fingerprint || 'certificate not created'}`),
|
||||
h('p', { class: 'muted small' }, `Their fingerprint: ${peer.fingerprint}`),
|
||||
h('p', { class: 'muted small' }, remote?.paired ? 'The other server has verified this server too.' : remote?.error ? `Other side: ${remote.error}` : 'The other server has not confirmed its side yet.'),
|
||||
peer.paired ? null : button('Verify the other server', () => command({ command: 'probe', id: peer.id }, 'Identity verified here. Ask the other admin to verify back.'), 'primary', busy),
|
||||
button('Refresh other-side status', () => refreshAccess(peer.id), '', busy));
|
||||
if (!peer.paired || !remote?.paired) {
|
||||
@@ -161,18 +167,31 @@
|
||||
const grid = panel.lastChild;
|
||||
const catalog = h('input', { type: 'checkbox', checked: (peer.grantScopes || []).includes('catalog') || (peer.grantScopes || []).includes('download') });
|
||||
const download = h('input', { type: 'checkbox', checked: (peer.grantScopes || []).includes('download') });
|
||||
catalog.onchange = () => { if (!catalog.checked) download.checked = false; };
|
||||
download.onchange = () => { if (download.checked) catalog.checked = true; };
|
||||
const grantHint = h('p', { class: 'muted small', role: 'status' });
|
||||
let grantAction = null;
|
||||
const updateGrantHint = () => {
|
||||
grantHint.textContent = !peer.paired
|
||||
? 'Granting is locked until identity pairing is complete. Finish the invitation exchange and verify this server above.'
|
||||
: (!catalog.checked && !download.checked ? 'Choose a permission above to enable Issue grant.' : '');
|
||||
grantHint.hidden = !grantHint.textContent;
|
||||
if (grantAction) grantAction.disabled = busy || !peer.paired || (!catalog.checked && !download.checked);
|
||||
};
|
||||
catalog.onchange = () => { if (!catalog.checked) download.checked = false; updateGrantHint(); };
|
||||
download.onchange = () => { if (download.checked) catalog.checked = true; updateGrantHint(); };
|
||||
updateGrantHint();
|
||||
const token = h('textarea', { rows: '3', readonly: true, placeholder: 'Issue a grant to create a one-time token.', 'aria-label': 'Grant token' }, pendingGrant?.id === peer.id ? pendingGrant.token : '');
|
||||
const outbound = h('section', { class: 'peer-step' }, h('h4', {}, 'We grant access to ', peerTag(peer.alias)),
|
||||
h('p', { class: 'muted small' }, `Status: ${peer.grantStatus}. Expires: ${date(peer.grantExpiresAt)}. Pausing preserves the grant; revoke only when you want to invalidate it.`),
|
||||
h('label', { class: 'check' }, catalog, h('span', {}, 'Browse approved catalog')),
|
||||
h('label', { class: 'check' }, download, h('span', {}, 'Download chapters (includes catalog access)')),
|
||||
peer.grantStatus === 'none' || peer.grantStatus === 'expired'
|
||||
grantAction = peer.grantStatus === 'none' || peer.grantStatus === 'expired'
|
||||
? button('Issue grant', async () => { const result = await command({ command: 'grant', id: peer.id,
|
||||
scopes: [...(catalog.checked ? ['catalog'] : []), ...(download.checked ? ['download'] : [])] }, 'Grant issued; send the token privately.');
|
||||
if (result?.grant) { pendingGrant = { id: peer.id, token: result.grant.token }; draw(); } }, 'primary', !peer.paired || (!catalog.checked && !download.checked) || busy)
|
||||
: button('Save grant permissions', () => command({ command: 'set', id: peer.id, patch: { grantScopes: [...(catalog.checked ? ['catalog'] : []), ...(download.checked ? ['download'] : [])] } }, 'Grant permissions updated'), '', (!catalog.checked && !download.checked) || busy),
|
||||
: button('Save grant permissions', () => command({ command: 'set', id: peer.id, patch: { grantScopes: [...(catalog.checked ? ['catalog'] : []), ...(download.checked ? ['download'] : [])] } }, 'Grant permissions updated'), '', !peer.paired || (!catalog.checked && !download.checked) || busy);
|
||||
updateGrantHint();
|
||||
const outbound = h('section', { class: 'peer-step' }, h('h4', {}, 'We grant access to ', peerTag(peer.alias)),
|
||||
localGrantStatus(peer),
|
||||
h('label', { class: 'check' }, catalog, h('span', {}, 'Browse approved catalog')),
|
||||
h('label', { class: 'check' }, download, h('span', {}, 'Download chapters (includes catalog access)')),
|
||||
grantHint,
|
||||
grantAction,
|
||||
peer.grantStatus === 'active' ? button('Pause access', () => command({ command: 'set', id: peer.id, patch: { servingPaused: true } }, 'Access paused; grant retained'))
|
||||
: peer.grantStatus === 'paused' ? button('Resume access', () => command({ command: 'set', id: peer.id, patch: { servingPaused: false } }, 'Access resumed')) : null,
|
||||
field('Grant token · copy once and send privately', token),
|
||||
@@ -182,7 +201,7 @@
|
||||
pendingGrant = null; await command({ command: 'revoke-grant', id: peer.id }, 'Grant revoked.');
|
||||
}, 'danger small') : null);
|
||||
const inbound = h('section', { class: 'peer-step' }, h('h4', {}, peerTag(peer.alias), ' grants access to us'),
|
||||
h('p', { class: 'muted small' }, remoteGrantText(access.get(peer.id))),
|
||||
remoteGrantStatus(access.get(peer.id)),
|
||||
h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!peer.receive, disabled: !peer.hasGrant,
|
||||
onchange: event => command({ command: 'set', id: peer.id, patch: { receive: event.target.checked } }, 'Local download permission saved') }),
|
||||
h('span', {}, 'Allow this server to download from them')),
|
||||
@@ -200,13 +219,31 @@
|
||||
h('span', {}, 'Let ordinary readers queue downloads')));
|
||||
grid.append(outbound, inbound); return panel;
|
||||
}
|
||||
function remoteGrantText(value) {
|
||||
if (!value) return 'Refresh to ask the other server for its current access status.';
|
||||
if (value.error) return `Status unavailable: ${value.error}`;
|
||||
function peerState(tone, title, detail) {
|
||||
return h('div', { class: `peer-state peer-state-${tone}`, role: 'status', 'aria-live': 'polite' },
|
||||
h('strong', {}, title), detail ? h('span', {}, detail) : null);
|
||||
}
|
||||
function localGrantStatus(peer) {
|
||||
const scopes = peer.grantScopes || [];
|
||||
const access = scopes.includes('download') ? 'Browse and download chapters' : scopes.includes('catalog') ? 'Browse approved catalog' : '';
|
||||
if (peer.grantStatus === 'active') return peerState('ok', 'Grant active', `${access} · expires ${date(peer.grantExpiresAt)}.`);
|
||||
if (peer.grantStatus === 'paused') return peerState('pending', 'Grant paused', 'The grant is retained. Resume access to make it usable again.');
|
||||
if (peer.grantStatus === 'expired') return peerState('warn', 'Grant expired', 'Issue a new grant to restore access.');
|
||||
return peerState('neutral', 'No grant issued', 'Choose the access level below to create a grant.');
|
||||
}
|
||||
function remoteGrantStatus(value) {
|
||||
if (!value) return peerState('pending', 'Status not checked', 'Refresh to ask the other server for its current access status.');
|
||||
if (value.error) return peerState('error', 'Could not confirm access', value.error);
|
||||
const grant = value.grant || {};
|
||||
const scopes = grant.scopes || [];
|
||||
const role = scopes.includes('download') ? 'Browse + download' : scopes.includes('catalog') ? 'Browse catalog' : 'No access grant';
|
||||
return `${value.paired ? 'Identity verified' : 'The other server has not verified us'} · ${role} · ${grant.status || 'unknown'} · expires ${date(grant.expiresAt)}`;
|
||||
if (!value.paired) return peerState('pending', 'Identity not verified by peer', 'Ask the other admin to verify this server.');
|
||||
if (grant.status === 'active') {
|
||||
const role = scopes.includes('download') ? 'Browse and download chapters' : 'Browse approved catalog';
|
||||
return peerState('ok', 'Active grant from peer', `${role} · expires ${date(grant.expiresAt)}.`);
|
||||
}
|
||||
if (grant.status === 'paused') return peerState('pending', 'Peer grant paused', 'The other admin can resume it without exchanging a new token.');
|
||||
if (grant.status === 'expired') return peerState('warn', 'Peer grant expired', 'Ask the other admin to issue a new grant.');
|
||||
return peerState('neutral', 'No active grant from peer', 'The other server has not granted catalog or download access.');
|
||||
}
|
||||
function limitPanel(peer) {
|
||||
const limits = peer.limits || {}, download = peer.download || {};
|
||||
|
||||
+23
-2
@@ -224,16 +224,24 @@
|
||||
const jobRow = (item, movable = false) => {
|
||||
const running = activeEntries.some(entry => entry.current.id === item.id);
|
||||
const availability = item.availability;
|
||||
const expected = availability?.expectedLastChapter;
|
||||
const sparse = /^\d+$/.test(String(expected || '')) && Number(availability?.numberedChapters) < Number(expected);
|
||||
const partialCoverage = availability?.coverage === 'partial' || sparse;
|
||||
const outcome = item.status === 'complete'
|
||||
? `${item.publishedCount ?? 0} chapter${item.publishedCount === 1 ? '' : 's'} published · finished ${when(item.completed)}`
|
||||
: item.status === 'ready' ? 'Awaiting edition confirmation' : item.status.replaceAll('_', ' ');
|
||||
return h('div', { class: 'scraper-item' + (running ? ' active' : ''), 'data-source': item.source },
|
||||
h('div', { class: 'grow' }, h('b', {}, item.title),
|
||||
item.status === 'complete' || availability ? h('div', { class: 'coverage-indicators' },
|
||||
item.status === 'complete' ? h('span', { class: 'coverage-badge coverage-complete' }, 'Hosted selection complete') : null,
|
||||
availability ? h('span', { class: 'coverage-badge ' + (partialCoverage ? 'coverage-partial' : 'coverage-unknown') },
|
||||
partialCoverage ? 'Series coverage partial' : 'Series coverage unverified') : null,
|
||||
item.status === 'complete' && !availability ? h('span', { class: 'coverage-badge coverage-unknown' }, 'Series coverage unverified') : null) : null,
|
||||
h('div', { class: 'muted small' }, `${item.source} · ${item.kind === 'discovery' ? 'Checking for updates' : item.scopeLabel || 'all missing chapters'} · ${outcome}`
|
||||
+ (item.existingPolicy === 'add-source' ? ' · alternate source' : '')),
|
||||
item.error ? h('div', { class: 'small error' }, item.error) : null,
|
||||
item.sourceTitle ? h('div', { class: 'small' }, `Source edition: ${item.sourceTitle}`) : null,
|
||||
availability ? h('div', { class: 'muted small' }, `${availability.selectedChapters} hosted selections${availability.specialReleases ? ` · ${availability.specialReleases} unnumbered releases` : ''}${availability.expectedLastChapter ? ` · metadata last chapter: ${availability.expectedLastChapter}` : ''} · ${availability.coverage === 'partial' ? 'Partial source coverage' : 'Whole-series completeness not verified'}`) : null),
|
||||
availability ? h('div', { class: 'muted small' }, `${availability.selectedChapters} hosted selections${availability.specialReleases ? ` · ${availability.specialReleases} unnumbered releases` : ''}${availability.expectedLastChapter ? ` · metadata last chapter: ${availability.expectedLastChapter}` : ''}`) : null),
|
||||
movable ? h('button', { class: 'btn small', disabled: running || pending[0]?.id === item.id, title: 'Move up', onclick: () => move(item, -1) }, '↑') : null,
|
||||
movable ? h('button', { class: 'btn small', disabled: running || pending.at(-1)?.id === item.id, title: 'Move down', onclick: () => move(item, 1) }, '↓') : null,
|
||||
item.status === 'error' ? h('button', { class: 'btn small primary', onclick: () => command({ command: 'control', action: 'retry', id: item.id }) }, 'Retry') : null,
|
||||
@@ -354,7 +362,20 @@
|
||||
return h('section', { class: 'scraper-source-card' }, h('h3', {}, adapter.label),
|
||||
h('p', { class: 'muted small' }, `${running ? 'Active' : state.sourcePaused?.[adapter.id] ? 'Paused by source protection' : 'Idle'} · ${waiting} waiting · ${problems} need attention`),
|
||||
sourceRateEditors.find(editor => editor.querySelector('b')?.textContent === adapter.label));
|
||||
}), h('p', { class: 'muted small' }, 'Each source runs sequentially with its own limits. New limits apply when that source starts or resumes a manga.'));
|
||||
}), h('p', { class: 'muted small' }, 'Each source runs sequentially with its own limits. New limits apply when that source starts or resumes a manga.'),
|
||||
h('h2', {}, 'Available-source log'),
|
||||
h('p', { class: 'muted small' }, 'A queued URL is only a lead. Catalog found, adapter compatibility and download rights are separate facts. This log does not approve or start a download.'),
|
||||
h('div', { class: 'scraper-queue' }, (state.sourceLedger || []).filter(entry => !sourceFilter || entry.source === sourceFilter).map(entry =>
|
||||
h('div', { class: 'scraper-item' }, h('div', { class: 'grow' },
|
||||
h('b', {}, entry.title || entry.sourceTitle || entry.seriesId || 'Unknown title'),
|
||||
h('div', { class: 'muted small' }, `${entry.source} · catalog ${entry.catalog} · adapter ${entry.adapter} · rights ${entry.rights}`),
|
||||
h('div', { class: 'muted small' }, `${entry.hostedCount == null ? 'Chapter count unknown' : `${entry.hostedCount} selected chapters`} · series coverage ${entry.coverage || 'unknown'} · checked ${when(entry.lastObservedAt)}`),
|
||||
entry.sourceTitle && entry.sourceTitle !== entry.title ? h('div', { class: 'small' }, `Source edition: ${entry.sourceTitle}`) : null,
|
||||
entry.reason ? h('div', { class: 'small' }, `Availability: ${entry.reason}`) : null,
|
||||
entry.lastFailure ? h('div', { class: 'small error' }, `${entry.lastFailure.kind}: ${entry.lastFailure.message}`) : null),
|
||||
entry.url && /^https?:\/\//.test(entry.url) ? h('a', { class: 'btn small', href: entry.url, target: '_blank', rel: 'noopener noreferrer' }, 'Source ↗') : null))),
|
||||
!(state.sourceLedger || []).some(entry => !sourceFilter || entry.source === sourceFilter)
|
||||
? h('p', { class: 'muted small' }, 'No source observations yet for this filter.') : null);
|
||||
card.append(activityPanel, queuePanel, updatesPanel, sourcesPanel);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const { createWeebHttp } = require('../plugins/broker');
|
||||
const { discoverWeebCatalog } = require('./weebcentral');
|
||||
const { mangaDexInfo, discoverMangaDexCatalog } = require('./mangadex');
|
||||
const { ArchiveControlError } = require('../lib/control');
|
||||
const { adapterForPeer } = require('../../peering/adapter');
|
||||
|
||||
function controlledHttp(http, shouldStop) {
|
||||
return { async get(...args) {
|
||||
@@ -87,6 +88,8 @@ const adapters = [{
|
||||
}];
|
||||
|
||||
function adapterFor(url) {
|
||||
const peer = adapterForPeer(url);
|
||||
if (peer) return peer;
|
||||
for (const adapter of adapters) {
|
||||
const info = adapter.match(url);
|
||||
if (info) return { adapter, info };
|
||||
|
||||
@@ -6,6 +6,7 @@ const { assertInside } = require('./names');
|
||||
const { chapterNumber } = require('./scope');
|
||||
const { isSpecialKey, parseSpecialFolder, parseKey } = require('../../chapter-identity');
|
||||
const { writeJsonAtomic } = require('./json-file');
|
||||
const { resolveWorkIdentity, sameWorkIdentity } = require('./work-identity');
|
||||
|
||||
async function readJson(file) {
|
||||
try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch { return {}; }
|
||||
@@ -20,7 +21,9 @@ function keyFromName(name) { return parseKey(name)?.key || null; }
|
||||
|
||||
function sourceKey(url, name = '') {
|
||||
try {
|
||||
const host = new URL(String(url)).hostname.replace(/^www\./, '').toLowerCase();
|
||||
const parsed = new URL(String(url));
|
||||
const host = parsed.hostname.replace(/^www\./, '').toLowerCase();
|
||||
if (parsed.protocol === 'peer:' && /^p[0-9a-f]{24}$/.test(host)) return `peer-${host}`;
|
||||
if (host === 'weebcentral.com') return 'weebcentral';
|
||||
if (host === 'mangadex.org') return 'mangadex';
|
||||
return host;
|
||||
@@ -53,7 +56,7 @@ async function scanLibrary(libraryRoot) {
|
||||
const chapterMeta = await readJson(path.join(bookPath, versionEntry.name, 'chapter.json'));
|
||||
const source = typeof chapterMeta.source === 'string' ? { name: chapterMeta.source, url: '' } : (chapterMeta.source || {});
|
||||
chapters.push({
|
||||
book, number, version: versionEntry.name,
|
||||
book, bookFolder: bookEntry.name, number, version: versionEntry.name,
|
||||
sourceName: String(source.name || ''), sourceUrl: String(source.url || ''),
|
||||
sourceKey: sourceKey(source.url, source.name),
|
||||
});
|
||||
@@ -75,6 +78,13 @@ function findManga(inventory, identity = {}) {
|
||||
const anilist = identity.anilist == null ? '' : String(identity.anilist);
|
||||
const explicit = identity.libraryMangaId == null ? '' : String(identity.libraryMangaId);
|
||||
if (explicit) return inventory.mangas.find(manga => manga.id === explicit) || null;
|
||||
if (identity.workIdentity) {
|
||||
const matches = inventory.mangas.filter(manga => sameWorkIdentity(
|
||||
resolveWorkIdentity(manga.meta, manga.id, identity.localServerId), identity.workIdentity));
|
||||
if (matches.length !== 1) return null;
|
||||
if (source && seriesId && matches[0].ids[source] != null && String(matches[0].ids[source]) !== seriesId) return null;
|
||||
return matches[0];
|
||||
}
|
||||
if (source && seriesId) {
|
||||
const bySourceId = inventory.mangas.find(manga => String(manga.ids[source] ?? '') === seriesId);
|
||||
if (bySourceId) return bySourceId;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const IMPORTED_FIELDS = ['title', 'altTitles', 'authors', 'status', 'year', 'tags', 'description'];
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const { normalizeWorkIdentity, sameWorkIdentity } = require('./work-identity');
|
||||
|
||||
// The effective fields remain at the top of manga.json. A snapshot of the last
|
||||
// imported values lets later imports refresh unchanged fields without replacing
|
||||
@@ -25,6 +26,12 @@ function mergeImportedMetadata(prior, imported, fallbackTitle) {
|
||||
throw new Error(`AniList identity conflict: existing ${oldId}, imported ${newId}; review the manga match`);
|
||||
}
|
||||
if (imported['x-anilist']) result['x-anilist'] = { ...(prior['x-anilist'] || {}), ...imported['x-anilist'] };
|
||||
const oldWorkIdentity = normalizeWorkIdentity(prior['x-work-identity']);
|
||||
const newWorkIdentity = normalizeWorkIdentity(imported['x-work-identity']);
|
||||
if (oldWorkIdentity && newWorkIdentity && !sameWorkIdentity(oldWorkIdentity, newWorkIdentity)) {
|
||||
throw new Error(`MangaCircus work identity conflict: existing ${oldWorkIdentity.kind} identity differs from imported ${newWorkIdentity.kind} identity`);
|
||||
}
|
||||
if (newWorkIdentity) result['x-work-identity'] = newWorkIdentity;
|
||||
const snapshot = { ...previous };
|
||||
for (const field of IMPORTED_FIELDS) if (Object.hasOwn(imported, field)) snapshot[field] = imported[field];
|
||||
if (Object.keys(snapshot).length) result['x-metadata'] = { ...(prior['x-metadata'] || {}), imported: snapshot };
|
||||
|
||||
@@ -10,6 +10,7 @@ const { cleanPart, chapterFolder, assertInside } = require('./names');
|
||||
const { sizeFromBuffer, imageSize } = require('../../library');
|
||||
const { directionFields } = require('../../reading-direction');
|
||||
const { mergeImportedMetadata } = require('./metadata-merge');
|
||||
const { normalizeWorkIdentity } = require('./work-identity');
|
||||
|
||||
const token = () => crypto.randomBytes(5).toString('hex');
|
||||
// Staging is a recovery asset, not disposable garbage. Legacy stages without an
|
||||
@@ -204,7 +205,16 @@ async function publishManifest(manifest, options = {}) {
|
||||
const isNew = !mangaDir;
|
||||
const finalName = cleanPart(manifest.series.folder || manifest.series.title);
|
||||
const finalDir = assertInside(libraryRoot, path.join(libraryRoot, finalName));
|
||||
if (!mangaDir && await exists(finalDir)) throw new Error(`Folder already belongs to another work: ${finalDir}`);
|
||||
if (!mangaDir && await exists(finalDir)) {
|
||||
const existing = await readMetadata(path.join(finalDir, 'manga.json'));
|
||||
const incomingIdentity = normalizeWorkIdentity(manifest.series.metadata?.['x-work-identity']);
|
||||
const existingIdentity = normalizeWorkIdentity(existing['x-work-identity']);
|
||||
if (incomingIdentity && existingIdentity && incomingIdentity.kind === existingIdentity.kind
|
||||
&& incomingIdentity.value === existingIdentity.value) {
|
||||
throw new Error(`Work identity matches the existing “${existing.title || finalName}” folder, but its source association was not resolved. Review the existing manga target; no files were overwritten.`);
|
||||
}
|
||||
throw new Error(`A library folder named “${finalName}” already exists, but this download could not be safely matched to it. No files were overwritten. Choose the existing manga as the target or review its work identity before syncing.`);
|
||||
}
|
||||
mangaDir = mangaDir || path.join(libraryRoot, `.staging-mcscraper-resume-${digest([finalName, [...sourceUrls].sort()])}`);
|
||||
await fsp.mkdir(mangaDir, { recursive: true, mode: 0o755 });
|
||||
const http = options.httpClient || new SlowHttp(options.http || manifest.http || {});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
const { normalizeWorkIdentity } = require('./work-identity');
|
||||
|
||||
function workKey(item) {
|
||||
const identity = normalizeWorkIdentity(item.workIdentity);
|
||||
if (identity) return `${identity.scheme}:${identity.kind}:${identity.value}`;
|
||||
if (item.libraryMangaId) return `library:${item.libraryMangaId}`;
|
||||
return `source:${item.source}:${item.seriesId || item.url}`;
|
||||
}
|
||||
|
||||
function sourceKey(item) {
|
||||
return `${item.source}:${item.seriesId || item.url}`;
|
||||
}
|
||||
|
||||
function recordSource(ledger, item, observation = {}) {
|
||||
const key = sourceKey(item);
|
||||
const prior = ledger[key] || {};
|
||||
const availability = observation.availability || item.availability;
|
||||
const count = observation.catalogTotal ?? item.catalogTotal;
|
||||
const observedAt = observation.observedAt || (count != null ? item.checked || item.completed : null) || null;
|
||||
const catalog = count != null ? 'found' : prior.catalog || 'unverified';
|
||||
const adapter = count != null
|
||||
? Number(count) > 0 ? 'compatible' : availability?.reason === 'unrecognized-labels' ? 'unsupported-labels' : 'no-selected-pages'
|
||||
: prior.adapter || 'unverified';
|
||||
ledger[key] = {
|
||||
...prior, workKey: workKey(item), source: item.source, seriesId: item.seriesId || null,
|
||||
url: item.url || prior.url || null, title: item.title || prior.title || null,
|
||||
sourceTitle: observation.sourceTitle || item.sourceTitle || prior.sourceTitle || null,
|
||||
language: availability?.language || prior.language || null,
|
||||
catalog, adapter, rights: prior.rights || 'unverified',
|
||||
hostedCount: count == null ? prior.hostedCount ?? null : Number(count),
|
||||
externalCount: availability?.externalReleases ?? prior.externalCount ?? null,
|
||||
coverage: availability?.coverage || prior.coverage || 'unknown',
|
||||
reason: observation.availability ? availability.reason || null : availability?.reason || prior.reason || null,
|
||||
lastObservedAt: observedAt || prior.lastObservedAt || null,
|
||||
evidence: count != null && observedAt ? { kind: 'scraper-catalog', jobId: item.id || null } : prior.evidence || { kind: 'queue-url', jobId: item.id || null },
|
||||
};
|
||||
if (observation.catalogTotal != null && Number(count) > 0) delete ledger[key].lastFailure;
|
||||
return ledger[key];
|
||||
}
|
||||
|
||||
function recordFailure(ledger, item, failure, at = new Date().toISOString()) {
|
||||
const row = recordSource(ledger, item);
|
||||
row.lastFailure = { kind: failure.kind, message: String(failure.message || '').slice(0, 300), at };
|
||||
return row;
|
||||
}
|
||||
|
||||
function ledgerRows(ledger) {
|
||||
return Object.values(ledger || {}).sort((a, b) => String(a.title || '').localeCompare(String(b.title || ''))
|
||||
|| String(a.source || '').localeCompare(String(b.source || '')));
|
||||
}
|
||||
|
||||
module.exports = { workKey, sourceKey, recordSource, recordFailure, ledgerRows };
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const SCHEME = 'mangacircus-work-v1';
|
||||
const KINDS = new Set(['anilist', 'mangadex', 'weebcentral', 'hoster', 'origin']);
|
||||
|
||||
function normalizeWorkIdentity(value) {
|
||||
if (!value || typeof value !== 'object' || value.scheme !== SCHEME
|
||||
|| !KINDS.has(value.kind) || typeof value.value !== 'string'
|
||||
|| !value.value.trim() || value.value.length > 300) return null;
|
||||
return { scheme: SCHEME, kind: value.kind, value: value.value.trim() };
|
||||
}
|
||||
|
||||
function resolveWorkIdentity(meta = {}, mangaId, serverId) {
|
||||
const carried = normalizeWorkIdentity(meta['x-work-identity']);
|
||||
if (carried) return carried;
|
||||
|
||||
const anilist = meta['x-anilist']?.id ?? meta['x-scraper']?.ids?.anilist;
|
||||
if (anilist != null && String(anilist).trim()) return { scheme: SCHEME, kind: 'anilist', value: String(anilist).trim() };
|
||||
|
||||
const ids = meta['x-scraper']?.ids || {};
|
||||
for (const source of ['mangadex', 'weebcentral']) {
|
||||
if (ids[source] != null && String(ids[source]).trim()) {
|
||||
return { scheme: SCHEME, kind: source, value: String(ids[source]).trim() };
|
||||
}
|
||||
}
|
||||
for (const source of Object.keys(ids).sort()) {
|
||||
if (!['anilist', 'mangadex', 'weebcentral'].includes(source) && !source.startsWith('peer-')
|
||||
&& ids[source] != null && String(ids[source]).trim()) {
|
||||
return { scheme: SCHEME, kind: 'hoster', value: `${source}:${String(ids[source]).trim()}` };
|
||||
}
|
||||
}
|
||||
|
||||
// A private, stable lineage ID lets a title safely make a round trip even if
|
||||
// neither server has external metadata. It reveals neither the folder nor title.
|
||||
if (typeof mangaId === 'string' && mangaId && typeof serverId === 'string' && serverId) {
|
||||
const value = crypto.createHash('sha256').update(`${serverId}\0${mangaId}`).digest('hex');
|
||||
return { scheme: SCHEME, kind: 'origin', value };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameWorkIdentity(left, right) {
|
||||
const a = normalizeWorkIdentity(left), b = normalizeWorkIdentity(right);
|
||||
return !!a && !!b && a.scheme === b.scheme && a.kind === b.kind && a.value === b.value;
|
||||
}
|
||||
|
||||
module.exports = { SCHEME, normalizeWorkIdentity, resolveWorkIdentity, sameWorkIdentity };
|
||||
+36
-1
@@ -7,6 +7,8 @@ const { adapterFor, adapters } = require('./adapters');
|
||||
const { ArchiveControlError } = require('./lib/control');
|
||||
const { sleep } = require('./lib/delay');
|
||||
const { normalizeScope, selectByScope, scopeLabel } = require('./lib/scope');
|
||||
const { normalizeWorkIdentity, resolveWorkIdentity } = require('./lib/work-identity');
|
||||
const { recordSource, recordFailure, sourceKey, ledgerRows } = require('./lib/source-ledger');
|
||||
const { scanLibrary, findManga, shouldDownload, isNewerThanLibrary, summarizeInventory, setAutoUpdate } = require('./lib/library-inventory');
|
||||
const { inspectLegacyWork, promoteLegacyWork } = require('./lib/legacy-publication');
|
||||
const { classifyFailure, safeMessage, progressKey } = require('./lib/failures');
|
||||
@@ -132,6 +134,7 @@ class ScraperService {
|
||||
this.state.incidents = this.state.incidents || [];
|
||||
this.state.circuits = this.state.circuits || {};
|
||||
this.state.updateChecks = this.state.updateChecks || {};
|
||||
this.state.sourceLedger = this.state.sourceLedger || {};
|
||||
// Older releases retained successful no-change checks in the work queue.
|
||||
// Keep their latest result before retiring those finished rows.
|
||||
for (const item of this.state.queue) {
|
||||
@@ -164,6 +167,9 @@ class ScraperService {
|
||||
item.error = 'The previous adapter selected no numbered hosted English chapters. Inspect this source again to verify its edition and availability, including pilots and specials.';
|
||||
if (item.failure) item.failure = { ...item.failure, message: item.error };
|
||||
}
|
||||
// Existing queue rows are leads. Only a recorded catalog observation may
|
||||
// promote one to an adapter-compatible source.
|
||||
if (!this.state.sourceLedger[sourceKey(item)]) recordSource(this.state.sourceLedger, item);
|
||||
}
|
||||
this.state.version = 4;
|
||||
this.state.currentId = null;
|
||||
@@ -287,13 +293,32 @@ class ScraperService {
|
||||
const generation = this.inventoryGeneration;
|
||||
this.inventoryAttemptedAt = Date.now();
|
||||
this.inventoryRefresh = Promise.resolve().then(() => this.scanInventory(this.libraryRoot))
|
||||
.then(inventory => {
|
||||
.then(async inventory => {
|
||||
// A chapter or metadata edit during the scan invalidates its result.
|
||||
if (generation === this.inventoryGeneration) {
|
||||
this.inventoryCache = inventory;
|
||||
this.inventoryCachedAt = Date.now();
|
||||
this.inventoryCacheGeneration = generation;
|
||||
}
|
||||
// Backfill source leads from existing library metadata without probing
|
||||
// hosts or asserting that their current catalog is complete.
|
||||
let added = 0;
|
||||
if (generation === this.inventoryGeneration) {
|
||||
for (const manga of inventory.mangas || []) {
|
||||
for (const source of manga.sources || []) {
|
||||
const found = this.resolveAdapter(source.url);
|
||||
if (!found) continue;
|
||||
const lead = { source: found.adapter.id, seriesId: found.info.id, url: source.url,
|
||||
title: manga.title, libraryMangaId: manga.id,
|
||||
workIdentity: resolveWorkIdentity(manga.meta, manga.id) };
|
||||
const existing = this.state.sourceLedger[sourceKey(lead)];
|
||||
if (existing && !existing.workKey?.startsWith('source:')) continue;
|
||||
recordSource(this.state.sourceLedger, lead);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (added) await this.persist();
|
||||
this.inventoryError = null;
|
||||
return inventory;
|
||||
}).catch(error => {
|
||||
@@ -478,6 +503,10 @@ class ScraperService {
|
||||
item.sourceTitle = title;
|
||||
item.catalogTotal = catalog.chapters?.length || 0;
|
||||
item.availability = catalog.availability || { selectedChapters: item.catalogTotal, coverage: 'unknown' };
|
||||
recordSource(this.state.sourceLedger ||= {}, item, {
|
||||
catalogTotal: item.catalogTotal, availability: item.availability,
|
||||
sourceTitle: title, observedAt: new Date().toISOString(),
|
||||
});
|
||||
await this.persist();
|
||||
if (changedEdition) throw Object.assign(new Error('The source edition title changed since inspection. Inspect and confirm it again before downloading.'), { failureKind: 'unavailable' });
|
||||
}
|
||||
@@ -529,6 +558,7 @@ class ScraperService {
|
||||
error: this.inventoryError,
|
||||
},
|
||||
adapters: this.adapterList.map(item => ({ id: item.id, label: item.label })),
|
||||
sourceLedger: ledgerRows(this.state.sourceLedger),
|
||||
events: await this.recentEvents(25),
|
||||
incidents: (this.state.incidents || []).slice(-50).reverse(), circuits: this.state.circuits || {},
|
||||
runtimeHeartbeat: new Date(this.runtimeHeartbeat).toISOString(),
|
||||
@@ -538,6 +568,8 @@ class ScraperService {
|
||||
async enqueue(input) {
|
||||
const found = this.resolveAdapter(input.url);
|
||||
if (!found) throw new Error('No installed scraper adapter accepts this URL');
|
||||
const workIdentity = normalizeWorkIdentity(input.workIdentity);
|
||||
if (input.workIdentity != null && !workIdentity) throw new Error('Invalid manga work identity');
|
||||
const scope = normalizeScope(input.scope || {});
|
||||
const policy = existingPolicy(input.existingPolicy);
|
||||
const signature = JSON.stringify([found.adapter.id, found.info.id, scope, policy, input.libraryMangaId || null]);
|
||||
@@ -551,10 +583,12 @@ class ScraperService {
|
||||
metadataSearch: String(input.metadataSearch || input.title || found.info.title).trim().slice(0, 200),
|
||||
metadataSearchExplicit: !!String(input.metadataSearch || '').trim(),
|
||||
scope, scopeLabel: scopeLabel(scope), existingPolicy: policy,
|
||||
...(workIdentity ? { workIdentity } : {}),
|
||||
...(input.libraryMangaId ? { libraryMangaId: String(input.libraryMangaId) } : {}),
|
||||
status: 'queued', order: this.state.queue.length, added: new Date().toISOString(),
|
||||
};
|
||||
this.state.queue.push(item);
|
||||
recordSource(this.state.sourceLedger ||= {}, item);
|
||||
await this.event('info', `Queued ${item.title}: ${item.scopeLabel}`, { id: item.id, seriesId: item.seriesId, title: item.title });
|
||||
return item;
|
||||
}
|
||||
@@ -768,6 +802,7 @@ class ScraperService {
|
||||
}
|
||||
const failure = classifyFailure(error);
|
||||
item.failure = failure; item.error = failure.message;
|
||||
recordFailure(this.state.sourceLedger ||= {}, item, failure);
|
||||
const incident = { id: crypto.randomUUID(), at: new Date().toISOString(), jobId: item.id, source, title: item.title, failure, state: 'open' };
|
||||
this.state.incidents.push(incident); this.state.incidents = this.state.incidents.slice(-200);
|
||||
if (failure.kind === 'unavailable') { item.status = 'unavailable'; incident.state = 'resolved'; incident.resolution = 'skipped-unavailable'; }
|
||||
|
||||
@@ -87,6 +87,8 @@ test('metadata refresh preserves legacy and manual fields while updating untouch
|
||||
const third = mergeImportedMetadata(second, { description: 'First', 'x-anilist': { id: 42 } }, 'Fallback');
|
||||
assert.equal(mergeImportedMetadata(third, { description: 'Revised', 'x-anilist': { id: 42 } }, 'Fallback').description, 'Revised');
|
||||
assert.throws(() => mergeImportedMetadata(first, { 'x-anilist': { id: 99 } }, 'Fallback'), /identity conflict/);
|
||||
assert.throws(() => mergeImportedMetadata({ 'x-work-identity': { scheme: 'mangacircus-work-v1', kind: 'origin', value: 'a'.repeat(64) } },
|
||||
{ 'x-work-identity': { scheme: 'mangacircus-work-v1', kind: 'origin', value: 'b'.repeat(64) } }, 'Fallback'), /work identity conflict/);
|
||||
});
|
||||
|
||||
test('publisher rejects a conflicting work ID before fetching any pages', async t => {
|
||||
@@ -107,6 +109,23 @@ test('publisher rejects a conflicting work ID before fetching any pages', async
|
||||
assert.equal((await fs.readdir(manga)).includes('v01'), false);
|
||||
});
|
||||
|
||||
test('publisher explains an unresolved same-title folder collision without touching its contents', async t => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mcscraper-title-collision-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const manga = path.join(root, 'Same Title');
|
||||
await fs.mkdir(manga);
|
||||
await fs.writeFile(path.join(manga, 'manga.json'), JSON.stringify({ title: 'Same Title' }));
|
||||
await fs.writeFile(path.join(manga, 'operator-note.txt'), 'keep');
|
||||
let requested = 0;
|
||||
await assert.rejects(publishManifest({ version: 1,
|
||||
series: { title: 'Same Title', direction: 'rtl', sources: [{ name: 'Peer', url: 'peer://p123/m123' }] },
|
||||
chapters: [{ number: '1', source: { name: 'Peer', url: 'peer://p123/m123' }, pages: [{ url: 'https://example.test/page.png' }] }],
|
||||
}, { libraryRoot: root, httpClient: { async get() { requested++; throw new Error('unexpected request'); } } }),
|
||||
/could not be safely matched.*No files were overwritten.*Choose the existing manga/s);
|
||||
assert.equal(requested, 0);
|
||||
assert.equal(await fs.readFile(path.join(manga, 'operator-note.txt'), 'utf8'), 'keep');
|
||||
});
|
||||
|
||||
test('queue scopes select exact catalog slices', () => {
|
||||
const chapters = [
|
||||
{ id: 'a', number: '1', volume: '1' }, { id: 'b', number: '2', volume: '1' },
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { recordSource, recordFailure, ledgerRows } = require('../lib/source-ledger');
|
||||
const { ScraperService } = require('../service');
|
||||
|
||||
test('a URL lead, a parsed catalog, and rights remain separate states', () => {
|
||||
const ledger = {};
|
||||
const item = { id: 'job', source: 'weebcentral', seriesId: 'series', url: 'https://weebcentral.com/series/series', title: 'Example' };
|
||||
assert.equal(recordSource(ledger, item).catalog, 'unverified');
|
||||
const row = recordSource(ledger, item, { catalogTotal: 0, availability: { reason: 'unrecognized-labels', coverage: 'unknown' },
|
||||
observedAt: '2026-10-13T12:00:00Z' });
|
||||
assert.equal(row.catalog, 'found');
|
||||
assert.equal(row.adapter, 'unsupported-labels');
|
||||
assert.equal(row.rights, 'unverified');
|
||||
assert.equal(row.hostedCount, 0);
|
||||
recordFailure(ledger, item, { kind: 'adapter', message: 'Parser could not read labels' });
|
||||
assert.equal(ledger['weebcentral:series'].lastFailure.kind, 'adapter');
|
||||
assert.equal(ledgerRows(ledger).length, 1);
|
||||
});
|
||||
|
||||
test('a catalog observation survives completed queue cleanup and service restart', async t => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mc-source-ledger-'));
|
||||
const dataRoot = path.join(root, 'data'), libraryRoot = path.join(root, 'library');
|
||||
await fs.mkdir(dataRoot);
|
||||
await fs.writeFile(path.join(dataRoot, 'queue.json'), JSON.stringify({ paused: true, queue: [],
|
||||
settings: { autoUpdateEnabled: false } }));
|
||||
const adapter = { id: 'fixture', label: 'Fixture', match: url => ({ source: 'fixture', id: url.split('/').at(-1), url, title: 'Example' }) };
|
||||
const options = { dataRoot, libraryRoot, adapters: [adapter], adapterFor: url => ({ adapter, info: adapter.match(url) }) };
|
||||
let service = await new ScraperService(options).init();
|
||||
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
const item = await service.enqueue({ url: 'https://example.test/series', inspect: true });
|
||||
assert.equal(service.state.sourceLedger['fixture:series'].catalog, 'unverified');
|
||||
await service.recordCatalog(item, { title: 'Canonical Example', chapters: [{ id: 'one' }],
|
||||
availability: { language: 'en', coverage: 'unknown' } });
|
||||
item.kind = 'download'; item.status = 'complete';
|
||||
await service.clearCompleted();
|
||||
await service.close();
|
||||
service = await new ScraperService(options).init();
|
||||
const [row] = (await service.publicState()).sourceLedger;
|
||||
assert.equal(service.state.queue.length, 0);
|
||||
assert.equal(row.sourceTitle, 'Canonical Example');
|
||||
assert.equal(row.adapter, 'compatible');
|
||||
assert.equal(row.hostedCount, 1);
|
||||
assert.equal(row.rights, 'unverified');
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { normalizeWorkIdentity, resolveWorkIdentity, sameWorkIdentity } = require('../lib/work-identity');
|
||||
const { findManga } = require('../lib/library-inventory');
|
||||
|
||||
test('work identity follows canonical source priority and preserves an imported lineage', () => {
|
||||
const anilist = { scheme: 'mangacircus-work-v1', kind: 'anilist', value: '123' };
|
||||
const meta = { 'x-anilist': { id: 123 }, 'x-scraper': { ids: { mangadex: 'md-1', weebcentral: 'wc-1' } } };
|
||||
assert.deepEqual(resolveWorkIdentity(meta, 'folder', 'server'), anilist);
|
||||
const imported = { scheme: 'mangacircus-work-v1', kind: 'origin', value: 'a'.repeat(64) };
|
||||
assert.deepEqual(resolveWorkIdentity({ ...meta, 'x-work-identity': imported }, 'other', 'other-server'), imported);
|
||||
assert.equal(normalizeWorkIdentity({ ...imported, value: '' }), null);
|
||||
assert.equal(sameWorkIdentity(imported, { ...imported }), true);
|
||||
assert.equal(sameWorkIdentity(imported, { ...imported, value: 'b'.repeat(64) }), false);
|
||||
assert.equal(resolveWorkIdentity({ 'x-scraper': { ids: { 'peer-p123': 'edition-only' } } }, 'folder', 'server').kind,
|
||||
'origin', 'A peer edition ID is not a canonical work identity');
|
||||
});
|
||||
|
||||
test('library matching uses a peer-carried stable identity before title or peer-edition IDs', () => {
|
||||
const localServerId = 'p-local-server';
|
||||
const inventory = { mangas: [
|
||||
{ id: 'Peer A Test Manga', meta: { title: 'Peer A Test Manga' }, ids: {}, sources: [], chapters: [] },
|
||||
{ id: 'Unrelated edition', meta: { title: 'Peer A Test Manga' }, ids: { 'peer-p-other': 'p-other-manga' }, sources: [], chapters: [] },
|
||||
] };
|
||||
const identity = resolveWorkIdentity(inventory.mangas[0].meta, inventory.mangas[0].id, localServerId);
|
||||
assert.equal(findManga(inventory, { source: 'peer-p-returning', seriesId: 'p-returning-manga',
|
||||
workIdentity: identity, localServerId }).id, 'Peer A Test Manga');
|
||||
assert.equal(findManga(inventory, { source: 'peer-p-returning', seriesId: 'p-returning-manga',
|
||||
workIdentity: { ...identity, value: 'f'.repeat(64) }, localServerId }), null,
|
||||
'A matching title alone must not merge unrelated work');
|
||||
});
|
||||
@@ -20,6 +20,8 @@ const { hosterLinks } = require('./greatest/hosters');
|
||||
const { secret } = require('./runtime/config');
|
||||
const { PeeringState, requirePeer } = require('./peering/state');
|
||||
const peerClient = require('./peering/client');
|
||||
const { DiscoveryStore } = require('./discovery/store');
|
||||
const { generate: generateDiscovery } = require('./discovery/engine');
|
||||
const {
|
||||
applyPulse, applyVisit, chapterInsight, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
|
||||
} = require('./reading-analytics');
|
||||
@@ -36,6 +38,9 @@ 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);
|
||||
const DISCOVERY_CACHE = path.join(DATA, 'scraper', 'metadata', 'anilist');
|
||||
let discoveryRun = null, discoveryError = null;
|
||||
const PUB = path.join(__dirname, 'public');
|
||||
const IMG = /\.(jpe?g|png|webp|gif|avif|bmp)$/i;
|
||||
const MIME = {
|
||||
@@ -50,7 +55,7 @@ const catalog = new Catalog(DATA);
|
||||
const greatestCatalog = loadGreatestCatalog();
|
||||
const greatestById = new Map(greatestCatalog.entries.map(entry => [entry.id, entry]));
|
||||
const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'), INDEX_FILE = path.join(DATA, 'library-index.json');
|
||||
const db = { users: {}, sessions: {}, progress: {}, reading: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, known: {} };
|
||||
const db = { users: {}, sessions: {}, progress: {}, reading: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, known: {} };
|
||||
// A corrupt db.json stops the server instead of silently starting empty and overwriting it.
|
||||
if (fs.existsSync(DB_FILE)) Object.assign(db, JSON.parse(fs.readFileSync(DB_FILE, 'utf8')));
|
||||
function writeAtomic(file, text) { fs.writeFileSync(file + '.tmp', text); fs.renameSync(file + '.tmp', file); }
|
||||
@@ -78,7 +83,8 @@ function checkPw(pw, stored) {
|
||||
return want.length === got.length && crypto.timingSafeEqual(want, got);
|
||||
}
|
||||
const DUMMY_HASH = hashPw('x');
|
||||
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true, zoom: {}, webhook: '' };
|
||||
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true,
|
||||
discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '' };
|
||||
const settingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
|
||||
|
||||
function createUser(name, pw, admin, replace) {
|
||||
@@ -217,6 +223,35 @@ async function scraperRequest(endpoint, body) {
|
||||
return data;
|
||||
}
|
||||
|
||||
function readDiscovery() {
|
||||
try { return discoveryStore.load(); }
|
||||
catch (error) { bad(503, error.message); }
|
||||
}
|
||||
function startDiscoveryRun() {
|
||||
if (discoveryRun?.status === 'running') bad(409, 'A discovery run is already active');
|
||||
readDiscovery(); // Refuse a new run if the on-disk store is corrupt.
|
||||
const id = crypto.randomUUID(), startedAt = new Date().toISOString();
|
||||
discoveryRun = { id, status: 'running', startedAt };
|
||||
discoveryError = null;
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
let queuedTitles = [], queueAvailable = true;
|
||||
try { queuedTitles = (await scraperRequest('/api/state')).queue?.filter(item => !['complete', 'removed'].includes(item.status)).map(item => item.title) || []; }
|
||||
catch { queueAvailable = false; }
|
||||
const result = generateDiscovery({ db, library: lib.mangas, greatest: greatestCatalog,
|
||||
cacheDir: DISCOVERY_CACHE, queuedTitles });
|
||||
const published = discoveryStore.publish({ ...result, startedAt });
|
||||
discoveryRun = { id, status: 'success', startedAt, finishedAt: published.lastRun.finishedAt,
|
||||
candidates: published.lastRun.candidateCount, queueAvailable };
|
||||
} catch (error) {
|
||||
discoveryError = String(error.message || error).slice(0, 300);
|
||||
discoveryRun = { id, status: 'failed', startedAt, finishedAt: new Date().toISOString() };
|
||||
console.error('[discovery]', discoveryError);
|
||||
}
|
||||
});
|
||||
return discoveryRun;
|
||||
}
|
||||
|
||||
const mangaDexSearchCache = new Map();
|
||||
let lastMangaDexSearch = 0;
|
||||
async function searchGreatestMangaDex(entry) {
|
||||
@@ -364,7 +399,8 @@ const routes = {
|
||||
if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode;
|
||||
if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode;
|
||||
if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone;
|
||||
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory']) if (typeof b[k] === 'boolean') s[k] = b[k];
|
||||
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory',
|
||||
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature']) if (typeof b[k] === 'boolean') s[k] = b[k];
|
||||
if (b.zoom && typeof b.zoom === 'object') {
|
||||
s.zoom = {};
|
||||
for (const k of ['flow', 'page', 'book', 'strip']) if (+b.zoom[k] > 0) s.zoom[k] = Math.min(8, Math.max(0.1, +b.zoom[k]));
|
||||
@@ -404,6 +440,35 @@ const routes = {
|
||||
};
|
||||
},
|
||||
|
||||
'GET /api/discovery': ({ u }) => {
|
||||
const state = readDiscovery(), enabled = !!settingsOf(u).discoveryEnabled;
|
||||
const feed = enabled ? state.feeds[u.name] || null : null;
|
||||
return { enabled, settings: { discoveryEnabled: enabled,
|
||||
discoveryReadingActivity: !!settingsOf(u).discoveryReadingActivity,
|
||||
discoveryMature: !!settingsOf(u).discoveryMature },
|
||||
generatedAt: feed?.generatedAt || null, revision: state.revision, lastRun: state.lastRun,
|
||||
feedback: db.discoveryFeedback?.[u.name] || {},
|
||||
items: (feed?.items || []).map(item => ({ ...item, candidate: state.candidates[item.candidateId] || null })).filter(item => item.candidate) };
|
||||
},
|
||||
'POST /api/discovery/feedback': ({ u, b }) => {
|
||||
if (!settingsOf(u).discoveryEnabled) bad(403, 'Enable personalized discovery first');
|
||||
const id = String(b.candidateId || ''), value = b.value === null ? null : String(b.value || '');
|
||||
if (!readDiscovery().candidates[id]) bad(404, 'Discovery candidate not found');
|
||||
if (value !== null && !['interested', 'maybe', 'dismissed', 'hidden'].includes(value)) bad(400, 'Unknown feedback');
|
||||
const mine = (db.discoveryFeedback ||= {})[u.name] ||= {};
|
||||
if (value === null) delete mine[id]; else mine[id] = value;
|
||||
save();
|
||||
return { candidateId: id, value };
|
||||
},
|
||||
'GET /api/admin/discovery': ({ u }) => {
|
||||
admin(u);
|
||||
const state = readDiscovery();
|
||||
return { revision: state.revision, lastRun: state.lastRun, currentRun: discoveryRun,
|
||||
lastError: discoveryError, optedInUsers: Object.values(db.users).filter(user => user.settings?.discoveryEnabled).length,
|
||||
candidateCount: Object.keys(state.candidates).length };
|
||||
},
|
||||
'POST /api/admin/discovery/run': ({ u }) => { admin(u); return startDiscoveryRun(); },
|
||||
|
||||
'GET /api/greatest': ({ u }) => ({
|
||||
title: greatestCatalog.title, updatedAt: greatestCatalog.updatedAt, periods: greatestCatalog.periods,
|
||||
ratings: GREATEST_RATINGS,
|
||||
@@ -634,6 +699,7 @@ const routes = {
|
||||
const name = String(b.name || '');
|
||||
if (name === u.name) bad(400, "You can't delete your own account.");
|
||||
delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.follows[name]; delete db.notes[name];
|
||||
if (db.discoveryFeedback) delete db.discoveryFeedback[name];
|
||||
if (db.greatestRatings) delete db.greatestRatings[name];
|
||||
for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k];
|
||||
save();
|
||||
@@ -709,7 +775,7 @@ const routes = {
|
||||
const remote = await peerRemote(() => peerClient.manga(peerStore, id, shareId));
|
||||
const present = await peerRemote(() => peerClient.catalog(peerStore, id));
|
||||
if (!present.some(entry => entry.id === shareId)) bad(404, 'Peer manga not available');
|
||||
return scraperRequest('/api/queue', { url: `peer://${id}/${shareId}`, title: remote.title,
|
||||
return scraperRequest('/api/queue', { url: `peer://${id}/${shareId}`, title: remote.title, workIdentity: remote.workIdentity,
|
||||
scope: b.scope || { mode: 'missing' }, existingPolicy: b.existingPolicy || 'skip-any',
|
||||
...(b.libraryMangaId ? { libraryMangaId: String(b.libraryMangaId) } : {}) });
|
||||
},
|
||||
@@ -727,7 +793,7 @@ const routes = {
|
||||
if (!available.has(mangaId)) { results.push({ id: mangaId, status: 'unavailable' }); continue; }
|
||||
try {
|
||||
const remote = await peerRemote(() => peerClient.manga(peerStore, id, mangaId));
|
||||
const result = await scraperRequest('/api/queue', { url: `peer://${id}/${mangaId}`, title: remote.title,
|
||||
const result = await scraperRequest('/api/queue', { url: `peer://${id}/${mangaId}`, title: remote.title, workIdentity: remote.workIdentity,
|
||||
scope: { mode: 'missing' }, existingPolicy: 'skip-any' });
|
||||
results.push({ id: mangaId, title: remote.title, status: 'queued', result });
|
||||
} catch (error) { results.push({ id: mangaId, status: 'failed', error: String(error.message || error).slice(0, 240) }); }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { generate } = require('../discovery/engine');
|
||||
const { DiscoveryStore } = require('../discovery/store');
|
||||
|
||||
test('discovery is bounded, diversified, opt-in, evidence-linked and user-specific', () => {
|
||||
const { loadCatalog } = require('../greatest');
|
||||
const greatest = loadCatalog();
|
||||
const users = { alice: { name: 'alice', settings: { discoveryEnabled: true } },
|
||||
bob: { name: 'bob', settings: { discoveryEnabled: true } },
|
||||
off: { name: 'off', settings: {} } };
|
||||
const db = { users, greatestRatings: { alice: { [greatest.entries[0].id]: 'flame' },
|
||||
bob: { [greatest.entries[20].id]: 'flame' } }, discoveryFeedback: { alice: {} }, progress: {} };
|
||||
const result = generate({ db, library: [], greatest, cacheDir: path.join(os.tmpdir(), 'nonexistent-discovery-cache') });
|
||||
assert.equal(Object.keys(result.candidates).length, 50);
|
||||
assert.ok(result.feeds.alice.items.length > 0 && result.feeds.alice.items.length <= 20);
|
||||
assert.ok(result.feeds.bob.items.length > 0);
|
||||
assert.equal(result.feeds.off.disabled, true);
|
||||
assert.ok(new Set(Object.values(result.candidates).map(c => c.period)).size >= 10, 'An early catalog page must not crowd out later eras');
|
||||
assert.ok(result.feeds.alice.items.every(item => item.evidenceIds.every(id => item.evidence.some(e => e.id === id))));
|
||||
assert.ok(result.feeds.alice.items.some(item => item.evidence.some(e => e.kind === 'personal')));
|
||||
assert.ok(!result.feeds.bob.items.some(item => item.evidence.some(e => e.label?.includes(greatest.entries[0].title) && e.kind === 'personal')));
|
||||
assert.notDeepEqual(result.feeds.alice.items.map(x => x.candidateId), result.feeds.bob.items.map(x => x.candidateId));
|
||||
const first = result.feeds.alice.items[0].candidateId;
|
||||
db.discoveryFeedback.alice[first] = 'dismissed';
|
||||
const rerun = generate({ db, library: [], greatest, cacheDir: path.join(os.tmpdir(), 'nonexistent-discovery-cache') });
|
||||
assert.ok(!rerun.feeds.alice.items.some(item => item.candidateId === first));
|
||||
assert.equal(rerun.feeds.bob.items.length, result.feeds.bob.items.length);
|
||||
});
|
||||
|
||||
test('discovery store publishes atomically and refuses to overwrite corrupt data', t => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-discovery-store-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const store = new DiscoveryStore(dir);
|
||||
assert.equal(store.load().revision, 0);
|
||||
const value = store.publish({ candidates: {}, feeds: {}, startedAt: new Date().toISOString(), examined: 0, clipped: 0 });
|
||||
assert.equal(value.revision, 1);
|
||||
store.publish({ candidates: {}, feeds: {}, startedAt: new Date().toISOString(), examined: 0, clipped: 0 });
|
||||
assert.equal(store.load().revision, 2);
|
||||
assert.ok(fs.existsSync(store.file + '.prev'));
|
||||
fs.writeFileSync(store.file, '{broken');
|
||||
assert.throws(() => store.publish({ candidates: {}, feeds: {} }), /refusing to overwrite/);
|
||||
assert.equal(fs.readFileSync(store.file, 'utf8'), '{broken');
|
||||
});
|
||||
@@ -68,6 +68,40 @@ test('three services integrate collections, auth, custom covers and private heal
|
||||
assert.equal((await (await request('/api/settings', { pageTone: 'sepia' })).json()).settings.pageTone, 'sepia');
|
||||
assert.equal((await (await request('/api/settings', { pageTone: 'invalid' })).json()).settings.pageTone, 'sepia');
|
||||
assert.equal((await (await anotherRequest('/api/me')).json()).settings.pageTone, 'original');
|
||||
assert.equal((await fetch(base + '/api/discovery')).status, 401);
|
||||
assert.equal((await anotherRequest('/api/admin/discovery/run', {})).status, 403);
|
||||
assert.equal((await request('/api/settings', { discoveryEnabled: true })).status, 200);
|
||||
assert.equal((await anotherRequest('/api/settings', { discoveryEnabled: true })).status, 200);
|
||||
const beforeDiscoveryQueue = fs.readFileSync(path.join(data, 'scraper', 'queue.json'), 'utf8');
|
||||
const firstRun = await request('/api/admin/discovery/run', {});
|
||||
assert.equal(firstRun.status, 200);
|
||||
const firstRunId = (await firstRun.json()).id;
|
||||
let discoveryAdmin;
|
||||
for (let n = 0; n < 50; n++) {
|
||||
discoveryAdmin = await (await request('/api/admin/discovery')).json();
|
||||
if (discoveryAdmin.currentRun?.id === firstRunId && discoveryAdmin.currentRun?.status === 'success') break;
|
||||
await delay(100);
|
||||
}
|
||||
assert.equal(discoveryAdmin.currentRun?.status, 'success');
|
||||
const discoveryMine = await (await request('/api/discovery')).json();
|
||||
const discoveryOther = await (await anotherRequest('/api/discovery')).json();
|
||||
assert.ok(discoveryMine.items.length > 0 && discoveryMine.items.length <= 20);
|
||||
assert.ok(discoveryOther.items.length > 0 && discoveryOther.items.length <= 20);
|
||||
assert.equal(discoveryMine.items[0].candidate.id, discoveryMine.items[0].candidateId);
|
||||
const dismissed = discoveryMine.items[0].candidateId;
|
||||
assert.equal((await request('/api/discovery/feedback', { candidateId: dismissed, value: 'dismissed' })).status, 200);
|
||||
assert.equal((await anotherRequest('/api/discovery')).status, 200);
|
||||
assert.equal((await anotherRequest('/api/discovery/feedback', { candidateId: 'nonexistent', value: 'interested' })).status, 404);
|
||||
const secondRun = await request('/api/admin/discovery/run', {});
|
||||
assert.equal(secondRun.status, 200);
|
||||
const secondRunId = (await secondRun.json()).id;
|
||||
for (let n = 0; n < 50; n++) {
|
||||
discoveryAdmin = await (await request('/api/admin/discovery')).json();
|
||||
if (discoveryAdmin.currentRun?.id === secondRunId && discoveryAdmin.currentRun?.status === 'success') break;
|
||||
await delay(100);
|
||||
}
|
||||
assert.ok(!(await (await request('/api/discovery')).json()).items.some(item => item.candidateId === dismissed));
|
||||
assert.equal(fs.readFileSync(path.join(data, 'scraper', 'queue.json'), 'utf8'), beforeDiscoveryQueue);
|
||||
assert.equal((await (await anotherRequest('/api/greatest')).json()).entries[0].myRating, null);
|
||||
assert.equal((await anotherRequest('/api/greatest/rating', { id: firstPick.id, rating: 'good' })).status, 200);
|
||||
const communityPick = (await (await request('/api/greatest')).json()).entries[0];
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Local-only research benchmark. Reads completed library files; writes no library state.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const library = path.resolve(__dirname, '..', 'library');
|
||||
const series = [
|
||||
'Solo Leveling',
|
||||
'Gosick',
|
||||
'The Martial God Who Regressed Back to Level 2',
|
||||
'The Exiled Heavy Knight Knows How to Game the System',
|
||||
];
|
||||
const extensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
|
||||
const codecs = [
|
||||
{ name: 'gzip-6', encode: (b) => zlib.gzipSync(b, { level: 6 }), decode: zlib.gunzipSync },
|
||||
{ name: 'brotli-4', encode: (b) => zlib.brotliCompressSync(b, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } }), decode: zlib.brotliDecompressSync },
|
||||
{ name: 'zstd-3', encode: (b) => zlib.zstdCompressSync(b, { params: { [zlib.constants.ZSTD_c_compressionLevel]: 3 } }), decode: zlib.zstdDecompressSync },
|
||||
{ name: 'zstd-10', encode: (b) => zlib.zstdCompressSync(b, { params: { [zlib.constants.ZSTD_c_compressionLevel]: 10 } }), decode: zlib.zstdDecompressSync },
|
||||
];
|
||||
|
||||
function firstCompleteChapter(manga) {
|
||||
const root = path.join(library, manga);
|
||||
for (const volume of fs.readdirSync(root, { withFileTypes: true }).filter((x) => x.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const volumePath = path.join(root, volume.name);
|
||||
for (const chapter of fs.readdirSync(volumePath, { withFileTypes: true }).filter((x) => x.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const chapterPath = path.join(volumePath, chapter.name);
|
||||
if (!fs.existsSync(path.join(chapterPath, 'chapter.json'))) continue;
|
||||
const images = fs.readdirSync(chapterPath).filter((f) => extensions.has(path.extname(f).toLowerCase())).sort();
|
||||
if (images.length) return { chapterPath, images };
|
||||
}
|
||||
}
|
||||
throw new Error(`No complete chapter for ${manga}`);
|
||||
}
|
||||
|
||||
function measure(fn) {
|
||||
const begin = process.hrtime.bigint();
|
||||
const result = fn();
|
||||
return { result, ms: Number(process.hrtime.bigint() - begin) / 1e6 };
|
||||
}
|
||||
|
||||
const chapters = series.map((manga) => ({ manga, ...firstCompleteChapter(manga) }));
|
||||
const totals = Object.fromEntries(codecs.map((codec) => [codec.name, { bytes: 0, encodeMs: 0, decodeMs: 0 }]));
|
||||
const byChapter = [];
|
||||
const seenHashes = new Set();
|
||||
let rawBytes = 0;
|
||||
let imageCount = 0;
|
||||
let duplicateBytes = 0;
|
||||
let duplicateCount = 0;
|
||||
const formats = {};
|
||||
let metadataRaw = 0;
|
||||
let metadataZstd = 0;
|
||||
const adaptive = { sampleBytes: 32768, threshold: 0.03, transmittedBytes: 0, sampleMs: 0, selectedEncodeMs: 0, selectedDecodeMs: 0, compressedImages: 0, missedUsefulImages: 0, wastedImages: 0 };
|
||||
|
||||
for (const chapter of chapters) {
|
||||
const row = { manga: chapter.manga, chapter: path.relative(library, chapter.chapterPath), images: chapter.images.length, rawBytes: 0, formats: {}, codecs: {} };
|
||||
const buffers = [];
|
||||
for (const file of chapter.images) {
|
||||
const buffer = fs.readFileSync(path.join(chapter.chapterPath, file));
|
||||
buffers.push(buffer);
|
||||
imageCount++;
|
||||
rawBytes += buffer.length;
|
||||
row.rawBytes += buffer.length;
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
formats[ext] = (formats[ext] || 0) + buffer.length;
|
||||
row.formats[ext] = (row.formats[ext] || 0) + 1;
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
if (seenHashes.has(hash)) { duplicateCount++; duplicateBytes += buffer.length; }
|
||||
else seenHashes.add(hash);
|
||||
const sample = buffer.subarray(0, Math.min(buffer.length, adaptive.sampleBytes));
|
||||
const sampleEncoded = measure(() => codecs[2].encode(sample));
|
||||
adaptive.sampleMs += sampleEncoded.ms;
|
||||
const shouldCompress = sampleEncoded.result.length <= sample.length * (1 - adaptive.threshold);
|
||||
for (const codec of codecs) {
|
||||
const encoded = measure(() => codec.encode(buffer));
|
||||
const decoded = measure(() => codec.decode(encoded.result));
|
||||
if (!decoded.result.equals(buffer)) throw new Error(`Round trip failed: ${codec.name} ${file}`);
|
||||
const total = totals[codec.name];
|
||||
total.bytes += encoded.result.length;
|
||||
total.encodeMs += encoded.ms;
|
||||
total.decodeMs += decoded.ms;
|
||||
const cell = row.codecs[codec.name] ||= { bytes: 0, encodeMs: 0, decodeMs: 0 };
|
||||
cell.bytes += encoded.result.length;
|
||||
cell.encodeMs += encoded.ms;
|
||||
cell.decodeMs += decoded.ms;
|
||||
if (codec.name === 'zstd-3') {
|
||||
if (shouldCompress) {
|
||||
adaptive.compressedImages++;
|
||||
adaptive.transmittedBytes += encoded.result.length;
|
||||
adaptive.selectedEncodeMs += encoded.ms;
|
||||
adaptive.selectedDecodeMs += decoded.ms;
|
||||
if (encoded.result.length >= buffer.length) adaptive.wastedImages++;
|
||||
} else {
|
||||
adaptive.transmittedBytes += buffer.length;
|
||||
if (encoded.result.length <= buffer.length * 0.97) adaptive.missedUsefulImages++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Packing a chapter lets zstd exploit any repetition across page boundaries.
|
||||
const packed = Buffer.concat(buffers);
|
||||
const chapterEncoded = measure(() => codecs[2].encode(packed));
|
||||
const chapterDecoded = measure(() => codecs[2].decode(chapterEncoded.result));
|
||||
if (!chapterDecoded.result.equals(packed)) throw new Error(`Packed round trip failed: ${chapter.manga}`);
|
||||
row.packedZstd3 = { bytes: chapterEncoded.result.length, encodeMs: chapterEncoded.ms, decodeMs: chapterDecoded.ms };
|
||||
const metadata = fs.readFileSync(path.join(chapter.chapterPath, 'chapter.json'));
|
||||
metadataRaw += metadata.length;
|
||||
metadataZstd += codecs[2].encode(metadata).length;
|
||||
byChapter.push(row);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
runtime: process.version,
|
||||
platform: `${process.platform}/${process.arch}`,
|
||||
sample: { imageCount, rawBytes, formats, duplicateCount, duplicateBytes, metadataRaw, metadataZstd },
|
||||
codecs: totals,
|
||||
adaptive,
|
||||
chapters: byChapter,
|
||||
method: 'One cold file read per image; encode/decode timed separately in-process using synchronous Node zlib. No network, TLS, disk-write, or browser decode time. Sizes omit protocol headers. Exact-byte round-trip verified.',
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
const https = require('node:https');
|
||||
const req = https.get({ host: '127.0.0.1', port: Number(process.env.PEER_PORT || 8093), path: '/health', rejectUnauthorized: false,
|
||||
timeout: 4000 }, res => {
|
||||
let text = ''; res.on('data', chunk => { text += chunk; if (text.length > 1000) req.destroy(); });
|
||||
res.on('end', () => { try { if (res.statusCode !== 200 || !JSON.parse(text).ok) process.exitCode = 1; } catch { process.exitCode = 1; } });
|
||||
});
|
||||
req.on('timeout', () => req.destroy()); req.on('error', () => { process.exitCode = 1; });
|
||||
+31
-2
@@ -8,7 +8,16 @@ const root = path.resolve(__dirname, '..'), dir = fs.mkdtempSync(path.join(os.tm
|
||||
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 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'));
|
||||
if (peerGatewayQa) {
|
||||
const peerData = path.join(data, 'peering');
|
||||
const peerUsage = path.join(data, 'peering-usage');
|
||||
fs.mkdirSync(peerUsage, { recursive: true });
|
||||
const { PeeringState } = require('../peering/state');
|
||||
if (process.platform === 'win32' && !process.env.OPENSSL_BIN) process.env.OPENSSL_BIN = 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe';
|
||||
new PeeringState(peerData).generateIdentity('ComposeQA');
|
||||
}
|
||||
fs.writeFileSync(path.join(data, 'scraper', 'queue.json'), JSON.stringify({ paused: true, queue: [], settings: { autoUpdateEnabled: false } }));
|
||||
fs.writeFileSync(tokenFile, crypto.randomBytes(32).toString('hex'), { mode: 0o600 });
|
||||
const png = new Canvas(180, 270, [60, 90, 140]).png();
|
||||
@@ -28,8 +37,9 @@ for (const [index, name] of ['Moon Garden', 'Before the Rain', 'Iron Harbor'].en
|
||||
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 args = ['compose', '-p', project, '-f', path.join(root, 'docker-compose.yml')];
|
||||
SCRAPER_TOKEN_PATH: tokenFile, READER_PORT: port, READER_BIND: '127.0.0.1', COOKIE_SECURE: '0', APP_IMAGE: process.env.APP_IMAGE || 'mangacircus:local',
|
||||
...(peerGatewayQa ? { PEER_DATA_PATH: path.join(data, 'peering'), PEER_USAGE_PATH: path.join(data, 'peering-usage'), PEER_PORT: process.env.QA_PEER_PORT || '8193' } : {}) };
|
||||
const args = ['compose', '-p', project, '-f', path.join(root, 'docker-compose.yml'), ...(peerGatewayQa ? ['--profile', 'peering'] : [])];
|
||||
const docker = (...command) => new Promise((resolve, reject) => {
|
||||
const child = spawn('docker', [...args, ...command], { cwd: root, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let result = ''; child.stdout.on('data', chunk => result += chunk); child.stderr.on('data', chunk => process.stderr.write(chunk));
|
||||
@@ -46,6 +56,15 @@ let keep = false;
|
||||
await docker('config', '-q'); await docker('up', '-d', '--no-build');
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
await until(() => fetch(url + '/health').then(r => r.ok), 'Reader did not start');
|
||||
if (peerGatewayQa) {
|
||||
const https = require('node:https');
|
||||
try {
|
||||
await until(() => new Promise(resolve => https.get({ hostname: '127.0.0.1', port: Number(env.PEER_PORT), path: '/health', rejectUnauthorized: false },
|
||||
response => { response.resume(); resolve(response.statusCode === 200); }).on('error', () => resolve(false))), 'Peer gateway did not start');
|
||||
} catch (error) { console.error(await docker('logs', '--tail', '30', 'peer-gateway').catch(() => 'Gateway logs unavailable')); throw error; }
|
||||
const gateway = await docker('ps', '--format', 'json');
|
||||
assert.ok(gateway.includes('peer-gateway'), 'Peer gateway missing from isolated Compose');
|
||||
}
|
||||
const signup = await fetch(url + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'qa', password: 'disposable-local-test' }) });
|
||||
assert.equal(signup.status, 200); const cookie = signup.headers.get('set-cookie').split(';')[0];
|
||||
const api = (endpoint, body) => fetch(url + endpoint, { headers: { cookie, 'content-type': 'application/json' }, ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) });
|
||||
@@ -79,6 +98,16 @@ let keep = false;
|
||||
const image = await api('/lib/' + encodeURIComponent('Moon Garden') + '/cover.png'); assert.equal(image.status, 200);
|
||||
state = await (await api('/api/admin/scraper')).json(); assert.equal(state.paused, true);
|
||||
if (process.argv.includes('--settings-ui')) await require('./test-settings-ui')(url, cookie);
|
||||
if (process.argv.includes('--discovery-ui')) {
|
||||
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
|
||||
assert.equal(login.status, 200);
|
||||
await require('./test-discovery-ui')(url, login.headers.get('set-cookie').split(';')[0]);
|
||||
}
|
||||
if (process.argv.includes('--peer-ui')) {
|
||||
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
|
||||
assert.equal(login.status, 200);
|
||||
await require('./test-peer-ui')(url, login.headers.get('set-cookie').split(';')[0]);
|
||||
}
|
||||
if (process.argv.includes('--collections-ui')) {
|
||||
// Each UI suite tests logout/ordinary-reader boundaries. Give the next suite
|
||||
// a fresh disposable admin session rather than reusing a logged-out cookie.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
module.exports = async function testDiscoveryUI(url, cookie) {
|
||||
const { chromium } = require('playwright');
|
||||
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const [name, ...value] = cookie.split('=');
|
||||
await context.addCookies([{ name, value: value.join('='), url }]);
|
||||
const page = await context.newPage(), errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
try {
|
||||
await page.goto(url + '/discover.html');
|
||||
await page.getByRole('heading', { name: 'Discover', exact: true }).waitFor();
|
||||
await page.getByText('Turn on personalized discovery').waitFor();
|
||||
await page.getByLabel('Personalized discovery').check();
|
||||
await page.getByRole('button', { name: 'Run discovery now' }).click();
|
||||
await page.getByText(/suggestions/, { exact: false }).first().waitFor();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.discovery-card').length > 0);
|
||||
await page.locator('.discovery-card').first().getByText('Why this is here · evidence').click();
|
||||
assert.ok(await page.locator('.discovery-card').first().locator('details li').count() > 0);
|
||||
await page.locator('.discovery-card').first().getByRole('button', { name: '👍 Maybe' }).click();
|
||||
await page.getByText('Preference saved').waitFor();
|
||||
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const overflow = await page.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth,
|
||||
elements: [...document.querySelectorAll('body *')].filter(el => el.getBoundingClientRect().right > innerWidth + 1)
|
||||
.slice(0, 8).map(el => `${el.tagName}.${el.className}: ${Math.round(el.getBoundingClientRect().right)}`) }));
|
||||
assert.ok(overflow.scroll <= overflow.width, `Discover page overflows on mobile: ${JSON.stringify(overflow)}`);
|
||||
const create = await page.evaluate(() => fetch('/api/admin/user', { method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'discovery-reader-qa', password: 'disposable-reader-test', admin: false }) }).then(response => response.status));
|
||||
assert.equal(create, 200);
|
||||
await page.evaluate(() => fetch('/api/logout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }));
|
||||
await page.evaluate(() => fetch('/api/login', { method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'discovery-reader-qa', password: 'disposable-reader-test' }) }));
|
||||
await page.reload();
|
||||
await page.getByRole('heading', { name: 'Discover', exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole('button', { name: 'Run discovery now' }).count(), 0);
|
||||
assert.equal(await page.evaluate(() => fetch('/api/admin/discovery').then(response => response.status)), 403);
|
||||
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
|
||||
} finally { await browser.close(); }
|
||||
};
|
||||
+54
-1
@@ -118,6 +118,49 @@ async function browserCheck(instance, cookie, peerId, shareId) {
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
|
||||
async function peerSettingsUiCheck(instance, cookie, options = {}) {
|
||||
const { chromium } = require('playwright');
|
||||
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const [name, ...value] = cookie.split('='); await context.addCookies([{ name, value: value.join('='), url: instance.url }]);
|
||||
const page = await context.newPage(), errors = []; page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(instance.url);
|
||||
await page.locator('.nav[data-peer-catalog-resolved="true"]').waitFor();
|
||||
assert.equal(await page.locator('.nav a[href="peer-catalog.html"]').count(), options.hasPeer ? 1 : 0,
|
||||
'Peer catalogs nav appears only after a peer is configured');
|
||||
if (options.peerId) {
|
||||
await page.goto(`${instance.url}/peers.html#admin/peering/${encodeURIComponent(options.peerId)}`);
|
||||
await page.getByRole('heading', { name: 'Access between servers' }).waitFor();
|
||||
const issue = page.getByRole('button', { name: options.outboundGrant ? 'Save grant permissions' : 'Issue grant', exact: true });
|
||||
if (options.paired) {
|
||||
await page.getByText('Peering confirmed on both servers', { exact: true }).waitFor();
|
||||
if (!options.outboundGrant) await page.getByLabel('Browse approved catalog').check();
|
||||
assert.equal(await issue.isDisabled(), false, 'A selected grant permission enables the grant action after pairing');
|
||||
} else {
|
||||
await page.getByText('Identity not verified yet', { exact: true }).waitFor();
|
||||
await page.getByLabel('Browse approved catalog').check();
|
||||
await page.getByLabel('Download chapters (includes catalog access)').check();
|
||||
assert.equal(await issue.isDisabled(), true, 'Grant issue remains locked before identity verification');
|
||||
await page.getByText(/Granting is locked until identity pairing is complete/).waitFor();
|
||||
}
|
||||
if (options.outboundGrant) await page.getByText('Grant active', { exact: true }).waitFor();
|
||||
if (options.inboundGrant) await page.getByText('Active grant from peer', { exact: true }).waitFor();
|
||||
if (options.paired) {
|
||||
const stateCard = page.locator('.peer-state-ok').first();
|
||||
const visual = await stateCard.evaluate(element => {
|
||||
const style = getComputedStyle(element);
|
||||
return { weight: getComputedStyle(element.querySelector('strong')).fontWeight,
|
||||
border: style.borderLeftWidth, background: style.backgroundColor };
|
||||
});
|
||||
assert.ok(Number(visual.weight) >= 700 && visual.border === '4px' && visual.background !== 'rgba(0, 0, 0, 0)',
|
||||
`Confirmed state must be visually prominent: ${JSON.stringify(visual)}`);
|
||||
}
|
||||
}
|
||||
assert.equal(errors.length, 0, `Peer settings browser errors: ${errors.join('; ')}`);
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let started = [], succeeded = false;
|
||||
try {
|
||||
@@ -138,6 +181,8 @@ async function main() {
|
||||
}), `${instance.label}: peer gateway health`);
|
||||
}
|
||||
const cookieA = await login(A), cookieB = await login(B);
|
||||
await peerSettingsUiCheck(A, cookieA, { hasPeer: false });
|
||||
await peerSettingsUiCheck(B, cookieB, { hasPeer: false });
|
||||
const stateA = (await json(`${A.url}/api/admin/peering`, cookieA)).data;
|
||||
const stateB = (await json(`${B.url}/api/admin/peering`, cookieB)).data;
|
||||
const hostUrlA = `https://host.docker.internal:${gateA}/`, hostUrlB = `https://host.docker.internal:${gateB}/`;
|
||||
@@ -149,6 +194,7 @@ async function main() {
|
||||
const peerA = importedA.data.peers.find(peer => peer.fingerprint === inviteB.fingerprint);
|
||||
const peerB = importedB.data.peers.find(peer => peer.fingerprint === inviteA.fingerprint);
|
||||
assert.ok(peerA && peerB);
|
||||
await peerSettingsUiCheck(B, cookieB, { hasPeer: true, peerId: peerB.id, paired: false });
|
||||
const probeA = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'probe', id: peerA.id });
|
||||
const probeB = await json(`${B.url}/api/admin/peering`, cookieB, { command: 'probe', id: peerB.id });
|
||||
if (probeA.response.status !== 200 || probeB.response.status !== 200) {
|
||||
@@ -161,6 +207,7 @@ async function main() {
|
||||
assert.equal(probeA.response.status, 200); assert.equal(probeB.response.status, 200);
|
||||
assert.equal(probeA.data.peers.find(peer => peer.id === peerA.id).paired, true);
|
||||
assert.equal(probeB.data.peers.find(peer => peer.id === peerB.id).paired, true);
|
||||
await peerSettingsUiCheck(A, cookieA, { hasPeer: true, peerId: peerA.id, paired: true });
|
||||
assert.ok(stateA.identity?.fingerprint && stateB.identity?.fingerprint);
|
||||
|
||||
let libraryA = [];
|
||||
@@ -173,8 +220,11 @@ async function main() {
|
||||
const grant = await json(`${A.url}/api/admin/peering`, cookieA, { command: 'grant', id: peerA.id, scopes: ['catalog'] });
|
||||
assert.equal(grant.response.status, 200);
|
||||
assert.equal((await json(`${B.url}/api/admin/peering`, cookieB, { command: 'accept-grant', id: peerB.id, token: grant.data.grant.token })).response.status, 200);
|
||||
await peerSettingsUiCheck(A, cookieA, { hasPeer: true, peerId: peerA.id, paired: true, outboundGrant: true });
|
||||
await peerSettingsUiCheck(B, cookieB, { hasPeer: true, peerId: peerB.id, paired: true, inboundGrant: true });
|
||||
const catalog = await json(`${B.url}/api/peering/catalog?peer=${encodeURIComponent(peerB.id)}`, cookieB);
|
||||
assert.equal(catalog.response.status, 200); assert.equal(catalog.data.items.length, 1);
|
||||
assert.equal(catalog.data.items[0].workIdentity?.scheme, 'mangacircus-work-v1', 'Peer catalogs expose a stable, opaque work identity');
|
||||
const shareId = catalog.data.items[0].id;
|
||||
const cover = await fetch(`${B.url}/api/peering/cover?peer=${encodeURIComponent(peerB.id)}&manga=${shareId}`, { headers: { cookie: cookieB } });
|
||||
assert.equal(cover.status, 200); assert.match(cover.headers.get('content-type'), /^image\/png/);
|
||||
@@ -189,7 +239,10 @@ async function main() {
|
||||
await browserCheck(B, cookieB, peerB.id, shareId);
|
||||
const queue = (await json(`${B.url}/api/admin/scraper`, cookieB)).data;
|
||||
assert.equal(queue.paused, true, 'Peer testing must leave the scraper queue paused');
|
||||
assert.equal(queue.queue.filter(item => item.url === `peer://${peerB.id}/${shareId}`).length, 1, 'Exactly one peer queue item is added');
|
||||
const peerQueueItems = queue.queue.filter(item => item.url === `peer://${peerB.id}/${shareId}`);
|
||||
assert.equal(peerQueueItems.length, 1, 'Exactly one peer queue item is added');
|
||||
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`);
|
||||
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.');
|
||||
|
||||
+20
-3
@@ -19,7 +19,7 @@ module.exports = async function testQueueUI(url, cookie) {
|
||||
];
|
||||
state.queue = [
|
||||
{ id: 'pilot-fixture', title: 'bleach', source: 'mangadex', kind: 'inspection', status: 'ready', sourceTitle: 'Bleach (Pre-Serialization)', availability: { selectedChapters: 2, specialReleases: 2, coverage: 'unknown' } },
|
||||
{ id: 'main-fixture', title: 'Bleach', source: 'mangadex', kind: 'download', status: 'complete', publishedCount: 1, sourceTitle: 'Bleach', availability: { selectedChapters: 1, expectedLastChapter: '686', coverage: 'partial' } },
|
||||
{ id: 'main-fixture', title: 'Bleach', source: 'mangadex', kind: 'download', status: 'complete', publishedCount: 1, sourceTitle: 'Bleach', availability: { selectedChapters: 1, numberedChapters: 1, expectedLastChapter: '686', highestHostedChapter: 686.1, coverage: 'unknown' } },
|
||||
{ id: 'external-fixture', title: 'Claymore', source: 'mangadex', kind: 'download', status: 'unavailable', error: 'MangaDex lists 8 English external-link releases, but no hosted English pages. External links are not downloaded.' },
|
||||
{ id: 'other-source-fixture', title: 'Another manga', source: 'weebcentral', kind: 'download', status: 'queued' },
|
||||
];
|
||||
@@ -63,7 +63,8 @@ module.exports = async function testQueueUI(url, cookie) {
|
||||
await page.waitForTimeout(3500);
|
||||
assert.equal(await queuePanel.locator('.scraper-completed').evaluate(el => el.open), true, 'Completed section lost a click during redraw');
|
||||
await queuePanel.getByText(/1 chapter published · finished/).waitFor();
|
||||
await queuePanel.getByText(/Partial source coverage/).waitFor();
|
||||
await queuePanel.getByText('Hosted selection complete', { exact: true }).waitFor();
|
||||
await queuePanel.getByText('Series coverage partial', { exact: true }).waitFor();
|
||||
page.once('dialog', dialog => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Clear completed downloads (1)' }).click();
|
||||
assert.deepEqual(commands.at(-1), { command: 'clear-completed', source: '' });
|
||||
@@ -95,7 +96,23 @@ module.exports = async function testQueueUI(url, cookie) {
|
||||
await page.screenshot({ path: path.join(shots, 'mobile.png'), fullPage: true });
|
||||
const detailWidth = await page.locator('[data-scraper-tab=queue] .scraper-queue .scraper-item > .grow').first().evaluate(el => el.getBoundingClientRect().width);
|
||||
assert.ok(detailWidth > 280, `Queue details squeezed by action buttons (${detailWidth}px)`);
|
||||
await page.route('**/api/manga?*', async route => {
|
||||
const title = new URL(route.request().url()).searchParams.get('m');
|
||||
const first = title === 'Bleach' ? 336 : 1;
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({
|
||||
id: title, title, meta: { title, sources: [{ name: 'MangaDex', url: 'https://mangadex.org/' }] },
|
||||
cover: null, direction: 'rtl', books: [{ key: 'v01', label: 'Vol. 1' }],
|
||||
chapters: [{ id: `v01/c${first}`, book: 'v01', label: `Ch. ${first}`, title: '',
|
||||
versions: [{ id: 'fixture', pages: 1, added: '2026-10-01', source: 'MangaDex', score: 0, my: 0 }] }],
|
||||
collections: [], relations: [], following: false, reading: null, continue: null,
|
||||
}) });
|
||||
});
|
||||
await page.goto(url + '/manga.html?m=Bleach');
|
||||
await page.getByText('Series coverage partial', { exact: true }).waitFor();
|
||||
await page.getByText(/starts at chapter 336/).waitFor();
|
||||
await page.goto(url + '/manga.html?m=Complete%20Fixture');
|
||||
assert.equal(await page.getByText('Series coverage partial', { exact: true }).count(), 0);
|
||||
assert.deepEqual(errors, []); assert.deepEqual(upstream, []);
|
||||
console.log('PASS: scraper activity, queue cleanup, source filters, edition controls, add form and mobile layout; no source traffic');
|
||||
console.log('PASS: scraper activity, queue coverage, manga gap warning, cleanup, source filters, edition controls and mobile layout; no source traffic');
|
||||
} finally { await browser.close(); }
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
# Discovery Scout — personalized manga discovery
|
||||
|
||||
Status: **vision and implementation plan, not implemented**. Updated 2026-10-09.
|
||||
Status: **deterministic first slice implemented; later phases remain a vision**. Updated 2026-10-09. See [`docs/discovery.md`](../docs/discovery.md) for the current contract and deliberate omissions.
|
||||
|
||||
## 1. Outcome
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Server peering — opt-in manga sharing
|
||||
|
||||
Status: **v1 implemented and isolated-tested on 2026-10-09**. This document remains
|
||||
the broader vision; the exact shipped behavior, deployment and limitations are in
|
||||
[`docs/peering.md`](../docs/peering.md). VM deployment remains future work.
|
||||
|
||||
## 1. Outcome and boundaries
|
||||
|
||||
Two MangaCircus servers can pair, show each other a deliberately limited manga catalog, and transfer selected chapters into the receiving server's normal library. An admin on the publishing server must explicitly enable **each manga** for sharing; the default is private. A reader on the receiving server can browse and request downloads, subject to local permissions. Neither server sends user accounts, reading progress, ratings, notes, collections, session cookies, or scraper credentials.
|
||||
|
||||
This is **server-to-server library transfer**, not synchronization. The publisher never writes to the receiver. The receiver never obtains unrestricted filesystem access. No automatic reciprocal sharing, title discovery, or mirroring follows from pairing.
|
||||
|
||||
The publishing server will necessarily learn the receiving **server's** identity/address, requested title/chapter IDs, byte counts, and request times. “No user data exchange” means the protocol carries no reader identity or activity records; it cannot make those unavoidable transfer observations disappear. The local server mediates browser requests so the reader's browser does not contact the remote peer.
|
||||
|
||||
Only share material the server operator is entitled to distribute. Peering must not be used to evade a source host's access controls or to export scraped credentials or source URLs.
|
||||
|
||||
## 2. Fit with MangaCircus
|
||||
|
||||
Preserve the current [architecture](../docs/architecture.md), [library format](../docs/library-format.md), and [scraper ownership](../docs/scraper-service.md): the reader owns accounts and admin settings, the scraper owns its queue and atomic chapter publication, and the library remains folders plus JSON rather than a database. Do not introduce a second writer to the library or scraper state.
|
||||
|
||||
Add one optional **peer gateway** process/container on each server that chooses to publish. It can serve multiple approved peers, receives authenticated peer traffic, reads a *read-only* library mount, exposes only approved catalog fields and opaque content IDs, and enforces upload quotas. It has no accounts database, reader data directory, scraper state, Docker socket, or writable library mount. Its small local state contains only peer grants and quota counters. Admin controls live in the reader UI and are written atomically to a reader-owned peering policy file that the gateway reads; the gateway is the sole writer of usage counters. Use one peer gateway per library, **not** one gateway per peer.
|
||||
|
||||
On the receiving side, add a **peer adapter** to the existing scraper daemon. It uses the existing single-owner queue, checkpoints, source-version policy, integrity checks, and publish-on-chapter-complete path. Treat each peer as a source lane, with one transfer worker per peer by default. This is not a separate downloader writing files beside the scraper.
|
||||
|
||||
The optional gateway is justified by isolation: an incoming network service should not have access to private reader/scraper data. It stays a small process built from the same project image, not a new platform, database, or agent.
|
||||
|
||||
## 3. Trust and pairing
|
||||
|
||||
### Recommended mechanism
|
||||
|
||||
Use **TLS 1.3 with mutual certificate authentication (mTLS)**. MangaCircus generates its own identity key and X.509 certificate **locally on each server**, through a guided admin action; no external certificate authority or command-line work is required for the initial private-network design. Generate the private key in server-side secret storage with restrictive permissions and show the admin only the public certificate and its fingerprint. Its **private key never leaves that server**. Admins exchange public pairing invitations **outside MangaCircus**—for example in a trusted messenger, on removable media, or by scanning a QR code in person—and compare the certificate/public-key fingerprints through a separate trusted channel. Each server pins the other server's public key and rejects a different key, even if DNS or the network route changes. TLS handles ephemeral session-key exchange and encrypted transport; we do not invent a crypto handshake. [TLS 1.3](https://www.rfc-editor.org/rfc/rfc8446.html) defines mutual certificate authentication.
|
||||
|
||||
Certificate validity and access authorization are separate:
|
||||
|
||||
- The certificate has `notBefore`/`notAfter` dates (proposal: 1 year) and is rejected outside them. Renewing a certificate with the *same* pinned key may be accepted after local validation; replacing the key requires a fresh out-of-band fingerprint check.
|
||||
- The publisher issues a **random 256-bit opaque grant token** for a specific consumer certificate fingerprint, scopes (`catalog`, `download`), `notBefore`, and `expiresAt` (proposal: 90 days). It stores only a hash of the token and checks it on every request. The grant cannot outlive either certificate. A grant can be revoked immediately without changing the identity key. The token is delivered out-of-band during pairing or across the already authenticated mTLS channel after both admins approve; it is never in a URL or browser response.
|
||||
- The token is an expiring authorization credential, **not** a second encryption key. A valid token without the matching client private key is useless. An expired/revoked grant, expired certificate, failed pin, or missing client certificate fails closed. No silent insecure fallback or automatic re-pairing.
|
||||
|
||||
This uses standard transport cryptography plus an auditable server-side grant record. Do not implement a custom signature protocol or home-grown JWT parser. Use the runtime's verified TLS stack and a cryptographically secure random generator; compare token hashes safely. If a standard certificate-bound token format is later needed for federation, evaluate [RFC 8705](https://www.rfc-editor.org/rfc/rfc8705.html) rather than adding protocol complexity now.
|
||||
|
||||
### Admin invitations and pairing flow
|
||||
|
||||
1. Admin A opens **Admin → Peering**, selects **Generate identity** once, then **Create invitation** for an intended peer. MangaCircus generates a single-use, short-lived invitation (proposal: 48 hours) containing A's server name, reachable peer URL, public certificate, fingerprint, invitation ID, and one-time pairing code. The invitation can be copied as text/file or shown as a QR code. It contains **no private key and no catalog/download grant**. An admin can cancel it before use.
|
||||
2. Admin A gives that invitation to B's admin outside the platform. B imports it and verifies A's fingerprint through a separate trusted channel. B generates its own local identity if needed and returns its own public invitation/card out-of-band. The one-time code links the two pending records and limits accidental pairing with the wrong server; it does not substitute for fingerprint verification or admin approval.
|
||||
3. Each admin approves the specific peer. A test mTLS connection proves possession of both private keys and checks pins, names, certificate validity, and clock skew. An expired or already-used invitation cannot create another peer. A new invitation is required for a different server; each server can have **multiple independent peers**, but importing an already-paired fingerprint must resolve to the existing peer rather than silently creating a duplicate.
|
||||
4. Each admin separately sets **Serve to this peer** on or off (default off), selects which manga/source versions that peer may see, and sets that peer's upload limits. Serving on enables issuance of A→B's time-limited catalog/download grant; serving off revokes/blocks that direction immediately. B stores A's token in server-only secret storage. If B also serves A, B issues an independent B→A grant and chooses its own share list and limits. One direction can be enabled without the other.
|
||||
5. Each admin may also set **Allow downloads from this peer** on or off locally (default off until paired and approved). This controls whether their own readers can browse/queue that peer's catalog. Turning it off pauses existing local jobs from that peer at a checkpoint; it does not delete queued work or already downloaded chapters. It is independent of the other server's serving choice and does not grant the other server access.
|
||||
6. The UI shows **pending / paired but inactive / serving / receiving / both directions / expires soon / expired / revoked / fingerprint changed**. Revocation closes new transfers immediately; an in-flight transfer is stopped at the next chunk/checkpoint. Key rotation pauses until new out-of-band verification.
|
||||
|
||||
Pairing is a **many-peer registry**, not a two-server global switch. Each peer has its own pinned identity, invitation history, direction switches, grant, manga allowlist, limits, queue status, and audit entries. Disabling one peer or one direction does not alter any other peer. Removing a peer invalidates both local directions and its pending invitations, but never erases already published chapters.
|
||||
|
||||
| A's Serve to B | B's Serve to A | Result, assuming each receiver locally permits downloads |
|
||||
|---|---|---|
|
||||
| Off | Off | Paired identity only; neither catalog is readable. |
|
||||
| On | Off | B can browse/download A's approved manga; A cannot browse B's. |
|
||||
| Off | On | A can browse/download B's approved manga; B cannot browse A's. |
|
||||
| On | On | Both can browse/download, using separate grants, selections, and limits. |
|
||||
|
||||
An admin may revoke a peer, a grant, or one manga's share independently. Removing a share withdraws its catalog and blob access immediately, not merely on the next catalog refresh. Already downloaded copies on the other server are not remotely deleted.
|
||||
|
||||
## 4. Shared catalog and content protocol
|
||||
|
||||
Version the protocol from day one (`/peer/v1`). Send a *projection* of library data, never raw `manga.json`, directory listings, absolute paths, or account/reader state.
|
||||
|
||||
| Operation | Minimum payload / behavior |
|
||||
|---|---|
|
||||
| Paged catalog | Publisher-scoped opaque manga ID, title, release year/status if present, compact description, optional approved cover thumbnail, chapter/volume counts, catalog revision, next cursor. Only admin-approved manga. |
|
||||
| Manga manifest | Approved volume/chapter IDs, labels, order, page counts, sizes, content revision and per-page SHA-256 hashes. No upstream host credentials or original image URLs. |
|
||||
| Content stream | Opaque blob ID from an authorized manifest; bounded-size image bytes, MIME type, hash, ETag, and HTTP range support for restartable transfers. Never accepts a filesystem path from the client. |
|
||||
|
||||
Sharing a manga covers its **completed, validated chapters only**. Hidden staging/partial downloads, unrelated files, and currently writing pages never enter the manifest. A completed new chapter becomes visible after the scraper's atomic publication and gateway index refresh. Revocation or share removal wins over a previously issued manifest/blob ID.
|
||||
|
||||
Catalog snapshots use cursor/revision pagination and bounded page sizes. A revision change causes a clean refresh, not an unbounded rescan per browser click. Local titles can be matched to remote titles only as **candidates**; uncertain same-name editions require the reader/admin to choose the correct local manga. Store publisher-specific IDs and content revisions to avoid conflating two sources with the same chapter number.
|
||||
|
||||
Input from a peer is untrusted even after pairing: cap manifests, chapter/page sizes and total transfer size; validate schemas and image MIME/content; reject traversal paths, archives/executables, and malformed names; stream rather than buffering a whole manga; verify each page hash before publication. Hashes detect corruption but do not prove editorial correctness. Never overwrite an existing chapter silently.
|
||||
|
||||
For transport efficiency, start with original image bytes and page-level resume. A [local compression test](server-peering-compression-test.md) found an average ~6% reduction with zstd-3 but a wide 0.7–10.7% spread by chapter, so codec negotiation is an optional later optimization, not a prerequisite for peering. Measure actual chapter-ready time over a real peer link before adding chapter packing or automatic compression.
|
||||
|
||||
## 5. Reader and queue experience
|
||||
|
||||
Add **Peer catalogs** as a reader-facing browse surface, separate from the local dashboard. Show one tile/tab per peer that currently serves this server and is locally allowed for downloads; peers that are only receiving from us do not appear as remote catalogs. A peer tile shows the publishing server's display name and grant status; search and manga details are fetched through the local reader backend. A reader can select a whole manga, a volume, individual chapters, newest missing, or a bounded chapter range using the existing fine-grained queue model. The UI previews expected chapters/bytes and highlights what is already local. Local role permissions still govern who may submit to the shared scraper queue; browsing does not itself start a transfer.
|
||||
|
||||
The resulting job stores the peer ID, remote manga/chapter IDs and revision, selected scope, duplicate policy, and progress checkpoint. Default policy is **skip matching existing content**; an explicit “add as another source” choice preserves the source variant when the same chapter exists from elsewhere. On receipt, the scraper publishes each complete chapter immediately through its existing atomic path, making it readable before the entire manga finishes. Interrupted transfers resume by validated ranges/checkpoints and never expose partial pages in the library.
|
||||
|
||||
No automatic remote refresh in v1. A future admin opt-in may schedule checks for *already selected* peer manga, but must use the same queue, grant, and rate policies. No peer request may be triggered by reading a page or opening the local dashboard.
|
||||
|
||||
## 6. Upload and download limits
|
||||
|
||||
The **publisher sets hard ceilings** per peer and globally: concurrent streams, bytes/second (token bucket), bytes/day, catalog requests/minute, allowed time window, max response size, and idle/transfer timeout. Proposed starting defaults: one stream per peer, two streams globally, 2 MiB/s per peer, and 10 GiB/day per peer. These are product defaults, not a promise about available bandwidth; admins can lower or raise them. Quotas and current usage must be visible, with a “pause this peer” control. Persist daily counters atomically across restarts; a restart must not reset a used quota.
|
||||
|
||||
The **consumer can only be stricter**: one active transfer per peer by default, local bandwidth cap, chapter spacing, time-of-day window, maximum queued bytes/free-space reserve, and pause/resume. The effective rate is the lower of the two sides' caps. Respect `Retry-After` on rate limits and back off on transient failures; never retry authentication failure, revocation, or fingerprint mismatch as if they were network glitches. Fair-share scheduling prevents one peer or one huge manga from starving the local source lanes. No same-peer parallel image workers by default.
|
||||
|
||||
The gateway responds with clear, bounded errors: unauthorized/expired, revoked, not shared, quota exhausted with retry time, stale manifest, and transient unavailable. The receiver surfaces these separately in queue status and retains checkpoints. Logs and metrics identify a peer by local alias/fingerprint prefix, not a token or private key.
|
||||
|
||||
## 7. Deployment and data ownership
|
||||
|
||||
Add an optional `peer-gateway` Compose profile/service. It mounts `/library` read-only, a read-only policy projection, gateway-owned counter storage, and certificate/token secrets. It has no mount for `data/db.json`, the reader's account store, or `data/scraper`; the scraper still alone has writable library access. Start with a listener reachable only on the operator's intended private network. Do not expose the scraper's private API or reuse the normal reader web route for binary transfer.
|
||||
|
||||
For the future VM, prefer a **tailnet-only raw TCP forwarder to the gateway's own mTLS listener**. Tailscale Serve's `--tcp` mode forwards raw TCP, whereas `--tls-terminated-tcp` ends TLS at Serve and is **not** the default design for end-to-end mTLS pinning. [Tailscale's Serve documentation](https://tailscale.com/docs/reference/tailscale-cli/serve) distinguishes the two. Tailnet reachability is defense in depth, not a substitute for pairing, grants, and quotas. Keep the existing Caddy reader reverse proxy separate; only consider public peering later with a dedicated mTLS endpoint, firewall policy, and an explicit threat review. Exact VM ports/hostnames await deployment configuration.
|
||||
|
||||
Suggested NoDB files (names are design targets, not existing files): reader-owned `data/peering/policy.json` for peers, share flags, and limit settings; gateway-owned `data/peering-gateway/usage.json` for quota counters; scraper-owned existing queue/checkpoints for receiver jobs. Private keys and grant tokens live in excluded secrets storage, not policy JSON, logs, exported cards, or Git. Atomic replace plus bounded backups for JSON; one writer per file. Backups of identity keys/tokens must be encrypted and restorable only by the operator. Losing a private key means re-pairing, not copying another server's key.
|
||||
|
||||
## 8. Implementation sequence and acceptance gates
|
||||
|
||||
1. **Contracts and threat model:** define the exact catalog/manifest schemas, stable peer IDs, many-peer policy model, independent direction switches, duplicate semantics, grant lifecycle, filesystem limits, and forbidden fields. Add protocol fixtures and negative tests before network code.
|
||||
2. **Identity and admin controls:** generate local keys/certs, expiring single-use public invitations, verified import, pinning, grant expiry/revocation, per-peer serving/download controls, per-manga opt-in, and audit events. Test wrong pin, missing client cert, invitation reuse/expiry, clock/expiry, key rotation, token replay with a different certificate, and immediate revocation of one direction without affecting another peer.
|
||||
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.
|
||||
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.
|
||||
|
||||
## 9. Decisions to settle before implementation
|
||||
|
||||
- Who may queue from a peer: any authenticated reader, or only readers with a new download permission? Recommendation: explicit local `canQueuePeerDownload` permission, admin-controlled.
|
||||
- Does sharing approval apply to every source version of a manga or a chosen edition? Recommendation: opt in **per manga and source version**, with a clear “all currently complete chapters / selected volumes” scope; new chapters follow that scope only if the admin opted into future additions.
|
||||
- How long should grants last, and should renewal require out-of-band confirmation? Recommendation: 90 days with an admin-visible renewal that retains the same pinned key; any changed key requires out-of-band re-verification.
|
||||
- How should two distinct MangaCircus accounts on different servers appear to each other? Recommendation: **they do not**. The publisher grants a server, and the receiver alone decides which of its users may browse and queue.
|
||||
Reference in new issue
Block a user