Track repair collection cycles and reconcile request availability
This commit is contained in:
@@ -19,6 +19,8 @@ from ..db import (
|
||||
get_request_cache_payload,
|
||||
get_request_cache_by_id,
|
||||
get_request_download_evidence,
|
||||
get_request_repairs,
|
||||
complete_request_repair,
|
||||
get_recent_snapshots,
|
||||
get_setting,
|
||||
set_setting,
|
||||
@@ -28,6 +30,7 @@ from ..db import (
|
||||
)
|
||||
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
||||
from .collector_search import read_search_status
|
||||
from .media_repair import current_cycle_torrents, evaluate_media_repair
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -975,9 +978,83 @@ def _build_presentation(
|
||||
}
|
||||
|
||||
|
||||
def _apply_repair_presentation(
|
||||
snapshot: Snapshot, repairs: List[Dict[str, Any]], arr_details: Dict[str, Any],
|
||||
arr_state: str, download: Dict[str, Any], catalog_found: bool,
|
||||
jellyfin_item: Any, public_url: Optional[str],
|
||||
) -> None:
|
||||
"""Describe the replacement, without erasing approval or unaffected episodes."""
|
||||
imported = all(repair.get("phase") == "indexing" for repair in repairs)
|
||||
unavailable = arr_state == "error" or any(repair.get("phase") == "unavailable" for repair in repairs)
|
||||
latest = repairs[-1]
|
||||
activity = _build_repair_activity(
|
||||
snapshot,
|
||||
action={"action_id": latest.get("actionId"), "status": "ok",
|
||||
"created_at": latest.get("startedAt"), "message": "A new collection cycle was requested."},
|
||||
arr_state="available" if imported else arr_state,
|
||||
arr_details={"availability": {"total": 1, "missing": 0}} if imported else arr_details,
|
||||
download=download, jellyfin_found=False,
|
||||
) or {}
|
||||
search = (arr_details.get("search") or {}).get("state")
|
||||
pipeline = {stage["id"]: stage for stage in snapshot.presentation["pipeline"]}
|
||||
if imported:
|
||||
label = "Replacement collected — updating Grizzlyflix"
|
||||
meaning = "The replacement has been imported. Waiting for Grizzlyflix to index the updated file."
|
||||
snapshot.state = NormalizedState.importing
|
||||
pipeline["download"].update(state="complete", summary="The replacement has been imported.", torrents=[], visible=False)
|
||||
pipeline["available"].update(label="Updating Grizzlyflix", state="active", stateLabel="Indexing", summary=meaning)
|
||||
snapshot.presentation["nextStep"] = {
|
||||
"title": "Wait for the updated file", "description": "This page will update when Grizzlyflix confirms the replacement.", "actionIds": [],
|
||||
}
|
||||
elif unavailable:
|
||||
label = "Repair status temporarily unavailable"
|
||||
meaning = "Magent cannot verify the replacement right now. The old library entry is not confirmation that the repair is complete."
|
||||
snapshot.state = NormalizedState.unknown
|
||||
pipeline["available"].update(state="waiting", stateLabel="Unconfirmed", summary="Waiting for the replacement to be verified.")
|
||||
snapshot.presentation["nextStep"] = {"title": "Recheck the request", "description": "Magent will retry automatically. You can also use Recheck request.", "actionIds": []}
|
||||
activity.update(state="attention", headline=label, message=meaning)
|
||||
elif download.get("visible"):
|
||||
label = activity.get("headline", "Replacement in progress")
|
||||
meaning = activity.get("message", "Magent is tracking the replacement download.")
|
||||
snapshot.state = NormalizedState.importing if download.get("state") == "completed" else NormalizedState.downloading
|
||||
else:
|
||||
label = "Searching for a replacement" if search == "searching" else "Replacement search queued" if search == "queued" else "Waiting for a replacement"
|
||||
meaning = "The affected content is being replaced. " + {
|
||||
"searching": "The collector is looking for a suitable release.",
|
||||
"queued": "The collector has queued the search.",
|
||||
"idle": "No download has started and the collector is not currently searching.",
|
||||
}.get(search, "Magent cannot currently confirm the search status.")
|
||||
snapshot.state = NormalizedState.searching if search in {"searching", "queued"} else NormalizedState.added_to_arr
|
||||
pipeline["download"].update(label="Replacement download", state="waiting", stateLabel="Pending",
|
||||
summary="Waiting for a replacement download to start.", torrents=[], visible=False)
|
||||
activity.update(state="searching" if search in {"searching", "queued"} else "waiting", headline=label, message=meaning)
|
||||
previous_activity = snapshot.presentation.get("repairActivity") or {}
|
||||
if search not in {"searching", "queued"} and previous_activity.get("state") == "attention" and (previous_activity.get("updatedAt") or "") >= latest["startedAt"]:
|
||||
label, meaning = "Repair needs attention", str(previous_activity.get("message") or meaning)
|
||||
activity.update(state="attention", headline=label, message=meaning)
|
||||
snapshot.presentation["status"] = {"label": label, "meaning": meaning}
|
||||
snapshot.presentation["repairActivity"] = activity
|
||||
if not imported:
|
||||
pipeline["available"].update(summary="The affected content will be available after the replacement is imported and indexed.")
|
||||
|
||||
counts = arr_details.get("availability") or {}
|
||||
targets = {episode.get("id") for repair in repairs for episode in repair.get("episodes", [])}
|
||||
# For a series, keep a route to unaffected episodes without claiming that the
|
||||
# repaired ones are ready (even while a stale series entry remains indexed).
|
||||
has_unaffected = snapshot.request_type == RequestType.tv and int(counts.get("available") or 0) > (len(targets) if imported else 0)
|
||||
if has_unaffected and catalog_found and isinstance(jellyfin_item, dict) and jellyfin_item.get("Id"):
|
||||
link = f"{public_url.rstrip('/')}/web/index.html#!/details?id={quote(str(jellyfin_item['Id']))}" if public_url else None
|
||||
pipeline["available"].update(label="Partially available", state="partial", stateLabel="Repair in progress",
|
||||
summary="Other collected episodes remain available. The selected episodes are being replaced." if not imported else "Other episodes remain available. Waiting for Grizzlyflix to index the repaired episodes.", link=link)
|
||||
snapshot.raw["jellyfin"].update(partial=True, link=link)
|
||||
|
||||
|
||||
async def build_snapshot(request_id: str) -> Snapshot:
|
||||
timeline = []
|
||||
runtime = get_runtime_settings()
|
||||
repair_records = await asyncio.to_thread(get_request_repairs, request_id, active_only=False)
|
||||
repair_cycle = repair_records[-1]["startedAt"] if repair_records else None
|
||||
active_repairs = [record for record in repair_records if not record.get("completedAt")]
|
||||
|
||||
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
@@ -1129,6 +1206,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
arr_details: Dict[str, Any] = {}
|
||||
arr_item = None
|
||||
arr_queue = None
|
||||
episodes = None
|
||||
media_status = jelly_request.get("media", {}).get("status")
|
||||
try:
|
||||
media_status_code = int(media_status) if media_status is not None else None
|
||||
@@ -1168,6 +1246,8 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
"state": await read_search_status(sonarr, RequestType.tv, series_id, episodes)
|
||||
}
|
||||
arr_details["availability"] = _episode_availability(episodes)
|
||||
counts = arr_details["availability"]
|
||||
arr_state = "available" if counts.get("total", 0) > 0 and not counts.get("missing") else "added"
|
||||
missing_by_season = _missing_episode_numbers_by_season(episodes)
|
||||
if missing_by_season:
|
||||
arr_details["missingEpisodes"] = missing_by_season
|
||||
@@ -1276,7 +1356,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
jellyfin_item = item
|
||||
break
|
||||
|
||||
if jellyfin_available and arr_state == "missing" and runtime.jellyfin_sync_to_arr:
|
||||
if jellyfin_available and not active_repairs and arr_state == "missing" and runtime.jellyfin_sync_to_arr:
|
||||
arr_details["note"] = "Found in Jellyfin but not tracked in Sonarr/Radarr."
|
||||
if snapshot.request_type == RequestType.movie:
|
||||
if runtime.radarr_quality_profile_id and runtime.radarr_root_folder:
|
||||
@@ -1317,6 +1397,30 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
catalog_found = jellyfin_available
|
||||
pending_repairs = []
|
||||
for repair in active_repairs:
|
||||
try:
|
||||
evidence = await evaluate_media_repair(
|
||||
repair, arr_item, {"found": catalog_found, "item": jellyfin_item}, episodes=episodes,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Unable to verify replacement request_id=%s", request_id)
|
||||
evidence = {"complete": False, "phase": "unavailable"}
|
||||
if evidence.get("complete"):
|
||||
await asyncio.to_thread(complete_request_repair, repair["id"])
|
||||
else:
|
||||
pending_repairs.append({**repair, "phase": evidence.get("phase")})
|
||||
repair_imported = bool(pending_repairs) and all(r["phase"] == "indexing" for r in pending_repairs)
|
||||
if pending_repairs:
|
||||
# Jellyfin can retain the original item while its replacement is missing.
|
||||
jellyfin_available = False
|
||||
if arr_state == "available" and not repair_imported:
|
||||
arr_state = "added"
|
||||
elif snapshot.request_type == RequestType.movie and arr_state == "added":
|
||||
# Also reconcile externally removed files, not only Magent repairs.
|
||||
jellyfin_available = False
|
||||
|
||||
qbit_state = "not_started"
|
||||
qbit_message = "No download attempt has been observed."
|
||||
download_ids = _download_ids(_queue_records(arr_queue))
|
||||
@@ -1333,6 +1437,17 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
request_tag = f"magent-{request_id}"
|
||||
torrents = await qbittorrent.get_torrents_by_tag(request_tag)
|
||||
torrent_list = torrents if isinstance(torrents, list) else []
|
||||
unfiltered_torrents = torrent_list
|
||||
torrent_list = current_cycle_torrents(torrent_list, repair_cycle)
|
||||
discarded_hashes = {str(t.get("hash") or "").lower() for t in unfiltered_torrents if t not in torrent_list}
|
||||
if repair_cycle and not download_history.get("observed"):
|
||||
current_hashes = {str(t.get("hash") or "").lower() for t in torrent_list}
|
||||
discarded_hashes.update(
|
||||
str(h).lower() for repair in active_repairs for h in repair.get("previousDownloadIds", [])
|
||||
if str(h).lower() not in current_hashes
|
||||
)
|
||||
download_ids = [h for h in download_ids if h.lower() not in discarded_hashes]
|
||||
download_visible = bool(download_ids) or bool(download_history.get("observed"))
|
||||
for torrent in torrent_list:
|
||||
if isinstance(torrent, dict):
|
||||
torrent["progressPercent"] = _torrent_progress(torrent)
|
||||
@@ -1476,8 +1591,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
)
|
||||
)
|
||||
|
||||
download_ids = _download_ids(_queue_records(arr_queue))
|
||||
if download_ids and qbittorrent.configured():
|
||||
if download_ids and qbittorrent.configured() and qbit_state == "paused":
|
||||
actions.append(
|
||||
ActionOption(
|
||||
id="resume_torrent",
|
||||
@@ -1506,12 +1620,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
if jellyfin_available and not is_partial:
|
||||
snapshot.actions = []
|
||||
snapshot.raw = {
|
||||
"repairCycle": repair_cycle,
|
||||
"jellyseerr": jelly_request,
|
||||
"arr": {
|
||||
"item": arr_item,
|
||||
"queue": arr_queue,
|
||||
"episodes": episodes,
|
||||
},
|
||||
"jellyfin": {
|
||||
"catalogFound": catalog_found,
|
||||
"publicUrl": runtime.jellyfin_public_url,
|
||||
"found": jellyfin_available,
|
||||
"available": jellyfin_available and snapshot.state in {
|
||||
@@ -1550,6 +1667,12 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
)
|
||||
if repair_activity:
|
||||
snapshot.presentation["repairActivity"] = repair_activity
|
||||
snapshot.presentation["repairCycle"] = repair_cycle
|
||||
if pending_repairs:
|
||||
_apply_repair_presentation(
|
||||
snapshot, pending_repairs, arr_details, arr_state, download_presentation,
|
||||
catalog_found, jellyfin_item, runtime.jellyfin_public_url,
|
||||
)
|
||||
status_presentation = snapshot.presentation.get("status")
|
||||
if isinstance(status_presentation, dict) and status_presentation.get("meaning"):
|
||||
snapshot.state_reason = str(status_presentation["meaning"])
|
||||
|
||||
Reference in New Issue
Block a user