"""Jellyseerr HTTP API client. Jellyseerr is optional. When configured, it can enrich the Jellyfin user list with email addresses, avatars, permissions, and request metadata. """ from __future__ import annotations import logging from typing import Any import requests from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout logger = logging.getLogger(__name__) # Jellyseerr numeric status enums (see Overseerr/Jellyseerr source). _REQUEST_STATUS: dict[int, str] = {1: "pending", 2: "approved", 3: "declined"} _MEDIA_STATUS: dict[int, str] = { 1: "unknown", 2: "pending", 3: "processing", 4: "partially_available", 5: "available", } _REQUEST_TYPE: dict[int, str] = {1: "movie", 2: "tv"} def _label(value: Any, table: dict[int, str]) -> str: try: return table.get(int(value), str(value)) except (TypeError, ValueError): return str(value) if value is not None else "" class JellyseerrClient: """Small wrapper around the Jellyseerr REST API.""" def __init__(self, base_url: str, api_key: str, timeout: float = DEFAULT_READ_TIMEOUT): if not base_url: raise ValueError("Jellyseerr URL is required") if not api_key: raise ValueError("Jellyseerr API key is required") self.base_url = base_url.rstrip("/") if self.base_url.endswith("/api/v1"): self.base_url = self.base_url[:-7] self.api_key = api_key # timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s. self.timeout = http_timeout(timeout) self.session = requests.Session() self.session.headers.update( { "X-Api-Key": api_key, "Accept": "application/json", } ) self._title_cache: dict[tuple[str, str], str] = {} def get(self, path: str, **params: Any) -> Any: """GET a Jellyseerr endpoint and include useful response text on errors.""" clean_params = {k: v for k, v in params.items() if v is not None and v != ""} logger.debug("Jellyseerr GET %s params=%s", path, sorted(clean_params.keys())) response = self.session.get(f"{self.base_url}/api/v1{path}", params=clean_params, timeout=self.timeout) try: response.raise_for_status() except requests.HTTPError as exc: detail = response.text[:500] logger.warning("Jellyseerr GET %s failed status=%s url=%s", path, response.status_code, response.url) raise requests.HTTPError( f"{response.status_code} for {response.url}: {detail}", response=response, ) from exc logger.debug("Jellyseerr GET %s ok status=%s", path, response.status_code) return response.json() def absolute_url(self, path: str | None) -> str: """Return an absolute URL for Jellyseerr-relative assets.""" if not path: return "" if path.startswith("http://") or path.startswith("https://"): return path if not path.startswith("/"): path = f"/{path}" return f"{self.base_url}{path}" def _resolve_title(self, media_type: Any, tmdb_id: Any) -> str: """Resolve a media title via /movie/{tmdbId} or /tv/{tmdbId}, cached. Jellyseerr's /request list doesn't include titles; they live on the Movie/Series records. Cached per (type, tmdbId) so repeated polls reuse. """ if not tmdb_id: return "" key = (str(media_type or ""), str(tmdb_id)) if key in self._title_cache: return self._title_cache[key] try: is_tv = str(media_type) in ("2", "tv") data = self.get(f"/{'tv' if is_tv else 'movie'}/{tmdb_id}") title = str(data.get("name" if is_tv else "title") or "") except Exception: title = "" self._title_cache[key] = title return title def jellyfin_users(self) -> list[dict[str, Any]]: """Return Jellyfin-linked users known to Jellyseerr. Jellyseerr has used both a top-level list payload and a wrapped `{ "users": [...] }` payload in different versions/docs, so accept either shape. """ payload = self.get("/settings/jellyfin/users") if isinstance(payload, list): users = [item for item in payload if isinstance(item, dict)] logger.info("Jellyseerr returned %s Jellyfin-linked users", len(users)) return users if isinstance(payload, dict): users = payload.get("users") if isinstance(users, list): mapped = [item for item in users if isinstance(item, dict)] logger.info("Jellyseerr returned %s Jellyfin-linked users (wrapped payload)", len(mapped)) return mapped logger.info("Jellyseerr returned no Jellyfin-linked users") return [] def users(self, page_size: int = 1000) -> list[dict[str, Any]]: """Return Jellyseerr users via the paginated /user list endpoint. Jellyseerr's list endpoint uses ``take`` and ``skip`` query params, not ``page``. """ results: list[dict[str, Any]] = [] take = max(1, int(page_size)) skip = 0 total_results: int | None = None while True: payload = self.get("/user", take=take, skip=skip) if not isinstance(payload, dict): return results page_results = payload.get("results") or [] page_items = ( [item for item in page_results if isinstance(item, dict)] if isinstance(page_results, list) else [] ) results.extend(page_items) page_info = payload.get("pageInfo") or {} if isinstance(page_info, dict): try: page_total = int(page_info.get("results") or 0) if page_total: total_results = page_total except (TypeError, ValueError): pass logger.debug( "Jellyseerr user page skip=%s take=%s -> %s results (total=%s)", skip, take, len(page_items), total_results if total_results is not None else "unknown", ) if not page_items: break skip += len(page_items) if len(page_items) < take: break if total_results is not None and skip >= total_results: break logger.info("Jellyseerr returned %s users", len(results)) return results def request_count(self) -> dict[str, int]: """Return normalized request counts from /api/v1/request/count. Jellyseerr reports pending/approved/declined/processing/available/total. Missing keys default to 0 so callers can rely on a stable shape. """ payload = self.get("/request/count") if not isinstance(payload, dict): payload = {} keys = ("total", "pending", "approved", "declined", "processing", "available") counts = {k: int(payload.get(k) or 0) for k in keys} logger.info( "Jellyseerr request counts total=%s pending=%s processing=%s", counts["total"], counts["pending"], counts["processing"], ) return counts def recent_requests(self, take: int = 20) -> list[dict[str, Any]]: """Return the most recently modified requests with resolved titles.""" take = max(1, min(int(take), 100)) payload = self.get("/request", sort="modified", skip=0, take=take) if not isinstance(payload, dict): return [] results = payload.get("results") or [] items = [r for r in results if isinstance(r, dict)] if isinstance(results, list) else [] mapped: list[dict[str, Any]] = [] for r in items: media = r.get("media") or {} tmdb_id = media.get("tmdbId") name = r.get("title") or media.get("title") or media.get("name") or "" if not name and tmdb_id: name = self._resolve_title(r.get("type"), tmdb_id) if not name: name = media.get("externalServiceSlug") or "" mapped.append( { "id": r.get("id"), "type": _label(r.get("type"), _REQUEST_TYPE), "name": name or "—", "status": _label(r.get("status"), _REQUEST_STATUS), "media_status": _label((media or {}).get("status"), _MEDIA_STATUS), "created_at": r.get("createdAt"), } ) return mapped def open_requests(self, max_per_filter: int = 100) -> list[dict[str, Any]]: """Return open (pending + approved) requests with resolved titles. Fetches pending and approved requests via Jellyseerr's filter param (not all 800+ historical requests), then resolves titles from /movie/{tmdbId} or /tv/{tmdbId}. Titles are cached on the client so subsequent polls are instant. """ results: list[dict[str, Any]] = [] take = 50 for filter_val in ("pending", "approved"): skip = 0 while skip < max_per_filter: payload = self.get("/request", filter=filter_val, sort="added", skip=skip, take=take) if not isinstance(payload, dict): break page = payload.get("results") or [] items = [r for r in page if isinstance(r, dict)] if isinstance(page, list) else [] for r in items: media = r.get("media") or {} tmdb_id = media.get("tmdbId") or r.get("tmdbId") # Diagnostic: log the first request's shape once so we can verify tmdbId. if not results and filter_val == "pending": logger.info( "Jellyseerr request sample: keys=%s media_keys=%s tmdbId=%s", sorted(r.keys()), sorted(media.keys()) if isinstance(media, dict) else "N/A", tmdb_id, ) name = r.get("title") or media.get("title") or media.get("name") or "" if not name and tmdb_id: name = self._resolve_title(r.get("type"), tmdb_id) if not name: name = media.get("externalServiceSlug") or "" results.append( { "id": r.get("id"), "type": _label(r.get("type"), _REQUEST_TYPE), "name": name or "—", "status": _label(r.get("status"), _REQUEST_STATUS), "media_status": _label((media or {}).get("status"), _MEDIA_STATUS), "created_at": r.get("createdAt"), } ) if len(items) < take: break skip += len(items) results.sort(key=lambda r: r.get("created_at") or 0, reverse=True) logger.info("Jellyseerr returned %s open requests (with titles)", len(results)) return results