Files
MaashkinoozheandClaude Opus 5.5 cd6a30a76b Discovery final review fixes: lean publish, robust scoring, cheap prune (BL-0051)
- Cache prune counts directory entries first and only reads files when over
  the cap; tests for cross-kind prune, schema-mismatch and corrupt entries.
- Scoring tolerates malformed manga.json and cached records (non-list tags,
  object or null relations, non-object items); a tag without a rank no longer
  passes the top-tag filter; authors without an integer AniList id are dropped.
- discovery.json publishes only candidates some feed shows; examined/clipped
  still describe the pool. Tests that asserted the published count equals the
  pool size now check the pool via candidatePool() and the published set
  against the feeds.
- The post-harvest rescore failure test now breaks discovery.json right before
  the publish (via a scraper stand-in) instead of relying on tags: 5, and a
  following successful harvest clears the stale lastError.
- Consent text names everything sent to AniList; paused label mentions admin
  harvests; the UI test asserts the XSS seed instead of skipping it.
- Slot-mix test proves the fresh/outside reservations matter.
- Docs: privacy note, fresh fallback reason, per-period cap, dry-run wording,
  published-candidate rule; plan/spec hygiene and spec status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:43:31 +01:00

128 lines
9.1 KiB
JavaScript

'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);
const clock = iso => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const fail = error => { note.textContent = error.message; note.className = 'error'; };
async function refresh() {
try { state = await api('api/discovery'); if (me.admin) adminState = await api('api/admin/discovery'); render(); }
catch (error) { fail(error); }
}
// Something is still working server-side: a scoring run, a harvest, or this user's refresh.
const busy = () => adminState?.currentRun?.status === 'running' || !!adminState?.currentHarvest
|| ['queued', 'running'].includes(state.refresh?.state);
function watch() {
if (active || !busy()) return;
active = setInterval(async () => {
await refresh();
if (!busy()) { clearInterval(active); active = null; }
}, 1500);
}
async function preference(key, checked) {
try { await api('api/settings', { [key]: checked }); await refresh(); }
catch (error) { fail(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) { fail(error); }
}
// Start a server job, then poll until it finishes; on refusal re-read the state (cooldown, pause).
async function start(endpoint, message) {
try {
await api(endpoint, {});
note.textContent = message; note.className = 'muted small';
} catch (error) { fail(error); }
await refresh();
watch();
}
const run = () => start('api/admin/discovery/run', 'Discovery running. Existing suggestions remain visible until it finishes.');
const refreshMine = () => start('api/discovery/refresh', 'Refresh queued. Your current suggestions stay visible until it finishes.');
const harvestNow = () => start('api/admin/discovery/harvest', 'Harvest started. Suggestions are re-scored when it finishes.');
function refreshButton() {
const refreshState = state.refresh || { state: 'idle' };
const label = refreshState.state === 'queued' ? `Queued (position ${refreshState.position})`
: refreshState.state === 'running' ? 'Fetching…'
: refreshState.blockedReason ? `Paused by AniList (${refreshState.blockedReason}) until the next nightly or admin harvest`
: refreshState.nextAllowedAt ? `Next refresh possible at ${clock(refreshState.nextAllowedAt)}` : null;
return h('button', { type: 'button', class: 'btn discovery-refresh', disabled: !!label, onclick: refreshMine }, label || 'Refresh my suggestions');
}
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. When on, the server sends AniList the IDs of titles you liked, read or own, their authors\' IDs and your top tags, without login, to find related work. 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 AniList marks as adult or with a known mature tag.'));
}
function harvestSettings() {
const settings = adminState?.settings || { harvestEnabled: false, harvestAt: '03:30' };
const enabled = h('input', { type: 'checkbox', checked: settings.harvestEnabled, onchange: save });
const time = h('input', { type: 'time', value: settings.harvestAt, 'aria-label': 'Harvest time', required: true, onchange: save });
async function save() {
if (!time.value) return;
try {
const saved = await api('api/admin/discovery/settings', { harvestEnabled: enabled.checked, harvestAt: time.value });
note.textContent = `Harvest settings saved (${saved.settings.harvestEnabled ? 'on' : 'off'}, ${saved.settings.harvestAt})`;
note.className = 'muted small';
await refresh();
} catch (error) { fail(error); }
}
return h('div', { class: 'row discovery-harvest-settings' }, h('label', { class: 'check' }, enabled, h('span', {}, 'Automatic harvest')), time);
}
function adminPanel() {
if (!me.admin) return null;
const runState = adminState?.currentRun?.status || adminState?.lastRun?.status || 'never';
const harvest = adminState?.harvest, cache = adminState?.cache;
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`),
h('p', { class: 'muted small' }, `Last harvest: ${harvest?.status || 'never'} · ${harvest?.requests ?? 0}/${harvest?.budget ?? 0} requests · ${harvest?.stopReason || '—'} · cache ${cache?.files ?? 0} files`),
h('p', { class: 'muted small' }, adminState?.nextNightlyAt ? `Next nightly harvest: ${new Date(adminState.nextNightlyAt).toLocaleString()}` : 'Automatic harvest is off.'),
adminState?.lastError ? h('p', { class: 'error' }, adminState.lastError) : null,
h('div', { class: 'row' }, button('Run discovery now', run, 'primary'), button('Harvest now', harvestNow)),
harvestSettings(),
h('p', { class: 'muted small' }, 'Harvests ask AniList for public metadata only. They never 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', {}, (item.reasons || []).map(reason => h('li', {}, reason)),
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,
entry.fetchedAt ? h('span', { class: 'muted' }, ` · fetched ${new Date(entry.fetchedAt).toLocaleDateString()}`) : 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.'));
main.append(h('div', { class: 'row discovery-refresh-row' }, refreshButton()));
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();
watch();
})();