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,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")
|
||||
|
||||
Reference in New Issue
Block a user