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)