"""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 ( BrowseMedia, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, ) from homeassistant.components.media_player.const import ( MediaClass, ) from urllib.parse import quote, unquote 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, entry=entry, ) # 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, entry: ConfigEntry | None = None, ) -> 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 entry: Config entry (for integration reload on scripts change) """ 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._entry = entry 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, on_scripts_changed=self._handle_ws_scripts_changed, ) 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") # Trigger an immediate refresh to restart the polling loop. # Without this, the polling loop stays stopped (it was disabled when # WebSocket was active) and the entity never becomes unavailable. self.hass.async_create_task(self.async_request_refresh()) # Schedule reconnect attempt self._schedule_reconnect() @callback def _handle_ws_scripts_changed(self) -> None: """Handle scripts changed notification from WebSocket.""" if self._entry: _LOGGER.info("Scripts changed, reloading integration") self.hass.async_create_task( self.hass.config_entries.async_reload(self._entry.entry_id) ) else: _LOGGER.warning("Cannot reload integration: entry not available") 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 | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA ) @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") if duration is None: return None try: return int(duration) except (ValueError, TypeError): return 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") if position is None: return None try: return int(position) except (ValueError, TypeError): return 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) # Media Browser support @staticmethod def _encode_media_id(folder_id: str, path: str = "") -> str: """Encode folder_id and path into media_content_id. Format: folder_id|encoded_path Root folder: folder_id| """ return f"{folder_id}|{quote(path, safe='')}" @staticmethod def _decode_media_id(media_content_id: str) -> tuple[str, str]: """Decode media_content_id into folder_id and path. Returns: Tuple of (folder_id, path) """ if not media_content_id or "|" not in media_content_id: return "", "" folder_id, encoded_path = media_content_id.split("|", 1) path = unquote(encoded_path) if encoded_path else "" return folder_id, path async def async_browse_media( self, media_content_type: str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the media browsing. Args: media_content_type: Type of media (unused, but required by HA) media_content_id: ID in format "folder_id|path" or None for root Returns: BrowseMedia object with children """ _LOGGER.debug("Browse media: type=%s, id=%s", media_content_type, media_content_id) # Root level - list all folders if not media_content_id: folders = await self.coordinator.client.get_media_folders() children = [ BrowseMedia( title=config["label"], media_class=MediaClass.DIRECTORY, media_content_type=MediaType.MUSIC, # All folders show as music media_content_id=self._encode_media_id(folder_id, ""), can_play=False, can_expand=True, ) for folder_id, config in folders.items() if config.get("enabled", True) ] return BrowseMedia( title="Media Folders", media_class=MediaClass.DIRECTORY, media_content_type=MediaType.MUSIC, media_content_id="", can_play=False, can_expand=True, children=children, ) # Browse specific folder folder_id, path = self._decode_media_id(media_content_id) if not folder_id: raise ValueError("Invalid media_content_id format") # Get folder contents from API browse_data = await self.coordinator.client.browse_folder(folder_id, path, offset=0, limit=5000) # Fetch folder metadata once (not per-item) for building absolute paths folders = await self.coordinator.client.get_media_folders() base_path = folders.get(folder_id, {}).get("path", "") # Detect path separator from server's base_path (Unix vs Windows) separator = '\\' if '\\' in base_path else '/' base_path_clean = base_path.rstrip('/\\') children = [] for item in browse_data.get("items", []): if item["type"] == "folder": # Subfolder item_path = f"{path}/{item['name']}" if path else item['name'] children.append( BrowseMedia( title=item["name"], media_class=MediaClass.DIRECTORY, media_content_type=MediaType.MUSIC, media_content_id=self._encode_media_id(folder_id, item_path), can_play=False, can_expand=True, ) ) elif item.get("is_media", False): # Media file - build absolute path for playback file_path_in_folder = f"{path}/{item['name']}" if path else item['name'] absolute_path = f"{base_path_clean}{separator}{file_path_in_folder.replace('/', separator)}" children.append( BrowseMedia( title=item["name"], media_class=MediaClass.MUSIC, media_content_type=MediaType.MUSIC, media_content_id=absolute_path, # Use absolute path as ID for playback can_play=True, can_expand=False, ) ) # Get current folder label current_title = path.split("/")[-1] if path else browse_data.get("label", folder_id) return BrowseMedia( title=current_title, media_class=MediaClass.DIRECTORY, media_content_type=MediaType.MUSIC, media_content_id=media_content_id, can_play=False, can_expand=True, children=children, ) async def async_play_media( self, media_type: str, media_id: str, **kwargs: Any ) -> None: """Play a media file. Args: media_type: Type of media (unused) media_id: Absolute file path to media file **kwargs: Additional arguments (unused) """ _LOGGER.debug("Play media: type=%s, id=%s", media_type, media_id) try: # media_id is the absolute file path from browse_media await self.coordinator.client.play_media_file(media_id) # Request immediate status update await self.coordinator.async_request_refresh() except MediaServerError as err: _LOGGER.error("Failed to play media file: %s", err)