from __future__ import annotations import json from datetime import datetime from typing import Any, Dict from ..clients.jellyfin import JellyfinClient from ..clients.sonarr import SonarrClient from ..runtime import get_runtime_settings def current_cycle_torrents(torrents: Any, cycle: str | None) -> list[Dict[str, Any]]: """Old seeding jobs are not proof of a replacement download. A same-hash retry is valid when it is downloading again or was added anew. Without a completion/add timestamp, a completed legacy job cannot prove that. """ rows = [item for item in torrents if isinstance(item, dict)] if isinstance(torrents, list) else [] if not cycle: return rows cutoff = datetime.fromisoformat(cycle).timestamp() def belongs(item: Dict[str, Any]) -> bool: try: progress = float(item.get("progress", 0)) completed = float(item.get("completion_on") or 0) added = float(item.get("added_on") or 0) except (TypeError, ValueError): return False return progress < 1 or max(completed, added) >= cutoff return [item for item in rows if belongs(item)] def _positive_ints(value: Any) -> list[int]: if not isinstance(value, list): return [] return [ int(item) for item in value if isinstance(item, int) and not isinstance(item, bool) and item > 0 ] def _media_signature(item: Any) -> Dict[str, str]: if not isinstance(item, dict): return {} result: Dict[str, str] = {} for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources"): value = item.get(key) if isinstance(value, (dict, list)): result[key] = json.dumps(value, separators=(",", ":"), sort_keys=True) elif value is not None and str(value).strip(): result[key] = str(value).strip() return result def _signature_changed(current: Dict[str, str], baseline: Dict[str, Any]) -> bool: previous = _media_signature(baseline) if not previous: return True return any(current.get(key) and current.get(key) != value for key, value in previous.items()) async def evaluate_media_repair( tracking: Dict[str, Any], arr_item: Any, jellyfin: Dict[str, Any], *, episodes: Any = None, ) -> Dict[str, Any]: request_id = str(tracking.get("requestId") or "").strip() action_id = str(tracking.get("actionId") or "").strip() media_type = str(tracking.get("mediaType") or "").strip().lower() collector_id = tracking.get("collectorId") if not request_id.isdigit() or media_type not in {"movie", "tv"} or not isinstance(collector_id, int): return {"complete": False, "phase": "invalid", "message": "Repair tracking information is incomplete."} jellyfin_item = jellyfin.get("item") original_file_ids = set(_positive_ints(tracking.get("originalFileIds"))) baselines = tracking.get("jellyfinBaseline") baselines = [item for item in baselines if isinstance(item, dict)] if isinstance(baselines, list) else [] found_at_start = tracking.get("jellyfinFoundAtStart") is True if not isinstance(arr_item, dict) or arr_item.get("id") != collector_id: return {"complete": False, "phase": "collecting", "message": "Waiting for the correct collector record."} if media_type == "movie": movie_file = arr_item.get("movieFile") if isinstance(arr_item, dict) else None current_file_id = movie_file.get("id") if isinstance(movie_file, dict) else None imported = arr_item.get("hasFile") is not False and isinstance(current_file_id, int) and current_file_id > 0 and current_file_id not in original_file_ids if not imported: return {"complete": False, "phase": "collecting", "message": "Waiting for Radarr to import the repaired movie file."} if not jellyfin.get("found") or not isinstance(jellyfin_item, dict): return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to index it."} current_signature = _media_signature(jellyfin_item) if action_id == "replace_media" and found_at_start: if not baselines or not _signature_changed(current_signature, baselines[0]): return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to refresh the existing title."} return {"complete": True, "phase": "complete", "message": "Radarr imported the repaired movie and Jellyfin has indexed the updated file."} target_rows = tracking.get("episodes") targets = [item for item in target_rows if isinstance(item, dict)] if isinstance(target_rows, list) else [] target_ids = { int(item["id"]) for item in targets if isinstance(item.get("id"), int) and int(item["id"]) > 0 } target_pairs = { (int(item["seasonNumber"]), int(item["episodeNumber"])) for item in targets if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int) } if not target_ids or not target_pairs: return {"complete": False, "phase": "invalid", "message": "No exact Sonarr episodes were recorded for this repair."} runtime = get_runtime_settings() sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) if episodes is None: episodes = await sonarr.get_episodes(collector_id) episode_map = { int(item["id"]): item for item in episodes if isinstance(item, dict) and isinstance(item.get("id"), int) } if isinstance(episodes, list) else {} imported = all( episode_id in episode_map and episode_map[episode_id].get("hasFile") is not False and ( episode_map[episode_id].get("hasFile") is True or ( isinstance(episode_map[episode_id].get("episodeFileId"), int) and episode_map[episode_id]["episodeFileId"] > 0 ) ) and episode_map[episode_id].get("episodeFileId") not in original_file_ids for episode_id in target_ids ) if not imported: return {"complete": False, "phase": "collecting", "message": "Waiting for Sonarr to import every repaired episode."} jellyfin_series_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if not jellyfin.get("found") or not jellyfin_series_id or not jellyfin_client.configured(): return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to index them."} jellyfin_episodes = await jellyfin_client.get_series_episodes(str(jellyfin_series_id)) current_by_pair = { (int(item["ParentIndexNumber"]), int(item["IndexNumber"])): item for item in jellyfin_episodes if isinstance(item.get("ParentIndexNumber"), int) and isinstance(item.get("IndexNumber"), int) } if not all(pair in current_by_pair for pair in target_pairs): return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for the exact episodes to appear in Jellyfin."} if action_id == "replace_media" and found_at_start: baseline_by_pair = { (int(item["seasonNumber"]), int(item["episodeNumber"])): item for item in baselines if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int) } if any(pair not in baseline_by_pair for pair in target_pairs): return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to confirm the existing entries changed."} if not all( _signature_changed(_media_signature(current_by_pair[pair]), baseline_by_pair[pair]) for pair in target_pairs ): return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to refresh every affected episode."} return {"complete": True, "phase": "complete", "message": "Sonarr imported every repaired episode and Jellyfin has indexed the updated files."}