"""Media player platform for Emby Media Player integration.""" from __future__ import annotations import logging import re from datetime import datetime from typing import Any from homeassistant.components.media_player import ( BrowseMedia, MediaPlayerDeviceClass, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, RepeatMode, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from . import EmbyConfigEntry, EmbyRuntimeData from .api import EmbyApiError from .browse_media import async_browse_media from .const import ( ATTR_CLIENT_NAME, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_ITEM_ID, ATTR_ITEM_TYPE, ATTR_PLAY_METHOD, ATTR_SESSION_ID, ATTR_USER_NAME, DOMAIN, EMBY_ID_PATTERN, ITEM_TYPE_AUDIO, ITEM_TYPE_EPISODE, ITEM_TYPE_MOVIE, MEDIA_TYPE_AUDIO, MEDIA_TYPE_VIDEO, PLAY_COMMAND_PLAY_LAST, PLAY_COMMAND_PLAY_NEXT, PLAY_COMMAND_PLAY_NOW, REPEAT_MODE_ALL, REPEAT_MODE_NONE, REPEAT_MODE_ONE, STALE_PRUNE_GRACE_SECONDS, STALE_SESSION_TIMEOUT, TICKS_PER_SECOND, ) from .coordinator import EmbyCoordinator, EmbySession _LOGGER = logging.getLogger(__name__) _EMBY_ID_RE = re.compile(EMBY_ID_PATTERN) SUPPORTED_FEATURES = ( MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.REPEAT_SET ) # HA RepeatMode <-> Emby repeat mode _HA_TO_EMBY_REPEAT = { RepeatMode.OFF: REPEAT_MODE_NONE, RepeatMode.ONE: REPEAT_MODE_ONE, RepeatMode.ALL: REPEAT_MODE_ALL, } _EMBY_TO_HA_REPEAT = {v: k for k, v in _HA_TO_EMBY_REPEAT.items()} # Explicit HA enqueue → Emby play-command mapping. Anything not in this map # falls back to "PlayNow". _ENQUEUE_TO_PLAY_COMMAND: dict[MediaPlayerEnqueue | None, str] = { None: PLAY_COMMAND_PLAY_NOW, MediaPlayerEnqueue.PLAY: PLAY_COMMAND_PLAY_NOW, MediaPlayerEnqueue.REPLACE: PLAY_COMMAND_PLAY_NOW, MediaPlayerEnqueue.NEXT: PLAY_COMMAND_PLAY_NEXT, MediaPlayerEnqueue.ADD: PLAY_COMMAND_PLAY_LAST, } def _device_class_for_client(client_name: str) -> MediaPlayerDeviceClass | None: """Pick a sensible device class from the Emby client identifier.""" name = (client_name or "").lower() if any(token in name for token in ("android tv", "fire tv", "roku", "kodi", "tv")): return MediaPlayerDeviceClass.TV if any(token in name for token in ("speaker", "music")): return MediaPlayerDeviceClass.SPEAKER return None async def async_setup_entry( hass: HomeAssistant, entry: EmbyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Emby media player entities.""" runtime_data: EmbyRuntimeData = entry.runtime_data coordinator = runtime_data.coordinator tracked_sessions: set[str] = set() setup_started = utcnow() @callback def async_update_entities() -> None: """Add new entities for new sessions and prune stale ones.""" if coordinator.data is None: return current_sessions = set(coordinator.data.keys()) new_sessions = current_sessions - tracked_sessions if new_sessions: new_entities = [ EmbyMediaPlayer(coordinator, entry, session_id) for session_id in new_sessions ] async_add_entities(new_entities) tracked_sessions.update(new_sessions) _LOGGER.debug( "Added %d new Emby media player entities", len(new_entities) ) _prune_stale_devices( hass, entry, coordinator, tracked_sessions, setup_started ) entry.async_on_unload(coordinator.async_add_listener(async_update_entities)) async_update_entities() @callback def _prune_stale_devices( hass: HomeAssistant, entry: EmbyConfigEntry, coordinator: EmbyCoordinator, tracked_sessions: set[str], setup_started: datetime, ) -> None: """Remove device registry entries for sessions absent for too long. We use the coordinator's per-session ``last_seen`` map as the source of truth, and skip pruning entirely for the first ``STALE_PRUNE_GRACE_SECONDS`` after setup so that sessions that haven't come online yet aren't wiped. """ if coordinator.data is None: return now = utcnow() if (now - setup_started).total_seconds() < STALE_PRUNE_GRACE_SECONDS: return device_registry = dr.async_get(hass) current_ids = set(coordinator.data.keys()) stale = tracked_sessions - current_ids if not stale: return for session_id in list(stale): last_seen = coordinator.get_session_last_seen(session_id) if last_seen is None: # Never seen, but we tracked it (created an entity) — likely # added during this HA boot but already gone. Use setup_started # as the floor. last_seen = setup_started if (now - last_seen).total_seconds() <= STALE_SESSION_TIMEOUT: continue device = device_registry.async_get_device( identifiers={(DOMAIN, session_id)} ) if device is not None: _LOGGER.debug("Removing stale Emby device %s", session_id) device_registry.async_remove_device(device.id) tracked_sessions.discard(session_id) coordinator.forget_session(session_id) class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity): """Representation of an Emby media player.""" _attr_has_entity_name = True _attr_name = None # use device name only _attr_supported_features = SUPPORTED_FEATURES def __init__( self, coordinator: EmbyCoordinator, entry: EmbyConfigEntry, session_id: str, ) -> None: """Initialize the Emby media player.""" super().__init__(coordinator) self._entry = entry self._session_id = session_id self._attr_unique_id = f"{entry.entry_id}_{session_id}" session = self._session client_name = session.client_name if session else "" device_class = _device_class_for_client(client_name) if device_class is not None: self._attr_device_class = device_class @property def _session(self) -> EmbySession | None: """Get current session data.""" if self.coordinator.data is None: return None return self.coordinator.data.get(self._session_id) @property def _runtime_data(self) -> EmbyRuntimeData: """Get runtime data.""" return self._entry.runtime_data @property def available(self) -> bool: """Return True if entity is available.""" ws_ok = self.coordinator.websocket_connected polling_ok = self.coordinator.last_update_success return (ws_ok or polling_ok) and self._session is not None @property def device_info(self) -> DeviceInfo: """Return device info.""" session = self._session device_name = session.device_name if session else "Unknown Device" client_name = session.client_name if session else "Unknown" return DeviceInfo( identifiers={(DOMAIN, self._session_id)}, name=device_name, manufacturer="Emby", model=client_name, sw_version=session.app_version if session else None, entry_type=DeviceEntryType.SERVICE, via_device=(DOMAIN, self._entry.entry_id), ) @property def state(self) -> MediaPlayerState: """Return the state of the player.""" session = self._session if session is None: return MediaPlayerState.OFF if session.is_playing: return MediaPlayerState.PLAYING if session.is_paused: return MediaPlayerState.PAUSED return MediaPlayerState.IDLE @property def volume_level(self) -> float | None: """Return volume level (0.0-1.0).""" session = self._session if session and session.play_state: return session.play_state.volume_level / 100 return None @property def is_volume_muted(self) -> bool | None: """Return True if volume is muted.""" session = self._session if session and session.play_state: return session.play_state.is_muted return None @property def media_content_id(self) -> str | None: """Return the content ID of current playing media.""" session = self._session if session and session.now_playing: return session.now_playing.item_id return None @property def media_content_type(self) -> MediaType | str | None: """Return the content type of current playing media.""" session = self._session if not session or not session.now_playing: return None np = session.now_playing if np.media_type == MEDIA_TYPE_AUDIO: return MediaType.MUSIC if np.media_type == MEDIA_TYPE_VIDEO: if np.item_type == ITEM_TYPE_MOVIE: return MediaType.MOVIE if np.item_type == ITEM_TYPE_EPISODE: return MediaType.TVSHOW return MediaType.VIDEO return None @property def media_title(self) -> str | None: """Return the title of current playing media.""" session = self._session if not session or not session.now_playing: return None np = session.now_playing if np.item_type == ITEM_TYPE_EPISODE and np.series_name: season = ( f"S{np.parent_index_number:02d}" if np.parent_index_number else "" ) episode = f"E{np.index_number:02d}" if np.index_number else "" return f"{np.series_name} {season}{episode} - {np.name}" return np.name @property def media_artist(self) -> str | None: """Return the artist of current playing media.""" session = self._session if session and session.now_playing: return session.now_playing.artist return None @property def media_album_name(self) -> str | None: """Return the album name of current playing media.""" session = self._session if session and session.now_playing: return session.now_playing.album return None @property def media_album_artist(self) -> str | None: """Return the album artist of current playing media.""" session = self._session if session and session.now_playing: return session.now_playing.album_artist return None @property def media_series_title(self) -> str | None: """Return the series title for TV shows.""" session = self._session if session and session.now_playing: return session.now_playing.series_name return None @property def media_season(self) -> str | None: """Return the season for TV shows.""" session = self._session if session and session.now_playing and session.now_playing.parent_index_number: return str(session.now_playing.parent_index_number) return None @property def media_episode(self) -> str | None: """Return the episode for TV shows.""" session = self._session if session and session.now_playing and session.now_playing.index_number: return str(session.now_playing.index_number) return None @property def media_duration(self) -> int | None: """Return the duration of current playing media in seconds.""" session = self._session if session and session.now_playing: return int(session.now_playing.duration_seconds) return None @property def media_position(self) -> int | None: """Return the position of current playing media in seconds.""" session = self._session if session and session.play_state: return int(session.play_state.position_seconds) return None @property def media_position_updated_at(self) -> datetime | None: """Return when position was last updated by the coordinator.""" session = self._session if session and session.play_state: return session.play_state.updated_at return None @property def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" session = self._session if session and session.play_state: return _EMBY_TO_HA_REPEAT.get(session.play_state.repeat_mode) return None @property def extra_state_attributes(self) -> dict[str, Any]: """Return extra state attributes.""" session = self._session if session is None: return {} attrs: dict[str, Any] = { ATTR_SESSION_ID: session.session_id, ATTR_DEVICE_ID: session.device_id, ATTR_DEVICE_NAME: session.device_name, ATTR_CLIENT_NAME: session.client_name, } if session.user_name: attrs[ATTR_USER_NAME] = session.user_name if session.now_playing: attrs[ATTR_ITEM_ID] = session.now_playing.item_id attrs[ATTR_ITEM_TYPE] = session.now_playing.item_type if session.play_state and session.play_state.play_method: attrs[ATTR_PLAY_METHOD] = session.play_state.play_method return attrs @property def media_image_remotely_accessible(self) -> bool: """The image is fetched server-side via our proxy.""" return False # ------------------------------------------------------------------------- # Image proxying — keeps the API key off the client browser # ------------------------------------------------------------------------- async def async_get_media_image( self, ) -> tuple[bytes | None, str | None]: """Fetch the current media image server-side using the API key in a header.""" session = self._session if not session or not session.now_playing: return None, None np = session.now_playing item_id = np.primary_image_item_id or np.item_id if not item_id: return None, None try: return await self._runtime_data.api.fetch_image( item_id, image_type="Primary", max_width=500, max_height=500, ) except EmbyApiError as err: _LOGGER.debug("Failed to fetch media image: %s", err) return None, None async def async_get_browse_image( self, media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: """Fetch a browse-tree image.""" if not media_content_id or not _EMBY_ID_RE.fullmatch(media_content_id): return None, None try: return await self._runtime_data.api.fetch_image( media_content_id, image_type="Primary", max_width=300, ) except EmbyApiError as err: _LOGGER.debug("Failed to fetch browse image: %s", err) return None, None # ------------------------------------------------------------------------- # Playback Control # ------------------------------------------------------------------------- async def async_media_play(self) -> None: """Resume playback.""" await self._runtime_data.api.play(self._session_id) async def async_media_pause(self) -> None: """Pause playback.""" await self._runtime_data.api.pause(self._session_id) async def async_media_stop(self) -> None: """Stop playback.""" await self._runtime_data.api.stop(self._session_id) async def async_media_next_track(self) -> None: """Skip to next track.""" await self._runtime_data.api.next_track(self._session_id) async def async_media_previous_track(self) -> None: """Skip to previous track.""" await self._runtime_data.api.previous_track(self._session_id) async def async_media_seek(self, position: float) -> None: """Seek to position.""" position_ticks = max(0, round(position * TICKS_PER_SECOND)) await self._runtime_data.api.seek(self._session_id, position_ticks) async def async_set_volume_level(self, volume: float) -> None: """Set volume level (0.0-1.0).""" volume_percent = int(max(0.0, min(1.0, volume)) * 100) await self._runtime_data.api.set_volume(self._session_id, volume_percent) async def async_mute_volume(self, mute: bool) -> None: """Mute or unmute.""" if mute: await self._runtime_data.api.mute(self._session_id) else: await self._runtime_data.api.unmute(self._session_id) async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" emby_mode = _HA_TO_EMBY_REPEAT.get(repeat, REPEAT_MODE_NONE) await self._runtime_data.api.set_repeat_mode(self._session_id, emby_mode) # ------------------------------------------------------------------------- # Media Browsing & Playing # ------------------------------------------------------------------------- async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any, ) -> None: """Play a piece of media.""" if not isinstance(media_id, str) or not media_id.strip(): raise ServiceValidationError("media_id must be a non-empty string") item_id = media_id.strip() if not _EMBY_ID_RE.fullmatch(item_id): raise ServiceValidationError( f"media_id is not a valid Emby item id: {media_id!r}" ) # Map HA enqueue semantics to Emby play commands. enqueue = kwargs.get("enqueue") play_command = _ENQUEUE_TO_PLAY_COMMAND.get( enqueue, PLAY_COMMAND_PLAY_NOW ) position = kwargs.get("position") if position is not None: if not isinstance(position, (int, float)) or position < 0: raise ServiceValidationError( "position must be a non-negative number" ) start_position_ticks = round(position * TICKS_PER_SECOND) else: start_position_ticks = 0 try: await self._runtime_data.api.play_media( self._session_id, item_ids=[item_id], play_command=play_command, start_position_ticks=start_position_ticks, ) except EmbyApiError as err: raise ServiceValidationError(str(err)) from err async def async_browse_media( self, media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Browse media.""" return await async_browse_media( self.hass, self._runtime_data.api, self._runtime_data.user_id, media_content_type, media_content_id, )