'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(); })();