From 3fc52f70c7cf65ce62a80847ea25ef510d708658 Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Tue, 1 Sep 2026 21:17:50 +1200 Subject: [PATCH] Show live repair progress on requests --- backend/app/routers/requests.py | 44 +++++- backend/app/services/snapshot.py | 220 ++++++++++++++++++++++++++ backend/tests/test_backend_quality.py | 88 +++++++++++ frontend/app/ops-redesign.css | 62 ++++++++ frontend/app/requests/[id]/page.tsx | 60 ++++++- 5 files changed, 469 insertions(+), 5 deletions(-) diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 64c791c..88e9269 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -2192,11 +2192,15 @@ async def action_replace_media( else: raise HTTPException(status_code=400, detail="Unknown request type") except HTTPException as exc: + detail = f"The media replacement could not be started: {exc.detail}" + await asyncio.to_thread( + save_action, request_id, "replace_media", "Replace media file", "failed", detail + ) _record_replacement_activity( linked_issue, user=user, event_type="replacement_failed", - message=f"The media replacement could not be started: {exc.detail}", + message=detail, ) raise except Exception as exc: @@ -2339,16 +2343,33 @@ async def action_search_missing_media( await sonarr.search(collector_id) message = "Sonarr refreshed the series and started a full missing-episode search." except HTTPException as exc: + detail = f"The missing-content search could not start: {exc.detail}" + await asyncio.to_thread( + save_action, + request_id, + "search_missing", + "Search for missing content", + "failed", + detail, + ) _record_replacement_activity( linked_issue, user=user, event_type="missing_search_failed", - message=f"The missing-content search could not start: {exc.detail}", + message=detail, ) raise except Exception as exc: logger.exception("missing content search failed request_id=%s", request_id) detail = "Sonarr/Radarr could not start the missing-content search." + await asyncio.to_thread( + save_action, + request_id, + "search_missing", + "Search for missing content", + "failed", + detail, + ) _record_replacement_activity( linked_issue, user=user, event_type="missing_search_failed", message=detail ) @@ -2416,16 +2437,33 @@ async def action_repair_subtitles( repaired_count = len(episode_ids) message = f"Bazarr started fresh {language.upper()} subtitle searches for {repaired_count} episode(s)." except HTTPException as exc: + detail = f"The subtitle repair could not start: {exc.detail}" + await asyncio.to_thread( + save_action, + request_id, + "repair_subtitles", + "Repair subtitles", + "failed", + detail, + ) _record_replacement_activity( linked_issue, user=user, event_type="subtitle_repair_failed", - message=f"The subtitle repair could not start: {exc.detail}", + message=detail, ) raise except Exception as exc: logger.exception("Bazarr subtitle repair failed request_id=%s", request_id) detail = "Bazarr could not start the subtitle repair." + await asyncio.to_thread( + save_action, + request_id, + "repair_subtitles", + "Repair subtitles", + "failed", + detail, + ) _record_replacement_activity( linked_issue, user=user, event_type="subtitle_repair_failed", message=detail ) diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index 26757aa..971163e 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -15,6 +15,7 @@ from ..clients.qbittorrent import QBittorrentClient from ..runtime import get_runtime_settings from ..db import ( save_snapshot, + get_recent_actions, get_request_cache_payload, get_request_cache_by_id, get_request_download_evidence, @@ -31,6 +32,8 @@ logger = logging.getLogger(__name__) JELLYFIN_SCAN_COOLDOWN_SECONDS = 300 _jellyfin_scan_key = "jellyfin_scan_last_at" +REPAIR_ACTIVITY_MAX_AGE = 7 * 24 * 60 * 60 +REPAIR_ACTION_IDS = {"replace_media", "search_missing", "repair_subtitles"} STATUS_LABELS = { @@ -430,6 +433,212 @@ def _torrent_progress(torrent: Dict[str, Any]) -> Optional[float]: return max(0.0, min(100.0, round(((size - amount_left) / size) * 100, 1))) +def _parse_action_time(value: Any) -> Optional[datetime]: + if not isinstance(value, str) or not value.strip(): + return None + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _latest_repair_action(request_id: str, *, now: Optional[datetime] = None) -> Optional[Dict[str, Any]]: + current_time = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + for action in get_recent_actions(request_id, 25): + if action.get("action_id") not in REPAIR_ACTION_IDS: + continue + created_at = _parse_action_time(action.get("created_at")) + if created_at is None: + continue + age_seconds = (current_time - created_at).total_seconds() + if 0 <= age_seconds <= REPAIR_ACTIVITY_MAX_AGE: + return action + return None + + +def _build_repair_activity( + snapshot: Snapshot, + *, + action: Optional[Dict[str, Any]], + arr_state: str, + arr_details: Dict[str, Any], + download: Dict[str, Any], + jellyfin_found: bool, +) -> Optional[Dict[str, Any]]: + if not action: + return None + + action_id = str(action.get("action_id") or "") + collector = ( + "Bazarr" + if action_id == "repair_subtitles" + else ("Sonarr" if snapshot.request_type == RequestType.tv else "Radarr") + ) + action_ok = str(action.get("status") or "").lower() == "ok" + action_message = str(action.get("message") or "The repair action was recorded.") + download_state = str(download.get("state") or "not_started") + download_visible = bool(download.get("visible")) + availability = arr_details.get("availability") + if not isinstance(availability, dict): + availability = {} + missing = int(availability.get("missing") or 0) + total = int(availability.get("total") or 0) + collection_complete = arr_state == "available" and ( + snapshot.request_type == RequestType.movie or (total > 0 and missing == 0) + ) + + submitted_step = { + "id": "submitted", + "label": "Repair requested", + "state": "complete", + "detail": "Magent recorded the issue and started the selected repair.", + } + + if not action_ok: + return { + "visible": True, + "actionId": action_id, + "state": "attention", + "headline": "Repair needs attention", + "message": action_message, + "service": collector, + "updatedAt": action.get("created_at"), + "steps": [ + submitted_step, + { + "id": "collector", + "label": f"{collector} hand-off", + "state": "attention", + "detail": action_message, + }, + ], + } + + if action_id == "repair_subtitles": + return { + "visible": True, + "actionId": action_id, + "state": "searching", + "headline": "Subtitle repair is running", + "message": ( + f"{action_message} Bazarr is checking the configured subtitle providers; " + "the issue can be confirmed once the replacement track is available." + ), + "service": collector, + "updatedAt": action.get("created_at"), + "steps": [ + submitted_step, + { + "id": "collector", + "label": "Bazarr accepted the search", + "state": "complete", + "detail": action_message, + }, + { + "id": "result", + "label": "Subtitle result", + "state": "active", + "detail": "Waiting for Bazarr to find and apply a suitable subtitle track.", + }, + ], + } + + if collection_complete: + headline = "Repair collected" + message = ( + f"{collector} now reports the replacement file as collected. " + + ( + "It is also available in Grizzlyflix." + if jellyfin_found + else "Grizzlyflix is indexing the updated file now." + ) + ) + state = "complete" if jellyfin_found else "indexing" + download_step_state = "complete" + available_step_state = "complete" if jellyfin_found else "active" + elif download_visible and download_state in {"downloading", "paused", "completed", "error", "missing"}: + state = { + "downloading": "downloading", + "completed": "importing", + "paused": "attention", + "error": "attention", + "missing": "attention", + }[download_state] + headline = { + "downloading": "Replacement download in progress", + "completed": "Replacement downloaded — waiting for import", + "paused": "Replacement download paused", + "error": "Replacement download cannot be checked", + "missing": "Replacement hand-off needs checking", + }[download_state] + message = { + "downloading": "The replacement is downloading now.", + "paused": "The replacement download is paused and needs attention.", + "completed": f"The download has finished and is waiting for {collector} to import it.", + "error": "Magent cannot currently read the replacement download from qBittorrent.", + "missing": "The collector reported a download, but it is not currently visible in qBittorrent.", + }[download_state] + download_step_state = "active" if download_state == "downloading" else ( + "complete" if download_state == "completed" else "attention" + ) + available_step_state = "waiting" + else: + state = "searching" + headline = "Replacement search in progress" + message = ( + f"{action_message} {collector} has accepted the search, but no replacement download " + "has been selected yet. Magent will keep checking." + ) + download_step_state = "waiting" + available_step_state = "waiting" + + return { + "visible": True, + "actionId": action_id, + "state": state, + "headline": headline, + "message": message, + "service": collector, + "updatedAt": action.get("created_at"), + "steps": [ + submitted_step, + { + "id": "collector", + "label": f"{collector} accepted the search", + "state": "complete", + "detail": action_message, + }, + { + "id": "download", + "label": "Replacement download", + "state": download_step_state, + "detail": ( + str(download.get("summary") or "Waiting for a suitable replacement release.") + if download_step_state != "waiting" + else "Waiting for a suitable release to be selected." + ), + }, + { + "id": "available", + "label": "Updated media available", + "state": available_step_state, + "detail": ( + "The repaired title is available in Grizzlyflix." + if jellyfin_found and collection_complete + else ( + "The media server is indexing the replacement." + if collection_complete + else "Waiting for download and import to finish." + ) + ), + }, + ], + } + + def _build_presentation( snapshot: Snapshot, *, @@ -1303,6 +1512,17 @@ async def build_snapshot(request_id: str) -> Snapshot: jellyfin_found=jellyfin_available, jellyfin_link=jellyfin_link, ) + repair_action = await asyncio.to_thread(_latest_repair_action, request_id) + repair_activity = _build_repair_activity( + snapshot, + action=repair_action, + arr_state=arr_state, + arr_details=arr_details, + download=download_presentation, + jellyfin_found=jellyfin_available, + ) + if repair_activity: + snapshot.presentation["repairActivity"] = repair_activity status_presentation = snapshot.presentation.get("status") if isinstance(status_presentation, dict) and status_presentation.get("meaning"): snapshot.state_reason = str(status_presentation["meaning"]) diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index e130551..52ecf03 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -36,6 +36,7 @@ from backend.app.services.operation_progress import ( from backend.app.services.snapshot import ( _apply_arr_identity, _build_presentation, + _build_repair_activity, _episode_availability, _torrent_progress, ) @@ -396,6 +397,93 @@ class RequestVisibilityTests(unittest.TestCase): class RequestPresentationTests(unittest.TestCase): + def test_repair_activity_shows_collector_search_before_download(self) -> None: + snapshot = Snapshot( + request_id="144", + title="Toy Story 2", + request_type=RequestType.movie, + state=NormalizedState.searching, + ) + + activity = _build_repair_activity( + snapshot, + action={ + "action_id": "replace_media", + "status": "ok", + "message": "Radarr removed the file and started a replacement search.", + "created_at": "2026-09-01T09:06:55+00:00", + }, + arr_state="searching", + arr_details={"availability": {"available": 0, "missing": 1, "total": 1}}, + download={"visible": False, "state": "not_started", "torrents": []}, + jellyfin_found=False, + ) + + self.assertIsNotNone(activity) + self.assertEqual(activity["state"], "searching") + self.assertEqual(activity["headline"], "Replacement search in progress") + self.assertEqual(activity["steps"][1]["state"], "complete") + self.assertEqual(activity["steps"][2]["state"], "waiting") + + def test_repair_activity_tracks_replacement_download(self) -> None: + snapshot = Snapshot( + request_id="144", + title="Toy Story 2", + request_type=RequestType.movie, + state=NormalizedState.downloading, + ) + + activity = _build_repair_activity( + snapshot, + action={ + "action_id": "replace_media", + "status": "ok", + "message": "Radarr started a replacement search.", + "created_at": "2026-09-01T09:06:55+00:00", + }, + arr_state="searching", + arr_details={"availability": {"available": 0, "missing": 1, "total": 1}}, + download={ + "visible": True, + "state": "downloading", + "summary": "Downloading (1 active).", + "torrents": [{"progress": 0.25}], + }, + jellyfin_found=False, + ) + + self.assertIsNotNone(activity) + self.assertEqual(activity["state"], "downloading") + self.assertEqual(activity["headline"], "Replacement download in progress") + self.assertEqual(activity["steps"][2]["state"], "active") + + def test_repair_activity_reports_collected_file_and_media_index(self) -> None: + snapshot = Snapshot( + request_id="144", + title="Toy Story 2", + request_type=RequestType.movie, + state=NormalizedState.importing, + ) + + activity = _build_repair_activity( + snapshot, + action={ + "action_id": "replace_media", + "status": "ok", + "message": "Radarr started a replacement search.", + "created_at": "2026-09-01T09:06:55+00:00", + }, + arr_state="available", + arr_details={"availability": {"available": 1, "missing": 0, "total": 1}}, + download={"visible": False, "state": "not_started", "torrents": []}, + jellyfin_found=False, + ) + + self.assertIsNotNone(activity) + self.assertEqual(activity["state"], "indexing") + self.assertEqual(activity["steps"][2]["state"], "complete") + self.assertEqual(activity["steps"][3]["state"], "active") + def test_torrent_progress_keeps_tenths_for_live_updates(self) -> None: self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4) diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index cca0750..c4aa6f1 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -1992,6 +1992,7 @@ button:disabled, } .request-overview, +.request-repair-activity, .request-journey, .request-advanced { border: 1px solid var(--ops-line); @@ -2005,6 +2006,63 @@ button:disabled, overflow: hidden; } +.request-repair-activity { + display: grid; + gap: 16px; + padding: 18px 20px; + border-color: rgba(126, 215, 255, 0.3); + background: + radial-gradient(circle at 6% 0%, rgba(14, 165, 233, 0.13), transparent 34%), + rgba(255, 255, 255, 0.018); +} +.request-repair-activity.is-complete { border-color: rgba(72, 224, 178, 0.34); } +.request-repair-activity.is-attention { border-color: rgba(255, 141, 157, 0.4); } +.request-repair-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} +.request-repair-heading h2 { margin: 0; font-size: clamp(1.15rem, 2vw, 1.55rem); } +.request-repair-heading p { max-width: 88ch; margin: 7px 0 0; color: var(--ops-muted); line-height: 1.5; } +.request-repair-meta { display: grid; justify-items: end; gap: 7px; color: var(--ops-muted); } +.request-repair-meta small { white-space: nowrap; font-size: 0.7rem; } +.request-repair-steps { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 9px; +} +.request-repair-step { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 9px; + min-width: 0; + padding: 12px; + border: 1px solid var(--ops-line-soft); + border-radius: var(--ops-radius); + background: rgba(255, 255, 255, 0.022); +} +.request-repair-step > i { + width: 10px; + height: 10px; + margin-top: 3px; + border: 2px solid currentColor; + border-radius: 50%; + color: var(--ops-muted); +} +.request-repair-step.is-complete > i { color: var(--request-green); background: currentColor; } +.request-repair-step.is-active > i { + color: var(--request-cyan); + box-shadow: 0 0 12px currentColor; + animation: request-operation-pulse 1.15s ease-in-out infinite; +} +.request-repair-step.is-attention > i { color: var(--request-red); background: currentColor; } +.request-repair-step > div { display: grid; gap: 4px; min-width: 0; } +.request-repair-step strong { color: var(--ops-text); font-size: 0.76rem; } +.request-repair-step span { color: var(--ops-muted); font-size: 0.72rem; line-height: 1.4; } + .request-overview-block { grid-column: span 6; display: grid; @@ -2288,6 +2346,7 @@ button:disabled, @media (max-width: 980px) { .request-stage { grid-column: span 6; } + .request-repair-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 720px) { @@ -2309,6 +2368,9 @@ button:disabled, .request-action-row button, .request-release button { width: 100%; } .request-operation-heading { align-items: flex-start; flex-direction: column; } + .request-repair-heading { flex-direction: column; } + .request-repair-meta { justify-items: start; } + .request-repair-steps { grid-template-columns: 1fr; } .request-operation-heading-actions { width: 100%; flex-wrap: wrap; } .request-operation-event { align-items: flex-start; } } diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index f03715d..f4eaba0 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -36,6 +36,24 @@ type PipelineStage = { link?: string | null } +type RepairActivityStep = { + id: string + label: string + state: 'complete' | 'active' | 'attention' | 'waiting' | string + detail: string +} + +type RepairActivity = { + visible?: boolean + actionId?: string + state?: 'searching' | 'downloading' | 'importing' | 'indexing' | 'complete' | 'attention' | string + headline?: string + message?: string + service?: string + updatedAt?: string | null + steps?: RepairActivityStep[] +} + type Snapshot = { request_id: string title: string @@ -57,6 +75,7 @@ type Snapshot = { } nextStep?: { title?: string; description?: string; actionIds?: string[] } pipeline?: PipelineStage[] + repairActivity?: RepairActivity } raw?: Record } @@ -288,6 +307,10 @@ export default function RequestTimelinePage() { (stage) => stage.id === 'available' && stage.state === 'active' ) ) + const repairIsActive = Boolean( + snapshot?.presentation?.repairActivity?.visible && + !['complete', 'attention'].includes(snapshot.presentation.repairActivity.state ?? '') + ) useEffect(() => { if (!requestId) return @@ -367,12 +390,15 @@ export default function RequestTimelinePage() { if (!stopped) console.error(error) } } - const timer = window.setInterval(() => void refresh(), awaitingMediaIndex ? 5_000 : 15_000) + const timer = window.setInterval( + () => void refresh(), + awaitingMediaIndex || repairIsActive ? 5_000 : 15_000, + ) return () => { stopped = true window.clearInterval(timer) } - }, [awaitingMediaIndex, requestId, router]) + }, [awaitingMediaIndex, repairIsActive, requestId, router]) const liveDownloadKey = useMemo(() => { const downloadStage = snapshot?.presentation?.pipeline?.find((stage) => stage.id === 'download') @@ -470,6 +496,7 @@ export default function RequestTimelinePage() { const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state) const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.' const download = presentation.download + const repairActivity = presentation.repairActivity const downloadVisible = Boolean(download?.visible) const nextStep = presentation.nextStep ?? { title: snapshot.actions[0]?.label ?? 'No action needed right now', @@ -753,6 +780,35 @@ export default function RequestTimelinePage() { )} + {repairActivity?.visible && ( +
+
+
+ Repair activity +

{repairActivity.headline ?? 'Repair in progress'}

+

{repairActivity.message ?? 'Magent is checking the repair with the connected services.'}

+
+
+ + {repairActivity.state === 'complete' ? 'Complete' : repairActivity.state === 'attention' ? 'Attention' : 'Live'} + + {repairActivity.updatedAt && Updated {formatWhen(repairActivity.updatedAt)}} +
+
+
+ {(repairActivity.steps ?? []).map((step) => ( +
+