"""Media player platform for Remote Media Player integration.""" from __future__ import annotations import logging from datetime import datetime, timedelta from typing import Any from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, UpdateFailed, ) from .api_client import MediaServerClient, MediaServerError from .const import ( DOMAIN, CONF_HOST, CONF_PORT, CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DEFAULT_NAME, ) _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the media player platform. Args: hass: Home Assistant instance entry: Config entry async_add_entities: Callback to add entities """ _LOGGER.debug("Setting up media player platform for %s", entry.entry_id) try: client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"] except KeyError: _LOGGER.error("Client not found in hass.data for entry %s", entry.entry_id) return # Get poll interval from options or data poll_interval = entry.options.get( CONF_POLL_INTERVAL, entry.data.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL), ) # Create update coordinator coordinator = MediaPlayerCoordinator( hass, client, poll_interval, ) # Fetch initial data - don't fail setup if this fails try: await coordinator.async_config_entry_first_refresh() except Exception as err: _LOGGER.warning("Initial data fetch failed, will retry: %s", err) # Continue anyway - the coordinator will retry # Create and add entity entity = RemoteMediaPlayerEntity( coordinator, entry, ) _LOGGER.info("Adding media player entity: %s", entity.unique_id) async_add_entities([entity]) class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator for fetching media player data.""" def __init__( self, hass: HomeAssistant, client: MediaServerClient, poll_interval: int, ) -> None: """Initialize the coordinator. Args: hass: Home Assistant instance client: Media Server API client poll_interval: Update interval in seconds """ super().__init__( hass, _LOGGER, name="Remote Media Player", update_interval=timedelta(seconds=poll_interval), ) self.client = client async def _async_update_data(self) -> dict[str, Any]: """Fetch data from the API. Returns: Media status data Raises: UpdateFailed: On API errors """ try: data = await self.client.get_status() _LOGGER.debug("Received media status: %s", data) return data except MediaServerError as err: raise UpdateFailed(f"Error communicating with server: {err}") from err except Exception as err: _LOGGER.exception("Unexpected error fetching media status") raise UpdateFailed(f"Unexpected error: {err}") from err class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPlayerEntity): """Representation of a Remote Media Player.""" _attr_has_entity_name = True _attr_name = None @property def available(self) -> bool: """Return True if entity is available.""" # Use the coordinator's last_update_success to detect server availability return self.coordinator.last_update_success def __init__( self, coordinator: MediaPlayerCoordinator, entry: ConfigEntry, ) -> None: """Initialize the media player entity. Args: coordinator: Data update coordinator entry: Config entry """ super().__init__(coordinator) self._entry = entry self._attr_unique_id = f"{entry.entry_id}_media_player" # Device info - must match button.py identifiers self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, name=entry.title, manufacturer="Remote Media Player", model="Media Server", sw_version="1.0.0", configuration_url=f"http://{entry.data[CONF_HOST]}:{int(entry.data[CONF_PORT])}/docs", ) @property def supported_features(self) -> MediaPlayerEntityFeature: """Return the supported features.""" return ( MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SEEK ) @property def state(self) -> MediaPlayerState | None: """Return the state of the player.""" if self.coordinator.data is None: return MediaPlayerState.OFF state = self.coordinator.data.get("state", "idle") state_map = { "playing": MediaPlayerState.PLAYING, "paused": MediaPlayerState.PAUSED, "stopped": MediaPlayerState.IDLE, "idle": MediaPlayerState.IDLE, } return state_map.get(state, MediaPlayerState.IDLE) @property def volume_level(self) -> float | None: """Return the volume level (0..1).""" if self.coordinator.data is None: return None volume = self.coordinator.data.get("volume", 0) return volume / 100.0 @property def is_volume_muted(self) -> bool | None: """Return True if volume is muted.""" if self.coordinator.data is None: return None return self.coordinator.data.get("muted", False) @property def media_content_type(self) -> MediaType | None: """Return the content type of current playing media.""" return MediaType.MUSIC @property def media_title(self) -> str | None: """Return the title of current playing media.""" if self.coordinator.data is None: return None return self.coordinator.data.get("title") @property def media_artist(self) -> str | None: """Return the artist of current playing media.""" if self.coordinator.data is None: return None return self.coordinator.data.get("artist") @property def media_album_name(self) -> str | None: """Return the album name of current playing media.""" if self.coordinator.data is None: return None return self.coordinator.data.get("album") @property def media_image_url(self) -> str | None: """Return the image URL of current playing media.""" if self.coordinator.data is None: return None return self.coordinator.data.get("album_art_url") @property def media_duration(self) -> int | None: """Return the duration of current playing media in seconds.""" if self.coordinator.data is None: return None duration = self.coordinator.data.get("duration") return int(duration) if duration is not None else None @property def media_position(self) -> int | None: """Return the position of current playing media in seconds.""" if self.coordinator.data is None: return None position = self.coordinator.data.get("position") return int(position) if position is not None else None @property def media_position_updated_at(self) -> datetime | None: """Return when the position was last updated.""" if self.coordinator.data is None: return None if self.coordinator.data.get("position") is not None: # Use last_update_success_time if available, otherwise use current time if hasattr(self.coordinator, 'last_update_success_time'): return self.coordinator.last_update_success_time return datetime.now() return None @property def source(self) -> str | None: """Return the current media source.""" if self.coordinator.data is None: return None return self.coordinator.data.get("source") async def async_media_play(self) -> None: """Send play command.""" try: await self.coordinator.client.play() await self.coordinator.async_request_refresh() except MediaServerError as err: _LOGGER.error("Failed to play: %s", err) async def async_media_pause(self) -> None: """Send pause command.""" try: await self.coordinator.client.pause() await self.coordinator.async_request_refresh() except MediaServerError as err: _LOGGER.error("Failed to pause: %s", err) async def async_media_stop(self) -> None: """Send stop command.""" try: await self.coordinator.client.stop() await self.coordinator.async_request_refresh() except MediaServerError as err: _LOGGER.error("Failed to stop: %s", err) async def async_media_next_track(self) -> None: """Send next track command.""" try: await self.coordinator.client.next_track() await self.coordinator.async_request_refresh() except MediaServerError as err: _LOGGER.error("Failed to skip to next track: %s", err) async def async_media_previous_track(self) -> None: """Send previous track command.""" try: await self.coordinator.client.previous_track() await self.coordinator.async_request_refresh() except MediaServerError as err: _LOGGER.error("Failed to go to previous track: %s", err) async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" try: await self.coordinator.client.set_volume(int(volume * 100)) await self.coordinator.async_request_refresh() except MediaServerError as err: _LOGGER.error("Failed to set volume: %s", err) async def async_mute_volume(self, mute: bool) -> None: """Mute/unmute the volume.""" try: # Toggle mute (API toggles, so call it if state differs) if self.is_volume_muted != mute: await self.coordinator.client.toggle_mute() await self.coordinator.async_request_refresh() except MediaServerError as err: _LOGGER.error("Failed to toggle mute: %s", err) async def async_media_seek(self, position: float) -> None: """Seek to a position.""" try: await self.coordinator.client.seek(position) await self.coordinator.async_request_refresh() except MediaServerError as err: _LOGGER.error("Failed to seek: %s", err)