Track repair collection cycles and reconcile request availability
Magent CI/CD / verify (push) Successful in 10m47s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m49s

This commit is contained in:
2026-09-06 22:06:30 +12:00
parent 8d720de500
commit 009bb35032
11 changed files with 762 additions and 151 deletions
+43 -13
View File
@@ -51,7 +51,11 @@ from ..db import (
record_seerr_media_failure,
clear_seerr_media_failure,
get_request_download_evidence,
start_request_repair,
get_request_repairs,
active_repair_request_ids,
)
from ..services.media_repair import current_cycle_torrents
from ..models import Snapshot, TriageResult, RequestType
from ..services.snapshot import (
_summarize_qbit,
@@ -1401,6 +1405,7 @@ def _get_recent_from_cache(
status_codes: Optional[list[int]] = None,
) -> List[Dict[str, Any]]:
items = _recent_cache.get("items") or []
repairing = active_repair_request_ids()
results = []
since_dt = _parse_iso_datetime(since_iso)
for item in items:
@@ -1414,6 +1419,8 @@ def _get_recent_from_cache(
item_dt = _parse_iso_datetime(candidate)
if not item_dt or item_dt < since_dt:
continue
if str(item.get("request_id")) in repairing:
item = {**item, "status": 5, "repairing": True}
if status_codes and item.get("status") not in status_codes:
continue
results.append(item)
@@ -1862,6 +1869,9 @@ def _record_replacement_activity(
message: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
tracking = (metadata or {}).get("repairTracking")
if event_type in {"replacement_started", "missing_search_started"} and isinstance(tracking, dict):
start_request_repair(tracking)
if not issue:
return
current_status = str(issue.get("status") or "new").strip().lower()
@@ -2183,6 +2193,27 @@ async def action_replace_media(
collector_id: Optional[int] = None
target_episodes: List[Dict[str, int]] = []
jellyfin_baseline: List[Dict[str, Any]] = []
repair_tracking: Dict[str, Any] = {}
def record_cycle() -> None:
repair_tracking.update({
"requestId": request_id,
"actionId": "replace_media",
"mediaType": snapshot.request_type.value,
"collectorId": collector_id,
"originalFileIds": file_ids,
"previousDownloadIds": list(dict.fromkeys(
list(snapshot.raw.get("qbittorrent", {}).get("downloadIds") or [])
+ [str(t.get("hash")) for t in snapshot.raw.get("qbittorrent", {}).get("torrents", []) if t.get("hash")]
)),
"episodes": target_episodes,
"jellyfinBaseline": jellyfin_baseline,
"jellyfinFoundAtStart": bool(snapshot.raw.get("jellyfin", {}).get("catalogFound",
snapshot.raw.get("jellyfin", {}).get("found"))),
"startedAt": datetime.now(timezone.utc).isoformat(),
})
start_request_repair(repair_tracking)
try:
if snapshot.request_type == RequestType.movie:
movie_id = arr_item.get("id")
@@ -2201,6 +2232,7 @@ async def action_replace_media(
if not radarr.configured():
raise HTTPException(status_code=400, detail="Radarr is not configured")
await radarr.monitor_movie(movie_id, True)
await asyncio.to_thread(record_cycle)
await radarr.delete_movie_file(file_ids[0])
await radarr.search(movie_id)
elif snapshot.request_type == RequestType.tv:
@@ -2263,6 +2295,7 @@ async def action_replace_media(
logger.warning("Could not capture Jellyfin episode baseline request_id=%s", request_id)
target_names = [_replacement_file_name(file_data) for file_data in selected_files]
await sonarr.monitor_episodes(episode_ids, True)
await asyncio.to_thread(record_cycle)
for selected_file_id in file_ids:
await sonarr.delete_episode_file(selected_file_id)
await sonarr.search_episodes(episode_ids)
@@ -2318,17 +2351,7 @@ async def action_replace_media(
event_type="replacement_started",
message=message,
metadata={
"repairTracking": {
"requestId": request_id,
"actionId": "replace_media",
"mediaType": snapshot.request_type.value,
"collectorId": collector_id,
"originalFileIds": file_ids,
"episodes": target_episodes,
"jellyfinBaseline": jellyfin_baseline,
"jellyfinFoundAtStart": bool(snapshot.raw.get("jellyfin", {}).get("found")),
"startedAt": datetime.now(timezone.utc).isoformat(),
}
"repairTracking": repair_tracking
},
)
return {
@@ -2688,6 +2711,8 @@ async def get_download_progress(
if seerr.configured():
await _ensure_request_access(seerr, int(request_id), user)
repairs = await asyncio.to_thread(get_request_repairs, request_id, active_only=False)
cycle = repairs[-1]["startedAt"] if repairs else None
evidence = await asyncio.to_thread(get_request_download_evidence, request_id, 20)
historical_torrents = evidence.get("torrents") if isinstance(evidence, dict) else []
hashes: List[str] = []
@@ -2717,7 +2742,7 @@ async def get_download_progress(
logger.warning("Live qBittorrent progress failed request_id=%s: %s", request_id, exc)
raise HTTPException(status_code=502, detail="Live download progress is temporarily unavailable") from exc
torrents = result if isinstance(result, list) else []
torrents = current_cycle_torrents(result, cycle)
for torrent in torrents:
if isinstance(torrent, dict):
torrent["progressPercent"] = _torrent_progress(torrent)
@@ -2739,6 +2764,8 @@ async def get_download_progress(
"summary": message,
"torrents": torrents,
"updated_at": datetime.now(timezone.utc).isoformat(),
"repairCycle": cycle,
"visible": bool(torrents) or bool(evidence.get("observed")),
}
@@ -2879,7 +2906,10 @@ async def recent_requests(
payload_json=json.dumps(details, ensure_ascii=True),
)
status_label = _status_label(status)
if status_label in {"Working on it", "Ready to watch", "Partially ready"}:
if row.get("repairing"):
status = 5
status_label = "Repair in progress"
elif status_label in {"Working on it", "Ready to watch", "Partially ready"}:
is_available = await _request_is_available_in_jellyfin(
jellyfin,
title,