"""Media player platform for Remote Media Player integration.""" from __future__ import annotations import asyncio 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, callback 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, MediaServerWebSocket from .const import ( DOMAIN, CONF_HOST, CONF_PORT, CONF_TOKEN, CONF_POLL_INTERVAL, CONF_USE_WEBSOCKET, DEFAULT_POLL_INTERVAL, DEFAULT_NAME, DEFAULT_USE_WEBSOCKET, DEFAULT_RECONNECT_INTERVAL, ) _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), ) # Get WebSocket setting from options or data use_websocket = entry.options.get( CONF_USE_WEBSOCKET, entry.data.get(CONF_USE_WEBSOCKET, DEFAULT_USE_WEBSOCKET), ) # Create update coordinator with WebSocket support coordinator = MediaPlayerCoordinator( hass, client, poll_interval, host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], token=entry.data[CONF_TOKEN], use_websocket=use_websocket, ) # Set up WebSocket connection if enabled await coordinator.async_setup() # 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 # Store coordinator for cleanup hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator # 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 with WebSocket support.""" def __init__( self, hass: HomeAssistant, client: MediaServerClient, poll_interval: int, host: str, port: int, token: str, use_websocket: bool = True, ) -> None: """Initialize the coordinator. Args: hass: Home Assistant instance client: Media Server API client poll_interval: Update interval in seconds host: Server hostname port: Server port token: API token use_websocket: Whether to use WebSocket for updates """ super().__init__( hass, _LOGGER, name="Remote Media Player", update_interval=timedelta(seconds=poll_interval), ) self.client = client self._host = host self._port = port self._token = token self._use_websocket = use_websocket self._ws_client: MediaServerWebSocket | None = None self._ws_connected = False self._reconnect_task: asyncio.Task | None = None self._poll_interval = poll_interval async def async_setup(self) -> None: """Set up the coordinator with WebSocket if enabled.""" if self._use_websocket: await self._connect_websocket() async def _connect_websocket(self) -> None: """Establish WebSocket connection.""" if self._ws_client: await self._ws_client.disconnect() self._ws_client = MediaServerWebSocket( host=self._host, port=self._port, token=self._token, on_status_update=self._handle_ws_status_update, on_disconnect=self._handle_ws_disconnect, ) if await self._ws_client.connect(): self._ws_connected = True # Disable polling - WebSocket handles all updates including position self.update_interval = None _LOGGER.info("WebSocket connected, polling disabled") else: self._ws_connected = False # Keep polling as fallback self.update_interval = timedelta(seconds=self._poll_interval) _LOGGER.warning("WebSocket failed, falling back to polling") # Schedule reconnect attempt self._schedule_reconnect() @callback def _handle_ws_status_update(self, status_data: dict[str, Any]) -> None: """Handle status update from WebSocket.""" self.async_set_updated_data(status_data) @callback def _handle_ws_disconnect(self) -> None: """Handle WebSocket disconnection.""" self._ws_connected = False # Re-enable polling as fallback self.update_interval = timedelta(seconds=self._poll_interval) _LOGGER.warning("WebSocket disconnected, falling back to polling") # Schedule reconnect attempt self._schedule_reconnect() def _schedule_reconnect(self) -> None: """Schedule a WebSocket reconnection attempt.""" if self._reconnect_task and not self._reconnect_task.done(): return # Already scheduled async def reconnect() -> None: await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) if self._use_websocket and not self._ws_connected: _LOGGER.info("Attempting WebSocket reconnect...") await self._connect_websocket() self._reconnect_task = self.hass.async_create_task(reconnect()) async def _async_update_data(self) -> dict[str, Any]: """Fetch data from the API (fallback when WebSocket unavailable). Returns: Media status data Raises: UpdateFailed: On API errors """ try: data = await self.client.get_status() _LOGGER.debug("HTTP poll received status: %s", data.get("state")) 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 async def async_shutdown(self) -> None: """Clean up resources.""" if self._reconnect_task: self._reconnect_task.cancel() try: await self._reconnect_task except asyncio.CancelledError: pass if self._ws_client: await self._ws_client.disconnect() 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 | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF ) @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) async def async_turn_on(self) -> None: """Send turn on command.""" try: await self.coordinator.client.turn_on() except MediaServerError as err: _LOGGER.error("Failed to turn on: %s", err) async def async_turn_off(self) -> None: """Send turn off command.""" try: await self.coordinator.client.turn_off() except MediaServerError as err: _LOGGER.error("Failed to turn off: %s", err) async def async_toggle(self) -> None: """Send toggle command.""" try: await self.coordinator.client.toggle() except MediaServerError as err: _LOGGER.error("Failed to toggle: %s", err)