diff --git a/docs/scraper-service.md b/docs/scraper-service.md index d4123e4..3c7ec4a 100644 --- a/docs/scraper-service.md +++ b/docs/scraper-service.md @@ -98,7 +98,11 @@ delays. No image or cover requests occur during inspection. Successful inspectio sets `status:"ready"`, displays the canonical `sourceTitle` and source availability, and waits for **Confirm download**. Ready items survive daemon restarts and suppress duplicate nightly checks. Other jobs may proceed while confirmation is pending. -Confirmation does not resume a deliberately paused queue or clear a source block. +Confirmation can save an inspected edition even while its source lane is paused; +the job then remains queued and makes no source requests until that lane is +reviewed and explicitly resumed. Confirmation does not resume a deliberately +paused queue or clear a source block. **Inspect again** remains unavailable +while the source lane is paused. The download rediscovers the catalog; an edition title change requires re-inspection and confirmation. Direct API clients can still enqueue authorized downloads without inspection by omitting the flag. Existing download/discovery jobs are unchanged. diff --git a/public/settings.js b/public/settings.js index 3cbecd4..18381ec 100644 --- a/public/settings.js +++ b/public/settings.js @@ -128,7 +128,17 @@ const summary = event.target.closest('summary'); const details = summary?.parentElement; const key = details?.dataset.scraperDisclosure; - if (key && Object.hasOwn(disclosureOpen, key)) disclosureOpen[key] = !details.open; + if (key && Object.hasOwn(disclosureOpen, key)) { + const intended = !details.open; + disclosureOpen[key] = intended; + // A filter click can redraw the panel while this summary click is + // bubbling. Apply the click to the replacement node as well; the + // browser's native toggle will only affect the detached original. + if (!details.isConnected) { + const replacement = card.querySelector(`[data-scraper-disclosure="${key}"]`); + if (replacement) replacement.open = intended; + } + } }); card.addEventListener('toggle', event => { const details = event.target; @@ -227,9 +237,12 @@ const expected = availability?.expectedLastChapter; const sparse = /^\d+$/.test(String(expected || '')) && Number(availability?.numberedChapters) < Number(expected); const partialCoverage = availability?.coverage === 'partial' || sparse; + const sourceIsPaused = !!state.sourcePaused?.[item.source]; 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('_', ' '); + : item.status === 'ready' ? 'Awaiting edition confirmation' + : item.status === 'queued' && sourceIsPaused ? 'Confirmed · waiting for source access review' + : 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' }, @@ -241,11 +254,17 @@ + (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, + item.status === 'ready' && sourceIsPaused ? h('div', { class: 'small error' }, + 'This source is paused after an access response. Confirming saves your choice, but downloading will not start until access is reviewed and the source is resumed.') : 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, - ['unavailable', 'quarantined', 'complete', 'ready'].includes(item.status) ? h('button', { class: 'btn small', onclick: () => command({ command: 'control', action: 'inspect', id: item.id }) }, 'Inspect again') : null, + ['unavailable', 'quarantined', 'complete', 'ready'].includes(item.status) ? h('button', { + class: 'btn small', disabled: sourceIsPaused, + title: sourceIsPaused ? 'Source access must be reviewed before another inspection' : '', + onclick: () => command({ command: 'control', action: 'inspect', id: item.id }), + }, 'Inspect again') : null, item.status === 'ready' ? h('button', { class: 'btn small primary', onclick: () => command({ command: 'control', action: 'confirm', id: item.id }) }, 'Confirm download') : null, h('button', { class: 'btn small', disabled: running, onclick: async () => { if (confirm(`Remove ${item.title} from the queue? Its library chapters will stay.`)) await command({ command: 'remove', id: item.id }); @@ -273,6 +292,8 @@ h('div', { class: 'row', style: 'margin-top:8px' }, state.paused ? h('button', { class: 'btn primary', onclick: () => command({ command: 'control', action: 'resume' }) }, 'Resume queue') : h('button', { class: 'btn', onclick: () => command({ command: 'control', action: 'pause' }) }, 'Pause queue'))), + Object.entries(state.sourcePaused || {}).filter(([, paused]) => paused).map(([source]) => + h('p', { class: 'small error', role: 'status' }, `${source} is paused after a source-access problem. Confirmed jobs wait; confirmation does not resume this source.`)), h('div', { class: 'field' }, h('span', {}, `Needs attention · ${attention.length}`), attention.length ? h('div', { class: 'scraper-queue' }, attention.map(item => jobRow(item))) : h('p', { class: 'muted small' }, 'Nothing needs action on this source.'), diff --git a/scraper/service.js b/scraper/service.js index 94905d0..274e5cb 100644 --- a/scraper/service.js +++ b/scraper/service.js @@ -654,7 +654,12 @@ class ScraperService { if (action === 'inspect' || action === 'confirm') { const item = this.state.queue.find(entry => entry.id === id); if (!item || Object.values(this.state.currentIds || {}).includes(item.id)) throw new Error('Job not found or already active'); - if (this.state.sourcePaused[item.source] || item.failure?.kind === 'access') throw new Error('Review the source access block before inspecting or confirming'); + // Confirmation only changes the persisted queue. The work loop still + // refuses to start jobs for a paused source, so an admin can approve an + // already inspected edition without accidentally retrying blocked access. + if (item.failure?.kind === 'access' || (action === 'inspect' && this.state.sourcePaused[item.source])) { + throw new Error('Review the source access block before inspecting'); + } if (action === 'confirm') { if (item.status !== 'ready' || item.kind !== 'inspection') throw new Error('Inspect the source first'); item.confirmedSourceTitle = item.sourceTitle; diff --git a/scraper/test/source-pause.test.js b/scraper/test/source-pause.test.js index 6513971..86b3467 100644 --- a/scraper/test/source-pause.test.js +++ b/scraper/test/source-pause.test.js @@ -24,3 +24,19 @@ test('an operator can pause one source while other lanes continue', async t => { await service.control('resume-source', 'weebcentral'); assert.equal(service.state.sourcePaused.weebcentral, undefined); }); + +test('confirmation can queue an inspected job without unpausing its source', async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mc-source-confirm-')); + const service = await new ScraperService({ libraryRoot: path.join(root, 'library'), + dataRoot: path.join(root, 'data') }).init(); + t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); }); + const item = { id: 'inspected', source: 'weebcentral', title: 'Sample', sourceTitle: 'Sample', + kind: 'inspection', status: 'ready' }; + service.state.queue.push(item); + await service.control('pause-source', 'weebcentral'); + await service.control('confirm', item.id); + assert.equal(item.kind, 'download'); + assert.equal(item.status, 'queued'); + assert.equal(service.state.sourcePaused.weebcentral, true); + await assert.rejects(service.control('inspect', item.id), /source access block/); +});