Files
javis/emby-mcp/src/emby_mcp/client.py
T
2026-10-04 15:31:44 +02:00

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 []