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