163 lines
8.1 KiB
Python
163 lines
8.1 KiB
Python
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."}
|