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, sets `status:"ready"`, displays the canonical `sourceTitle` and source availability,
and waits for **Confirm download**. Ready items survive daemon restarts and suppress and waits for **Confirm download**. Ready items survive daemon restarts and suppress
duplicate nightly checks. Other jobs may proceed while confirmation is pending. 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 The download rediscovers the catalog; an edition title change requires re-inspection
and confirmation. Direct API clients can still enqueue authorized downloads without and confirmation. Direct API clients can still enqueue authorized downloads without
inspection by omitting the flag. Existing download/discovery jobs are unchanged. 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 summary = event.target.closest('summary');
const details = summary?.parentElement; const details = summary?.parentElement;
const key = details?.dataset.scraperDisclosure; 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 => { card.addEventListener('toggle', event => {
const details = event.target; const details = event.target;
@@ -227,9 +237,12 @@
const expected = availability?.expectedLastChapter; const expected = availability?.expectedLastChapter;
const sparse = /^\d+$/.test(String(expected || '')) && Number(availability?.numberedChapters) < Number(expected); const sparse = /^\d+$/.test(String(expected || '')) && Number(availability?.numberedChapters) < Number(expected);
const partialCoverage = availability?.coverage === 'partial' || sparse; const partialCoverage = availability?.coverage === 'partial' || sparse;
const sourceIsPaused = !!state.sourcePaused?.[item.source];
const outcome = item.status === 'complete' const outcome = item.status === 'complete'
? `${item.publishedCount ?? 0} chapter${item.publishedCount === 1 ? '' : 's'} published · finished ${when(item.completed)}` ? `${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 }, return h('div', { class: 'scraper-item' + (running ? ' active' : ''), 'data-source': item.source },
h('div', { class: 'grow' }, h('b', {}, item.title), h('div', { class: 'grow' }, h('b', {}, item.title),
item.status === 'complete' || availability ? h('div', { class: 'coverage-indicators' }, item.status === 'complete' || availability ? h('div', { class: 'coverage-indicators' },
@@ -241,11 +254,17 @@
+ (item.existingPolicy === 'add-source' ? ' · alternate source' : '')), + (item.existingPolicy === 'add-source' ? ' · alternate source' : '')),
item.error ? h('div', { class: 'small error' }, item.error) : null, item.error ? h('div', { class: 'small error' }, item.error) : null,
item.sourceTitle ? h('div', { class: 'small' }, `Source edition: ${item.sourceTitle}`) : 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), 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[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, 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, 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, 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 () => { 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 }); 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('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 primary', onclick: () => command({ command: 'control', action: 'resume' }) }, 'Resume queue')
: h('button', { class: 'btn', onclick: () => command({ command: 'control', action: 'pause' }) }, 'Pause 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}`), h('div', { class: 'field' }, h('span', {}, `Needs attention · ${attention.length}`),
attention.length ? h('div', { class: 'scraper-queue' }, attention.map(item => jobRow(item))) attention.length ? h('div', { class: 'scraper-queue' }, attention.map(item => jobRow(item)))
: h('p', { class: 'muted small' }, 'Nothing needs action on this source.'), : 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') { if (action === 'inspect' || action === 'confirm') {
const item = this.state.queue.find(entry => entry.id === id); 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 (!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 (action === 'confirm') {
if (item.status !== 'ready' || item.kind !== 'inspection') throw new Error('Inspect the source first'); if (item.status !== 'ready' || item.kind !== 'inspection') throw new Error('Inspect the source first');
item.confirmedSourceTitle = item.sourceTitle; 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'); await service.control('resume-source', 'weebcentral');
assert.equal(service.state.sourcePaused.weebcentral, undefined); 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/);
});