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, get_recent_snapshots, get_setting, set_setting from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop logger = logging.getLogger(__name__) JELLYFIN_SCAN_COOLDOWN_SECONDS = 300 _jellyfin_scan_key = "jellyfin_scan_last_at" 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 async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None: if snapshot.state not in {NormalizedState.available, NormalizedState.completed}: 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: prev_state = previous[0].get("state") if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}: 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 _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 ) allow_remote = mode == "always_js" and jellyseerr.configured() 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 if cached_request is None and not allow_remote: timeline.append(TimelineHop(service="Jellyseerr", 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 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" 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 allow_remote: 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" 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 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.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="Push to 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 _maybe_refresh_jellyfin(snapshot) await asyncio.to_thread(save_snapshot, snapshot) return snapshot