Allow queued confirmation during source pause
This commit is contained in:
1 parent
a22913129a
commit
b96ab42add
4 files changed
+51
-5
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user