Files
haos-hacs-emby-media-player/custom_components/emby_player/websocket.py
T
alexei.dolgolyov 6ae0ed1787
Release / release (push) Successful in 2s
chore: release v0.2.0
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.
2026-05-26 13:16:36 +03:00

332 lines
11 KiB
Python

"""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")