Files
haos-hacs-emby-media-player/custom_components/emby_player/coordinator.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

417 lines
15 KiB
Python

"""Data coordinator for Emby Media Player integration."""
from __future__ import annotations
import logging
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.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.util.dt import utcnow
from .api import EmbyApiClient, EmbyApiError, EmbyAuthenticationError
from .const import (
DEFAULT_SCAN_INTERVAL_WS,
DOMAIN,
TICKS_PER_SECOND,
WS_MESSAGE_PLAYBACK_PROGRESS,
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_STOP,
WS_MESSAGE_SESSIONS,
)
from .websocket import EmbyWebSocket
_LOGGER = logging.getLogger(__name__)
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."""
item_id: str
name: str
media_type: str # Audio, Video
item_type: str # Movie, Episode, Audio, etc.
artist: str | None = None
album: str | None = None
album_artist: str | None = None
series_name: str | None = None
season_name: str | None = None
index_number: int | None = None # Episode number
parent_index_number: int | None = None # Season number
duration_ticks: int = 0
primary_image_tag: str | None = None
primary_image_item_id: str | None = None
backdrop_image_tags: tuple[str, ...] = ()
genres: tuple[str, ...] = ()
production_year: int | None = None
overview: str | None = None
@property
def duration_seconds(self) -> float:
"""Get duration in seconds."""
return self.duration_ticks / TICKS_PER_SECOND if self.duration_ticks else 0
@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
position_ticks: int = 0
can_seek: bool = True
repeat_mode: str = "RepeatNone"
shuffle_mode: str = "Sorted"
play_method: str | None = None # DirectPlay, DirectStream, Transcode
@property
def position_seconds(self) -> float:
"""Get position in seconds."""
return self.position_ticks / TICKS_PER_SECOND if self.position_ticks else 0
@dataclass(frozen=True)
class EmbySession:
"""Represents an Emby client session."""
session_id: str
device_id: str
device_name: str
client_name: str
app_version: str | None = None
user_id: str | None = None
user_name: str | None = None
supports_remote_control: bool = True
now_playing: EmbyNowPlaying | None = None
play_state: EmbyPlayState | None = None
playable_media_types: tuple[str, ...] = ()
supported_commands: tuple[str, ...] = ()
last_seen: datetime = field(default_factory=utcnow)
@property
def is_playing(self) -> bool:
"""Return True if media is currently playing (not paused)."""
return (
self.now_playing is not None
and self.play_state is not None
and not self.play_state.is_paused
)
@property
def is_paused(self) -> bool:
"""Return True if media is paused."""
return (
self.now_playing is not None
and self.play_state is not None
and self.play_state.is_paused
)
@property
def is_idle(self) -> bool:
"""Return True if session is idle (no media playing)."""
return self.now_playing is None
class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
"""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,
hass: HomeAssistant,
api: EmbyApiClient,
websocket: EmbyWebSocket,
scan_interval: int,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=scan_interval),
)
self.api = api
self._websocket = websocket
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."""
if await self._websocket.connect():
await self._websocket.subscribe_to_sessions()
self._remove_ws_callback = self._websocket.add_callback(
self._handle_ws_message
)
# 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(
"Emby WebSocket connection failed, using polling fallback"
)
@callback
def _handle_ws_message(self, message_type: str, data: Any) -> None:
"""Handle incoming WebSocket message."""
_LOGGER.debug("Handling WebSocket message: %s", message_type)
if message_type == WS_MESSAGE_SESSIONS:
if isinstance(data, list):
sessions = self._parse_sessions(data)
self._record_last_seen(sessions)
self.async_set_updated_data(sessions)
return
if message_type in (
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_PROGRESS,
):
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 / periodic refresh)."""
request_started = utcnow()
try:
sessions_data = await self.api.get_sessions()
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
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:
if not session_data.get("SupportsRemoteControl", False):
continue
session_id = session_data.get("Id")
if not session_id:
continue
now_playing_data = session_data.get("NowPlayingItem")
now_playing = (
self._parse_now_playing(now_playing_data)
if now_playing_data
else None
)
play_state_data = session_data.get("PlayState")
play_state = (
self._parse_play_state(play_state_data)
if play_state_data
else None
)
sessions[session_id] = EmbySession(
session_id=session_id,
device_id=session_data.get("DeviceId", ""),
device_name=session_data.get("DeviceName", "Unknown Device"),
client_name=session_data.get("Client", "Unknown Client"),
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
),
now_playing=now_playing,
play_state=play_state,
playable_media_types=tuple(
session_data.get("PlayableMediaTypes", []) or []
),
supported_commands=tuple(
session_data.get("SupportedCommands", []) or []
),
last_seen=now,
)
return sessions
def _parse_now_playing(self, data: dict[str, Any]) -> EmbyNowPlaying:
"""Parse now playing item data."""
artists = data.get("Artists", []) or []
artist = ", ".join(artists) if artists else data.get("AlbumArtist")
# 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")
return EmbyNowPlaying(
item_id=data.get("Id", ""),
name=data.get("Name", ""),
media_type=data.get("MediaType", ""),
item_type=data.get("Type", ""),
artist=artist,
album=data.get("Album"),
album_artist=data.get("AlbumArtist"),
series_name=data.get("SeriesName"),
season_name=data.get("SeasonName"),
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=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(
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.
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()