chore: release v0.2.0
Release / release (push) Successful in 2s

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:
2026-05-26 13:16:36 +03:00
parent 56c1125ef2
commit 6ae0ed1787
18 changed files with 2170 additions and 645 deletions
+238 -83
View File
@@ -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