From 711cd743e2980d15e2602337b43299eae00a66cc Mon Sep 17 00:00:00 2001 From: Marvin Schenkel Date: Mon, 5 Oct 2026 14:57:03 +0200 Subject: [PATCH] Add dashboard display media players to Music Assistant (#179386) --- .../components/music_assistant/__init__.py | 30 +- .../components/music_assistant/const.py | 5 + .../components/music_assistant/entity.py | 61 +- .../components/music_assistant/helpers.py | 7 +- .../music_assistant/media_player.py | 469 ++++++- .../components/music_assistant/services.py | 8 + .../components/music_assistant/services.yaml | 5 + .../components/music_assistant/strings.json | 21 + tests/components/music_assistant/common.py | 51 +- tests/components/music_assistant/conftest.py | 2 + .../test_dashboard_media_player.ambr | 166 +++ .../test_dashboard_media_player.py | 1116 +++++++++++++++++ tests/components/music_assistant/test_init.py | 139 +- 13 files changed, 2047 insertions(+), 33 deletions(-) create mode 100644 tests/components/music_assistant/snapshots/test_dashboard_media_player.ambr create mode 100644 tests/components/music_assistant/test_dashboard_media_player.py diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index c23d5c59ed86..a8ffa5dc9ef1 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -38,7 +38,7 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) -from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER +from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DASHBOARD_ID_PREFIX, DOMAIN, LOGGER from .helpers import get_music_assistant_client from .services import register_actions @@ -240,14 +240,20 @@ async def async_setup_entry( # noqa: C901 mass.subscribe(handle_player_config_updated, EventType.PLAYER_CONFIG_UPDATED) ) - # check if any playerconfigs have been removed while we were disconnected + # check if any playerconfigs have been removed while we were disconnected. + # never clean up dashboard devices: their registration is connection-scoped, + # so the cache may still be empty right after a reconnect. all_player_configs = await mass.config.get_player_configs() player_ids = {player.player_id for player in all_player_configs} dev_reg = dr.async_get(hass) dev_entries = dr.async_entries_for_config_entry(dev_reg, entry.entry_id) for device in dev_entries: for identifier in device.identifiers: - if identifier[0] == DOMAIN and identifier[1] not in player_ids: + if ( + identifier[0] == DOMAIN + and not identifier[1].startswith(DASHBOARD_ID_PREFIX) + and identifier[1] not in player_ids + ): dev_reg.async_remove_device(device.id) return True @@ -297,7 +303,11 @@ async def async_remove_config_entry_device( device_entry: dr.AnyDeviceEntry, ) -> bool: """Remove a config entry from a device.""" - player_id = next( + if not isinstance(device_entry, dr.DeviceEntry): + # child devices cannot be removed on their own + return False + # the identifier is a player_id, or f"dashboard:{dashboard_id}" for displays + identifier_value = next( ( identifier[1] for identifier in device_entry.identifiers @@ -305,16 +315,20 @@ async def async_remove_config_entry_device( ), None, ) - if player_id is None: + if identifier_value is None: # this should not be possible at all, but guard it anyways return False mass = get_music_assistant_client(hass, config_entry.entry_id) - if mass.players.get(player_id) is None: - # player is already removed on the server, this is an orphaned device + if identifier_value.startswith(DASHBOARD_ID_PREFIX): + dashboard_id = identifier_value.removeprefix(DASHBOARD_ID_PREFIX) + # the display endpoint is still live, refuse removal + return mass.dashboard.get(dashboard_id) is None + if mass.players.get(identifier_value) is None: + # player is already gone from the server, this is an orphaned device return True # try to remove the player from the server try: - await mass.config.remove_player_config(player_id) + await mass.config.remove_player_config(identifier_value) except ActionUnavailable: return False else: diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 7278e3aef1b7..5bba56abc73a 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -80,4 +80,9 @@ ATTR_USERNAME = "username" ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha" +# prefix of dashboard device identifiers and entity unique ids, which tells +# them apart from player ids (a display may share its id with a player) +DASHBOARD_ID_PREFIX = "dashboard:" +DASHBOARD_DEVICE_MODEL = "Dashboard display" + LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/music_assistant/entity.py b/homeassistant/components/music_assistant/entity.py index 5208b3605c3d..cf88b5b73db6 100644 --- a/homeassistant/components/music_assistant/entity.py +++ b/homeassistant/components/music_assistant/entity.py @@ -7,10 +7,11 @@ from music_assistant_models.event import MassEvent from music_assistant_models.player import Player, PlayerOption from homeassistant.const import EntityCategory -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity -from .const import DOMAIN +from .const import DASHBOARD_DEVICE_MODEL, DOMAIN +from .helpers import dashboard_identifier if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient @@ -29,7 +30,7 @@ class MusicAssistantEntity(Entity): provider = self.mass.get_provider(self.player.provider) if TYPE_CHECKING: assert provider is not None - self._attr_device_info = DeviceInfo( + self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, player_id)}, manufacturer=self.player.device_info.manufacturer or provider.name, model=self.player.device_info.model or self.player.name, @@ -129,3 +130,57 @@ class MusicAssistantPlayerOptionEntity(MusicAssistantEntity): def on_player_option_update(self, player_option: PlayerOption) -> None: """Callback for player option updates.""" + + +class MusicAssistantDashboardEntity(Entity): + """Base entity for a Music Assistant dashboard display device.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, mass: MusicAssistantClient, dashboard_id: str) -> None: + """Initialize MusicAssistantDashboardEntity.""" + self.mass = mass + self.dashboard_id = dashboard_id + # this is only ever constructed for a dashboard_id known to the cache + dashboard = mass.dashboard.get(dashboard_id) + if TYPE_CHECKING: + assert dashboard is not None + self._attr_unique_id = dashboard_identifier(dashboard_id) + # namespaced: Fully Kiosk registers dashboard_id == player_id, and a bare + # id here would merge this device into the player's own device + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + name=dashboard.name, + manufacturer="Music Assistant", + model=DASHBOARD_DEVICE_MODEL, + ) + + @property + @override + def available(self) -> bool: + """Return availability of entity.""" + return self.mass.dashboard.get(self.dashboard_id) is not None and bool( + self.mass.connection.connected + ) + + @override + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.async_on_remove( + self.mass.subscribe( + self.__on_dashboards_updated, EventType.DASHBOARDS_UPDATED + ) + ) + + async def __on_dashboards_updated(self, event: MassEvent) -> None: + """Refresh availability, and the device name if it was re-registered.""" + if ( + (dashboard := self.mass.dashboard.get(self.dashboard_id)) is not None + and self.device_entry is not None + and self.device_entry.name != dashboard.name + ): + dr.async_get(self.hass).async_update_device( + self.device_entry.id, name=dashboard.name + ) + self.async_write_ha_state() diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py index 1321c9edf57e..a1849e226c26 100644 --- a/homeassistant/components/music_assistant/helpers.py +++ b/homeassistant/components/music_assistant/helpers.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from .const import DOMAIN +from .const import DASHBOARD_ID_PREFIX, DOMAIN if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient @@ -19,6 +19,11 @@ if TYPE_CHECKING: from . import MusicAssistantConfigEntry +def dashboard_identifier(dashboard_id: str) -> str: + """Return the device identifier and entity unique id of a dashboard display.""" + return f"{DASHBOARD_ID_PREFIX}{dashboard_id}" + + def catch_musicassistant_error[**_P, _R]( func: Callable[_P, Coroutine[Any, Any, _R]], ) -> Callable[_P, Coroutine[Any, Any, _R]]: diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 176fb34bc3c9..ff8606cb9efa 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -10,6 +10,7 @@ from music_assistant_client.helpers import LinkedUser from music_assistant_models.auth import AuthProviderType from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( + DashboardType, EventType, MediaType, PlayerFeature, @@ -18,7 +19,7 @@ from music_assistant_models.enums import ( QueueOption, RepeatMode as MassRepeatMode, ) -from music_assistant_models.errors import MediaNotFoundError +from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType from music_assistant_models.player_queue import PlayerQueue @@ -26,7 +27,9 @@ from music_assistant_models.player_queue import PlayerQueue from homeassistant.components import media_source, tts from homeassistant.components.media_player import ( ATTR_MEDIA_EXTRA, + BrowseError, BrowseMedia, + MediaClass, MediaPlayerDeviceClass, MediaPlayerEnqueue, MediaPlayerEntity, @@ -60,14 +63,20 @@ from .const import ( ATTR_REPEAT_MODE, ATTR_SHUFFLE_ENABLED, DOMAIN, + LOGGER, +) +from .entity import MusicAssistantDashboardEntity, MusicAssistantEntity +from .helpers import ( + catch_musicassistant_error, + catch_user_not_found, + dashboard_identifier, ) -from .entity import MusicAssistantEntity -from .helpers import catch_musicassistant_error, catch_user_not_found from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item if TYPE_CHECKING: from music_assistant_client.client import MusicAssistantClient + from music_assistant_models.dashboard import DashboardDevice from music_assistant_models.player import Player SUPPORTED_FEATURES_BASE = ( @@ -127,6 +136,15 @@ MASS_ICON_TO_MDI: Mapping[str, str] = { } +MEDIA_CONTENT_TYPE_DASHBOARD = "dashboard" +NOW_PLAYING_ID_PREFIX = f"{DashboardType.NOW_PLAYING.value}/" +# the DashboardType value doubles as the provider domain for get_provider_icon +DASHBOARD_ICON_TYPES = frozenset({DashboardType.PARTY, DashboardType.MUSIC_QUIZ}) +DASHBOARD_ICON_PROVIDER_DOMAINS = frozenset( + icon_type.value for icon_type in DASHBOARD_ICON_TYPES +) + + def _get_mdi_icon(icon: str) -> str: """Return an MDI icon for a Music Assistant icon.""" if icon.startswith("mdi:"): @@ -136,6 +154,22 @@ def _get_mdi_icon(icon: str) -> str: return MASS_ICON_TO_MDI.get(icon, "mdi:speaker") +def _get_player_artwork_url(mass: MusicAssistantClient, player: Player) -> str | None: + """Return the artwork URL for a player's current media, or None.""" + if player.current_media and player.current_media.image_url: + # prefer player.current_media which reflects the live state + # (e.g. current track art from radio stream metadata) + return player.current_media.image_url + if ( + player.active_source + and (queue := mass.player_queues.get(player.active_source)) + and queue.current_item + ): + # fallback to static media item image from queue + return mass.get_media_item_image_url(queue.current_item) + return None + + async def async_setup_entry( hass: HomeAssistant, entry: MusicAssistantConfigEntry, @@ -151,6 +185,46 @@ async def async_setup_entry( # register callback to add players when they are discovered entry.runtime_data.platform_handlers.setdefault(Platform.MEDIA_PLAYER, add_player) + # known_dashboard_ids is scoped to this setup call, so a reload starts + # fresh and re-adds whatever is in the dashboard cache. + known_dashboard_ids: set[str] = set() + entity_registry = er.async_get(hass) + + def add_dashboards() -> None: + """Add dashboard players for endpoints not yet known to HA. + + An id already seen this run is skipped only while it still has a + registered entity - if its entity was removed in the meantime (e.g. + a stale device deleted via the UI), it's treated as new again so a + later re-registration doesn't leave it without an entity. + """ + new_entities: list[MusicAssistantDashboardPlayer] = [] + for dashboard in mass.dashboard.dashboards: + if dashboard.dashboard_id in known_dashboard_ids and ( + entity_registry.async_get_entity_id( + Platform.MEDIA_PLAYER, + DOMAIN, + dashboard_identifier(dashboard.dashboard_id), + ) + ): + continue + known_dashboard_ids.add(dashboard.dashboard_id) + new_entities.append( + MusicAssistantDashboardPlayer(mass, dashboard.dashboard_id) + ) + if new_entities: + async_add_entities(new_entities) + + def handle_dashboards_updated(event: MassEvent) -> None: + """Handle the dashboard endpoint cache being refreshed.""" + add_dashboards() + + entry.async_on_unload( + mass.subscribe(handle_dashboards_updated, EventType.DASHBOARDS_UPDATED) + ) + + add_dashboards() + class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Representation of MediaPlayerEntity from Music Assistant Player.""" @@ -275,7 +349,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_volume_level = volume / 100 if volume is not None else None self._attr_is_volume_muted = player.volume_muted self._update_media_attributes(player, active_queue) - self._update_media_image_url(player, active_queue) + self._update_media_image_url(player) @catch_musicassistant_error @override @@ -671,20 +745,9 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): query, ) - def _update_media_image_url( - self, player: Player, queue: PlayerQueue | None - ) -> None: + def _update_media_image_url(self, player: Player) -> None: """Update image URL.""" - image_url: str | None - if player.current_media and player.current_media.image_url: - # prefer player.current_media which reflects the live state - # (e.g. current track art from radio stream metadata) - image_url = player.current_media.image_url - elif queue and queue.current_item: - # fallback to static media item image from queue - image_url = self.mass.get_media_item_image_url(queue.current_item) - else: - image_url = None + image_url = _get_player_artwork_url(self.mass, player) # check if the image is provided via music-assistant and therefore # not accessible from the outside @@ -768,3 +831,375 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): if PlayerFeature.SELECT_SOUND_MODE in self.player.supported_features: supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE self._attr_supported_features = supported_features + + +class MusicAssistantDashboardPlayer(MusicAssistantDashboardEntity, MediaPlayerEntity): + """Representation of a Music Assistant dashboard display device.""" + + _attr_name = None + _attr_device_class = MediaPlayerDeviceClass.TV + _attr_supported_features = ( + MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.TURN_OFF + ) + + def __init__(self, mass: MusicAssistantClient, dashboard_id: str) -> None: + """Initialize MusicAssistantDashboardPlayer.""" + super().__init__(mass, dashboard_id) + # fetched provider icons per domain, None when the provider has none + self._provider_icon_cache: dict[str, tuple[bytes, str] | None] = {} + + @override + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + self._update_from_session() + self.async_on_remove( + self.mass.subscribe( + self.__on_session_updated, EventType.DASHBOARD_SESSIONS_UPDATED + ) + ) + # unscoped: the now_playing session's target player changes per session + self.async_on_remove( + self.mass.subscribe( + self.__on_player_or_queue_updated, EventType.PLAYER_UPDATED + ) + ) + self.async_on_remove( + self.mass.subscribe( + self.__on_player_or_queue_updated, EventType.QUEUE_UPDATED + ) + ) + + @catch_musicassistant_error + @override + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Show a dashboard on this display.""" + if media_type != MEDIA_CONTENT_TYPE_DASHBOARD: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="dashboard_invalid_media_type", + translation_placeholders={ + "expected": MEDIA_CONTENT_TYPE_DASHBOARD, + "media_type": str(media_type), + }, + ) + dashboard_type, player_id = self._parse_play_media_id(media_id) + await self.mass.dashboard.show(self.dashboard_id, dashboard_type, player_id) + + @catch_musicassistant_error + @override + async def async_turn_off(self) -> None: + """Hide the dashboard from this display.""" + await self.mass.dashboard.hide(self.dashboard_id) + + @override + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Browse the dashboards this display can show.""" + # the browse websocket path doesn't filter on availability + dashboard = self.mass.dashboard.get(self.dashboard_id) + if dashboard is None: + raise BrowseError( + translation_domain=DOMAIN, + translation_key="dashboard_display_not_available", + translation_placeholders={"display": self.dashboard_id}, + ) + if media_content_id in (None, ""): + return self._build_root_listing(dashboard) + if ( + media_content_id == DashboardType.NOW_PLAYING.value + and DashboardType.NOW_PLAYING in dashboard.supported_types + ): + return self._build_now_playing_listing() + raise BrowseError( + translation_domain=DOMAIN, + translation_key="dashboard_media_not_found", + translation_placeholders={"media_content_id": str(media_content_id)}, + ) + + @property + @override + def media_image_hash(self) -> str | None: + """Hash for the active session's icon, invalidated when the session type changes.""" + session = self.mass.dashboard.get_session(self.dashboard_id) + if session is not None and session.dashboard in DASHBOARD_ICON_TYPES: + return session.dashboard.value + # fall back to the base class hash of media_image_url, so its + # proxy path serves MA-hosted (non-remotely-accessible) artwork + return super().media_image_hash + + @override + async def async_get_media_image(self) -> tuple[bytes | None, str | None]: + """Fetch the provider icon for an active party/music_quiz session.""" + session = self.mass.dashboard.get_session(self.dashboard_id) + if session is None or session.dashboard not in DASHBOARD_ICON_TYPES: + # let the base class fetch/proxy media_image_url itself + return await super().async_get_media_image() + return await self._fetch_provider_icon(session.dashboard.value) + + @override + 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 provider icon for a browse tree leaf.""" + if media_content_id not in DASHBOARD_ICON_PROVIDER_DOMAINS: + return None, None + return await self._fetch_provider_icon(media_content_id) + + async def __on_session_updated(self, event: MassEvent) -> None: + """Handle the dashboard's active session changing.""" + self._update_from_session() + self.async_write_ha_state() + + async def __on_player_or_queue_updated(self, event: MassEvent) -> None: + """Refresh now_playing session attributes on player/queue updates.""" + session = self.mass.dashboard.get_session(self.dashboard_id) + if session is None or session.dashboard != DashboardType.NOW_PLAYING: + return + player = self.mass.players.get(session.player_id) if session.player_id else None + if event.event == EventType.PLAYER_UPDATED: + matches = event.object_id == session.player_id + else: + matches = event.object_id in ( + player.active_source if player else None, + player.active_group if player else None, + session.player_id, + ) + if not matches: + return + previous_title = self._attr_media_title + previous_image_url = self._attr_media_image_url + self._update_from_session() + if ( + self._attr_media_title != previous_title + or self._attr_media_image_url != previous_image_url + ): + self.async_write_ha_state() + + def _update_from_session(self) -> None: + """Update state and media attributes from the active session.""" + session = self.mass.dashboard.get_session(self.dashboard_id) + if session is None: + self._attr_state = MediaPlayerState.IDLE + self._attr_media_content_type = None + self._attr_media_content_id = None + self._attr_media_title = None + self._clear_media_image() + return + self._attr_state = MediaPlayerState.PLAYING + self._attr_media_content_type = MEDIA_CONTENT_TYPE_DASHBOARD + if session.dashboard == DashboardType.PARTY: + self._attr_media_content_id = DashboardType.PARTY.value + self._attr_media_title = "Party" + self._clear_media_image() + elif session.dashboard == DashboardType.MUSIC_QUIZ: + self._attr_media_content_id = DashboardType.MUSIC_QUIZ.value + self._attr_media_title = "Music quiz" + self._clear_media_image() + elif session.dashboard == DashboardType.NOW_PLAYING: + player_id = session.player_id or "" + self._attr_media_content_id = f"{NOW_PLAYING_ID_PREFIX}{player_id}" + player = self.mass.players.get(player_id) if player_id else None + player_label = player.name if player is not None else player_id + self._attr_media_title = f"Now playing: {player_label}" + self._update_session_player_image(player) + else: + # a dashboard type this client doesn't recognize (UNKNOWN) + self._attr_media_content_id = session.dashboard.value + self._attr_media_title = session.dashboard.value + self._clear_media_image() + + def _valid_content_ids(self, dashboard: DashboardDevice) -> list[str]: + """List this display's playable content ids, for error messages.""" + ids = [ + supported.value + for supported in dashboard.supported_types + if supported not in (DashboardType.UNKNOWN, DashboardType.NOW_PLAYING) + ] + if DashboardType.NOW_PLAYING in dashboard.supported_types: + # not directly playable on its own; it always needs a player segment + ids.append(f"{NOW_PLAYING_ID_PREFIX}") + return sorted(ids) + + def _parse_play_media_id( + self, media_content_id: str + ) -> tuple[DashboardType, str | None]: + """Validate a play_media content id and split it into a type and player id.""" + dashboard = self.mass.dashboard.get(self.dashboard_id) + if dashboard is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="dashboard_display_not_available", + translation_placeholders={"display": self.dashboard_id}, + ) + valid_ids = self._valid_content_ids(dashboard) + + if media_content_id == DashboardType.NOW_PLAYING.value: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="dashboard_now_playing_requires_player", + translation_placeholders={ + "dashboard": DashboardType.NOW_PLAYING.value, + "expected": f"{NOW_PLAYING_ID_PREFIX}", + }, + ) + + player_id: str | None = None + if media_content_id.startswith(NOW_PLAYING_ID_PREFIX): + dashboard_type = DashboardType.NOW_PLAYING + player_id = media_content_id.removeprefix(NOW_PLAYING_ID_PREFIX) + else: + dashboard_type = DashboardType(media_content_id) + + if dashboard_type == DashboardType.UNKNOWN: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="dashboard_unknown_type", + translation_placeholders={ + "dashboard": media_content_id, + "valid_ids": ", ".join(valid_ids), + }, + ) + if dashboard_type not in dashboard.supported_types: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="dashboard_type_not_supported", + translation_placeholders={ + "display": dashboard.name, + "dashboard": dashboard_type.value, + "valid_ids": ", ".join(valid_ids), + }, + ) + if dashboard_type == DashboardType.NOW_PLAYING: + player = self.mass.players.get(player_id) if player_id else None + if player is None or not player.expose_to_ha: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="dashboard_player_not_available", + translation_placeholders={"player_id": str(player_id)}, + ) + return dashboard_type, player_id + + def _clear_media_image(self) -> None: + """Clear the media image, e.g. for sessions served by async_get_media_image.""" + self._attr_media_image_url = None + self._attr_media_image_remotely_accessible = False + + def _update_session_player_image(self, player: Player | None) -> None: + """Update the media image url from the now_playing session's player.""" + image_url = _get_player_artwork_url(self.mass, player) if player else None + self._attr_media_image_remotely_accessible = bool( + image_url and self.mass.server_url not in image_url + ) + self._attr_media_image_url = image_url + + async def _fetch_provider_icon( + self, provider_domain: str + ) -> tuple[bytes | None, str | None]: + """Fetch a provider icon, caching definite answers but not transient errors.""" + if provider_domain in self._provider_icon_cache: + return self._provider_icon_cache[provider_domain] or (None, None) + try: + icon = await self.mass.get_provider_icon(provider_domain) + except MusicAssistantError: + LOGGER.debug( + "Failed to fetch provider icon for %s", provider_domain, exc_info=True + ) + return None, None + self._provider_icon_cache[provider_domain] = icon + if icon is None: + LOGGER.debug("No provider icon available for %s", provider_domain) + return None, None + return icon + + def _build_root_listing(self, dashboard: DashboardDevice) -> BrowseMedia: + """Build the root browse listing, filtered to this display's dashboards.""" + children: list[BrowseMedia] = [] + if DashboardType.PARTY in dashboard.supported_types: + children.append( + BrowseMedia( + media_class=MediaClass.APP, + media_content_id=DashboardType.PARTY.value, + media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD, + title="Party", + can_play=True, + can_expand=False, + thumbnail=self.get_browse_image_url( + MEDIA_CONTENT_TYPE_DASHBOARD, DashboardType.PARTY.value + ), + ) + ) + if DashboardType.MUSIC_QUIZ in dashboard.supported_types: + children.append( + BrowseMedia( + media_class=MediaClass.APP, + media_content_id=DashboardType.MUSIC_QUIZ.value, + media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD, + title="Music quiz", + can_play=True, + can_expand=False, + thumbnail=self.get_browse_image_url( + MEDIA_CONTENT_TYPE_DASHBOARD, DashboardType.MUSIC_QUIZ.value + ), + ) + ) + if DashboardType.NOW_PLAYING in dashboard.supported_types: + children.append( + BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=DashboardType.NOW_PLAYING.value, + media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD, + title="Now playing", + can_play=False, + can_expand=True, + children_media_class=MediaClass.APP, + ) + ) + + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD, + title=dashboard.name, + can_play=False, + can_expand=True, + children=children, + ) + + def _build_now_playing_listing(self) -> BrowseMedia: + """Build the now playing folder, one playable child per exposed player.""" + players = sorted( + (player for player in self.mass.players if player.expose_to_ha), + key=lambda player: player.name, + ) + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id=DashboardType.NOW_PLAYING.value, + media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD, + title="Now playing", + can_play=False, + can_expand=True, + children_media_class=MediaClass.APP, + children=[ + BrowseMedia( + media_class=MediaClass.APP, + media_content_id=f"{NOW_PLAYING_ID_PREFIX}{player.player_id}", + media_content_type=MEDIA_CONTENT_TYPE_DASHBOARD, + title=player.name, + can_play=True, + can_expand=False, + thumbnail=_get_player_artwork_url(self.mass, player), + ) + for player in players + ], + ) diff --git a/homeassistant/components/music_assistant/services.py b/homeassistant/components/music_assistant/services.py index 3fd434779b6e..39c5e3ad2bfa 100644 --- a/homeassistant/components/music_assistant/services.py +++ b/homeassistant/components/music_assistant/services.py @@ -8,6 +8,7 @@ import probatio from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, DOMAIN as MEDIA_PLAYER_DOMAIN, + MediaPlayerDeviceClass, ) from homeassistant.components.tts import DOMAIN as TTS_DOMAIN from homeassistant.const import ATTR_CONFIG_ENTRY_ID @@ -86,6 +87,9 @@ DEFAULT_OFFSET = 0 DEFAULT_LIMIT = 25 DEFAULT_SORT_ORDER = "name" +# player-only entity services; dashboard display entities don't implement them +PLAYER_ENTITY_DEVICE_CLASSES = [MediaPlayerDeviceClass.SPEAKER] + @callback def register_actions(hass: HomeAssistant) -> None: @@ -137,6 +141,7 @@ def register_actions(hass: HomeAssistant) -> None: DOMAIN, SERVICE_PLAY_MEDIA_ADVANCED, entity_domain=MEDIA_PLAYER_DOMAIN, + entity_device_classes=PLAYER_ENTITY_DEVICE_CLASSES, schema={ probatio.Required(ATTR_MEDIA_ID): probatio.All( probatio.EnsureList(), [cv.string] @@ -155,6 +160,7 @@ def register_actions(hass: HomeAssistant) -> None: DOMAIN, SERVICE_PLAY_ANNOUNCEMENT, entity_domain=MEDIA_PLAYER_DOMAIN, + entity_device_classes=PLAYER_ENTITY_DEVICE_CLASSES, schema=probatio.All( cv.make_entity_service_schema( { @@ -178,6 +184,7 @@ def register_actions(hass: HomeAssistant) -> None: DOMAIN, SERVICE_TRANSFER_QUEUE, entity_domain=MEDIA_PLAYER_DOMAIN, + entity_device_classes=PLAYER_ENTITY_DEVICE_CLASSES, schema={ probatio.Optional(ATTR_SOURCE_PLAYER): cv.entity_id, probatio.Optional(ATTR_AUTO_PLAY): probatio.Coerce(bool), @@ -189,6 +196,7 @@ def register_actions(hass: HomeAssistant) -> None: DOMAIN, SERVICE_GET_QUEUE, entity_domain=MEDIA_PLAYER_DOMAIN, + entity_device_classes=PLAYER_ENTITY_DEVICE_CLASSES, schema=None, func="_async_handle_get_queue", supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index 6e7e1df214c4..740836345f9b 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -5,6 +5,7 @@ play_media: entity: domain: media_player integration: music_assistant + device_class: speaker supported_features: - media_player.MediaPlayerEntityFeature.PLAY_MEDIA fields: @@ -58,6 +59,7 @@ play_announcement: entity: domain: media_player integration: music_assistant + device_class: speaker supported_features: - media_player.MediaPlayerEntityFeature.PLAY_MEDIA - media_player.MediaPlayerEntityFeature.MEDIA_ANNOUNCE @@ -100,12 +102,14 @@ transfer_queue: entity: domain: media_player integration: music_assistant + device_class: speaker fields: source_player: selector: entity: domain: media_player integration: music_assistant + device_class: speaker auto_play: example: "true" selector: @@ -116,6 +120,7 @@ get_queue: entity: domain: media_player integration: music_assistant + device_class: speaker supported_features: - media_player.MediaPlayerEntityFeature.PLAY_MEDIA diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 3ad74fde5b97..5aa925f75583 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -258,6 +258,27 @@ } }, "exceptions": { + "dashboard_display_not_available": { + "message": "The display {display} is not available." + }, + "dashboard_invalid_media_type": { + "message": "Dashboard displays only accept the media content type {expected}, got {media_type}." + }, + "dashboard_media_not_found": { + "message": "The media {media_content_id} was not found." + }, + "dashboard_now_playing_requires_player": { + "message": "The dashboard {dashboard} requires a player, expected {expected}." + }, + "dashboard_player_not_available": { + "message": "The player {player_id} is unknown or not exposed to Home Assistant." + }, + "dashboard_type_not_supported": { + "message": "The display {display} does not support the dashboard {dashboard}, expected one of: {valid_ids}." + }, + "dashboard_unknown_type": { + "message": "The dashboard {dashboard} is unknown, expected one of: {valid_ids}." + }, "invalid_username": { "message": "The username {username} does not exist on the Music Assistant server." }, diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index 3a648f22637d..3ad3cb4aba98 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -5,7 +5,8 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock from music_assistant_models.api import MassEvent -from music_assistant_models.enums import EventType +from music_assistant_models.dashboard import DashboardDevice, DashboardSession +from music_assistant_models.enums import DashboardType, EventType from music_assistant_models.media_items import ( Album, Artist, @@ -197,15 +198,61 @@ async def trigger_subscription_callback( await hass.async_block_till_done() +def setup_dashboards(music_assistant_client: MagicMock) -> None: + """Seed the mocked client with dashboard endpoints and two active sessions.""" + music_assistant_client.dashboard._dashboards = { + "chromecast_kitchen": DashboardDevice( + dashboard_id="chromecast_kitchen", + name="Kitchen Display", + supported_types={DashboardType.PARTY, DashboardType.NOW_PLAYING}, + provider_domain_hint="chromecast", + ), + "fully_kiosk_hallway": DashboardDevice( + dashboard_id="fully_kiosk_hallway", + name="Hallway Display", + supported_types={ + DashboardType.PARTY, + DashboardType.NOW_PLAYING, + DashboardType.MUSIC_QUIZ, + DashboardType.UNKNOWN, + }, + provider_domain_hint="fully_kiosk", + ), + "unmapped_player_display": DashboardDevice( + dashboard_id="unmapped_player_display", + name="Unmapped Player Display", + supported_types={DashboardType.NOW_PLAYING}, + ), + } + music_assistant_client.dashboard._sessions = { + "chromecast_kitchen": DashboardSession( + dashboard_id="chromecast_kitchen", + name="Kitchen Display", + dashboard=DashboardType.NOW_PLAYING, + player_id="00:00:00:00:00:01", + ), + "unmapped_player_display": DashboardSession( + dashboard_id="unmapped_player_display", + name="Unmapped Player Display", + dashboard=DashboardType.NOW_PLAYING, + player_id="not-exposed-player", + ), + } + + def snapshot_music_assistant_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, platform: Platform, + *, + unique_id_prefix: str | None = None, ) -> None: - """Snapshot MusicAssistant entities.""" + """Snapshot MusicAssistant entities, optionally only those with a unique id prefix.""" entities = hass.states.async_all(platform) for entity_state in entities: entity_entry = entity_registry.async_get(entity_state.entity_id) + if unique_id_prefix and not entity_entry.unique_id.startswith(unique_id_prefix): + continue assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py index 73b25760d251..9baeb1ac3166 100644 --- a/tests/components/music_assistant/conftest.py +++ b/tests/components/music_assistant/conftest.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import AsyncGenerator, Generator from unittest.mock import MagicMock, patch +from music_assistant_client.dashboard import Dashboard from music_assistant_client.music import Music from music_assistant_client.player_queues import PlayerQueues from music_assistant_client.players import Players @@ -68,6 +69,7 @@ async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]: client.players = Players(client) client.player_queues = PlayerQueues(client) client.music = Music(client) + client.dashboard = Dashboard(client) client.server_url = client.server_info.base_url client.get_media_item_image_url = MagicMock(return_value=None) client.config = MagicMock() diff --git a/tests/components/music_assistant/snapshots/test_dashboard_media_player.ambr b/tests/components/music_assistant/snapshots/test_dashboard_media_player.ambr new file mode 100644 index 000000000000..a0c64fad9450 --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_dashboard_media_player.ambr @@ -0,0 +1,166 @@ +# serializer version: 1 +# name: test_dashboard_media_player_snapshot[media_player.hallway_display-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.hallway_display', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'dashboard:fully_kiosk_hallway', + 'unit_of_measurement': None, + }) +# --- +# name: test_dashboard_media_player_snapshot[media_player.hallway_display-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'tv', + : 'Hallway Display', + : , + }), + 'context': , + 'entity_id': 'media_player.hallway_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_dashboard_media_player_snapshot[media_player.kitchen_display-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.kitchen_display', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'dashboard:chromecast_kitchen', + 'unit_of_measurement': None, + }) +# --- +# name: test_dashboard_media_player_snapshot[media_player.kitchen_display-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'tv', + : 'Kitchen Display', + : 'now_playing/00:00:00:00:00:01', + : 'dashboard', + : 'Now playing: Test Player 1', + : , + }), + 'context': , + 'entity_id': 'media_player.kitchen_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_dashboard_media_player_snapshot[media_player.unmapped_player_display-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.unmapped_player_display', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'dashboard:unmapped_player_display', + 'unit_of_measurement': None, + }) +# --- +# name: test_dashboard_media_player_snapshot[media_player.unmapped_player_display-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'tv', + : 'Unmapped Player Display', + : 'now_playing/not-exposed-player', + : 'dashboard', + : 'Now playing: not-exposed-player', + : , + }), + 'context': , + 'entity_id': 'media_player.unmapped_player_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/music_assistant/test_dashboard_media_player.py b/tests/components/music_assistant/test_dashboard_media_player.py new file mode 100644 index 000000000000..1995ef557bd9 --- /dev/null +++ b/tests/components/music_assistant/test_dashboard_media_player.py @@ -0,0 +1,1116 @@ +"""Test Music Assistant dashboard display media player entities.""" + +import dataclasses +from unittest.mock import AsyncMock, MagicMock, call + +from music_assistant_models.dashboard import DashboardDevice, DashboardSession +from music_assistant_models.enums import DashboardType, EventType +from music_assistant_models.errors import MusicAssistantError +from music_assistant_models.player import PlayerMedia +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_TITLE, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + MediaPlayerEntityFeature, +) +from homeassistant.components.music_assistant.const import ( + ATTR_URL, + DASHBOARD_ID_PREFIX, + DOMAIN, +) +from homeassistant.components.music_assistant.services import SERVICE_PLAY_ANNOUNCEMENT +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .common import ( + setup_dashboards, + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +KITCHEN_ENTITY_ID = "media_player.kitchen_display" +HALLWAY_ENTITY_ID = "media_player.hallway_display" +UNMAPPED_ENTITY_ID = "media_player.unmapped_player_display" + +PROVIDER_ICON_BYTES = b"" +PROVIDER_ICON_CONTENT_TYPE = "image/svg+xml" + + +def _mock_provider_icon( + music_assistant_client: MagicMock, + result: tuple[bytes, str] | None = ( + PROVIDER_ICON_BYTES, + PROVIDER_ICON_CONTENT_TYPE, + ), +) -> AsyncMock: + """Mock the client's get_provider_icon and return the mock.""" + music_assistant_client.get_provider_icon = AsyncMock(return_value=result) + return music_assistant_client.get_provider_icon + + +async def _get_party_thumbnail_url( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entity_id: str +) -> str: + """Return the party icon's proxy url from the root browse listing.""" + client = await hass_ws_client(hass) + await client.send_json( + {"id": 1, "type": "media_player/browse_media", "entity_id": entity_id} + ) + response = await client.receive_json() + assert response["success"] + party_child = next( + child + for child in response["result"]["children"] + if child["media_content_id"] == "party" + ) + return party_child["thumbnail"] + + +def _dashboards_event_data(music_assistant_client: MagicMock) -> list[dict]: + """Build the DASHBOARDS_UPDATED event payload for the cache's current state.""" + return [ + dashboard.to_dict() + for dashboard in music_assistant_client.dashboard._dashboards.values() + ] + + +def _sessions_event_data(music_assistant_client: MagicMock) -> list[dict]: + """Build the DASHBOARD_SESSIONS_UPDATED event payload for the cache's current state.""" + return [ + session.to_dict() + for session in music_assistant_client.dashboard._sessions.values() + ] + + +async def _play_media( + hass: HomeAssistant, entity_id: str, media_content_id: str +) -> None: + """Call media_player.play_media with the dashboard content type.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_CONTENT_TYPE: "dashboard", + ATTR_MEDIA_CONTENT_ID: media_content_id, + }, + blocking=True, + ) + + +async def test_dashboard_player_entities_from_cache( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + music_assistant_client: MagicMock, +) -> None: + """Test dashboard media players are created from the seeded cache.""" + setup_dashboards(music_assistant_client) + config_entry = await setup_integration_from_fixtures(hass, music_assistant_client) + + device = device_registry.async_get_device_by_identifier( + (DOMAIN, "dashboard:chromecast_kitchen"), config_entry.entry_id + ) + assert device + assert device.manufacturer == "Music Assistant" + assert device.model == "Dashboard display" + assert device.name == "Kitchen Display" + + entry = entity_registry.async_get(KITCHEN_ENTITY_ID) + assert entry + assert entry.unique_id == "dashboard:chromecast_kitchen" + + # chromecast_kitchen has an active now_playing session for Test Player 1 + state = hass.states.get(KITCHEN_ENTITY_ID) + assert state + assert state.state == "playing" + assert state.attributes["device_class"] == "tv" + assert state.attributes[ATTR_MEDIA_CONTENT_TYPE] == "dashboard" + assert state.attributes[ATTR_MEDIA_CONTENT_ID] == "now_playing/00:00:00:00:00:01" + assert state.attributes[ATTR_MEDIA_TITLE] == "Now playing: Test Player 1" + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.TURN_OFF + ) + + # fully_kiosk_hallway has no active session + hallway_state = hass.states.get(HALLWAY_ENTITY_ID) + assert hallway_state + assert hallway_state.state == "idle" + assert hallway_state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) is None + + # unmapped_player_display has an active session for a player unknown to + # HA; the title falls back to the raw player id + unmapped_state = hass.states.get(UNMAPPED_ENTITY_ID) + assert unmapped_state + assert unmapped_state.state == "playing" + assert ( + unmapped_state.attributes[ATTR_MEDIA_CONTENT_ID] + == "now_playing/not-exposed-player" + ) + assert ( + unmapped_state.attributes[ATTR_MEDIA_TITLE] == "Now playing: not-exposed-player" + ) + + +async def test_dashboard_play_media_party( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test play_media with the party dashboard type.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + await _play_media(hass, KITCHEN_ENTITY_ID, "party") + + assert music_assistant_client.send_command.call_args == call( + "dashboard/show", + dashboard_id="chromecast_kitchen", + dashboard=DashboardType.PARTY, + player_id=None, + require_schema=39, + ) + + +async def test_dashboard_play_media_music_quiz( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test play_media with the music_quiz dashboard type.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + await _play_media(hass, HALLWAY_ENTITY_ID, "music_quiz") + + assert music_assistant_client.send_command.call_args == call( + "dashboard/show", + dashboard_id="fully_kiosk_hallway", + dashboard=DashboardType.MUSIC_QUIZ, + player_id=None, + require_schema=39, + ) + + +async def test_dashboard_play_media_now_playing( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test play_media with a now_playing dashboard and player id.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + await _play_media(hass, HALLWAY_ENTITY_ID, "now_playing/00:00:00:00:00:02") + + assert music_assistant_client.send_command.call_args == call( + "dashboard/show", + dashboard_id="fully_kiosk_hallway", + dashboard=DashboardType.NOW_PLAYING, + player_id="00:00:00:00:00:02", + require_schema=39, + ) + + +async def test_dashboard_play_media_wrong_content_type( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test play_media rejects any media_content_type but dashboard.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: KITCHEN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: "party", + }, + blocking=True, + ) + + assert exc_info.value.translation_key == "dashboard_invalid_media_type" + assert exc_info.value.translation_placeholders == { + "expected": "dashboard", + "media_type": "music", + } + + +async def test_dashboard_play_media_unknown_type( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test play_media rejects an unknown dashboard type.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + with pytest.raises(ServiceValidationError) as exc_info: + await _play_media(hass, KITCHEN_ENTITY_ID, "not_a_dashboard") + + assert exc_info.value.translation_key == "dashboard_unknown_type" + + +async def test_dashboard_play_media_unsupported_type( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test play_media rejects a type not in the display's supported_types.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + # chromecast_kitchen only supports party and now_playing + with pytest.raises(ServiceValidationError) as exc_info: + await _play_media(hass, KITCHEN_ENTITY_ID, "music_quiz") + + assert exc_info.value.translation_key == "dashboard_type_not_supported" + assert exc_info.value.translation_placeholders == { + "display": "Kitchen Display", + "dashboard": "music_quiz", + "valid_ids": "now_playing/, party", + } + + +async def test_dashboard_play_media_now_playing_missing_player( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test play_media rejects now_playing without a player segment.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + with pytest.raises(ServiceValidationError) as exc_info: + await _play_media(hass, HALLWAY_ENTITY_ID, "now_playing") + + assert exc_info.value.translation_key == "dashboard_now_playing_requires_player" + + +async def test_dashboard_play_media_now_playing_unknown_player( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test play_media rejects an unknown player id.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + with pytest.raises(ServiceValidationError) as exc_info: + await _play_media(hass, HALLWAY_ENTITY_ID, "now_playing/does-not-exist") + + assert exc_info.value.translation_key == "dashboard_player_not_available" + assert exc_info.value.translation_placeholders == {"player_id": "does-not-exist"} + + +async def test_dashboard_play_media_now_playing_unexposed_player( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test play_media rejects a player that is not exposed to HA.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + hidden_player = dataclasses.replace( + music_assistant_client.players._players["00:00:00:00:00:01"], + player_id="hidden-player", + name="Hidden Player", + expose_to_ha=False, + ) + music_assistant_client.players._players["hidden-player"] = hidden_player + + with pytest.raises(ServiceValidationError) as exc_info: + await _play_media(hass, HALLWAY_ENTITY_ID, "now_playing/hidden-player") + + assert exc_info.value.translation_key == "dashboard_player_not_available" + assert exc_info.value.translation_placeholders == {"player_id": "hidden-player"} + + +async def test_dashboard_turn_off( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test turn_off hides the dashboard.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: KITCHEN_ENTITY_ID}, + blocking=True, + ) + + assert music_assistant_client.send_command.call_args == call( + "dashboard/hide", + dashboard_id="chromecast_kitchen", + require_schema=39, + ) + + +async def test_dashboard_browse_media_root( + hass: HomeAssistant, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the root browse listing is filtered to the display's supported_types.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + client = await hass_ws_client(hass) + + # chromecast_kitchen supports party and now_playing, but not music_quiz + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": KITCHEN_ENTITY_ID, + } + ) + response = await client.receive_json() + assert response["success"] + result = response["result"] + assert [child["title"] for child in result["children"]] == [ + "Party", + "Now playing", + ] + + party_child = result["children"][0] + assert party_child["media_content_id"] == "party" + assert party_child["media_content_type"] == "dashboard" + assert party_child["media_class"] == "app" + assert party_child["can_play"] is True + assert party_child["can_expand"] is False + assert party_child["thumbnail"].startswith( + f"/api/media_player_proxy/{KITCHEN_ENTITY_ID}/browse_media/dashboard/party?" + ) + + now_playing_child = result["children"][1] + assert now_playing_child["media_content_id"] == "now_playing" + assert now_playing_child["media_class"] == "directory" + assert now_playing_child["can_play"] is False + assert now_playing_child["can_expand"] is True + assert now_playing_child["thumbnail"] is None + + # fully_kiosk_hallway supports party/now_playing/music_quiz (+ UNKNOWN, + # which is always excluded) + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": HALLWAY_ENTITY_ID, + } + ) + response = await client.receive_json() + assert response["success"] + hallway_children = response["result"]["children"] + assert [child["title"] for child in hallway_children] == [ + "Party", + "Music quiz", + "Now playing", + ] + music_quiz_child = hallway_children[1] + assert music_quiz_child["thumbnail"].startswith( + f"/api/media_player_proxy/{HALLWAY_ENTITY_ID}/browse_media/dashboard/music_quiz?" + ) + + +async def test_dashboard_browse_media_now_playing_folder( + hass: HomeAssistant, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the now playing folder lists only players exposed to HA, sorted by name.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + # add a player that is not exposed to HA; it must not show up + hidden_player = dataclasses.replace( + music_assistant_client.players._players["00:00:00:00:00:01"], + player_id="hidden-player", + name="Aaa Hidden Player", + expose_to_ha=False, + ) + music_assistant_client.players._players["hidden-player"] = hidden_player + + # give Test Player 1 an image so its browse thumbnail is populated + art_url = "https://example.com/art.jpg" + music_assistant_client.players._players[ + "00:00:00:00:00:01" + ].current_media = PlayerMedia(uri="spotify://track/x", image_url=art_url) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": HALLWAY_ENTITY_ID, + "media_content_type": "dashboard", + "media_content_id": "now_playing", + } + ) + response = await client.receive_json() + assert response["success"] + result = response["result"] + assert result["media_content_id"] == "now_playing" + assert result["title"] == "Now playing" + + children = result["children"] + assert [child["title"] for child in children] == [ + "My Super Test Player 2", + "Test Group Player 1", + "Test Player 1", + ] + for child in children: + assert child["media_content_type"] == "dashboard" + assert child["media_class"] == "app" + assert child["can_play"] is True + assert child["can_expand"] is False + + test_player_1_child = next( + child for child in children if child["title"] == "Test Player 1" + ) + assert test_player_1_child["media_content_id"] == "now_playing/00:00:00:00:00:01" + assert test_player_1_child["thumbnail"] == art_url + + # players without current media get no thumbnail + other_child = next(child for child in children if child["title"] != "Test Player 1") + assert other_child["thumbnail"] is None + + +async def test_dashboard_browse_image( + hass: HomeAssistant, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, + hass_client: ClientSessionGenerator, +) -> None: + """Test the browse thumbnail serves the provider icon and caches it.""" + setup_dashboards(music_assistant_client) + get_provider_icon = _mock_provider_icon(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + thumbnail_url = await _get_party_thumbnail_url( + hass, hass_ws_client, KITCHEN_ENTITY_ID + ) + + client = await hass_client() + resp = await client.get(thumbnail_url) + assert resp.status == 200 + assert await resp.read() == PROVIDER_ICON_BYTES + assert resp.content_type == PROVIDER_ICON_CONTENT_TYPE + + # a second fetch for the same provider domain must not re-hit the server + resp = await client.get(thumbnail_url) + assert resp.status == 200 + get_provider_icon.assert_awaited_once_with("party") + + +async def test_dashboard_browse_image_no_icon_cached( + hass: HomeAssistant, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, + hass_client: ClientSessionGenerator, +) -> None: + """Test a missing provider icon returns 404 and is not fetched again.""" + setup_dashboards(music_assistant_client) + get_provider_icon = _mock_provider_icon(music_assistant_client, None) + await setup_integration_from_fixtures(hass, music_assistant_client) + thumbnail_url = await _get_party_thumbnail_url( + hass, hass_ws_client, KITCHEN_ENTITY_ID + ) + + client = await hass_client() + assert (await client.get(thumbnail_url)).status == 404 + assert (await client.get(thumbnail_url)).status == 404 + get_provider_icon.assert_awaited_once_with("party") + + +async def test_dashboard_browse_image_error_not_cached( + hass: HomeAssistant, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, + hass_client: ClientSessionGenerator, +) -> None: + """Test a failed provider icon fetch returns 404 and is retried.""" + setup_dashboards(music_assistant_client) + get_provider_icon = _mock_provider_icon(music_assistant_client) + get_provider_icon.side_effect = MusicAssistantError("boom") + await setup_integration_from_fixtures(hass, music_assistant_client) + thumbnail_url = await _get_party_thumbnail_url( + hass, hass_ws_client, KITCHEN_ENTITY_ID + ) + + client = await hass_client() + assert (await client.get(thumbnail_url)).status == 404 + + # the next request retries and caches the recovered result + get_provider_icon.side_effect = None + resp = await client.get(thumbnail_url) + assert resp.status == 200 + assert await resp.read() == PROVIDER_ICON_BYTES + assert resp.content_type == PROVIDER_ICON_CONTENT_TYPE + assert get_provider_icon.await_count == 2 + + +@pytest.mark.parametrize( + "media_content_id", ["not_a_real_id", "now_playing/00:00:00:00:00:01"] +) +async def test_dashboard_browse_image_rejects_unknown_content_id( + hass: HomeAssistant, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, + hass_client: ClientSessionGenerator, + media_content_id: str, +) -> None: + """Test the browse thumbnail rejects any id besides the two icon domains. + + This bounds the icon cache to the party/music_quiz keys it's sized for, + and must reject before ever touching the cache or the server. + """ + setup_dashboards(music_assistant_client) + get_provider_icon = _mock_provider_icon(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + thumbnail_url = await _get_party_thumbnail_url( + hass, hass_ws_client, KITCHEN_ENTITY_ID + ) + + client = await hass_client() + resp = await client.get( + thumbnail_url.replace("/dashboard/party?", f"/dashboard/{media_content_id}?") + ) + assert resp.status == 404 + get_provider_icon.assert_not_awaited() + + +async def test_dashboard_dynamic_add_and_unavailable( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test entities appear for a newly registered endpoint, and go unavailable.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + assert hass.states.get("media_player.new_display") is None + + music_assistant_client.dashboard._dashboards["new_display"] = DashboardDevice( + dashboard_id="new_display", + name="New Display", + supported_types={DashboardType.PARTY}, + ) + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.DASHBOARDS_UPDATED, + data=_dashboards_event_data(music_assistant_client), + ) + + new_state = hass.states.get("media_player.new_display") + assert new_state + assert new_state.state == "idle" + + # the endpoint disappears from the cache (e.g. provider unloaded); the + # entity stays but becomes unavailable + del music_assistant_client.dashboard._dashboards["new_display"] + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.DASHBOARDS_UPDATED, + data=_dashboards_event_data(music_assistant_client), + ) + + new_state = hass.states.get("media_player.new_display") + assert new_state + assert new_state.state == STATE_UNAVAILABLE + + kitchen_state = hass.states.get(KITCHEN_ENTITY_ID) + assert kitchen_state + assert kitchen_state.state != STATE_UNAVAILABLE + + +async def test_dashboard_session_mirroring( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test the entity mirrors a session starting, changing and ending.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + assert hass.states.get(HALLWAY_ENTITY_ID).state == "idle" + + # a party session starts + music_assistant_client.dashboard._sessions["fully_kiosk_hallway"] = ( + DashboardSession( + dashboard_id="fully_kiosk_hallway", + name="Hallway Display", + dashboard=DashboardType.PARTY, + ) + ) + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.DASHBOARD_SESSIONS_UPDATED, + data=_sessions_event_data(music_assistant_client), + ) + state = hass.states.get(HALLWAY_ENTITY_ID) + assert state.state == "playing" + assert state.attributes[ATTR_MEDIA_CONTENT_ID] == "party" + assert state.attributes[ATTR_MEDIA_TITLE] == "Party" + + # a now_playing session takes over, for a specific player + music_assistant_client.dashboard._sessions["fully_kiosk_hallway"] = ( + DashboardSession( + dashboard_id="fully_kiosk_hallway", + name="Hallway Display", + dashboard=DashboardType.NOW_PLAYING, + player_id="00:00:00:00:00:02", + ) + ) + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.DASHBOARD_SESSIONS_UPDATED, + data=_sessions_event_data(music_assistant_client), + ) + state = hass.states.get(HALLWAY_ENTITY_ID) + assert state.state == "playing" + assert state.attributes[ATTR_MEDIA_CONTENT_ID] == "now_playing/00:00:00:00:00:02" + assert state.attributes[ATTR_MEDIA_TITLE] == "Now playing: My Super Test Player 2" + + # the session ends + del music_assistant_client.dashboard._sessions["fully_kiosk_hallway"] + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.DASHBOARD_SESSIONS_UPDATED, + data=_sessions_event_data(music_assistant_client), + ) + state = hass.states.get(HALLWAY_ENTITY_ID) + assert state.state == "idle" + assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) is None + + +async def test_dashboard_session_media_image_party_and_music_quiz( + hass: HomeAssistant, + music_assistant_client: MagicMock, + hass_client: ClientSessionGenerator, +) -> None: + """Test party/music_quiz sessions serve the provider icon, hashed by session type.""" + setup_dashboards(music_assistant_client) + _mock_provider_icon(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + state = hass.states.get(HALLWAY_ENTITY_ID) + assert state.attributes.get("entity_picture") is None + + music_assistant_client.dashboard._sessions["fully_kiosk_hallway"] = ( + DashboardSession( + dashboard_id="fully_kiosk_hallway", + name="Hallway Display", + dashboard=DashboardType.PARTY, + ) + ) + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.DASHBOARD_SESSIONS_UPDATED, + data=_sessions_event_data(music_assistant_client), + ) + # the icon is served through the media proxy, not a direct media_image_url + state = hass.states.get(HALLWAY_ENTITY_ID) + party_picture = state.attributes["entity_picture"] + assert party_picture.startswith(f"/api/media_player_proxy/{HALLWAY_ENTITY_ID}?") + client = await hass_client() + resp = await client.get(party_picture) + assert resp.status == 200 + assert await resp.read() == PROVIDER_ICON_BYTES + assert resp.content_type == PROVIDER_ICON_CONTENT_TYPE + + music_assistant_client.dashboard._sessions["fully_kiosk_hallway"] = ( + DashboardSession( + dashboard_id="fully_kiosk_hallway", + name="Hallway Display", + dashboard=DashboardType.MUSIC_QUIZ, + ) + ) + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.DASHBOARD_SESSIONS_UPDATED, + data=_sessions_event_data(music_assistant_client), + ) + state = hass.states.get(HALLWAY_ENTITY_ID) + assert state.attributes["entity_picture"] != party_picture + + del music_assistant_client.dashboard._sessions["fully_kiosk_hallway"] + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.DASHBOARD_SESSIONS_UPDATED, + data=_sessions_event_data(music_assistant_client), + ) + state = hass.states.get(HALLWAY_ENTITY_ID) + assert state.attributes.get("entity_picture") is None + + +async def test_dashboard_now_playing_session_media_image( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test a now_playing session mirrors its player's artwork, refreshed on QUEUE_UPDATED.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + # chromecast_kitchen's seeded now_playing session targets Test Player 1, + # which starts without any media + state = hass.states.get(KITCHEN_ENTITY_ID) + assert state.attributes.get("entity_picture") is None + + art_url = "https://example.com/art.jpg" + music_assistant_client.players._players[ + "00:00:00:00:00:01" + ].current_media = PlayerMedia(uri="spotify://track/x", image_url=art_url) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.QUEUE_UPDATED, "00:00:00:00:00:01" + ) + + state = hass.states.get(KITCHEN_ENTITY_ID) + # a non-MA-hosted url is remotely accessible, so entity_picture is the raw url + assert state.attributes["entity_picture"] == art_url + + +async def test_dashboard_now_playing_session_media_image_ma_hosted( + hass: HomeAssistant, + music_assistant_client: MagicMock, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> None: + """Test MA-hosted now_playing artwork is proxied and actually fetched. + + Regression test: media_image_hash/async_get_media_image used to always + return None for a now_playing session, so the base class's local-proxy + fallback (which needs media_image_hash to build the proxy url, and + fetches through async_get_media_image) never engaged and entity_picture + silently disappeared for any MA-hosted artwork. + """ + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + art_url = f"{music_assistant_client.server_url}/imageproxy/track.jpg" + music_assistant_client.players._players[ + "00:00:00:00:00:01" + ].current_media = PlayerMedia(uri="spotify://track/x", image_url=art_url) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.QUEUE_UPDATED, "00:00:00:00:00:01" + ) + + state = hass.states.get(KITCHEN_ENTITY_ID) + entity_picture = state.attributes["entity_picture"] + # MA-hosted artwork is not remotely accessible, so it's served through + # the local media proxy rather than as a direct url + assert entity_picture.startswith(f"/api/media_player_proxy/{KITCHEN_ENTITY_ID}?") + + aioclient_mock.get( + art_url, content=b"artwork-bytes", headers={"Content-Type": "image/jpeg"} + ) + client = await hass_client() + response = await client.get(entity_picture) + assert response.status == 200 + assert await response.read() == b"artwork-bytes" + + +async def test_dashboard_now_playing_session_media_image_player_updated( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test a now_playing session's artwork also refreshes on PLAYER_UPDATED.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + state = hass.states.get(KITCHEN_ENTITY_ID) + assert state.attributes.get("entity_picture") is None + + art_url = "https://example.com/player-art.jpg" + music_assistant_client.players._players[ + "00:00:00:00:00:01" + ].current_media = PlayerMedia(uri="spotify://track/x", image_url=art_url) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_UPDATED, "00:00:00:00:00:01" + ) + + state = hass.states.get(KITCHEN_ENTITY_ID) + assert state.attributes["entity_picture"] == art_url + + +async def test_dashboard_now_playing_session_media_image_active_group_match( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test QUEUE_UPDATED for the session player's active_group also refreshes artwork. + + Mirrors MusicAssistantEntity.__on_mass_update's active_group matching, so + artwork stays in sync when the session's player is joined into a group. + """ + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + music_assistant_client.players._players[ + "00:00:00:00:00:01" + ].active_group = "group-1" + + art_url = "https://example.com/group-art.jpg" + music_assistant_client.players._players[ + "00:00:00:00:00:01" + ].current_media = PlayerMedia(uri="spotify://track/x", image_url=art_url) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.QUEUE_UPDATED, "group-1" + ) + + state = hass.states.get(KITCHEN_ENTITY_ID) + assert state.attributes["entity_picture"] == art_url + + +async def test_dashboard_now_playing_session_title_on_player_rename( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test now_playing session title refreshes when the player is renamed.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + # Kitchen display shows now_playing for Test Player 1 + state = hass.states.get(KITCHEN_ENTITY_ID) + assert state.attributes[ATTR_MEDIA_TITLE] == "Now playing: Test Player 1" + + # Player is renamed + music_assistant_client.players._players["00:00:00:00:00:01"] = dataclasses.replace( + music_assistant_client.players._players["00:00:00:00:00:01"], + name="Renamed Player", + ) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_UPDATED, "00:00:00:00:00:01" + ) + + # Title should reflect the new player name + state = hass.states.get(KITCHEN_ENTITY_ID) + assert state.attributes[ATTR_MEDIA_TITLE] == "Now playing: Renamed Player" + + +async def test_dashboard_session_transition_now_playing_remote_to_party( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test switching from a now_playing session with remote art to a party session. + + Regression test for _clear_media_image: without resetting + media_image_remotely_accessible, entity_picture would incorrectly + short-circuit to the (now None) media_image_url instead of falling + through to the local proxy serving the party icon. + """ + setup_dashboards(music_assistant_client) + _mock_provider_icon(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + art_url = "https://example.com/remote-art.jpg" + music_assistant_client.players._players[ + "00:00:00:00:00:01" + ].current_media = PlayerMedia(uri="spotify://track/x", image_url=art_url) + await trigger_subscription_callback( + hass, music_assistant_client, EventType.QUEUE_UPDATED, "00:00:00:00:00:01" + ) + state = hass.states.get(KITCHEN_ENTITY_ID) + assert state.attributes["entity_picture"] == art_url + + music_assistant_client.dashboard._sessions["chromecast_kitchen"] = DashboardSession( + dashboard_id="chromecast_kitchen", + name="Kitchen Display", + dashboard=DashboardType.PARTY, + ) + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.DASHBOARD_SESSIONS_UPDATED, + data=_sessions_event_data(music_assistant_client), + ) + + state = hass.states.get(KITCHEN_ENTITY_ID) + assert state.attributes["entity_picture"].startswith( + f"/api/media_player_proxy/{KITCHEN_ENTITY_ID}?" + ) + + +async def test_dashboard_browse_media_unknown_content_id( + hass: HomeAssistant, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test browsing an id that isn't a known dashboard or the now_playing folder.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": KITCHEN_ENTITY_ID, + "media_content_type": "dashboard", + "media_content_id": "not_a_real_id", + } + ) + response = await client.receive_json() + assert response["success"] is False + assert response["error"]["code"] == "unknown_error" + assert response["error"]["message"] == "The media not_a_real_id was not found" + + +async def test_dashboard_browse_media_display_gone_from_cache( + hass: HomeAssistant, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test browsing a display that dropped out of the dashboard cache. + + The browse websocket command fetches the entity directly and does not + filter on availability, so this can be hit on a still-registered but + unavailable display. + """ + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + client = await hass_ws_client(hass) + + del music_assistant_client.dashboard._dashboards["chromecast_kitchen"] + + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": KITCHEN_ENTITY_ID, + } + ) + response = await client.receive_json() + assert response["success"] is False + assert response["error"]["code"] == "unknown_error" + assert ( + response["error"]["message"] + == "The display chromecast_kitchen is not available" + ) + + +async def test_dashboard_browse_media_now_playing_folder_unsupported( + hass: HomeAssistant, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test browsing the now_playing folder directly on a display that lacks it.""" + setup_dashboards(music_assistant_client) + music_assistant_client.dashboard._dashboards["party_only_display"] = ( + DashboardDevice( + dashboard_id="party_only_display", + name="Party Only Display", + supported_types={DashboardType.PARTY}, + ) + ) + await setup_integration_from_fixtures(hass, music_assistant_client) + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.party_only_display", + "media_content_type": "dashboard", + "media_content_id": "now_playing", + } + ) + response = await client.receive_json() + assert response["success"] is False + assert response["error"]["code"] == "unknown_error" + assert response["error"]["message"] == "The media now_playing was not found" + + +async def test_dashboard_media_player_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + music_assistant_client: MagicMock, +) -> None: + """Test dashboard display media player entities against a snapshot.""" + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + snapshot_music_assistant_entities( + hass, + entity_registry, + snapshot, + Platform.MEDIA_PLAYER, + unique_id_prefix=DASHBOARD_ID_PREFIX, + ) + + +async def test_platform_entity_service_rejects_display( + hass: HomeAssistant, music_assistant_client: MagicMock +) -> None: + """Test a player-only platform entity service fails cleanly on a display. + + These services (play_media, play_announcement, transfer_queue, + get_queue) are registered for every music_assistant media_player + entity; a display doesn't implement them and must be rejected up + front, not crash with an AttributeError. + """ + setup_dashboards(music_assistant_client) + await setup_integration_from_fixtures(hass, music_assistant_client) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_ANNOUNCEMENT, + { + ATTR_ENTITY_ID: KITCHEN_ENTITY_ID, + ATTR_URL: "http://blah.com/announcement.mp3", + }, + blocking=True, + ) + + +async def test_platform_entity_service_skips_display_indirect_target( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + music_assistant_client: MagicMock, +) -> None: + """Test a display targeted indirectly is skipped rather than rejected. + + Naming the display's entity directly raises (see + test_platform_entity_service_rejects_display); targeting it indirectly, + e.g. through a device or area that also holds a supported entity, must + instead silently skip it while still servicing the supported one. + """ + setup_dashboards(music_assistant_client) + config_entry = await setup_integration_from_fixtures(hass, music_assistant_client) + + player_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "00:00:00:00:00:01"), config_entry.entry_id + ) + display_device = device_registry.async_get_device_by_identifier( + (DOMAIN, "dashboard:chromecast_kitchen"), config_entry.entry_id + ) + assert player_device + assert display_device + + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_ANNOUNCEMENT, + { + ATTR_DEVICE_ID: [player_device.id, display_device.id], + ATTR_URL: "http://blah.com/announcement.mp3", + }, + blocking=True, + ) + + assert music_assistant_client.send_command.call_args == call( + "players/cmd/play_announcement", + require_schema=None, + player_id="00:00:00:00:00:01", + url="http://blah.com/announcement.mp3", + pre_announce=None, + volume_level=None, + pre_announce_url=None, + message=None, + tts_engine=None, + ) diff --git a/tests/components/music_assistant/test_init.py b/tests/components/music_assistant/test_init.py index 51496fad1955..f43036710888 100644 --- a/tests/components/music_assistant/test_init.py +++ b/tests/components/music_assistant/test_init.py @@ -2,11 +2,13 @@ from unittest.mock import AsyncMock, MagicMock -from music_assistant_models.enums import EventType +from music_assistant_models.dashboard import DashboardDevice +from music_assistant_models.enums import DashboardType, EventType from music_assistant_models.errors import ActionUnavailable, AuthenticationRequired from homeassistant.components.music_assistant.const import ( ATTR_CONF_EXPOSE_PLAYER_TO_HA, + DASHBOARD_DEVICE_MODEL, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState @@ -18,7 +20,11 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component -from .common import setup_integration_from_fixtures, trigger_subscription_callback +from .common import ( + setup_dashboards, + setup_integration_from_fixtures, + trigger_subscription_callback, +) from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -78,6 +84,135 @@ async def test_remove_config_entry_device( assert response["success"] is True +async def test_remove_dashboard_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test dashboard device removal is refused while the endpoint is live.""" + assert await async_setup_component(hass, "config", {}) + setup_dashboards(music_assistant_client) + config_entry = await setup_integration_from_fixtures(hass, music_assistant_client) + client = await hass_ws_client(hass) + + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, "dashboard:chromecast_kitchen"), config_entry.entry_id + ) + assert device_entry + + # the endpoint is still live - removal must be refused + response = await client.remove_device(device_entry.id) + assert response["success"] is False + assert device_registry.async_get(device_entry.id) + + # the endpoint is gone from the server for good - removal is now allowed + del music_assistant_client.dashboard._dashboards["chromecast_kitchen"] + response = await client.remove_device(device_entry.id) + assert response["success"] is True + assert not device_registry.async_get(device_entry.id) + + # it comes back online later, without HA ever reloading in between - + # its entity must not stay missing (regression for a "ghost" bug) + music_assistant_client.dashboard._dashboards["chromecast_kitchen"] = ( + DashboardDevice( + dashboard_id="chromecast_kitchen", + name="Kitchen Display", + supported_types={DashboardType.PARTY, DashboardType.NOW_PLAYING}, + provider_domain_hint="chromecast", + ) + ) + await trigger_subscription_callback( + hass, + music_assistant_client, + EventType.DASHBOARDS_UPDATED, + data=[ + dashboard.to_dict() + for dashboard in music_assistant_client.dashboard._dashboards.values() + ], + ) + assert hass.states.get("media_player.kitchen_display") + + +async def test_dashboard_device_survives_reload_with_empty_cache( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + music_assistant_client: MagicMock, +) -> None: + """Test dashboard devices survive a reload with a momentarily empty cache. + + Regression test: dashboard registrations are connection-scoped, so right + after an MA server restart + HA entry reload the dashboard cache can be + empty before the physical endpoints have re-registered. The startup + stale-device cleanup must not treat that as "gone for good". + """ + setup_dashboards(music_assistant_client) + config_entry = await setup_integration_from_fixtures(hass, music_assistant_client) + assert device_registry.async_get_device_by_identifier( + (DOMAIN, "dashboard:chromecast_kitchen"), config_entry.entry_id + ) + + # simulate an MA server restart: the dashboard cache is empty again, + # as if nothing had re-registered yet + music_assistant_client.dashboard._dashboards = {} + music_assistant_client.dashboard._sessions = {} + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert device_registry.async_get_device_by_identifier( + (DOMAIN, "dashboard:chromecast_kitchen"), config_entry.entry_id + ) + state = hass.states.get("media_player.kitchen_display") + assert state + assert state.state == "unavailable" + + +async def test_dashboard_device_id_namespaced( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + music_assistant_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a dashboard endpoint sharing a player's id gets its own device. + + Fully Kiosk registers dashboard_id == player_id. If the dashboard + device identifier were not namespaced, the display would merge into + the player's device, and removing the player would delete the display. + """ + assert await async_setup_component(hass, "config", {}) + setup_dashboards(music_assistant_client) + collision_id = "00:00:00:00:00:01" + music_assistant_client.dashboard._dashboards[collision_id] = DashboardDevice( + dashboard_id=collision_id, + name="Player Display", + supported_types={DashboardType.PARTY}, + ) + config_entry = await setup_integration_from_fixtures(hass, music_assistant_client) + client = await hass_ws_client(hass) + + player_device = device_registry.async_get_device_by_identifier( + (DOMAIN, collision_id), config_entry.entry_id + ) + dashboard_device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"dashboard:{collision_id}"), config_entry.entry_id + ) + assert player_device + assert dashboard_device + assert player_device.id != dashboard_device.id + assert player_device.model != DASHBOARD_DEVICE_MODEL + assert dashboard_device.model == DASHBOARD_DEVICE_MODEL + + # removing the player device must not be refused because a dashboard + # endpoint happens to be live under the same bare id, and must not + # touch the (separate) display device + music_assistant_client.config.remove_player_config = AsyncMock() + response = await client.remove_device(player_device.id) + assert response["success"] is True + await hass.async_block_till_done() + assert not device_registry.async_get(player_device.id) + assert device_registry.async_get(dashboard_device.id) + + async def test_player_config_expose_to_ha_toggle( hass: HomeAssistant, device_registry: dr.DeviceRegistry,