Production-readiness pass: security hardening, performance improvements, new services (send_message, set_repeat, refresh_library), diagnostics, reauth flow, image proxy, per-instance device IDs, exponential WS reconnect backoff, ID validation, stale device cleanup, and supporting integration plumbing. Three rounds of independent code review applied. See RELEASE_NOTES.md for the full changelog.
This commit is contained in:
@@ -3,22 +3,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .const import (
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
COMMAND_DISPLAY_MESSAGE,
|
||||
COMMAND_MUTE,
|
||||
COMMAND_SET_REPEAT_MODE,
|
||||
COMMAND_SET_VOLUME,
|
||||
COMMAND_UNMUTE,
|
||||
DEFAULT_DEVICE_VERSION,
|
||||
DEFAULT_PORT,
|
||||
DEVICE_ID,
|
||||
DEVICE_NAME,
|
||||
DEVICE_VERSION,
|
||||
EMBY_ID_PATTERN,
|
||||
ENDPOINT_ARTISTS,
|
||||
ENDPOINT_ITEMS,
|
||||
ENDPOINT_LIBRARY_REFRESH,
|
||||
ENDPOINT_PREFIX_EMBY,
|
||||
ENDPOINT_PREFIX_NONE,
|
||||
ENDPOINT_SESSIONS,
|
||||
ENDPOINT_SYSTEM_INFO,
|
||||
ENDPOINT_USERS,
|
||||
ENDPOINT_USERS_PUBLIC,
|
||||
IMAGE_FETCH_TIMEOUT_SECONDS,
|
||||
PLAY_COMMAND_PLAY_NOW,
|
||||
PLAYBACK_COMMAND_NEXT_TRACK,
|
||||
PLAYBACK_COMMAND_PAUSE,
|
||||
@@ -30,6 +40,10 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=15)
|
||||
_IMAGE_TIMEOUT = aiohttp.ClientTimeout(total=IMAGE_FETCH_TIMEOUT_SECONDS)
|
||||
_EMBY_ID_RE = re.compile(EMBY_ID_PATTERN)
|
||||
|
||||
|
||||
class EmbyApiError(Exception):
|
||||
"""Base exception for Emby API errors."""
|
||||
@@ -43,56 +57,88 @@ class EmbyAuthenticationError(EmbyApiError):
|
||||
"""Exception for authentication errors."""
|
||||
|
||||
|
||||
def _validate_emby_id(value: str, field_name: str = "id") -> str:
|
||||
"""Reject IDs that don't look like Emby identifiers."""
|
||||
if not isinstance(value, str) or not _EMBY_ID_RE.fullmatch(value):
|
||||
raise EmbyApiError(f"Invalid Emby {field_name}: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
class EmbyApiClient:
|
||||
"""Emby REST API client."""
|
||||
"""Emby REST API client.
|
||||
|
||||
The aiohttp session is owned by the caller (Home Assistant); this class
|
||||
never closes it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
api_key: str,
|
||||
session: aiohttp.ClientSession,
|
||||
device_id: str,
|
||||
port: int = DEFAULT_PORT,
|
||||
ssl: bool = False,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
verify_ssl: bool = True,
|
||||
client_version: str = DEFAULT_DEVICE_VERSION,
|
||||
) -> None:
|
||||
"""Initialize the Emby API client."""
|
||||
self._host = host
|
||||
if not host or not host.strip():
|
||||
raise ValueError("host must not be empty")
|
||||
if not api_key:
|
||||
raise ValueError("api_key must not be empty")
|
||||
if not device_id:
|
||||
raise ValueError("device_id must not be empty")
|
||||
|
||||
self._host = host.strip().rstrip("/")
|
||||
self._port = port
|
||||
self._api_key = api_key
|
||||
self._ssl = ssl
|
||||
self._verify_ssl = verify_ssl
|
||||
self._session = session
|
||||
self._owns_session = session is None
|
||||
self._device_id = device_id
|
||||
self._client_version = client_version
|
||||
|
||||
protocol = "https" if ssl else "http"
|
||||
self._base_url = f"{protocol}://{host}:{port}"
|
||||
self._base_url = f"{protocol}://{self._host}:{port}"
|
||||
# Discovered at test_connection(); set to "" if server is configured
|
||||
# without the /emby prefix.
|
||||
self._prefix: str = ENDPOINT_PREFIX_EMBY
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""Return the base URL."""
|
||||
"""Return the base URL (no API prefix)."""
|
||||
return self._base_url
|
||||
|
||||
async def _ensure_session(self) -> aiohttp.ClientSession:
|
||||
"""Ensure an aiohttp session exists."""
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession()
|
||||
self._owns_session = True
|
||||
return self._session
|
||||
@property
|
||||
def prefix(self) -> str:
|
||||
"""Return the working API path prefix (e.g. "/emby" or "")."""
|
||||
return self._prefix
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the aiohttp session if we own it."""
|
||||
if self._owns_session and self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
"""Return the device id used to identify this client to Emby."""
|
||||
return self._device_id
|
||||
|
||||
def _get_headers(self) -> dict[str, str]:
|
||||
def _get_headers(self, *, content_json: bool = True) -> dict[str, str]:
|
||||
"""Get headers for API requests."""
|
||||
return {
|
||||
headers = {
|
||||
"X-Emby-Token": self._api_key,
|
||||
"X-Emby-Client": DEVICE_NAME,
|
||||
"X-Emby-Device-Name": DEVICE_NAME,
|
||||
"X-Emby-Device-Id": DEVICE_ID,
|
||||
"X-Emby-Client-Version": DEVICE_VERSION,
|
||||
"Content-Type": "application/json",
|
||||
"X-Emby-Device-Id": self._device_id,
|
||||
"X-Emby-Client-Version": self._client_version,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if content_json:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
def _ssl_kwarg(self) -> dict[str, Any]:
|
||||
"""Return the ssl kwarg for aiohttp depending on config."""
|
||||
if not self._ssl:
|
||||
return {}
|
||||
return {"ssl": self._verify_ssl}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
@@ -100,22 +146,29 @@ class EmbyApiClient:
|
||||
endpoint: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
*,
|
||||
absolute: bool = False,
|
||||
) -> Any:
|
||||
"""Make an API request."""
|
||||
session = await self._ensure_session()
|
||||
url = f"{self._base_url}{endpoint}"
|
||||
"""Make an API request.
|
||||
|
||||
``endpoint`` is expected to begin with "/" (e.g. "/System/Info").
|
||||
When ``absolute`` is False the discovered prefix ("/emby" by default)
|
||||
is prepended.
|
||||
"""
|
||||
path = endpoint if absolute else f"{self._prefix}{endpoint}"
|
||||
url = f"{self._base_url}{path}"
|
||||
|
||||
_LOGGER.debug("Making %s request to %s", method, url)
|
||||
|
||||
try:
|
||||
async with session.request(
|
||||
async with self._session.request(
|
||||
method,
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
params=params,
|
||||
json=data,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
ssl=False if not self._ssl else None, # Disable SSL verification if not using SSL
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
**self._ssl_kwarg(),
|
||||
) as response:
|
||||
_LOGGER.debug("Response status: %s", response.status)
|
||||
|
||||
@@ -125,19 +178,22 @@ class EmbyApiClient:
|
||||
raise EmbyAuthenticationError("Access forbidden")
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
_LOGGER.error("API error %s: %s", response.status, text)
|
||||
_LOGGER.debug("API error %s: %s", response.status, text)
|
||||
raise EmbyApiError(f"API error {response.status}: {text}")
|
||||
|
||||
if response.status == 204 or response.content_length == 0:
|
||||
return None
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "application/json" in content_type:
|
||||
return await response.json()
|
||||
return await response.text()
|
||||
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Connection error to %s: %s", url, err)
|
||||
_LOGGER.debug("Connection error to %s: %s", url, err)
|
||||
raise EmbyConnectionError(f"Connection error: {err}") from err
|
||||
except TimeoutError as err:
|
||||
_LOGGER.error("Timeout connecting to %s", url)
|
||||
_LOGGER.debug("Timeout connecting to %s", url)
|
||||
raise EmbyConnectionError(f"Connection timeout: {err}") from err
|
||||
|
||||
async def _get(
|
||||
@@ -162,34 +218,46 @@ class EmbyApiClient:
|
||||
async def test_connection(self) -> dict[str, Any]:
|
||||
"""Test the connection to the Emby server.
|
||||
|
||||
Tries both /emby/System/Info and /System/Info endpoints.
|
||||
Returns server info if successful.
|
||||
Tries both /emby/System/Info and /System/Info and pins the working
|
||||
prefix for subsequent calls.
|
||||
"""
|
||||
# Try with /emby prefix first (standard Emby)
|
||||
try:
|
||||
_LOGGER.debug("Trying connection with /emby prefix")
|
||||
return await self._get(ENDPOINT_SYSTEM_INFO)
|
||||
except (EmbyConnectionError, EmbyApiError) as err:
|
||||
_LOGGER.debug("Connection with /emby prefix failed: %s", err)
|
||||
last_error: Exception | None = None
|
||||
for prefix in (ENDPOINT_PREFIX_EMBY, ENDPOINT_PREFIX_NONE):
|
||||
url_path = f"{prefix}{ENDPOINT_SYSTEM_INFO}"
|
||||
try:
|
||||
_LOGGER.debug("Probing %s for connectivity", url_path)
|
||||
result = await self._request("GET", url_path, absolute=True)
|
||||
self._prefix = prefix
|
||||
_LOGGER.debug("Using API prefix %r", prefix or "<none>")
|
||||
return result
|
||||
except EmbyAuthenticationError:
|
||||
raise
|
||||
except (EmbyConnectionError, EmbyApiError) as err:
|
||||
last_error = err
|
||||
continue
|
||||
|
||||
# Try without /emby prefix (some Emby configurations)
|
||||
try:
|
||||
_LOGGER.debug("Trying connection without /emby prefix")
|
||||
return await self._get("/System/Info")
|
||||
except (EmbyConnectionError, EmbyApiError) as err:
|
||||
_LOGGER.debug("Connection without /emby prefix failed: %s", err)
|
||||
raise EmbyConnectionError(
|
||||
f"Cannot connect to Emby server at {self._base_url}. "
|
||||
"Please verify the host, port, and that the server is running."
|
||||
) from err
|
||||
raise EmbyConnectionError(
|
||||
f"Cannot connect to Emby server at {self._base_url}. "
|
||||
f"Last error: {last_error}"
|
||||
)
|
||||
|
||||
async def get_server_info(self) -> dict[str, Any]:
|
||||
"""Get server information."""
|
||||
return await self._get(ENDPOINT_SYSTEM_INFO)
|
||||
|
||||
async def get_users(self) -> list[dict[str, Any]]:
|
||||
"""Get list of users."""
|
||||
return await self._get(ENDPOINT_USERS)
|
||||
"""Get list of users.
|
||||
|
||||
Falls back to the public users endpoint if the API key is not an admin
|
||||
token (HTTP 401/403 on the authenticated endpoint).
|
||||
"""
|
||||
try:
|
||||
return await self._get(ENDPOINT_USERS)
|
||||
except EmbyAuthenticationError:
|
||||
_LOGGER.debug(
|
||||
"API key is not admin, falling back to /Users/Public"
|
||||
)
|
||||
return await self._get(ENDPOINT_USERS_PUBLIC)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Sessions
|
||||
@@ -203,7 +271,7 @@ class EmbyApiClient:
|
||||
self, user_id: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get sessions that can be remotely controlled."""
|
||||
params = {}
|
||||
params: dict[str, Any] = {}
|
||||
if user_id:
|
||||
params["ControllableByUserId"] = user_id
|
||||
|
||||
@@ -222,25 +290,25 @@ class EmbyApiClient:
|
||||
start_position_ticks: int = 0,
|
||||
) -> None:
|
||||
"""Send play command to a session."""
|
||||
if not item_ids:
|
||||
raise EmbyApiError("item_ids are required")
|
||||
_validate_emby_id(session_id, "session_id")
|
||||
for item_id in item_ids:
|
||||
_validate_emby_id(item_id, "item_id")
|
||||
|
||||
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing"
|
||||
params = {
|
||||
params: dict[str, Any] = {
|
||||
"ItemIds": ",".join(item_ids),
|
||||
"PlayCommand": play_command,
|
||||
}
|
||||
if start_position_ticks > 0:
|
||||
params["StartPositionTicks"] = start_position_ticks
|
||||
|
||||
_LOGGER.debug(
|
||||
"Sending play_media: endpoint=%s, session_id=%s, item_ids=%s, command=%s",
|
||||
endpoint,
|
||||
session_id,
|
||||
item_ids,
|
||||
play_command,
|
||||
)
|
||||
await self._post(endpoint, params=params)
|
||||
|
||||
async def _playback_command(self, session_id: str, command: str) -> None:
|
||||
"""Send a playback command to a session."""
|
||||
_validate_emby_id(session_id, "session_id")
|
||||
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing/{command}"
|
||||
await self._post(endpoint)
|
||||
|
||||
@@ -266,28 +334,38 @@ class EmbyApiClient:
|
||||
|
||||
async def seek(self, session_id: str, position_ticks: int) -> None:
|
||||
"""Seek to a position."""
|
||||
_validate_emby_id(session_id, "session_id")
|
||||
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing/{PLAYBACK_COMMAND_SEEK}"
|
||||
await self._post(endpoint, params={"SeekPositionTicks": position_ticks})
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Volume Control
|
||||
# General Commands
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _send_command(
|
||||
self, session_id: str, command: str, arguments: dict[str, Any] | None = None
|
||||
self,
|
||||
session_id: str,
|
||||
command: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Send a general command to a session."""
|
||||
"""Send a general command to a session.
|
||||
|
||||
Emby's /Command endpoint accepts arguments as strings; numeric values
|
||||
are stringified here.
|
||||
"""
|
||||
_validate_emby_id(session_id, "session_id")
|
||||
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Command"
|
||||
data: dict[str, Any] = {"Name": command}
|
||||
if arguments:
|
||||
# Emby expects arguments as strings
|
||||
data["Arguments"] = {k: str(v) for k, v in arguments.items()}
|
||||
await self._post(endpoint, data=data)
|
||||
|
||||
async def set_volume(self, session_id: str, volume: int) -> None:
|
||||
"""Set volume level (0-100)."""
|
||||
volume = max(0, min(100, volume))
|
||||
await self._send_command(session_id, COMMAND_SET_VOLUME, {"Volume": volume})
|
||||
volume = max(0, min(100, int(volume)))
|
||||
await self._send_command(
|
||||
session_id, COMMAND_SET_VOLUME, {"Volume": volume}
|
||||
)
|
||||
|
||||
async def mute(self, session_id: str) -> None:
|
||||
"""Mute the session."""
|
||||
@@ -297,15 +375,40 @@ class EmbyApiClient:
|
||||
"""Unmute the session."""
|
||||
await self._send_command(session_id, COMMAND_UNMUTE)
|
||||
|
||||
async def set_repeat_mode(self, session_id: str, mode: str) -> None:
|
||||
"""Set the session's repeat mode (RepeatNone/RepeatOne/RepeatAll)."""
|
||||
await self._send_command(
|
||||
session_id, COMMAND_SET_REPEAT_MODE, {"RepeatMode": mode}
|
||||
)
|
||||
|
||||
async def display_message(
|
||||
self,
|
||||
session_id: str,
|
||||
text: str,
|
||||
header: str | None = None,
|
||||
timeout_ms: int | None = None,
|
||||
) -> None:
|
||||
"""Display a message on the client device."""
|
||||
args: dict[str, Any] = {"Text": text}
|
||||
if header is not None:
|
||||
args["Header"] = header
|
||||
if timeout_ms is not None:
|
||||
args["TimeoutMs"] = int(timeout_ms)
|
||||
await self._send_command(session_id, COMMAND_DISPLAY_MESSAGE, args)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Library Browsing
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def get_views(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Get user's library views (top-level folders)."""
|
||||
_validate_emby_id(user_id, "user_id")
|
||||
endpoint = f"{ENDPOINT_USERS}/{user_id}/Views"
|
||||
result = await self._get(endpoint)
|
||||
return result.get("Items", [])
|
||||
if not isinstance(result, dict):
|
||||
return []
|
||||
items = result.get("Items", [])
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
async def get_items(
|
||||
self,
|
||||
@@ -321,6 +424,9 @@ class EmbyApiClient:
|
||||
fields: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Get items from the library."""
|
||||
_validate_emby_id(user_id, "user_id")
|
||||
if parent_id is not None:
|
||||
_validate_emby_id(parent_id, "parent_id")
|
||||
endpoint = f"{ENDPOINT_USERS}/{user_id}/Items"
|
||||
|
||||
params: dict[str, Any] = {
|
||||
@@ -339,13 +445,13 @@ class EmbyApiClient:
|
||||
params["SearchTerm"] = search_term
|
||||
if fields:
|
||||
params["Fields"] = ",".join(fields)
|
||||
else:
|
||||
params["Fields"] = "PrimaryImageAspectRatio,BasicSyncInfo"
|
||||
|
||||
return await self._get(endpoint, params=params)
|
||||
|
||||
async def get_item(self, user_id: str, item_id: str) -> dict[str, Any]:
|
||||
"""Get a single item by ID."""
|
||||
_validate_emby_id(user_id, "user_id")
|
||||
_validate_emby_id(item_id, "item_id")
|
||||
endpoint = f"{ENDPOINT_USERS}/{user_id}/Items/{item_id}"
|
||||
return await self._get(endpoint)
|
||||
|
||||
@@ -357,7 +463,9 @@ class EmbyApiClient:
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
"""Get artists."""
|
||||
endpoint = "/emby/Artists"
|
||||
_validate_emby_id(user_id, "user_id")
|
||||
if parent_id is not None:
|
||||
_validate_emby_id(parent_id, "parent_id")
|
||||
params: dict[str, Any] = {
|
||||
"UserId": user_id,
|
||||
"StartIndex": start_index,
|
||||
@@ -368,25 +476,72 @@ class EmbyApiClient:
|
||||
if parent_id:
|
||||
params["ParentId"] = parent_id
|
||||
|
||||
return await self._get(endpoint, params=params)
|
||||
return await self._get(ENDPOINT_ARTISTS, params=params)
|
||||
|
||||
def get_image_url(
|
||||
async def refresh_library(self) -> None:
|
||||
"""Trigger a server-side library scan."""
|
||||
await self._post(ENDPOINT_LIBRARY_REFRESH)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Image fetching
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_image_path(
|
||||
self,
|
||||
item_id: str,
|
||||
image_type: str = "Primary",
|
||||
max_width: int | None = None,
|
||||
max_height: int | None = None,
|
||||
) -> str:
|
||||
"""Get the URL for an item's image."""
|
||||
url = f"{self._base_url}{ENDPOINT_ITEMS}/{item_id}/Images/{image_type}"
|
||||
params = []
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""Build the URL and query params for an item image.
|
||||
|
||||
Returns ``(url, params)``. The API key is intentionally NOT included;
|
||||
the caller is responsible for sending the X-Emby-Token header. The
|
||||
item_id is validated to prevent path traversal.
|
||||
"""
|
||||
_validate_emby_id(item_id, "item_id")
|
||||
if image_type not in ALLOWED_IMAGE_TYPES:
|
||||
raise EmbyApiError(f"Invalid image_type: {image_type!r}")
|
||||
url = (
|
||||
f"{self._base_url}{self._prefix}{ENDPOINT_ITEMS}"
|
||||
f"/{item_id}/Images/{image_type}"
|
||||
)
|
||||
params: dict[str, str] = {}
|
||||
if max_width:
|
||||
params.append(f"maxWidth={max_width}")
|
||||
params["maxWidth"] = str(int(max_width))
|
||||
if max_height:
|
||||
params.append(f"maxHeight={max_height}")
|
||||
params.append(f"api_key={self._api_key}")
|
||||
params["maxHeight"] = str(int(max_height))
|
||||
return url, params
|
||||
|
||||
if params:
|
||||
url += "?" + "&".join(params)
|
||||
async def fetch_image(
|
||||
self,
|
||||
item_id: str,
|
||||
image_type: str = "Primary",
|
||||
max_width: int | None = None,
|
||||
max_height: int | None = None,
|
||||
) -> tuple[bytes, str | None]:
|
||||
"""Fetch an image. Returns ``(content, content_type)``.
|
||||
|
||||
return url
|
||||
Uses the X-Emby-Token header so the API key is not exposed in URLs.
|
||||
"""
|
||||
url, params = self.get_image_path(
|
||||
item_id, image_type, max_width, max_height
|
||||
)
|
||||
try:
|
||||
async with self._session.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=self._get_headers(content_json=False),
|
||||
timeout=_IMAGE_TIMEOUT,
|
||||
**self._ssl_kwarg(),
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
raise EmbyApiError(
|
||||
f"Image fetch failed with status {response.status}"
|
||||
)
|
||||
content = await response.read()
|
||||
return content, response.headers.get("Content-Type")
|
||||
except aiohttp.ClientError as err:
|
||||
raise EmbyConnectionError(f"Image fetch error: {err}") from err
|
||||
except TimeoutError as err:
|
||||
raise EmbyConnectionError(f"Image fetch timeout: {err}") from err
|
||||
|
||||
Reference in New Issue
Block a user