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