diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index da99095..5d94c68 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -117,7 +117,7 @@ def _operation_result_message( if "/queue" in normalized_path and normalized_method == "GET": return _queue_result_message(service, result) if "/command" in normalized_path and normalized_method == "POST": - return f"{service} accepted the {_command_name(payload)} and queued it for processing." + return f"{service} accepted the {_command_name(payload)} and put it in line to run. This does not mean a download has started." if "/release" in normalized_path: if normalized_method == "GET": count = len(_result_items(result, "records", "items")) @@ -129,10 +129,10 @@ def _operation_result_message( return f"{service} accepted the selected release and sent it to the download client." if "/qualityprofile" in normalized_path and normalized_method == "GET": count = len(_result_items(result)) - return f"{service} returned {_count_message(count, 'available quality profile')}." + return f"{service} returned {_count_message(count, 'download quality setting')}." if "/rootfolder" in normalized_path and normalized_method == "GET": count = len(_result_items(result)) - return f"{service} returned {_count_message(count, 'configured library location')}." + return f"{service} returned {_count_message(count, 'library folder')}." if "/indexer" in normalized_path and normalized_method == "GET": count = len(_result_items(result)) return f"{service} reports {_count_message(count, 'configured search source')}." @@ -174,7 +174,7 @@ def _operation_result_message( if "/health" in normalized_path: issues = _result_items(result) if not issues: - return "Prowlarr reports that all configured indexers are healthy." + return "The download search sources are working normally." first = next((item for item in issues if isinstance(item, dict)), {}) detail = str(first.get("message") or first.get("source") or "").strip() suffix = f" First issue: {detail}" if detail else "" @@ -182,9 +182,9 @@ def _operation_result_message( if "/search" in normalized_path: results = _result_items(result, "results", "records") return ( - f"Prowlarr found {_count_message(len(results), 'possible release')}." + f"Prowlarr found {_count_message(len(results), 'possible download')}." if results - else "Prowlarr did not find any possible releases." + else "Prowlarr did not find any possible downloads." ) if service == "Bazarr" and normalized_method == "PATCH" and "/subtitles" in normalized_path: @@ -193,9 +193,9 @@ def _operation_result_message( return f"Bazarr accepted a fresh {language} subtitle search for the {target}." if normalized_method == "GET": - return f"{service} completed the check successfully." + return f"{service} finished this check without reporting a problem." if normalized_method == "POST": - return f"{service} accepted the request and started processing it." + return f"{service} received the request. Its result will be checked separately." if normalized_method == "PUT": return f"{service} saved the requested changes." if normalized_method == "DELETE": @@ -247,7 +247,7 @@ def _operation_messages(service: str, method: str, path: str) -> tuple[str, str] if service == "Bazarr" and "/subtitles" in normalized_path and normalized_method == "PATCH": return "Asking Bazarr for fresh subtitles…", "Bazarr started the subtitle search" if service == "Prowlarr" and "/health" in normalized_path: - return "Checking Prowlarr indexer health…", "Prowlarr returned its indexer health" + return "Checking whether the download search sources are working…", "Prowlarr returned its indexer health" return f"Contacting {service}…", f"{service} responded" diff --git a/backend/app/clients/jellyfin.py b/backend/app/clients/jellyfin.py index 220dc04..fa97476 100644 --- a/backend/app/clients/jellyfin.py +++ b/backend/app/clients/jellyfin.py @@ -15,9 +15,9 @@ def _availability_message(result: Any) -> str: or (isinstance(items, list) and len(items) > 0) ) return ( - "The title is available to watch in Jellyfin." + "Grizzlyflix returned possible matches. Magent still needs to check the exact title and file." if available - else "The title is not currently available in Jellyfin." + else "Grizzlyflix did not find this title in its library search." ) diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py index 52d5c69..f367faa 100644 --- a/backend/app/clients/qbittorrent.py +++ b/backend/app/clients/qbittorrent.py @@ -8,20 +8,26 @@ from ..services.operation_progress import finish_remote_call, start_remote_call def _torrent_state_text(state: Any) -> str: normalized = str(state or "").strip().lower() - if "pause" in normalized: + if normalized in {"uploading", "stalledup", "forcedup", "queuedup", "pausedup", "stoppedup", "completed"}: + return "finished" + if "pause" in normalized or normalized == "stoppeddl": return "paused" if "stall" in normalized: - return "stalled" + return "waiting for data" if normalized.startswith("queued"): return "waiting in the queue" - if "downloading" in normalized or normalized in {"forcedl", "metadl", "checkingdl"}: + if normalized == "metadl": + return "getting the download details" + if normalized in {"checkingdl", "checkingup", "checkingresumedata"}: + return "checking the downloaded files" + if "downloading" in normalized or normalized in {"forcedl", "forceddl"}: return "downloading" - if "upload" in normalized or normalized in {"stalledup", "forcedup"}: - return "finished and seeding" + if "upload" in normalized: + return "downloaded and sharing with others" if normalized in {"completed", "missingfiles"}: return "finished" if normalized == "completed" else "missing files" if "error" in normalized: - return "in an error state" + return "unable to continue" return "present" @@ -31,14 +37,14 @@ def _torrent_result_message(result: Any) -> str: return "qBittorrent found no matching downloads." first = next((item for item in torrents if isinstance(item, dict)), {}) if len(torrents) == 1: - name = str(first.get("name") or "the matching download").strip() progress = first.get("progress") progress_text = ( - f" and {max(0, min(100, round(progress * 100)))}% complete" + f" — {max(0, min(100, round(progress * 100)))}% complete" if isinstance(progress, (int, float)) else "" ) - return f'qBittorrent found "{name}"; it is {_torrent_state_text(first.get("state"))}{progress_text}.' + state_text = _torrent_state_text(first.get("state")) + return f'{"Downloading" if state_text == "downloading" else "The download is " + state_text}{progress_text}.' active = sum( 1 for item in torrents diff --git a/backend/app/services/operation_progress.py b/backend/app/services/operation_progress.py index 9bf1183..8984e94 100644 --- a/backend/app/services/operation_progress.py +++ b/backend/app/services/operation_progress.py @@ -70,7 +70,7 @@ def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> To "id": uuid.uuid4().hex, "service": "Magent", "state": "complete", - "message": "Magent received the action.", + "message": "Your action has been received. Magent is starting the checks.", "started_at": now_iso, "finished_at": now_iso, "duration_ms": 0, @@ -178,9 +178,9 @@ def finish_operation(operation_id: str, *, success: bool, status_code: Optional[ "service": "Magent", "state": "complete" if success else "error", "message": ( - "Magent finished processing the action." + "This action has finished. Check the request status for what happens next." if success - else "Magent could not complete the action." + else "This action could not be completed. Open the activity details to see which step needs attention." ), "started_at": now_iso, "finished_at": now_iso, diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 22bf1e3..80f17e8 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -240,7 +240,7 @@ class OperationMessageTests(unittest.TestCase): ) self.assertEqual(queue_message, "Sonarr has no matching downloads in its queue.") - self.assertEqual(health_message, "Prowlarr reports that all configured indexers are healthy.") + self.assertEqual(health_message, "The download search sources are working normally.") def test_download_and_jellyfin_results_include_actual_state(self) -> None: torrent_message = _torrent_result_message( @@ -249,11 +249,11 @@ class OperationMessageTests(unittest.TestCase): self.assertEqual( torrent_message, - 'qBittorrent found "Arrival.2016"; it is downloading and 42% complete.', + 'Downloading — 42% complete.', ) self.assertEqual( _availability_message({"TotalRecordCount": 0, "Items": []}), - "The title is not currently available in Jellyfin.", + "Grizzlyflix did not find this title in its library search.", ) def test_bazarr_subtitle_search_is_explained_in_plain_english(self) -> None: @@ -270,6 +270,16 @@ class OperationMessageTests(unittest.TestCase): "Bazarr accepted a fresh EN subtitle search for the selected episode.", ) + def test_library_search_does_not_claim_playable_media(self) -> None: + message = _availability_message({"TotalRecordCount": 1, "Items": [{"Name": "Example"}]}) + self.assertIn("still needs to check the exact title and file", message) + self.assertNotIn("available to watch", message) + + def test_finished_or_paused_download_is_not_described_as_stuck(self) -> None: + for state in ["stalledUP", "stoppedUP", "pausedUP"]: + self.assertIn("finished", _torrent_result_message([{"state": state, "progress": 1}])) + self.assertIn("paused", _torrent_result_message([{"state": "stoppedDL", "progress": .3}])) + def test_http_failures_are_translated_without_exposing_stack_traces(self) -> None: self.assertEqual( _operation_error_message("Radarr", 500), diff --git a/frontend/UI.md b/frontend/UI.md index 84981b7..5387e5f 100644 --- a/frontend/UI.md +++ b/frontend/UI.md @@ -8,6 +8,7 @@ - Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions. - Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens. - A media repair starts a new collection cycle: preserve Requested/Approved and unaffected TV episodes, but do not reuse an old torrent or Jellyfin entry to mark the replacement ready. Show Pending → Downloading → Indexing → Available from collector/file evidence. Subtitle-only repairs must not reset video availability. +- Request action feedback uses a compact Latest activity card beside download status; full event history opens in a native modal dialog without expanding the page. Keep messages outcome-based and do not equate a successful service response with playable media. - Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both. - The guided issue form uses `portal/IssueFlowStep.tsx`: show one expanded step, collapse completed answers into Change rows, and keep repairs behind the final submit action. Movies use the selected title's managed file directly; only TV needs a season/episode picker. Multi-select controls must expose `aria-pressed` and a visible selected state. @@ -19,6 +20,7 @@ Build the frontend before reviewing. The scripts in `scripts/` run using Node an - `review_account_ui.cjs`: fixture-only login and profile interaction checks. - `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation. - `review_repair_pipeline_ui.cjs`: fixture-only movie/TV replacement stages, old-cycle progress rejection, desktop/mobile layout and automatic availability transitions. +- `review_activity_ui.cjs`: fixture-only latest activity, desktop/tablet/mobile placement, modal history, keyboard dismissal and focus restoration. - `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement. The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository. diff --git a/frontend/app/requests/[id]/LatestActivity.tsx b/frontend/app/requests/[id]/LatestActivity.tsx new file mode 100644 index 0000000..63a501e --- /dev/null +++ b/frontend/app/requests/[id]/LatestActivity.tsx @@ -0,0 +1,57 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import './latest-activity.css' + +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[] } + +export default function LatestActivity({ operation, besideDownload, onDismiss }: { + operation: Operation; besideDownload: boolean; onDismiss: () => void +}) { + const dialog = useRef(null) + const trigger = useRef(null) + const [open, setOpen] = useState(false) + const latest = [...operation.events].sort((a, b) => + (a.finished_at ?? a.started_at ?? '').localeCompare(b.finished_at ?? b.started_at ?? '') + ).at(-1) + const status = operation.status === 'running' ? 'Working' : operation.status === 'complete' ? 'Finished' : 'Needs attention' + + useEffect(() => { + if (!open) return + const element = dialog.current + element?.showModal() + const previous = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + element?.close() + document.body.style.overflow = previous + trigger.current?.focus() + } + }, [open]) + + return ( +
+ + setOpen(false)} onClose={() => setOpen(false)} onClick={(event) => { if (event.target === event.currentTarget) setOpen(false) }}> +
+
+
Activity details

{operation.label}

{status} · {operation.events.length} updates
+ +
+
    + {operation.events.map((event) =>
  1. + {event.state === 'active' ? 'Working' : event.state === 'error' ? 'Needs attention' : 'Done'} +
    {event.service}

    {event.message}

    +
  2. )} +
+ {operation.status !== 'running' &&
} +
+
+
+ ) +} diff --git a/frontend/app/requests/[id]/latest-activity.css b/frontend/app/requests/[id]/latest-activity.css new file mode 100644 index 0000000..e43ae98 --- /dev/null +++ b/frontend/app/requests/[id]/latest-activity.css @@ -0,0 +1,28 @@ +.latest-activity.beside-download { grid-column: 7 / -1; grid-row: 2; } +.latest-activity.full-row { grid-column: 1 / -1; } +.latest-activity .latest-activity-trigger { display: grid; gap: .6rem; width: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; box-shadow: none; text-transform: none; } +.latest-activity-trigger:focus-visible { outline: 2px solid var(--ops-accent, #83d7f7); outline-offset: 6px; } +.latest-activity-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; } +.latest-activity-message { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-size: .875rem; line-height: 1.5; font-weight: 400; } +.latest-activity-more { color: var(--ops-accent, #83d7f7); font-size: .75rem; } +.latest-activity-badge { font-size: .7rem; font-weight: 500; color: var(--ops-muted, #bbb); } +.latest-activity-badge.is-error { color: #ff9b9b; } +.latest-activity-badge.is-complete { color: #55dec0; } +.activity-dialog { position: fixed; inset: 0; margin: auto; width: min(720px, calc(100vw - 32px)); max-width: none; max-height: min(760px, calc(100dvh - 40px)); padding: 0; border: 1px solid var(--ops-border, #444); border-radius: 16px; color: var(--ops-text, #eee); background: var(--ops-surface, #1c1c1e); overflow: auto; box-shadow: 0 24px 80px #0008; } +.activity-dialog::backdrop { background: #000a; backdrop-filter: blur(5px); } +.activity-dialog-content { padding: 1.25rem; } +.activity-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } +.activity-dialog h2 { font-size: 1.2rem; margin: .4rem 0; } +.activity-dialog small { color: var(--ops-muted, #bbb); } +.activity-dialog-events { list-style: none; padding: 0; margin: 1.25rem 0 0; display: grid; gap: .65rem; } +.activity-dialog-events li { display: grid; grid-template-columns: 85px minmax(0, 1fr); gap: .8rem; padding: .9rem; border: 1px solid var(--ops-border, #444); border-radius: 10px; } +.activity-dialog-events strong { font-size: .8rem; } +.activity-dialog-events p { margin: .3rem 0 0; font-size: .875rem; line-height: 1.5; overflow-wrap: anywhere; } +.activity-event-state { font-size: .7rem; color: #55dec0; } +.is-error > .activity-event-state { color: #ff9b9b; } +.is-active > .activity-event-state { color: #83d7f7; } +.activity-dialog footer { display: flex; justify-content: flex-end; margin-top: 1rem; } +@media (max-width: 720px) { + .latest-activity.beside-download { grid-column: 1 / -1; grid-row: auto; } + .activity-dialog-events li { grid-template-columns: 1fr; gap: .4rem; } +} diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index e2eec0f..b1aea40 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -1,6 +1,7 @@ 'use client' import PageHeading from '../../ui/PageHeading' +import LatestActivity from './LatestActivity' import Image from 'next/image' import { useParams, useRouter } from 'next/navigation' @@ -200,12 +201,6 @@ const torrentProgress = (torrent: Record) => { const formatProgress = (progress: number) => `${progress.toFixed(1).replace(/\.0$/, '')}% complete` -const formatDuration = (duration?: number | null) => { - if (typeof duration !== 'number' || Number.isNaN(duration)) return null - if (duration < 1000) return `${Math.max(0, Math.round(duration))}ms` - return `${(duration / 1000).toFixed(1)}s` -} - const mergeLiveDownload = (current: Snapshot, live: LiveDownloadProgress): Snapshot => { if (String(current.request_id) !== String(live.request_id)) return current if ((current.presentation?.repairCycle ?? null) !== (live.repairCycle ?? null)) return current @@ -583,7 +578,10 @@ export default function RequestTimelinePage() { }) if (!stopped && progressResponse.ok) { const progress = await progressResponse.json() - if (Array.isArray(progress?.events)) setOperationProgress(progress) + if (Array.isArray(progress?.events)) { + setOperationProgress(progress) + return progress as OperationProgress + } } } catch (error) { if (!stopped) console.error(error) @@ -594,8 +592,21 @@ export default function RequestTimelinePage() { const timer = window.setInterval(() => void refreshProgress(), 650) try { const response = await request - await refreshProgress() + const finalProgress = await refreshProgress() + if (!finalProgress || finalProgress.status === 'running') { + setOperationProgress((current) => current?.id === operationId ? { + ...current, status: response.ok ? 'complete' : 'error', + events: [...current.events, { id: 'result', service: 'Magent', state: response.ok ? 'complete' : 'error', + message: response.ok ? 'This action has finished. Check the request status for what happens next.' : 'This action could not be completed. Check the message beside the request controls.' }], + } : current) + } return response + } catch (error) { + setOperationProgress((current) => current?.id === operationId ? { + ...current, status: 'error', events: [...current.events, { id: 'connection-error', service: 'Magent', state: 'error', + message: 'The connection was interrupted. Recheck the request before trying the action again—it may already have started.' }], + } : current) + throw error } finally { stopped = true window.clearInterval(timer) @@ -753,6 +764,7 @@ export default function RequestTimelinePage() { {download?.lastSeenAt && !download?.torrents?.length && Last observed {formatWhen(download.lastSeenAt)}} )} + {operationProgress && setOperationProgress(null)} />}
@@ -793,43 +805,6 @@ export default function RequestTimelinePage() { {actionError ?? actionMessage}
)} - {operationProgress && ( -
-
-
- Remote activity - {operationProgress.label} -
-
- {formatDuration(operationProgress.duration_ms) && ( - {formatDuration(operationProgress.duration_ms)} - )} - - {operationProgress.status === 'running' ? 'In progress' : operationProgress.status} - - {operationProgress.status !== 'running' && ( - - )} -
-
-
- {operationProgress.events.map((event) => ( -
-