from typing import Any, Dict, List, Optional import asyncio import logging import re from datetime import datetime, timezone from urllib.parse import quote import httpx from ..clients.jellyseerr import JellyseerrClient from ..clients.jellyfin import JellyfinClient from ..clients.sonarr import SonarrClient from ..clients.radarr import RadarrClient from ..clients.prowlarr import ProwlarrClient 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, get_request_repairs, complete_request_repair, get_recent_snapshots, get_setting, set_setting, is_seerr_media_failure_suppressed, record_seerr_media_failure, clear_seerr_media_failure, ) 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 from .download_labels import label_episode_downloads from .arr import RootFolderNotFoundError, resolve_root_folder_path 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 = { 1: "Waiting for approval", 2: "Approved", 3: "Declined", 4: "Ready to watch", 5: "Working on it", 6: "Partially ready", } def _status_label(value: Any) -> str: try: numeric = int(value) return STATUS_LABELS.get(numeric, f"Status {numeric}") except (TypeError, ValueError): return "Unknown" def _pick_first(value: Any) -> Optional[Dict[str, Any]]: if isinstance(value, list): return value[0] if value else None if isinstance(value, dict): return value return None def _apply_arr_identity(snapshot: Snapshot, arr_item: Any) -> None: """Use the collector's authoritative identity when cached Seerr metadata is sparse.""" if not isinstance(arr_item, dict): return if snapshot.title in {None, "", "Unknown"}: title = arr_item.get("title") or arr_item.get("seriesTitle") if isinstance(title, str) and title.strip(): snapshot.title = title.strip() if not snapshot.year: year = arr_item.get("year") try: snapshot.year = int(year) if year else snapshot.year except (TypeError, ValueError): pass def _normalize_media_title(value: Any) -> Optional[str]: if not isinstance(value, str): return None normalized = re.sub(r"[^a-z0-9]+", " ", value.lower()).strip() return normalized or None def _canonical_provider_key(value: str) -> str: normalized = value.strip().lower() if normalized.endswith("id"): normalized = normalized[:-2] return normalized def extract_request_provider_ids(payload: Any) -> Dict[str, str]: provider_ids: Dict[str, str] = {} candidates: List[Any] = [] if isinstance(payload, dict): candidates.append(payload) media = payload.get("media") if isinstance(media, dict): candidates.append(media) for candidate in candidates: if not isinstance(candidate, dict): continue embedded = candidate.get("ProviderIds") or candidate.get("providerIds") if isinstance(embedded, dict): for key, value in embedded.items(): if value is None: continue text = str(value).strip() if text: provider_ids[_canonical_provider_key(str(key))] = text for key in ("tmdbId", "tvdbId", "imdbId", "tmdb_id", "tvdb_id", "imdb_id"): value = candidate.get(key) if value is None: continue text = str(value).strip() if text: provider_ids[_canonical_provider_key(key)] = text return provider_ids def jellyfin_item_matches_request( item: Dict[str, Any], *, title: Optional[str], year: Optional[int], request_type: RequestType, request_payload: Optional[Dict[str, Any]] = None, ) -> bool: request_provider_ids = extract_request_provider_ids(request_payload or {}) item_provider_ids = extract_request_provider_ids(item) shared = set(request_provider_ids) & set(item_provider_ids) if shared: # Conflicting metadata must never fall through to title matching. return all(request_provider_ids[key] == item_provider_ids[key] for key in shared) request_title = _normalize_media_title(title) if not request_title: return False item_titles = [ _normalize_media_title(item.get("Name")), _normalize_media_title(item.get("OriginalTitle")), _normalize_media_title(item.get("SortName")), _normalize_media_title(item.get("SeriesName")), _normalize_media_title(item.get("title")), ] item_titles = [candidate for candidate in item_titles if candidate] item_year = item.get("ProductionYear") or item.get("Year") try: item_year_value = int(item_year) if item_year is not None else None except (TypeError, ValueError): item_year_value = None if year and item_year_value and int(year) != item_year_value: return False if request_title in item_titles: return True return False def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]: response = exc.response if response is None: return None try: payload = response.json() except ValueError: payload = response.text if isinstance(payload, dict): message = payload.get("message") or payload.get("error") return str(message).strip() if message else str(payload) if isinstance(payload, str): trimmed = payload.strip() return trimmed or None return str(payload) def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool: response = exc.response if response is None: return False return response.status_code == 404 or response.status_code >= 500 async def _get_seerr_media_details( jellyseerr: JellyseerrClient, request_type: RequestType, tmdb_id: int ) -> Optional[Dict[str, Any]]: media_type = request_type.value if media_type not in {"movie", "tv"}: return None if is_seerr_media_failure_suppressed(media_type, tmdb_id): logger.debug("Seerr snapshot hydration suppressed: media_type=%s tmdb_id=%s", media_type, tmdb_id) return None try: if request_type == RequestType.movie: details = await jellyseerr.get_movie(int(tmdb_id)) else: details = await jellyseerr.get_tv(int(tmdb_id)) except httpx.HTTPStatusError as exc: if _should_persist_seerr_media_failure(exc): record_seerr_media_failure( media_type, int(tmdb_id), status_code=exc.response.status_code if exc.response is not None else None, error_message=_extract_http_error_message(exc), ) return None if isinstance(details, dict): clear_seerr_media_failure(media_type, int(tmdb_id)) return details return None async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None: collector_item = snapshot.raw.get("arr", {}).get("item") if isinstance(snapshot.raw, dict) else None collector_stats = collector_item.get("statistics") if isinstance(collector_item, dict) else None collector_has_file = bool( isinstance(collector_item, dict) and ( collector_item.get("hasFile") or snapshot.request_type == RequestType.tv and isinstance(collector_stats, dict) and collector_stats.get("episodeFileCount") ) ) if snapshot.state not in {NormalizedState.available, NormalizedState.completed} and not ( snapshot.state == NormalizedState.importing and collector_has_file ): return runtime = get_runtime_settings() client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if not client.configured(): return last_scan = get_setting(_jellyfin_scan_key) if last_scan: try: parsed = datetime.fromisoformat(last_scan.replace("Z", "+00:00")) if (datetime.now(timezone.utc) - parsed).total_seconds() < JELLYFIN_SCAN_COOLDOWN_SECONDS: return except ValueError: pass previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1) if previous: previous_payload = previous[0].get("payload") or {} previous_jellyfin = (previous_payload.get("raw") or {}).get("jellyfin") or {} if previous_jellyfin.get("found"): return try: await client.refresh_library() except Exception as exc: logger.warning("Jellyfin library refresh failed: %s", exc) return set_setting(_jellyfin_scan_key, datetime.now(timezone.utc).isoformat()) logger.info("Jellyfin library refresh triggered: request_id=%s", snapshot.request_id) def _queue_records(queue: Any) -> List[Dict[str, Any]]: if isinstance(queue, dict): records = queue.get("records") if isinstance(records, list): return records if isinstance(queue, list): return queue return [] def _filter_queue(queue: Any, item_id: Optional[int], request_type: RequestType) -> Any: if not item_id: return queue records = _queue_records(queue) if not records: return queue key = "seriesId" if request_type == RequestType.tv else "movieId" filtered = [record for record in records if record.get(key) == item_id] if isinstance(queue, dict): filtered_queue = dict(queue) filtered_queue["records"] = filtered filtered_queue["totalRecords"] = len(filtered) return filtered_queue return filtered def _download_ids(records: List[Dict[str, Any]]) -> List[str]: ids = [] for record in records: download_id = record.get("downloadId") or record.get("download_id") if isinstance(download_id, str) and download_id: ids.append(download_id) return ids def _missing_episode_numbers_by_season(episodes: Any) -> Dict[int, List[int]]: if not isinstance(episodes, list): return {} grouped: Dict[int, List[int]] = {} now = datetime.now(timezone.utc) for episode in episodes: if not isinstance(episode, dict): continue if not episode.get("monitored", True): continue if episode.get("hasFile"): continue air_date = episode.get("airDateUtc") if isinstance(air_date, str): try: aired_at = datetime.fromisoformat(air_date.replace("Z", "+00:00")) except ValueError: aired_at = None if aired_at and aired_at > now: continue season_number = episode.get("seasonNumber") episode_number = episode.get("episodeNumber") if not isinstance(episode_number, int): episode_number = episode.get("absoluteEpisodeNumber") if isinstance(season_number, int) and isinstance(episode_number, int): grouped.setdefault(season_number, []).append(episode_number) for season_number in list(grouped.keys()): grouped[season_number] = sorted(set(grouped[season_number])) return grouped def _episode_availability(episodes: Any) -> Dict[str, Any]: if not isinstance(episodes, list): return {"available": 0, "missing": 0, "total": 0, "seasons": []} now = datetime.now(timezone.utc) season_rows: Dict[int, Dict[str, Any]] = {} for episode in episodes: if not isinstance(episode, dict) or not episode.get("monitored", True): continue air_date = episode.get("airDateUtc") if isinstance(air_date, str): try: aired_at = datetime.fromisoformat(air_date.replace("Z", "+00:00")) except ValueError: aired_at = None if aired_at and aired_at > now: continue season_number = episode.get("seasonNumber") if not isinstance(season_number, int): continue row = season_rows.setdefault( season_number, {"seasonNumber": season_number, "available": 0, "missing": 0, "total": 0}, ) row["total"] += 1 if episode.get("hasFile"): row["available"] += 1 else: row["missing"] += 1 seasons = [season_rows[key] for key in sorted(season_rows)] return { "available": sum(int(row["available"]) for row in seasons), "missing": sum(int(row["missing"]) for row in seasons), "total": sum(int(row["total"]) for row in seasons), "seasons": seasons, } def _unmonitored_season_options(series: Any, episodes: Any) -> List[Dict[str, int]]: """Describe regular Sonarr seasons that can be added to an existing request.""" if not isinstance(series, dict) or not isinstance(series.get("seasons"), list): return [] episode_rows = [episode for episode in episodes if isinstance(episode, dict)] if isinstance(episodes, list) else [] options: List[Dict[str, int]] = [] for season in series["seasons"]: if not isinstance(season, dict) or season.get("monitored") is not False: continue season_number = season.get("seasonNumber") if not isinstance(season_number, int) or season_number <= 0: continue matching = [episode for episode in episode_rows if episode.get("seasonNumber") == season_number] statistics = season.get("statistics") if isinstance(season.get("statistics"), dict) else {} episode_count = statistics.get("totalEpisodeCount") if not isinstance(episode_count, int): episode_count = statistics.get("episodeCount") if not isinstance(episode_count, int): episode_count = len(matching) available = statistics.get("episodeFileCount") if not isinstance(available, int): available = sum(1 for episode in matching if episode.get("hasFile") is True) options.append( { "seasonNumber": season_number, "episodeCount": max(0, episode_count), "available": max(0, available), } ) return sorted(options, key=lambda item: item["seasonNumber"]) def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]: if not torrents: return {"state": "idle", "message": "0 active downloads."} downloading_states = {"downloading", "stalleddl", "queueddl", "checkingdl", "forceddl"} paused_states = {"pauseddl", "pausedup"} completed_states = {"uploading", "stalledup", "queuedup", "checkingup", "forcedup", "stoppedup"} downloading = [t for t in torrents if str(t.get("state", "")).lower() in downloading_states] paused = [t for t in torrents if str(t.get("state", "")).lower() in paused_states] completed = [t for t in torrents if str(t.get("state", "")).lower() in completed_states] if downloading: return { "state": "downloading", "message": f"Downloading ({len(downloading)} active).", } if paused: return { "state": "paused", "message": f"Paused ({len(paused)} paused).", } if completed: return { "state": "completed", "message": f"Completed/seeding ({len(completed)} seeding).", } return { "state": "idle", "message": "0 active downloads.", } def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[str]: if not path: return None if not path.startswith("/"): path = f"/{path}" if cache_mode == "cache": return f"/images/tmdb?path={quote(path)}&size={size}" return f"https://image.tmdb.org/t/p/{size}{path}" def _torrent_progress(torrent: Dict[str, Any]) -> Optional[float]: progress = torrent.get("progress") try: numeric = float(progress) except (TypeError, ValueError): numeric = -1 if 0 <= numeric <= 1: return round(numeric * 100, 1) try: size = float(torrent.get("size")) amount_left = float(torrent.get("amount_left")) except (TypeError, ValueError): return None if size <= 0: return None 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" download_step_detail = f"{collector} reports the replacement file as collected and imported." 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" ) download_step_detail = str( download.get("summary") or "Magent found the replacement download in qBittorrent." ) 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" download_step_detail = "Waiting for a suitable release to be selected." 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": download_step_detail, }, { "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, *, approved: bool, arr_state: str, arr_details: Dict[str, Any], prowlarr_state: str, download: Dict[str, Any], jellyfin_found: bool, jellyfin_link: Optional[str], ) -> Dict[str, Any]: collector = "Sonarr" if snapshot.request_type == RequestType.tv else "Radarr" noun = "episode" if snapshot.request_type == RequestType.tv else "movie" availability = arr_details.get("availability") if not isinstance(availability, dict): availability = {"available": 0, "missing": 0, "total": 0, "seasons": []} available = int(availability.get("available") or 0) missing = int(availability.get("missing") or 0) total = int(availability.get("total") or 0) partial = available > 0 and missing > 0 jellyfin_partial = bool( jellyfin_found and snapshot.request_type == RequestType.tv and missing > 0 ) fully_available = bool(jellyfin_found and not jellyfin_partial) download_visible = bool(download.get("visible")) download_state = str(download.get("state") or "not_started") search_status = str((arr_details.get("search") or {}).get("state") or "unavailable") search_in_progress = search_status in {"searching", "queued"} search_label = { "searching": "Searching", "queued": "Search queued", "idle": "Not searching", }.get(search_status, "Search unknown") search_target = "episode releases" if snapshot.request_type == RequestType.tv else "a matching release" search_detail = { "searching": f"{collector} is searching for {search_target}.", "queued": f"Search queued — waiting for {collector} to start.", "idle": f"Not currently searching for this {'series' if snapshot.request_type == RequestType.tv else 'movie'}.", }.get(search_status, f"Search status unavailable — unable to check {collector}.") if snapshot.state == NormalizedState.requested: status_label = "Waiting for approval" meaning = "This request has been received, but it must be approved before collection can begin." elif snapshot.state == NormalizedState.needs_add: status_label = "Approved, but not yet in the library queue" meaning = ( f"The request was approved, but it has not reached the {collector} collector yet. " "Adding it to the library queue is the next step." ) elif jellyfin_partial: status_label = f"Partially available — {available} of {total} episodes collected" meaning = ( f"Some of this request is ready to watch; {missing} episode{'s' if missing != 1 else ''} " f"{'is' if missing == 1 else 'are'} still missing. {search_detail}" ) elif snapshot.state in {NormalizedState.completed, NormalizedState.available}: status_label = "Available to watch" meaning = "Collection is complete and the title is available on the media server." elif download_visible and download_state == "paused": status_label = "Download paused" meaning = "A release was collected, but its qBittorrent download is paused and needs to be resumed." elif download_visible and download_state == "missing": status_label = "Download attempt is no longer visible" meaning = ( "A download was previously queued for this request, but qBittorrent no longer reports it. " "A fresh release search may be required." ) elif download_visible and download_state == "error": status_label = "Unable to read the current download" meaning = ( "A download attempt exists, but Magent cannot currently read its progress from qBittorrent." ) elif snapshot.state == NormalizedState.downloading: status_label = "Download in progress" meaning = "A release has been collected and is currently downloading." elif snapshot.state == NormalizedState.importing: if arr_state == "available" and not jellyfin_found: status_label = "Collected — waiting for the media server" meaning = ( f"{collector} has collected and imported this title, but it is not visible on " "the media server yet." ) else: status_label = "Downloaded — waiting for library import" meaning = f"The download has finished and {collector} is preparing it for the media server." elif arr_state == "error": status_label = "Unable to read the library queue" meaning = ( f"The request is approved, but Magent could not read its current state from {collector}. " "The service may be temporarily unavailable." ) elif arr_state in {"added", "searching"} and snapshot.request_type == RequestType.tv and total: if partial: status_label = f"Partially collected — {missing} episode{'s' if missing != 1 else ''} still missing" meaning = ( f"The request was approved and sent to {collector}. {available} of {total} aired " f"episodes have been collected; {missing} still need a matching release." ) elif missing: status_label = f"Added to library queue — waiting for {missing} episode{'s' if missing != 1 else ''}" meaning = ( f"The request was approved and sent to the {collector} collector, but none of the " f"{total} aired episodes have been collected yet." ) else: status_label = "Added to library queue" meaning = f"The request was approved and sent to the {collector} collector." elif arr_state in {"added", "searching"}: status_label = "Added to library queue — waiting for a matching release" meaning = ( f"The request was approved and sent to the {collector} collector, but a usable release " "has not been collected yet." ) elif snapshot.state == NormalizedState.failed: status_label = "This request needs attention" meaning = snapshot.state_reason or "Magent could not determine the next stage for this request." else: status_label = "Approved — preparing collection" if approved else "Request received" meaning = snapshot.state_reason or "Magent is checking where this request is in the collection process." action_ids = [action.id for action in snapshot.actions] if fully_available: next_title = "Ready to watch" next_description = "Collection is complete. Open the title on the media server when you are ready." recommended = [] elif "resume_torrent" in action_ids: next_title = "Resume the interrupted download" next_description = "The download exists but is not currently progressing. Resume it to continue collection." recommended = ["resume_torrent"] elif "readd_to_arr" in action_ids: next_title = "Add this request to the library queue" next_description = f"Send the approved request to {collector} so collection can begin." recommended = ["readd_to_arr"] elif search_in_progress and arr_state != "available": next_title = "Wait for the search results" if search_status == "searching" else "Wait for the queued search" next_description = f"{search_detail} This page will update automatically." recommended = [] elif "search_auto" in action_ids or "search_releases" in action_ids: if snapshot.request_type == RequestType.tv and missing: target = f"the {missing} missing episode{'s' if missing != 1 else ''}" else: target = f"a matching {noun} release" next_title = f"Search for {target}" next_description = ( "Run an automatic search, or review the available releases and choose one manually." ) recommended = [action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids] elif download_state == "downloading": next_title = "Let the current download finish" next_description = "Magent is tracking the active download; no action is needed right now." recommended = [] elif snapshot.state == NormalizedState.importing and arr_state == "available": next_title = "Wait for the media server to index this title" next_description = ( f"{collector} has completed its work. Use Recheck request to see whether the title " "has appeared on the media server." ) recommended = [] elif snapshot.state in {NormalizedState.completed, NormalizedState.available}: next_title = "Ready to watch" next_description = "Collection is complete. Open the title on the media server when you are ready." recommended = [] elif snapshot.state == NormalizedState.requested: next_title = "Wait for approval" next_description = "An administrator must approve this request before collection can start." recommended = [] else: next_title = "Magent is checking the next step" next_description = "No safe action is available until the current service state is known." recommended = [] requested_stage = { "id": "requested", "label": "Requested", "state": "complete", "summary": "Request received", } approved_stage = { "id": "approved", "label": "Approved", "state": "complete" if approved else "active", "summary": "Approved for collection" if approved else "Waiting for approval", } library_state_label = None if arr_state == "missing": library_state, library_summary = "attention", "Not yet added to the collector" elif arr_state == "error": library_state, library_summary = "attention", f"Unable to read {collector}" elif fully_available or (arr_state == "available" and not partial): library_state, library_summary = "complete", "Collection complete — no search needed" elif arr_state in {"added", "searching", "available"}: library_state = "active" if search_in_progress else "attention" if search_status == "unavailable" else "waiting" library_state_label = search_label library_summary = search_detail if download_visible and download_state == "downloading" and not search_in_progress: library_state, library_state_label = "active", "Downloading" library_summary = f"Download in progress. {search_detail}" if partial: library_state = "partial" library_summary = f"{available} of {total} episodes collected. {library_summary}" else: library_state, library_summary = "waiting", "Waiting for collector information" if fully_available: search_state, search_summary = "complete", "No further search needed" elif arr_state == "available" and not partial: search_state, search_summary = "complete", "A release was collected" elif search_in_progress: search_state, search_summary = "active", search_detail elif download_visible and download_state in {"downloading", "paused", "completed"} and not partial: search_state = "complete" search_summary = "A release was found" elif arr_state in {"added", "searching"} and (missing or snapshot.request_type == RequestType.movie): search_state = "waiting" if search_status == "idle" and prowlarr_state == "ok" else "attention" search_summary = search_detail else: search_state, search_summary = "waiting", "Search has not started" completed_download_summary = ( "The requested content has been collected and is available to watch. " "No further action is needed." ) if fully_available: download_stage_state, download_summary = "complete", completed_download_summary pipeline_download_visible = False pipeline_torrents: List[Dict[str, Any]] = [] elif arr_state == "available": download_stage_state = "complete" download_summary = f"{collector} has imported the collected file" pipeline_download_visible = False pipeline_torrents = [] elif download_visible: download_stage_state = { "downloading": "active", "paused": "attention", "completed": "complete", "missing": "attention", "error": "attention", }.get(download_state, "waiting") download_summary = str(download.get("summary") or "A prior download attempt was found") pipeline_download_visible = True pipeline_torrents = download.get("torrents") or [] else: download_stage_state, download_summary = "waiting", "No download attempt yet" pipeline_download_visible = False pipeline_torrents = [] if jellyfin_partial: available_label = "Partially available" available_state = "partial" available_state_label = "Partly ready" available_summary = f"{available} of {total} episodes are ready to watch in Grizzlyflix." elif jellyfin_found: available_label = "Available to watch" available_state = "complete" available_state_label = "Ready" available_summary = "This title is ready to watch in Grizzlyflix." elif arr_state == "available": available_label = "Adding to Grizzlyflix" available_state = "active" available_state_label = "Indexing" available_summary = "The download is complete. Grizzlyflix is indexing this title now." else: available_label = "Media server" available_state = "waiting" available_state_label = "Waiting" available_summary = "This title has not reached Grizzlyflix yet." display_download = dict(download) if fully_available: display_download.update( { "visible": False, "state": "completed", "summary": completed_download_summary, "torrents": [], } ) return { "status": {"label": status_label, "meaning": meaning}, "download": display_download, "nextStep": { "title": next_title, "description": next_description, "actionIds": recommended, }, "pipeline": [ requested_stage, approved_stage, { "id": "library", "label": "Library collection", "state": library_state, "stateLabel": library_state_label or library_state, "searchStatus": search_status, "summary": library_summary, "available": available, "missing": missing, "total": total, "seasons": availability.get("seasons") or [], "unmonitoredSeasons": arr_details.get("unmonitoredSeasons") or [], "missingEpisodes": arr_details.get("missingEpisodes") or {}, }, { "id": "search", "label": "Release search", "state": search_state, "summary": search_summary, "actionIds": [] if fully_available or search_in_progress else [ action_id for action_id in ("search_auto", "search_releases") if action_id in action_ids ], }, { "id": "download", "label": "Download complete" if fully_available else "Download", "state": download_stage_state, "summary": download_summary, "visible": pipeline_download_visible, "torrents": pipeline_torrents, }, { "id": "available", "label": available_label, "state": available_state, "stateLabel": available_state_label, "summary": available_summary, "link": jellyfin_link, }, ], } 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) sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key) qbittorrent = QBittorrentClient( runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password, ) snapshot = Snapshot( request_id=request_id, title="Unknown", state=NormalizedState.unknown, state_reason="Awaiting configuration", ) cached_request = None mode = (runtime.requests_data_source or "prefer_cache").lower() if mode != "always_js" and request_id.isdigit(): cached_request = get_request_cache_payload(int(request_id)) if cached_request is not None: logging.getLogger(__name__).debug( "snapshot cache hit: request_id=%s mode=%s", request_id, mode ) else: logging.getLogger(__name__).debug( "snapshot cache miss: request_id=%s mode=%s", request_id, mode ) if cached_request is not None: cache_meta = get_request_cache_by_id(int(request_id)) cached_title = cache_meta.get("title") if cache_meta else None if cached_title and isinstance(cached_request, dict): media = cached_request.get("media") if not isinstance(media, dict): media = {} cached_request["media"] = media if not media.get("title") and not media.get("name"): media["title"] = cached_title media["name"] = cached_title if not cached_request.get("title") and not cached_request.get("name"): cached_request["title"] = cached_title allow_remote = mode == "always_js" and jellyseerr.configured() if not jellyseerr.configured() and not cached_request: timeline.append(TimelineHop(service="Seerr", status="not_configured")) timeline.append(TimelineHop(service="Sonarr/Radarr", status="not_configured")) timeline.append(TimelineHop(service="Prowlarr", status="not_configured")) timeline.append(TimelineHop(service="qBittorrent", status="not_configured")) snapshot.timeline = timeline return snapshot if cached_request is None and not allow_remote: timeline.append(TimelineHop(service="Seerr", status="cache_miss")) snapshot.timeline = timeline snapshot.state = NormalizedState.unknown snapshot.state_reason = "Request not found in cache" return snapshot jelly_request = cached_request if allow_remote and (jelly_request is None or mode == "always_js"): try: jelly_request = await jellyseerr.get_request(request_id) logging.getLogger(__name__).debug( "snapshot Seerr fetch: request_id=%s mode=%s", request_id, mode ) except Exception as exc: timeline.append(TimelineHop(service="Seerr", status="error", details={"error": str(exc)})) snapshot.timeline = timeline snapshot.state = NormalizedState.failed snapshot.state_reason = "Failed to reach Seerr" return snapshot if not jelly_request: timeline.append(TimelineHop(service="Seerr", status="not_found")) snapshot.timeline = timeline snapshot.state = NormalizedState.unknown snapshot.state_reason = "Request not found in Seerr" return snapshot jelly_status = jelly_request.get("status", "unknown") jelly_status_label = _status_label(jelly_status) jelly_type = jelly_request.get("type") or "unknown" media = jelly_request.get("media", {}) if isinstance(jelly_request, dict) else {} if not isinstance(media, dict): media = {} snapshot.title = ( media.get("title") or media.get("name") or jelly_request.get("title") or jelly_request.get("name") or "Unknown" ) snapshot.year = media.get("year") or jelly_request.get("year") snapshot.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.unknown poster_path = None backdrop_path = None if isinstance(media, dict): poster_path = media.get("posterPath") or media.get("poster_path") backdrop_path = media.get("backdropPath") or media.get("backdrop_path") if snapshot.title in {None, "", "Unknown"} and jellyseerr.configured(): tmdb_id = jelly_request.get("media", {}).get("tmdbId") if tmdb_id: details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id)) if isinstance(details, dict): if snapshot.request_type == RequestType.movie: snapshot.title = details.get("title") or snapshot.title release_date = details.get("releaseDate") snapshot.year = int(release_date[:4]) if release_date else snapshot.year elif snapshot.request_type == RequestType.tv: snapshot.title = details.get("name") or details.get("title") or snapshot.title first_air = details.get("firstAirDate") snapshot.year = int(first_air[:4]) if first_air else snapshot.year poster_path = poster_path or details.get("posterPath") or details.get("poster_path") backdrop_path = ( backdrop_path or details.get("backdropPath") or details.get("backdrop_path") ) cache_mode = (runtime.artwork_cache_mode or "remote").lower() snapshot.artwork = { "poster_path": poster_path, "backdrop_path": backdrop_path, "poster_url": _artwork_url(poster_path, "w342", cache_mode), "backdrop_url": _artwork_url(backdrop_path, "w780", cache_mode), } timeline.append( TimelineHop( service="Seerr", status=jelly_status_label, details={ "requestedBy": jelly_request.get("requestedBy", {}).get("displayName") or jelly_request.get("requestedBy", {}).get("username") or jelly_request.get("requestedBy", {}).get("jellyfinUsername") or jelly_request.get("requestedBy", {}).get("email"), "createdAt": jelly_request.get("createdAt"), "updatedAt": jelly_request.get("updatedAt"), "approved": jelly_request.get("isApproved"), "statusCode": jelly_status, }, ) ) arr_state = None arr_details: Dict[str, Any] = {} arr_item = None arr_queue = None episodes = None if snapshot.request_type == RequestType.tv: tvdb_id = jelly_request.get("media", {}).get("tvdbId") if tvdb_id: try: series = await sonarr.get_series_by_tvdb_id(int(tvdb_id)) arr_item = _pick_first(series) arr_details["series"] = arr_item arr_state = "added" if arr_item else "missing" if arr_item: stats = arr_item.get("statistics") if isinstance(arr_item, dict) else None if isinstance(stats, dict): file_count = stats.get("episodeFileCount") total_count = ( stats.get("totalEpisodeCount") if isinstance(stats.get("totalEpisodeCount"), int) else stats.get("episodeCount") ) if ( isinstance(file_count, int) and isinstance(total_count, int) and total_count > 0 and file_count >= total_count ): arr_state = "available" if arr_item and isinstance(arr_item.get("id"), int): series_id = int(arr_item["id"]) arr_queue = await sonarr.get_queue(series_id) arr_queue = _filter_queue(arr_queue, series_id, RequestType.tv) arr_details["queue"] = arr_queue episodes = await sonarr.get_episodes(series_id) arr_details["search"] = { "state": await read_search_status(sonarr, RequestType.tv, series_id, episodes) } arr_details["availability"] = _episode_availability(episodes) arr_details["unmonitoredSeasons"] = _unmonitored_season_options(arr_item, 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 except Exception as exc: arr_state = "error" arr_details["error"] = str(exc) elif snapshot.request_type == RequestType.movie: tmdb_id = jelly_request.get("media", {}).get("tmdbId") if tmdb_id: try: movie = await radarr.get_movie_by_tmdb_id(int(tmdb_id)) arr_item = _pick_first(movie) if not arr_item: title_hint = ( jelly_request.get("media", {}).get("title") or jelly_request.get("title") or snapshot.title ) year_hint = ( jelly_request.get("media", {}).get("year") or jelly_request.get("year") or snapshot.year ) try: all_movies = await radarr.get_movies() except Exception: all_movies = None if isinstance(all_movies, list): for candidate in all_movies: if not isinstance(candidate, dict): continue if tmdb_id and candidate.get("tmdbId") == int(tmdb_id): arr_item = candidate break if title_hint and candidate.get("title") == title_hint: if not year_hint or candidate.get("year") == year_hint: arr_item = candidate break arr_details["movie"] = arr_item if arr_item: if arr_item.get("hasFile"): arr_state = "available" else: arr_state = "added" else: arr_state = "missing" arr_details["availability"] = { "available": 1 if arr_item and arr_item.get("hasFile") else 0, "missing": 0 if arr_item and arr_item.get("hasFile") else 1, "total": 1, "seasons": [], } if arr_item and isinstance(arr_item.get("id"), int): arr_queue = await radarr.get_queue(int(arr_item["id"])) arr_queue = _filter_queue(arr_queue, int(arr_item["id"]), RequestType.movie) arr_details["queue"] = arr_queue arr_details["search"] = { "state": await read_search_status(radarr, RequestType.movie, int(arr_item["id"])) } except Exception as exc: arr_state = "error" arr_details["error"] = str(exc) if arr_state is None: arr_state = "unknown" if arr_state == "added" and (arr_details.get("search") or {}).get("state") == "searching": arr_state = "searching" _apply_arr_identity(snapshot, arr_item) timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details)) prowlarr_state = "unknown" try: prowlarr_health = await prowlarr.get_health() if isinstance(prowlarr_health, list) and len(prowlarr_health) > 0: prowlarr_state = "issues" timeline.append(TimelineHop(service="Prowlarr", status="issues", details={"health": prowlarr_health})) else: prowlarr_state = "ok" timeline.append(TimelineHop(service="Prowlarr", status="ok")) except Exception as exc: prowlarr_state = "error" timeline.append(TimelineHop(service="Prowlarr", status="error", details={"error": str(exc)})) jellyfin_available = False jellyfin_item = None if jellyfin.configured() and snapshot.title: types = ["Movie"] if snapshot.request_type == RequestType.movie else ["Series"] try: search = await jellyfin.search_items(snapshot.title, types, limit=50) except Exception: search = None if isinstance(search, dict): items = search.get("Items") or search.get("items") or [] for item in items: if not isinstance(item, dict): continue if jellyfin_item_matches_request( item, title=snapshot.title, year=snapshot.year, request_type=snapshot.request_type, request_payload=jelly_request, ): jellyfin_available = True jellyfin_item = item break 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: radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) if radarr_client.configured(): try: root_folder = await resolve_root_folder_path( radarr_client, runtime.radarr_root_folder, "Radarr" ) except RootFolderNotFoundError as exc: logger.warning("Skipping Jellyfin-to-Radarr sync: %s", exc) root_folder = "" tmdb_id = jelly_request.get("media", {}).get("tmdbId") if tmdb_id and root_folder: try: await radarr_client.add_movie( int(tmdb_id), runtime.radarr_quality_profile_id, root_folder, monitored=False, search_for_movie=False, ) except Exception: pass if snapshot.request_type == RequestType.tv: if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder: sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) if sonarr_client.configured(): try: root_folder = await resolve_root_folder_path( sonarr_client, runtime.sonarr_root_folder, "Sonarr" ) except RootFolderNotFoundError as exc: logger.warning("Skipping Jellyfin-to-Sonarr sync: %s", exc) root_folder = "" tvdb_id = jelly_request.get("media", {}).get("tvdbId") if tvdb_id and root_folder: try: await sonarr_client.add_series( int(tvdb_id), runtime.sonarr_quality_profile_id, root_folder, monitored=False, search_missing=False, ) 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)) download_history = await asyncio.to_thread(get_request_download_evidence, request_id, 100) torrent_list: List[Dict[str, Any]] = [] download_visible = bool(download_ids) or bool(download_history.get("observed")) qbit_error = None try: if qbittorrent.configured(): if download_ids: torrents = await qbittorrent.get_torrents_by_hashes("|".join(h.lower() for h in download_ids)) torrent_list = torrents if isinstance(torrents, list) else [] else: request_tag = f"magent-{request_id}" torrents = await qbittorrent.get_torrents_by_tag(request_tag) torrent_list = torrents if isinstance(torrents, list) else [] label_episode_downloads(torrent_list, arr_queue) 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) if torrent_list: download_visible = True summary = _summarize_qbit(torrent_list) qbit_state = str(summary.get("state") or "idle") qbit_message = str(summary.get("message") or "Download found in qBittorrent.") elif download_ids: qbit_state = "missing" qbit_message = ( "The collector queued a download, but it is no longer visible in qBittorrent." ) elif download_history.get("observed"): qbit_state = "missing" qbit_message = ( "A previous download was observed, but it is not currently visible in qBittorrent." ) except Exception as exc: qbit_error = str(exc) if download_visible: qbit_state = "error" qbit_message = ( "A download attempt exists, but Magent cannot currently read its state from qBittorrent." ) download_presentation = { "visible": download_visible, "observed": download_visible, "state": qbit_state, "summary": qbit_message, "torrents": torrent_list, "lastSeenAt": download_history.get("last_seen_at"), } timeline.append( TimelineHop( service="qBittorrent", status=qbit_state, details={ **download_presentation, "error": qbit_error, }, ) ) status_code = None try: status_code = int(jelly_status) except (TypeError, ValueError): status_code = None derived_approved = bool(jelly_request.get("isApproved")) or status_code in {2, 4, 5, 6} if derived_approved: snapshot.state = NormalizedState.approved snapshot.state_reason = "Approved and queued for processing." else: snapshot.state = NormalizedState.requested snapshot.state_reason = "Waiting for approval before we can search." queue_records = _queue_records(arr_queue) if qbit_state in {"downloading", "paused"}: snapshot.state = NormalizedState.downloading snapshot.state_reason = "Downloading in qBittorrent." if qbit_message: snapshot.state_reason = qbit_message elif qbit_state == "completed": if arr_state == "available": snapshot.state = NormalizedState.importing snapshot.state_reason = "The collector imported the file. Waiting for the media server to index it." else: snapshot.state = NormalizedState.importing snapshot.state_reason = "Download finished. Waiting for library import." elif queue_records: if arr_state == "missing": snapshot.state_reason = "Queue shows a download, but qBittorrent has no active torrent." else: snapshot.state_reason = "Waiting for download to start in qBittorrent." elif arr_state == "missing" and derived_approved: snapshot.state = NormalizedState.needs_add snapshot.state_reason = "Approved, but not yet added to Sonarr/Radarr." elif arr_state == "searching": snapshot.state = NormalizedState.searching snapshot.state_reason = "Searching for a matching release." elif arr_state == "available": snapshot.state = NormalizedState.importing snapshot.state_reason = "Collected by Sonarr/Radarr and waiting for the media server to index it." elif arr_state == "added" and snapshot.state == NormalizedState.approved: snapshot.state = NormalizedState.added_to_arr snapshot.state_reason = "Item is present in Sonarr/Radarr" if jellyfin_available: missing_episodes = arr_details.get("missingEpisodes") if snapshot.request_type == RequestType.tv and isinstance(missing_episodes, dict) and missing_episodes: snapshot.state = NormalizedState.importing snapshot.state_reason = "Some episodes are available in Jellyfin, but the request is still incomplete." for hop in timeline: if hop.service == "Seerr": hop.status = "Partially ready" else: snapshot.state = NormalizedState.completed snapshot.state_reason = "Ready to watch in Jellyfin." for hop in timeline: if hop.service == "Seerr": hop.status = "Available" elif hop.service == "Sonarr/Radarr" and hop.status not in {"error"}: hop.status = "available" snapshot.timeline = timeline actions: List[ActionOption] = [] if arr_state == "missing": actions.append( ActionOption( id="readd_to_arr", label=f"Add to {'Sonarr' if snapshot.request_type == RequestType.tv else 'Radarr'}", risk="medium", description="Send this approved request to the library collector.", ) ) elif arr_item and arr_state != "available" and qbit_state not in {"downloading", "completed"}: missing_count = int((arr_details.get("availability") or {}).get("missing") or 0) automatic_label = ( f"Search automatically for {missing_count} missing episode{'s' if missing_count != 1 else ''}" if snapshot.request_type == RequestType.tv and missing_count else "Search automatically for a release" ) actions.append( ActionOption( id="search_auto", label=automatic_label, risk="low", description="Ask the library collector to find and download the best permitted match.", ) ) actions.append( ActionOption( id="search_releases", label="Review available releases", risk="low", description="Search the configured indexers and choose a release yourself.", ) ) if download_ids and qbittorrent.configured() and qbit_state == "paused": actions.append( ActionOption( id="resume_torrent", label="Resume the download", risk="low", description="Resume the existing qBittorrent job if it is paused or stalled.", ) ) snapshot.actions = actions jellyfin_link = None if runtime.jellyfin_public_url and jellyfin_available: base_url = runtime.jellyfin_public_url.rstrip("/") jellyfin_item_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None if jellyfin_item_id: jellyfin_link = f"{base_url}/web/index.html#!/details?id={quote(str(jellyfin_item_id))}" else: query = quote(snapshot.title or "") jellyfin_link = f"{base_url}/web/index.html#!/search?query={query}" availability = arr_details.get("availability") or {} is_partial = bool( jellyfin_available and snapshot.request_type == RequestType.tv and int(availability.get("missing") or 0) > 0 ) 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 { NormalizedState.available, NormalizedState.completed, }, "partial": is_partial, "link": jellyfin_link, "item": jellyfin_item, }, "qbittorrent": { **download_presentation, "downloadIds": download_ids, "error": qbit_error, }, } snapshot.presentation = _build_presentation( snapshot, approved=derived_approved, arr_state=arr_state, arr_details=arr_details, prowlarr_state=prowlarr_state, download=download_presentation, 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 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"]) await _maybe_refresh_jellyfin(snapshot) await asyncio.to_thread(save_snapshot, snapshot) return snapshot