"""Async client for the Emby API.""" from __future__ import annotations import logging from typing import Any from urllib.parse import quote import httpx from .config import Settings log = logging.getLogger(__name__) # Fields Emby only returns on request. _LIST_FIELDS = "ProductionYear,ParentId,PremiereDate" _DETAIL_FIELDS = ( "Overview,Genres,ProductionYear,PremiereDate,People,Studios,Taglines," "ProviderIds,ParentId,MediaSources,Path,CommunityRating,OfficialRating" ) def _seg(value: Any) -> str: """One URL path segment: IDs come from the LLM and must not add path parts (e.g. ``123/Delete`` or ``../System/Restart``).""" return quote(str(value), safe="") class EmbyClient: """Thin async wrapper around Emby's REST API.""" def __init__(self, settings: Settings) -> None: self.settings = settings self._http = httpx.AsyncClient( base_url=settings.base_url, headers={ "X-Emby-Token": settings.api_key, "Accept": "application/json", }, timeout=settings.timeout, ) async def aclose(self) -> None: await self._http.aclose() # ---- helpers ---------------------------------------------------------- async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: r = await self._http.get(path, params=params) r.raise_for_status() return r.json() async def _post( self, path: str, json: dict[str, Any] | None = None, params: dict[str, Any] | None = None ) -> Any: r = await self._http.post(path, json=json, params=params) r.raise_for_status() if r.content: try: return r.json() except ValueError: return None return None # ---- API: Items (media library) --------------------------------------- async def list_items( self, parent_id: str | None = None, recursive: bool = True, sort_by: str | None = "SortName", sort_order: str = "Ascending", limit: int | None = None, search_term: str | None = None, item_types: str | None = None, ) -> dict[str, Any]: """List or search items from the media library (server-side filtering).""" params: dict[str, Any] = { "Recursive": str(recursive).lower(), "SortOrder": sort_order, "Fields": _LIST_FIELDS, } if sort_by: params["SortBy"] = sort_by if parent_id: params["ParentId"] = parent_id if limit: params["Limit"] = limit if search_term: params["SearchTerm"] = search_term if item_types: params["IncludeItemTypes"] = item_types return await self._get("/Items", params=params) async def get_item(self, item_id: str) -> dict[str, Any]: """Get details for a specific item (``GET /Items/{id}`` does not exist in Emby).""" raw = await self._get( "/Items", params={"Ids": item_id, "Recursive": "true", "Fields": _DETAIL_FIELDS} ) items = raw.get("Items") if isinstance(raw, dict) else None if not items: raise LookupError(f"No Emby item with id {item_id!r}. Use search_items to find it.") return items[0] # ---- API: Users ------------------------------------------------------- async def list_users(self) -> list[dict[str, Any]]: """List all users on the server.""" raw = await self._get("/Users/Query") if isinstance(raw, dict): return [u for u in raw.get("Items", []) if isinstance(u, dict)] return raw if isinstance(raw, list) else [] async def get_user(self, user_id: str) -> dict[str, Any]: """Get details for a specific user.""" return await self._get(f"/Users/{_seg(user_id)}") # ---- API: Sessions ---------------------------------------------------- async def list_sessions(self) -> list[dict[str, Any]]: """List active sessions (connected devices/apps).""" raw = await self._get("/Sessions") return raw if isinstance(raw, list) else [] async def get_system_info(self) -> dict[str, Any]: """Get server system info.""" return await self._get("/System/Info") # ---- API: Playback ---------------------------------------------------- async def play_on_session( self, session_id: str, item_ids: list[str], play_command: str = "PlayNow" ) -> Any: """Remote-control a client session to play items.""" return await self._post( f"/Sessions/{_seg(session_id)}/Playing", params={"ItemIds": ",".join(item_ids), "PlayCommand": play_command}, ) async def get_playback_info(self, item_id: str, user_id: str | None = None) -> dict[str, Any]: """Get playback info for an item (transcoding options, etc.).""" params = {} if user_id: params["UserId"] = user_id return await self._get(f"/Items/{_seg(item_id)}/PlaybackInfo", params=params) # ---- API: Device Info ------------------------------------------------- async def list_devices(self) -> list[dict[str, Any]]: """List registered devices (Emby wraps them in ``{"Items": [...]}``).""" raw = await self._get("/Devices") if isinstance(raw, dict): raw = raw.get("Items", []) return [d for d in raw if isinstance(d, dict)] if isinstance(raw, list) else []