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
+183 -96
View File
@@ -3,129 +3,181 @@
from __future__ import annotations
import asyncio
import inspect
import json
import logging
from collections.abc import Callable
import random
from collections.abc import Awaitable, Callable
from typing import Any
import aiohttp
from .const import (
DEVICE_ID,
DEFAULT_DEVICE_VERSION,
DEVICE_NAME,
DEVICE_VERSION,
WEBSOCKET_PATH,
WS_HEARTBEAT,
WS_MESSAGE_FORCE_KEEP_ALIVE,
WS_MESSAGE_KEEP_ALIVE,
WS_MESSAGE_PLAYBACK_PROGRESS,
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_STOP,
WS_MESSAGE_SESSIONS,
WS_MESSAGE_SESSIONS_START,
WS_MESSAGE_SESSIONS_STOP,
WS_RECONNECT_MAX_DELAY,
WS_RECONNECT_MIN_DELAY,
)
_LOGGER = logging.getLogger(__name__)
# Message types we're interested in
TRACKED_MESSAGE_TYPES = {
WS_MESSAGE_SESSIONS,
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_STOP,
WS_MESSAGE_PLAYBACK_PROGRESS,
}
# Message types we surface to subscribers
TRACKED_MESSAGE_TYPES = frozenset(
{
WS_MESSAGE_SESSIONS,
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_STOP,
WS_MESSAGE_PLAYBACK_PROGRESS,
}
)
# Callbacks may be sync or async; both forms are supported.
WSCallback = Callable[[str, Any], Awaitable[None] | None]
# Bound exponent so we don't overflow on long outages.
_MAX_BACKOFF_EXPONENT = 6
class EmbyWebSocket:
"""WebSocket client for real-time Emby updates."""
"""WebSocket client for real-time Emby updates.
The aiohttp session is owned by Home Assistant and is never closed here.
"""
def __init__(
self,
host: str,
port: int,
api_key: str,
device_id: str,
session: aiohttp.ClientSession,
ssl: bool = False,
session: aiohttp.ClientSession | None = None,
verify_ssl: bool = True,
client_version: str = DEFAULT_DEVICE_VERSION,
) -> None:
"""Initialize the WebSocket 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._device_id = device_id
self._ssl = ssl
self._verify_ssl = verify_ssl
self._session = session
self._owns_session = session is None
self._client_version = client_version
protocol = "wss" if ssl else "ws"
self._url = f"{protocol}://{host}:{port}{WEBSOCKET_PATH}"
self._url = f"{protocol}://{self._host}:{port}{WEBSOCKET_PATH}"
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._callbacks: list[Callable[[str, Any], None]] = []
self._listen_task: asyncio.Task | None = None
self._callbacks: list[WSCallback] = []
self._listen_task: asyncio.Task[None] | None = None
self._reconnect_task: asyncio.Task[None] | None = None
self._running = False
self._reconnect_interval = 30 # seconds
self._reconnect_attempts = 0
@property
def connected(self) -> bool:
"""Return True if connected to WebSocket."""
return self._ws is not None and not self._ws.closed
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
def _ssl_kwarg(self) -> dict[str, Any]:
"""Return ssl kwarg for aiohttp depending on config."""
if not self._ssl:
return {}
return {"ssl": self._verify_ssl}
def _backoff_delay(self) -> float:
"""Compute exponential backoff with jitter for reconnects."""
exponent = min(self._reconnect_attempts, _MAX_BACKOFF_EXPONENT)
base = min(
WS_RECONNECT_MAX_DELAY,
WS_RECONNECT_MIN_DELAY * (2**exponent),
)
jitter = random.uniform(0, base * 0.2) # noqa: S311 - non-crypto jitter
return base + jitter
async def connect(self) -> bool:
"""Connect to Emby WebSocket."""
"""Connect to Emby WebSocket. Returns True on success."""
if self.connected:
return True
session = await self._ensure_session()
# Build WebSocket URL with authentication params
params = {
"api_key": self._api_key,
"deviceId": DEVICE_ID,
# API token in headers (not query string) keeps it out of proxy logs.
headers = {
"X-Emby-Token": self._api_key,
"X-Emby-Client": DEVICE_NAME,
"X-Emby-Device-Name": DEVICE_NAME,
"X-Emby-Device-Id": self._device_id,
"X-Emby-Client-Version": self._client_version,
}
# deviceId is also required as a query param by some Emby versions.
params = {"deviceId": self._device_id}
try:
self._ws = await session.ws_connect(
self._ws = await self._session.ws_connect(
self._url,
params=params,
heartbeat=30,
headers=headers,
heartbeat=WS_HEARTBEAT,
timeout=aiohttp.ClientTimeout(total=10),
**self._ssl_kwarg(),
)
self._running = True
_LOGGER.debug("Connected to Emby WebSocket at %s", self._url)
# Start listening for messages
self._listen_task = asyncio.create_task(self._listen())
return True
except aiohttp.WSServerHandshakeError as err:
if err.status in (401, 403):
_LOGGER.warning("WebSocket auth failed: %s", err)
self._running = False
return False
_LOGGER.warning("WebSocket handshake failed: %s", err)
return False
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to connect to Emby WebSocket: %s", err)
return False
except Exception as err:
_LOGGER.exception("Unexpected error connecting to WebSocket: %s", err)
except TimeoutError:
_LOGGER.warning("Timeout connecting to Emby WebSocket")
return False
self._running = True
self._reconnect_attempts = 0
_LOGGER.debug("Connected to Emby WebSocket at %s", self._url)
self._listen_task = asyncio.create_task(
self._listen(), name="emby_ws_listen"
)
return True
async def _listen(self) -> None:
"""Listen for WebSocket messages."""
if not self._ws:
ws = self._ws
if ws is None:
return
try:
async for msg in self._ws:
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
await self._handle_message(data)
await self._handle_message(json.loads(msg.data))
except json.JSONDecodeError:
_LOGGER.warning("Invalid JSON received: %s", msg.data)
_LOGGER.debug("Invalid JSON received: %s", msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
_LOGGER.error(
"WebSocket error: %s", self._ws.exception() if self._ws else "Unknown"
_LOGGER.debug(
"WebSocket error: %s",
ws.exception() if ws else "Unknown",
)
break
@@ -139,50 +191,78 @@ class EmbyWebSocket:
except asyncio.CancelledError:
_LOGGER.debug("WebSocket listener cancelled")
except Exception as err:
_LOGGER.exception("Error in WebSocket listener: %s", err)
raise
except Exception: # noqa: BLE001 - log and reconnect
_LOGGER.exception("Unexpected error in WebSocket listener")
finally:
self._ws = None
self._schedule_reconnect()
# Attempt reconnection if still running
if self._running:
_LOGGER.info(
"WebSocket disconnected, will reconnect in %d seconds",
self._reconnect_interval,
)
asyncio.create_task(self._reconnect())
def _schedule_reconnect(self) -> None:
"""Schedule a reconnect attempt unless one is already pending."""
if not self._running:
return
if self._reconnect_task is not None and not self._reconnect_task.done():
# Already scheduled; do not stack reconnects.
return
async def _reconnect(self) -> None:
"""Attempt to reconnect to WebSocket."""
await asyncio.sleep(self._reconnect_interval)
self._reconnect_attempts += 1
delay = self._backoff_delay()
_LOGGER.info(
"WebSocket disconnected, reconnecting in %.1fs (attempt %d)",
delay,
self._reconnect_attempts,
)
self._reconnect_task = asyncio.create_task(
self._reconnect(delay), name="emby_ws_reconnect"
)
if self._running and not self.connected:
_LOGGER.debug("Attempting WebSocket reconnection...")
if await self.connect():
await self.subscribe_to_sessions()
async def _reconnect(self, delay: float) -> None:
"""Attempt to reconnect to WebSocket after a delay."""
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
return
if not self._running or self.connected:
return
_LOGGER.debug("Attempting WebSocket reconnection...")
if await self.connect():
await self.subscribe_to_sessions()
async def _handle_message(self, message: dict[str, Any]) -> None:
"""Handle an incoming WebSocket message."""
msg_type = message.get("MessageType", "")
data = message.get("Data")
_LOGGER.debug("Received WebSocket message: %s", msg_type)
# Echo ForceKeepAlive so Emby doesn't drop the connection.
if msg_type == WS_MESSAGE_FORCE_KEEP_ALIVE:
await self._send_message(WS_MESSAGE_KEEP_ALIVE, "")
return
if msg_type in TRACKED_MESSAGE_TYPES:
# Notify all callbacks
for callback in self._callbacks:
try:
callback(msg_type, data)
except Exception:
_LOGGER.exception("Error in WebSocket callback")
if msg_type not in TRACKED_MESSAGE_TYPES:
return
for cb in list(self._callbacks):
try:
result = cb(msg_type, data)
if inspect.isawaitable(result):
# Detach so a slow async callback doesn't block the reader.
asyncio.create_task(
_swallow_callback(result),
name="emby_ws_callback",
)
except Exception: # noqa: BLE001 - never let a cb kill us
_LOGGER.exception("Error in WebSocket callback")
async def subscribe_to_sessions(self) -> None:
"""Subscribe to session updates."""
if not self.connected:
_LOGGER.warning("Cannot subscribe: WebSocket not connected")
_LOGGER.debug("Cannot subscribe: WebSocket not connected")
return
# Request session updates every 1500ms
# Request session updates roughly every 1500ms.
await self._send_message(WS_MESSAGE_SESSIONS_START, "0,1500")
_LOGGER.debug("Subscribed to session updates")
@@ -193,23 +273,19 @@ class EmbyWebSocket:
async def _send_message(self, message_type: str, data: Any) -> None:
"""Send a message through the WebSocket."""
if not self._ws or self._ws.closed:
ws = self._ws
if ws is None or ws.closed:
return
message = {
"MessageType": message_type,
"Data": data,
}
try:
await self._ws.send_json(message)
except Exception as err:
_LOGGER.warning("Failed to send WebSocket message: %s", err)
await ws.send_json({"MessageType": message_type, "Data": data})
except aiohttp.ClientError as err:
_LOGGER.debug("Failed to send WebSocket message: %s", err)
def add_callback(self, callback: Callable[[str, Any], None]) -> Callable[[], None]:
"""Add a callback for WebSocket messages.
def add_callback(self, callback: WSCallback) -> Callable[[], None]:
"""Register a callback for tracked WebSocket messages.
Returns a function to remove the callback.
Returns a function that removes the callback when called.
"""
self._callbacks.append(callback)
@@ -219,26 +295,37 @@ class EmbyWebSocket:
return remove_callback
async def disconnect(self) -> None:
"""Disconnect from WebSocket."""
async def close(self) -> None:
"""Close the WebSocket and cancel any pending reconnect."""
self._running = False
if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
try:
await self._reconnect_task
except asyncio.CancelledError:
pass
self._reconnect_task = None
if self._listen_task and not self._listen_task.done():
self._listen_task.cancel()
try:
await self._listen_task
except asyncio.CancelledError:
pass
self._listen_task = None
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
self._callbacks.clear()
_LOGGER.debug("Disconnected from Emby WebSocket")
async def close(self) -> None:
"""Close the WebSocket and session."""
await self.disconnect()
if self._owns_session and self._session and not self._session.closed:
await self._session.close()
async def _swallow_callback(awaitable: Awaitable[None]) -> None:
"""Run an async callback and log any exception."""
try:
await awaitable
except Exception: # noqa: BLE001
_LOGGER.exception("Error in async WebSocket callback")