From e3332bec1f5eff674aad70f0f76ad60f3d9f27ab Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Sun, 13 Sep 2026 12:16:28 +1200 Subject: [PATCH] Explain search outcomes and provide a direct version selection action --- frontend/app/requests/[id]/LatestActivity.tsx | 14 +++---- frontend/app/requests/[id]/page.tsx | 17 +++++++- scripts/review_manual_selection_ui.cjs | 6 +-- scripts/review_search_outcomes_ui.cjs | 39 +++++++++++++++++++ 4 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 scripts/review_search_outcomes_ui.cjs diff --git a/frontend/app/requests/[id]/LatestActivity.tsx b/frontend/app/requests/[id]/LatestActivity.tsx index a1ff086..75f04e9 100644 --- a/frontend/app/requests/[id]/LatestActivity.tsx +++ b/frontend/app/requests/[id]/LatestActivity.tsx @@ -5,7 +5,7 @@ import './latest-activity.css' import { lockBodyScroll } from '../../lib/scrollLock' type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string } -type Operation = { id: string; label: string; status: string; events: Event[] } +type Operation = { summary?: { title: string; message: string; next: string; action?: string }; id: string; label: string; status: string; events: Event[] } export default function LatestActivity({ operation, besideDownload, onDismiss }: { operation: Operation; besideDownload: boolean; onDismiss: () => void @@ -21,8 +21,8 @@ export default function LatestActivity({ operation, besideDownload, onDismiss }: const choosing = /[1-9]\d* releases? (found|shown)/i.test(message) const sent = operation.status === 'complete' && /sent|accepted.*release/i.test(message) && /download|release|Sonarr|Radarr/i.test(message) const interrupted = latest?.id === 'connection-error' - const status = working ? 'Working on it' : choosing ? 'Choose a download' : operation.status === 'complete' ? 'Done' : 'Needs your attention' - const currentStep = working + const status = operation.summary?.title ?? (working ? 'Working on it' : choosing ? 'Choose a download' : operation.status === 'complete' ? 'Done' : 'Needs your attention') + const currentStep = operation.summary?.message ?? (working ? /send release/i.test(operation.label) ? 'Sending your download...' : /search/i.test(operation.label) ? 'Looking for a download...' : latest?.service === 'Jellyfin' ? 'Checking if it is ready to watch...' @@ -32,13 +32,13 @@ export default function LatestActivity({ operation, besideDownload, onDismiss }: : choosing ? 'The search is finished. Choose a version to download.' : sent ? 'Your download has been sent.' : operation.status === 'error' ? 'We could not finish this step.' - : 'This check is finished.' - const nextStep = working ? 'Please wait. You can close this box while we work.' + : 'This check is finished.') + const nextStep = operation.summary?.next ?? (working ? 'Please wait. You can close this box while we work.' : interrupted ? 'Close this box and check the request before trying again.' : choosing ? 'Close this box to see the available downloads.' : sent ? 'You can close this box. The request will update when the download starts.' : operation.status === 'error' ? 'Close this box to review the request and its available options.' - : 'Close this box to see the updated request status.' + : 'Close this box to see the updated request status.') const progress =
useEffect(() => { @@ -72,7 +72,7 @@ export default function LatestActivity({ operation, besideDownload, onDismiss }: {progress}

{nextStep}

- {!working && } + {!working && } diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index f0df045..3320a44 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -148,6 +148,7 @@ type OperationEvent = { } type OperationProgress = { + summary?: { title: string; message: string; next: string; action?: string } id: string label: string status: 'running' | 'complete' | 'error' | string @@ -601,8 +602,22 @@ export default function RequestTimelinePage() { try { result = await response.clone().json() } catch { /* Non-JSON error is handled below. */ } const needsAttention = !response.ok || result?.status === 'attention' || result?.outcome === 'attention' const finalState = needsAttention ? 'error' : result?.status === 'searching' ? 'searching' : 'complete' + const interactiveSearch = /\/actions\/search(?:\?|$)/.test(url) + const items = Array.isArray(result?.releases) ? result.releases : [] + const available = items.some((item: ReleaseOption) => item.selectable && !item.requiresOverride) + const outside = items.some((item: ReleaseOption) => item.selectable && item.requiresOverride) + const canChoose = available || (outside && result?.canIgnoreProfileLimits === true) + const summary = interactiveSearch + ? !response.ok + ? { title: 'Search could not finish', message: 'We could not complete the search.', next: 'Try again shortly. If it keeps happening, contact an admin.' } + : canChoose + ? { title: available ? 'Downloads found' : 'Other versions are available', message: available ? 'Choose the version you want to download.' : 'These versions are outside your usual download settings.', next: available ? 'Your download starts after you choose a version.' : 'You can review them and confirm a download outside your profile.', action: 'Choose a version' } + : { title: items.length ? 'No suitable downloads' : 'Nothing available yet', message: items.length ? 'The versions found cannot be downloaded with your current settings.' : 'No downloads were found in this search.', next: result?.nextOffset != null ? 'You can check the next group of missing episodes.' : 'You can try again later.', action: result?.nextOffset != null ? 'View search results' : undefined } + : response.ok && /\/actions\/grab$/.test(url) + ? { title: 'Waiting to start', message: 'Your download has been sent.', next: 'Close this box to follow its progress. It may take a moment to start.' } + : undefined setOperationProgress((current) => current?.id === operationId ? { - ...current, status: finalState, + ...current, status: finalState, summary, events: [...current.events.map(event => event.state === 'active' ? { ...event, state: 'complete' as const } : event), { id: 'result', service: 'Magent', state: needsAttention ? 'error' : 'complete', message: result?.message || (typeof result?.detail === 'string' ? result.detail : response.ok ? 'Action completed. The pipeline will update as the media services report progress.' : 'The action failed. Recheck the request before trying again.') }], } : current) diff --git a/scripts/review_manual_selection_ui.cjs b/scripts/review_manual_selection_ui.cjs index 9730eb1..7552cd9 100644 --- a/scripts/review_manual_selection_ui.cjs +++ b/scripts/review_manual_selection_ui.cjs @@ -28,7 +28,7 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114'; allowed = permission; await page.setViewportSize({ width, height: 950 }); await page.goto(base + '/requests/42'); await page.getByRole('button', { name: 'Search and choose a download', exact: true }).first().click(); - await page.getByRole('button', { name: 'Dismiss activity' }).click(); + await page.getByRole('button', { name: allowed ? 'Choose a version' : 'View search results', exact: true }).click(); const picker = page.getByRole('dialog', { name: 'Choose an available download' }); await picker.waitFor(); await picker.getByText('WEBDL-2160p is not wanted in profile', { exact: true }).waitFor(); assert.equal(await picker.getByText('Best pick', { exact: true }).count(), 0); @@ -47,9 +47,9 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114'; assert.equal(writes.length, before); page.once('dialog', dialog => dialog.accept()); await picker.getByRole('button', { name: 'Download outside profile' }).click(); - await page.getByRole('dialog', { name: 'Done', exact: true }).getByText('Your download has been sent.', { exact: true }).waitFor(); + await page.getByRole('dialog', { name: 'Waiting to start', exact: true }).getByText('Your download has been sent.', { exact: true }).waitFor(); assert.equal(await page.locator('.activity-dialog-events').count(), 0); - assert.equal(await page.getByRole('dialog', { name: 'Done', exact: true }).getByRole('progressbar').getAttribute('aria-valuenow'), '100'); + assert.equal(await page.getByRole('dialog', { name: 'Waiting to start', exact: true }).getByRole('progressbar').getAttribute('aria-valuenow'), '100'); assert.equal(writes.at(-1).ignoreProfileLimits, true); assert.equal(writes.at(-1).selectionToken, 'signed-fixture'); } diff --git a/scripts/review_search_outcomes_ui.cjs b/scripts/review_search_outcomes_ui.cjs new file mode 100644 index 0000000..ff9bc5a --- /dev/null +++ b/scripts/review_search_outcomes_ui.cjs @@ -0,0 +1,39 @@ +const assert = require('node:assert/strict'); +const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright'); +const base = process.env.REVIEW_BASE || 'http://localhost:3114'; +(async () => { + const browser = await chromium.launch(); + try { + const context = await browser.newContext(); + await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]); + let scenario = 'empty'; let allowed = true; const writes = [], errors = []; + await context.route('**/api/**', async route => { + const request = route.request(), path = new URL(request.url()).pathname; + const reply = json => route.fulfill({ json }); + if (path === '/api/auth/me') return reply({ username: 'Viewer', role: 'user', features: { requests: true, ignore_profile_limits: allowed } }); + if (path.endsWith('/snapshot')) return reply({ request_id: '42', title: 'Trying', request_type: 'tv', state: 'ADDED_TO_ARR', timeline: [], actions: [{ id: 'search_releases', label: 'Search and choose a download', requires_confirmation: false }], presentation: { status: { label: 'Waiting', meaning: 'Two missing episodes.' }, nextStep: { title: 'Search', description: 'Find missing episodes.', actionIds: ['search_releases'] }, pipeline: [] } }); + if (path.endsWith('/language')) return reply({ language: null }); + if (path.includes('/operations/')) return reply({ id: path.split('/').pop(), status: 'complete', events: [] }); + if (path.endsWith('/actions/search')) return scenario === 'error' ? route.fulfill({ status: 502, json: { detail: 'Service unavailable' } }) : reply({ status: 'ok', outcome: 'attention', releases: [], nextOffset: null }); + if (path.endsWith('/actions/grab')) { writes.push(request.postDataJSON()); return reply({ status: 'ok', message: 'Selected release sent to Sonarr.' }); } + if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' }); + return reply({}); + }); + const page = await context.newPage(); page.on('pageerror', e => errors.push(e.message)); + for (const width of [1440, 390]) { + for (const test of ['empty', 'error']) { + scenario = test; + await page.setViewportSize({width, height: 950}); + await page.goto(base + '/requests/42'); + await page.getByRole('button', {name: 'Search and choose a download', exact:true}).first().click(); + const dialog = page.getByRole('dialog', {name: test === 'empty' ? 'Nothing available yet' : 'Search could not finish', exact:true}); + await dialog.waitFor(); + assert.equal(await dialog.getByRole('button', {name:'Choose a version'}).count(), 0); + await dialog.getByText(test === 'empty' ? 'You can try again later.' : 'Try again shortly. If it keeps happening, contact an admin.', {exact:true}).waitFor(); + assert(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)); + } + } + assert.deepEqual(errors, []); + console.log('Passed: empty search and unavailable service outcomes on desktop/mobile; no unavailable selection action or overflow. APIs intercepted.'); + } finally { await browser.close(); } +})().catch(e => { console.error(e); process.exitCode = 1; });