Show live repair progress on requests
Magent CI/CD / verify (push) Canceled after 4m53s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-01 21:17:50 +12:00
parent 6391fbfd81
commit 3fc52f70c7
5 changed files with 469 additions and 5 deletions
+41 -3
View File
@@ -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
)
+220
View File
@@ -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"])
+88
View File
@@ -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)
+62
View File
@@ -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; }
}
+58 -2
View File
@@ -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<string, any>
}
@@ -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() {
)}
</section>
{repairActivity?.visible && (
<section className={`request-repair-activity is-${repairActivity.state ?? 'searching'}`} aria-live="polite">
<div className="request-repair-heading">
<div>
<span className="section-kicker">Repair activity</span>
<h2>{repairActivity.headline ?? 'Repair in progress'}</h2>
<p>{repairActivity.message ?? 'Magent is checking the repair with the connected services.'}</p>
</div>
<div className="request-repair-meta">
<span className={`request-operation-status is-${repairActivity.state === 'attention' ? 'error' : repairActivity.state === 'complete' ? 'complete' : 'running'}`}>
{repairActivity.state === 'complete' ? 'Complete' : repairActivity.state === 'attention' ? 'Attention' : 'Live'}
</span>
{repairActivity.updatedAt && <small>Updated {formatWhen(repairActivity.updatedAt)}</small>}
</div>
</div>
<div className="request-repair-steps">
{(repairActivity.steps ?? []).map((step) => (
<div className={`request-repair-step is-${step.state}`} key={step.id}>
<i aria-hidden="true" />
<div>
<strong>{step.label}</strong>
<span>{step.detail}</span>
</div>
</div>
))}
</div>
</section>
)}
<section className="request-journey" aria-labelledby="request-journey-heading">
<div className="request-journey-heading">
<div>