Allow queued confirmation during source pause

This commit is contained in:
Codex committed 2026-10-14 09:42:21 +02:00
1 parent a22913129a
commit b96ab42add
4 files changed
+51 -5

No files matched your search

+5 -1
View File
@@ -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.
+24 -3
View File
@@ -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.'),
+6 -1
View File
@@ -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;
+16
View File
@@ -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/);
});