"""Emby WebSocket client for real-time updates.""" from __future__ import annotations import asyncio import inspect import json import logging import random from collections.abc import Awaitable, Callable from typing import Any import aiohttp from .const import ( DEFAULT_DEVICE_VERSION, DEVICE_NAME, 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 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. 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, verify_ssl: bool = True, client_version: str = DEFAULT_DEVICE_VERSION, ) -> None: """Initialize the WebSocket client.""" 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._client_version = client_version protocol = "wss" if ssl else "ws" self._url = f"{protocol}://{self._host}:{port}{WEBSOCKET_PATH}" self._ws: aiohttp.ClientWebSocketResponse | 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_attempts = 0 @property def connected(self) -> bool: """Return True if connected to WebSocket.""" return self._ws is not None and not self._ws.closed 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. Returns True on success.""" if self.connected: return True # 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 self._session.ws_connect( self._url, params=params, headers=headers, heartbeat=WS_HEARTBEAT, timeout=aiohttp.ClientTimeout(total=10), **self._ssl_kwarg(), ) 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 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.""" ws = self._ws if ws is None: return try: async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: try: await self._handle_message(json.loads(msg.data)) except json.JSONDecodeError: _LOGGER.debug("Invalid JSON received: %s", msg.data) elif msg.type == aiohttp.WSMsgType.ERROR: _LOGGER.debug( "WebSocket error: %s", ws.exception() if ws else "Unknown", ) break elif msg.type in ( aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING, ): _LOGGER.debug("WebSocket connection closed") break except asyncio.CancelledError: _LOGGER.debug("WebSocket listener cancelled") raise except Exception: # noqa: BLE001 - log and reconnect _LOGGER.exception("Unexpected error in WebSocket listener") finally: self._ws = None self._schedule_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 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" ) 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") # 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 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.debug("Cannot subscribe: WebSocket not connected") return # Request session updates roughly every 1500ms. await self._send_message(WS_MESSAGE_SESSIONS_START, "0,1500") _LOGGER.debug("Subscribed to session updates") async def unsubscribe_from_sessions(self) -> None: """Unsubscribe from session updates.""" if self.connected: await self._send_message(WS_MESSAGE_SESSIONS_STOP, "") async def _send_message(self, message_type: str, data: Any) -> None: """Send a message through the WebSocket.""" ws = self._ws if ws is None or ws.closed: return try: 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: WSCallback) -> Callable[[], None]: """Register a callback for tracked WebSocket messages. Returns a function that removes the callback when called. """ self._callbacks.append(callback) def remove_callback() -> None: if callback in self._callbacks: self._callbacks.remove(callback) return remove_callback 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 _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")