"""Async Jellyfin API client — discovery, libraries, items, streaming.""" import logging import aiohttp logger = logging.getLogger(__name__) _SHORT = aiohttp.ClientTimeout(total=10) _MEDIUM = aiohttp.ClientTimeout(total=30) _LONG = aiohttp.ClientTimeout(total=60) class JellyfinClient: def __init__(self, url: str, api_key: str): self.url = url.rstrip("/") self.api_key = api_key @property def _headers(self) -> dict: return {"X-Emby-Token": self.api_key, "Accept": "application/json"} # ------------------------------------------------------------------ # Discovery # ------------------------------------------------------------------ async def test_connection(self) -> dict: async with aiohttp.ClientSession() as s: async with s.get( f"{self.url}/System/Info/Public", timeout=_SHORT ) as r: r.raise_for_status() return await r.json() async def get_libraries(self) -> list[dict]: async with aiohttp.ClientSession() as s: async with s.get( f"{self.url}/Library/VirtualFolders", headers=self._headers, timeout=_SHORT, ) as r: r.raise_for_status() data = await r.json() return data if isinstance(data, list) else [] # ------------------------------------------------------------------ # Items (movies / series) # ------------------------------------------------------------------ async def get_items(self, library_id: str, item_type: str) -> list[dict]: """Fetch all items of a given type from one library (paginated).""" all_items: list[dict] = [] start = 0 limit = 200 while True: params = { "ParentId": library_id, "IncludeItemTypes": item_type, "Recursive": "true", "Fields": "Overview,Genres,OfficialRating,ProductionYear", "Limit": str(limit), "StartIndex": str(start), } async with aiohttp.ClientSession() as s: async with s.get( f"{self.url}/Items", params=params, headers=self._headers, timeout=_LONG, ) as r: r.raise_for_status() data = await r.json() batch = data.get("Items", []) all_items.extend(batch) total = data.get("TotalRecordCount", 0) if len(all_items) >= total or not batch: break start += len(batch) return all_items # ------------------------------------------------------------------ # Series structure (seasons + episodes) # ------------------------------------------------------------------ async def get_seasons(self, series_id: str) -> list[dict]: async with aiohttp.ClientSession() as s: async with s.get( f"{self.url}/Shows/{series_id}/Seasons", params={"Fields": "Overview"}, headers=self._headers, timeout=_MEDIUM, ) as r: r.raise_for_status() return (await r.json()).get("Items", []) async def get_episodes(self, series_id: str, season_id: str) -> list[dict]: async with aiohttp.ClientSession() as s: async with s.get( f"{self.url}/Shows/{series_id}/Episodes", params={"SeasonId": season_id, "Fields": "Overview"}, headers=self._headers, timeout=_MEDIUM, ) as r: r.raise_for_status() return (await r.json()).get("Items", []) # ------------------------------------------------------------------ # URL builders # ------------------------------------------------------------------ def build_stream_url(self, item_id: str) -> str: return ( f"{self.url}/Videos/{item_id}/stream" f"?static=true&api_key={self.api_key}&Container=mkv" ) def build_image_url(self, item_id: str, max_width: int = 400) -> str: return ( f"{self.url}/Items/{item_id}/Images/Primary" f"?api_key={self.api_key}&MaxWidth={max_width}" )