from typing import Any, Dict, List, Optional import asyncio import logging from datetime import datetime, timezone from urllib.parse import quote 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_request_cache_payload from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop 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 _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 _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}" async def build_snapshot(request_id: str) -> Snapshot: timeline = [] runtime = get_runtime_settings() 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 not jellyseerr.configured() and not cached_request: timeline.append(TimelineHop(service="Jellyseerr", 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 jelly_request = cached_request if (jelly_request is None or mode == "always_js") and jellyseerr.configured(): try: jelly_request = await jellyseerr.get_request(request_id) logging.getLogger(__name__).debug( "snapshot jellyseerr fetch: request_id=%s mode=%s", request_id, mode ) except Exception as exc: timeline.append(TimelineHop(service="Jellyseerr", status="error", details={"error": str(exc)})) snapshot.timeline = timeline snapshot.state = NormalizedState.failed snapshot.state_reason = "Failed to reach Jellyseerr" return snapshot if not jelly_request: timeline.append(TimelineHop(service="Jellyseerr", status="not_found")) snapshot.timeline = timeline snapshot.state = NormalizedState.unknown snapshot.state_reason = "Request not found in Jellyseerr" return snapshot jelly_status = jelly_request.get("status", "unknown") jelly_status_label = _status_label(jelly_status) jelly_type = jelly_request.get("type") or "unknown" snapshot.title = jelly_request.get("media", {}).get("title", "Unknown") snapshot.year = jelly_request.get("media", {}).get("year") snapshot.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.unknown media = jelly_request.get("media", {}) if isinstance(jelly_request, dict) else {} 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: try: if snapshot.request_type == RequestType.movie: details = await jellyseerr.get_movie(int(tmdb_id)) if isinstance(details, dict): 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 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") ) elif snapshot.request_type == RequestType.tv: details = await jellyseerr.get_tv(int(tmdb_id)) if isinstance(details, dict): 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") ) except Exception: pass 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="Jellyseerr", 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 media_status = jelly_request.get("media", {}).get("status") try: media_status_code = int(media_status) if media_status is not None else None except (TypeError, ValueError): media_status_code = 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) 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" elif arr_item.get("isAvailable"): arr_state = "searching" else: arr_state = "added" else: arr_state = "missing" 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 except Exception as exc: arr_state = "error" arr_details["error"] = str(exc) if arr_state is None: arr_state = "unknown" if arr_state == "missing" and media_status_code in {4}: arr_state = "available" elif arr_state == "missing" and media_status_code in {6}: arr_state = "added" timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details)) try: prowlarr_health = await prowlarr.get_health() if isinstance(prowlarr_health, list) and len(prowlarr_health) > 0: timeline.append(TimelineHop(service="Prowlarr", status="issues", details={"health": prowlarr_health})) else: timeline.append(TimelineHop(service="Prowlarr", status="ok")) except Exception as exc: 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) 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 name = item.get("Name") or item.get("title") year = item.get("ProductionYear") or item.get("Year") if name and name.strip().lower() == (snapshot.title or "").strip().lower(): if snapshot.year and year and int(year) != int(snapshot.year): continue jellyfin_available = True jellyfin_item = item break if jellyfin_available 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(): root_folder = await _resolve_root_folder_path( radarr_client, runtime.radarr_root_folder, "Radarr" ) tmdb_id = jelly_request.get("media", {}).get("tmdbId") if tmdb_id: 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(): root_folder = await _resolve_root_folder_path( sonarr_client, runtime.sonarr_root_folder, "Sonarr" ) tvdb_id = jelly_request.get("media", {}).get("tvdbId") if tvdb_id: try: await sonarr_client.add_series( int(tvdb_id), runtime.sonarr_quality_profile_id, root_folder, monitored=False, search_missing=False, ) except Exception: pass qbit_state = None qbit_message = None try: download_ids = _download_ids(_queue_records(arr_queue)) torrent_list: List[Dict[str, Any]] = [] if qbittorrent.configured(): if download_ids: torrents = await qbittorrent.get_torrents_by_hashes("|".join(download_ids)) torrent_list = torrents if isinstance(torrents, list) else [] else: category = f"magent-{request_id}" torrents = await qbittorrent.get_torrents_by_category(category) torrent_list = torrents if isinstance(torrents, list) else [] summary = _summarize_qbit(torrent_list) qbit_state = summary.get("state") qbit_message = summary.get("message") timeline.append( TimelineHop( service="qBittorrent", status=summary["state"], details={ "summary": summary["message"], "torrents": torrent_list, }, ) ) except Exception as exc: timeline.append(TimelineHop(service="qBittorrent", status="error", details={"error": str(exc)})) 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.completed snapshot.state_reason = "In your library and ready to watch." 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 added to the library yet." elif arr_state == "searching": snapshot.state = NormalizedState.searching snapshot.state_reason = "Searching for a matching release." elif arr_state == "available": snapshot.state = NormalizedState.completed snapshot.state_reason = "In your library and ready to watch." 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 and snapshot.state not in { NormalizedState.downloading, NormalizedState.importing, }: snapshot.state = NormalizedState.completed snapshot.state_reason = "Ready to watch in Jellyfin." snapshot.timeline = timeline actions: List[ActionOption] = [] if arr_state == "missing": actions.append( ActionOption( id="readd_to_arr", label="Add to the library queue (Sonarr/Radarr)", risk="medium", ) ) elif arr_item and arr_state != "available": actions.append( ActionOption( id="search_auto", label="Search and auto-download", risk="low", ) ) actions.append( ActionOption( id="search_releases", label="Search and choose a download", risk="low", ) ) download_ids = _download_ids(_queue_records(arr_queue)) if download_ids and qbittorrent.configured(): actions.append( ActionOption( id="resume_torrent", label="Resume the download", risk="low", ) ) snapshot.actions = actions jellyfin_link = None if runtime.jellyfin_public_url and snapshot.state in { NormalizedState.available, NormalizedState.completed, }: base_url = runtime.jellyfin_public_url.rstrip("/") query = quote(snapshot.title or "") jellyfin_link = f"{base_url}/web/index.html#!/search?query={query}" snapshot.raw = { "jellyseerr": jelly_request, "arr": { "item": arr_item, "queue": arr_queue, }, "jellyfin": { "publicUrl": runtime.jellyfin_public_url, "available": snapshot.state in { NormalizedState.available, NormalizedState.completed, }, "link": jellyfin_link, "item": jellyfin_item, }, } await asyncio.to_thread(save_snapshot, snapshot) return snapshot