6ae0ed1787
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.
417 lines
15 KiB
Python
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()
|