154 lines
5.4 KiB
Python
154 lines
5.4 KiB
Python
"""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 [] |