Show live repair progress on requests
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user