Files
manage/backend/src/media_library_viewer_api/clients/jellyseerr.py
T

136 lines
5.0 KiB
Python

"""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