"""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 logger = logging.getLogger(__name__) class JellyseerrClient: """Small wrapper around the Jellyseerr REST API.""" def __init__(self, base_url: str, api_key: str, timeout: int = 30): 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 self.timeout = timeout self.session = requests.Session() self.session.headers.update( { "X-Api-Key": api_key, "Accept": "application/json", } ) 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 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