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
+195 -62
View File
@@ -3,15 +3,22 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import timedelta
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.util.dt import utcnow
from .api import EmbyApiClient, EmbyApiError
from .api import EmbyApiClient, EmbyApiError, EmbyAuthenticationError
from .const import (
DEFAULT_SCAN_INTERVAL_WS,
DOMAIN,
TICKS_PER_SECOND,
WS_MESSAGE_PLAYBACK_PROGRESS,
@@ -24,7 +31,17 @@ from .websocket import EmbyWebSocket
_LOGGER = logging.getLogger(__name__)
@dataclass
def _safe_int(value: Any, default: int | None = None) -> int | None:
"""Best-effort int coercion that tolerates strings and bad data."""
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
@dataclass(frozen=True)
class EmbyNowPlaying:
"""Currently playing media information."""
@@ -42,8 +59,8 @@ class EmbyNowPlaying:
duration_ticks: int = 0
primary_image_tag: str | None = None
primary_image_item_id: str | None = None
backdrop_image_tags: list[str] = field(default_factory=list)
genres: list[str] = field(default_factory=list)
backdrop_image_tags: tuple[str, ...] = ()
genres: tuple[str, ...] = ()
production_year: int | None = None
overview: str | None = None
@@ -53,10 +70,11 @@ class EmbyNowPlaying:
return self.duration_ticks / TICKS_PER_SECOND if self.duration_ticks else 0
@dataclass
@dataclass(frozen=True)
class EmbyPlayState:
"""Playback state information."""
updated_at: datetime
is_paused: bool = False
is_muted: bool = False
volume_level: int = 100 # 0-100
@@ -72,7 +90,7 @@ class EmbyPlayState:
return self.position_ticks / TICKS_PER_SECOND if self.position_ticks else 0
@dataclass
@dataclass(frozen=True)
class EmbySession:
"""Represents an Emby client session."""
@@ -86,8 +104,9 @@ class EmbySession:
supports_remote_control: bool = True
now_playing: EmbyNowPlaying | None = None
play_state: EmbyPlayState | None = None
playable_media_types: list[str] = field(default_factory=list)
supported_commands: list[str] = field(default_factory=list)
playable_media_types: tuple[str, ...] = ()
supported_commands: tuple[str, ...] = ()
last_seen: datetime = field(default_factory=utcnow)
@property
def is_playing(self) -> bool:
@@ -114,7 +133,14 @@ class EmbySession:
class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
"""Coordinator for Emby data with WebSocket + polling fallback."""
"""Coordinator for Emby data with WebSocket + polling fallback.
When the WebSocket is connected we trust it as the source of truth for
each session. A slow REST poll still runs as a safety net and merges
its result with the WS-derived state: any session whose ``last_seen``
is newer than the moment we started the REST request keeps the WS
version intact.
"""
def __init__(
self,
@@ -132,18 +158,34 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
)
self.api = api
self._websocket = websocket
self._ws_connected = False
self._remove_ws_callback: callable | None = None
self._poll_interval = scan_interval
self._remove_ws_callback: Callable[[], None] | None = None
# Per-session last-seen timestamps (kept after a session leaves
# ``data`` so we can age it out cleanly).
self._session_last_seen: dict[str, datetime] = {}
@property
def websocket_connected(self) -> bool:
"""Return True if WebSocket is currently connected."""
return self._websocket.connected
def get_session_last_seen(self, session_id: str) -> datetime | None:
"""Return the last time we saw this session, or None if never."""
return self._session_last_seen.get(session_id)
def forget_session(self, session_id: str) -> None:
"""Drop a session's last-seen entry (called when its device is pruned)."""
self._session_last_seen.pop(session_id, None)
async def async_setup(self) -> None:
"""Set up the coordinator with WebSocket connection."""
# Try to establish WebSocket connection
if await self._websocket.connect():
await self._websocket.subscribe_to_sessions()
self._remove_ws_callback = self._websocket.add_callback(
self._handle_ws_message
)
self._ws_connected = True
# When WS is connected, slow REST polling to a safety-net cadence.
self.update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL_WS)
_LOGGER.info("Emby WebSocket connected, using real-time updates")
else:
_LOGGER.warning(
@@ -156,35 +198,114 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
_LOGGER.debug("Handling WebSocket message: %s", message_type)
if message_type == WS_MESSAGE_SESSIONS:
# Full session list received
if isinstance(data, list):
sessions = self._parse_sessions(data)
self._record_last_seen(sessions)
self.async_set_updated_data(sessions)
return
elif message_type in (
if message_type in (
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_STOP,
WS_MESSAGE_PLAYBACK_PROGRESS,
):
# Individual session update - trigger a refresh to get full state
# We could optimize this by updating only the affected session,
# but a full refresh ensures consistency
self.hass.async_create_task(self.async_request_refresh())
self._apply_playback_event(data, stopped=False)
return
if message_type == WS_MESSAGE_PLAYBACK_STOP:
self._apply_playback_event(data, stopped=True)
@callback
def _apply_playback_event(self, data: Any, *, stopped: bool) -> None:
"""Update a single session in place from a PlaybackProgress/Start/Stop event."""
if not isinstance(data, dict):
return
# SessionId is the per-client session; PlaySessionId is per-stream
# and can collide across devices, so we don't fall back to it.
session_id = data.get("SessionId")
if not session_id or not self.data:
return
session = self.data.get(session_id)
if session is None:
# We don't yet know this session; the next Sessions push will
# introduce it.
return
now = utcnow()
if stopped:
updated = replace(
session,
now_playing=None,
play_state=None,
last_seen=now,
)
else:
now_playing_data = data.get("NowPlayingItem") or data.get("Item")
now_playing = (
self._parse_now_playing(now_playing_data)
if now_playing_data
else session.now_playing
)
play_state_data = data.get("PlayState") or data
play_state = self._parse_play_state(play_state_data)
updated = replace(
session,
now_playing=now_playing,
play_state=play_state,
last_seen=now,
)
new_data = dict(self.data)
new_data[session_id] = updated
self._session_last_seen[session_id] = now
self.async_set_updated_data(new_data)
async def _async_update_data(self) -> dict[str, EmbySession]:
"""Fetch sessions from Emby API (polling fallback)."""
"""Fetch sessions from Emby API (polling fallback / periodic refresh)."""
request_started = utcnow()
try:
sessions_data = await self.api.get_sessions()
return self._parse_sessions(sessions_data)
except EmbyAuthenticationError as err:
# Surfacing ConfigEntryAuthFailed triggers the HA reauth flow.
raise ConfigEntryAuthFailed(
f"Authentication failed: {err}"
) from err
except EmbyApiError as err:
raise UpdateFailed(f"Error fetching Emby sessions: {err}") from err
def _parse_sessions(self, sessions_data: list[dict[str, Any]]) -> dict[str, EmbySession]:
rest_sessions = self._parse_sessions(sessions_data)
# If WebSocket is connected, prefer any session our WS callback has
# touched since we kicked off this REST request — its state is more
# current than what the REST snapshot just returned.
if self._websocket.connected and self.data:
for sid, ws_session in self.data.items():
if (
sid in rest_sessions
and ws_session.last_seen > request_started
):
rest_sessions[sid] = ws_session
self._record_last_seen(rest_sessions)
return rest_sessions
def _record_last_seen(self, sessions: dict[str, EmbySession]) -> None:
"""Update the last-seen map from a freshly parsed sessions dict."""
now = utcnow()
for sid in sessions:
self._session_last_seen[sid] = now
def _parse_sessions(
self, sessions_data: list[dict[str, Any]]
) -> dict[str, EmbySession]:
"""Parse session data into EmbySession objects."""
sessions: dict[str, EmbySession] = {}
now = utcnow()
for session_data in sessions_data:
# Only include sessions that support remote control
if not session_data.get("SupportsRemoteControl", False):
continue
@@ -192,19 +313,21 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
if not session_id:
continue
# Parse now playing item
now_playing = None
now_playing_data = session_data.get("NowPlayingItem")
if now_playing_data:
now_playing = self._parse_now_playing(now_playing_data)
now_playing = (
self._parse_now_playing(now_playing_data)
if now_playing_data
else None
)
# Parse play state
play_state = None
play_state_data = session_data.get("PlayState")
if play_state_data:
play_state = self._parse_play_state(play_state_data)
play_state = (
self._parse_play_state(play_state_data)
if play_state_data
else None
)
session = EmbySession(
sessions[session_id] = EmbySession(
session_id=session_id,
device_id=session_data.get("DeviceId", ""),
device_name=session_data.get("DeviceName", "Unknown Device"),
@@ -212,29 +335,33 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
app_version=session_data.get("ApplicationVersion"),
user_id=session_data.get("UserId"),
user_name=session_data.get("UserName"),
supports_remote_control=session_data.get("SupportsRemoteControl", True),
supports_remote_control=session_data.get(
"SupportsRemoteControl", True
),
now_playing=now_playing,
play_state=play_state,
playable_media_types=session_data.get("PlayableMediaTypes", []),
supported_commands=session_data.get("SupportedCommands", []),
playable_media_types=tuple(
session_data.get("PlayableMediaTypes", []) or []
),
supported_commands=tuple(
session_data.get("SupportedCommands", []) or []
),
last_seen=now,
)
sessions[session_id] = session
return sessions
def _parse_now_playing(self, data: dict[str, Any]) -> EmbyNowPlaying:
"""Parse now playing item data."""
# Get artists as string
artists = data.get("Artists", [])
artists = data.get("Artists", []) or []
artist = ", ".join(artists) if artists else data.get("AlbumArtist")
# Get the image item ID (for series/seasons, might be different from item ID)
# Pick the most relevant image source.
image_item_id = data.get("Id")
if data.get("SeriesId"):
image_item_id = data.get("SeriesId")
elif data.get("ParentId") and data.get("Type") == "Audio":
image_item_id = data.get("ParentId") # Use album ID for music
image_item_id = data.get("ParentId")
return EmbyNowPlaying(
item_id=data.get("Id", ""),
@@ -246,38 +373,44 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
album_artist=data.get("AlbumArtist"),
series_name=data.get("SeriesName"),
season_name=data.get("SeasonName"),
index_number=data.get("IndexNumber"),
parent_index_number=data.get("ParentIndexNumber"),
duration_ticks=data.get("RunTimeTicks", 0),
index_number=_safe_int(data.get("IndexNumber")),
parent_index_number=_safe_int(data.get("ParentIndexNumber")),
duration_ticks=_safe_int(data.get("RunTimeTicks"), default=0) or 0,
primary_image_tag=data.get("PrimaryImageTag"),
primary_image_item_id=image_item_id,
backdrop_image_tags=data.get("BackdropImageTags", []),
genres=data.get("Genres", []),
production_year=data.get("ProductionYear"),
backdrop_image_tags=tuple(data.get("BackdropImageTags", []) or []),
genres=tuple(data.get("Genres", []) or []),
production_year=_safe_int(data.get("ProductionYear")),
overview=data.get("Overview"),
)
def _parse_play_state(self, data: dict[str, Any]) -> EmbyPlayState:
"""Parse play state data."""
return EmbyPlayState(
is_paused=data.get("IsPaused", False),
is_muted=data.get("IsMuted", False),
volume_level=data.get("VolumeLevel", 100),
position_ticks=data.get("PositionTicks", 0),
can_seek=data.get("CanSeek", True),
repeat_mode=data.get("RepeatMode", "RepeatNone"),
shuffle_mode=data.get("ShuffleMode", "Sorted"),
updated_at=utcnow(),
is_paused=bool(data.get("IsPaused", False)),
is_muted=bool(data.get("IsMuted", False)),
volume_level=_safe_int(data.get("VolumeLevel"), default=100) or 0,
position_ticks=_safe_int(data.get("PositionTicks"), default=0) or 0,
can_seek=bool(data.get("CanSeek", True)),
repeat_mode=str(data.get("RepeatMode") or "RepeatNone"),
shuffle_mode=str(data.get("ShuffleMode") or "Sorted"),
play_method=data.get("PlayMethod"),
)
def update_scan_interval(self, interval: int) -> None:
"""Update the polling scan interval."""
self.update_interval = timedelta(seconds=interval)
_LOGGER.debug("Updated scan interval to %d seconds", interval)
"""Update the polling scan interval.
If WebSocket is connected, this only takes effect after disconnect.
"""
self._poll_interval = interval
if not self._websocket.connected:
self.update_interval = timedelta(seconds=interval)
_LOGGER.debug("Updated polling interval to %d seconds", interval)
async def async_shutdown(self) -> None:
"""Shut down the coordinator."""
if self._remove_ws_callback:
self._remove_ws_callback()
self._remove_ws_callback = None
await self._websocket.close()