From d16f08847e383382dbc42a9f38670d7b28463db5 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 26 May 2025 21:53:44 +0200 Subject: [PATCH] Convert to button entity --- .../components/music_assistant/__init__.py | 35 ++++- .../components/music_assistant/button.py | 54 +++++++ .../components/music_assistant/helpers.py | 28 ++++ .../components/music_assistant/icons.json | 10 +- .../music_assistant/media_player.py | 72 ++------- .../components/music_assistant/services.yaml | 6 - .../components/music_assistant/strings.json | 11 +- .../snapshots/test_button.ambr | 145 ++++++++++++++++++ .../components/music_assistant/test_button.py | 48 ++++++ .../music_assistant/test_media_player.py | 40 ----- 10 files changed, 333 insertions(+), 116 deletions(-) create mode 100644 homeassistant/components/music_assistant/button.py create mode 100644 homeassistant/components/music_assistant/helpers.py create mode 100644 tests/components/music_assistant/snapshots/test_button.ambr create mode 100644 tests/components/music_assistant/test_button.py diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index a2d2dae9e3f..32024c5ad13 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -3,7 +3,8 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, field from typing import TYPE_CHECKING from music_assistant_client import MusicAssistantClient @@ -31,7 +32,7 @@ if TYPE_CHECKING: from homeassistant.helpers.typing import ConfigType -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 @@ -39,6 +40,7 @@ LISTEN_READY_TIMEOUT = 30 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] +type PlayerAddCallback = Callable[[str], None] @dataclass @@ -47,6 +49,8 @@ class MusicAssistantEntryData: mass: MusicAssistantClient listen_task: asyncio.Task + discovered_players: set[str] = field(default_factory=set) + platform_handlers: dict[Platform, PlayerAddCallback] = field(default_factory=dict) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -122,6 +126,33 @@ async def async_setup_entry( # initialize platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # register listener for new players + async def handle_player_added(event: MassEvent) -> None: + """Handle Mass Player Added event.""" + if TYPE_CHECKING: + assert event.object_id is not None + if event.object_id in entry.runtime_data.discovered_players: + return + player = mass.players.get(event.object_id) + if TYPE_CHECKING: + assert player is not None + if not player.expose_to_ha: + return + entry.runtime_data.discovered_players.add(event.object_id) + # run callback for each platform + for callback in entry.runtime_data.platform_handlers.values(): + callback(event.object_id) + + entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED)) + + # add all current players + for player in mass.players: + if not player.expose_to_ha: + continue + entry.runtime_data.discovered_players.add(player.player_id) + for callback in entry.runtime_data.platform_handlers.values(): + callback(player.player_id) + # register listener for removed players async def handle_player_removed(event: MassEvent) -> None: """Handle Mass Player Removed event.""" diff --git a/homeassistant/components/music_assistant/button.py b/homeassistant/components/music_assistant/button.py new file mode 100644 index 00000000000..a7f88e9ad9a --- /dev/null +++ b/homeassistant/components/music_assistant/button.py @@ -0,0 +1,54 @@ +"""Music Assistant Button platform.""" + +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MusicAssistantConfigEntry +from .entity import MusicAssistantEntity +from .helpers import catch_musicassistant_error + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MusicAssistantConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Music Assistant MediaPlayer(s) from Config Entry.""" + mass = entry.runtime_data.mass + + def add_player(player_id: str) -> None: + """Handle add player.""" + async_add_entities( + [ + # Add button entity to favorite the currently playing item on the player + MusicAssistantFavoriteButton(mass, player_id) + ] + ) + + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.BUTTON, add_player) + + +class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity): + """Representation of a Button entity to favorite the currently playing item on a player.""" + + entity_description = ButtonEntityDescription( + key="favorite_now_playing", + translation_key="favorite_now_playing", + icon="mdi:heart", + ) + + @property + def available(self) -> bool: + """Return availability of entity.""" + # mark the button as unavailable if the player has no current media item + return super().available and self.player.current_media is not None + + @catch_musicassistant_error + async def async_press(self) -> None: + """Handle the button press command.""" + await self.mass.players.add_currently_playing_to_favorites(self.player_id) diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py new file mode 100644 index 00000000000..b228e99f76f --- /dev/null +++ b/homeassistant/components/music_assistant/helpers.py @@ -0,0 +1,28 @@ +"""Helpers for the Music Assistant integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +import functools +from typing import Any + +from music_assistant_models.errors import MusicAssistantError + +from homeassistant.exceptions import HomeAssistantError + + +def catch_musicassistant_error[**_P, _R]( + func: Callable[_P, Coroutine[Any, Any, _R]], +) -> Callable[_P, Coroutine[Any, Any, _R]]: + """Check and convert commands to players.""" + + @functools.wraps(func) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + """Catch Music Assistant errors and convert to Home Assistant error.""" + try: + return await func(*args, **kwargs) + except MusicAssistantError as err: + error_msg = str(err) or err.__class__.__name__ + raise HomeAssistantError(error_msg) from err + + return wrapper diff --git a/homeassistant/components/music_assistant/icons.json b/homeassistant/components/music_assistant/icons.json index 4eb1a05a427..24c6eb2a202 100644 --- a/homeassistant/components/music_assistant/icons.json +++ b/homeassistant/components/music_assistant/icons.json @@ -1,11 +1,17 @@ { + "entity": { + "button": { + "favorite_now_playing": { + "default": "mdi:heart-plus" + } + } + }, "services": { "play_media": { "service": "mdi:play" }, "play_announcement": { "service": "mdi:bullhorn" }, "transfer_queue": { "service": "mdi:transfer" }, "search": { "service": "mdi:magnify" }, "get_queue": { "service": "mdi:playlist-music" }, - "get_library": { "service": "mdi:music-box-multiple" }, - "add_currently_playing_to_favorites": { "service": "mdi:heart-plus" } + "get_library": { "service": "mdi:music-box-multiple" } } } diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index b0bce6d83b8..8d4e69bf082 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -3,11 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine, Mapping +from collections.abc import Mapping from contextlib import suppress -import functools import os -from typing import TYPE_CHECKING, Any, Concatenate +from typing import TYPE_CHECKING, Any from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( @@ -18,7 +17,7 @@ from music_assistant_models.enums import ( QueueOption, RepeatMode as MassRepeatMode, ) -from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError +from music_assistant_models.errors import MediaNotFoundError from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType, Track from music_assistant_models.player_queue import PlayerQueue @@ -40,7 +39,7 @@ from homeassistant.components.media_player import ( SearchMediaQuery, async_process_play_media_url, ) -from homeassistant.const import ATTR_NAME, STATE_OFF +from homeassistant.const import ATTR_NAME, STATE_OFF, Platform from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -76,6 +75,7 @@ from .const import ( DOMAIN, ) from .entity import MusicAssistantEntity +from .helpers import catch_musicassistant_error from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item @@ -118,26 +118,6 @@ SERVICE_PLAY_MEDIA_ADVANCED = "play_media" SERVICE_PLAY_ANNOUNCEMENT = "play_announcement" SERVICE_TRANSFER_QUEUE = "transfer_queue" SERVICE_GET_QUEUE = "get_queue" -SERVICE_ADD_FAVORITE = "add_currently_playing_to_favorites" - - -def catch_musicassistant_error[_R, **P]( - func: Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]], -) -> Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]]: - """Check and log commands to players.""" - - @functools.wraps(func) - async def wrapper( - self: MusicAssistantPlayer, *args: P.args, **kwargs: P.kwargs - ) -> _R: - """Catch Music Assistant errors and convert to Home Assistant error.""" - try: - return await func(self, *args, **kwargs) - except MusicAssistantError as err: - error_msg = str(err) or err.__class__.__name__ - raise HomeAssistantError(error_msg) from err - - return wrapper async def async_setup_entry( @@ -147,33 +127,13 @@ async def async_setup_entry( ) -> None: """Set up Music Assistant MediaPlayer(s) from Config Entry.""" mass = entry.runtime_data.mass - added_ids = set() - async def handle_player_added(event: MassEvent) -> None: - """Handle Mass Player Added event.""" - if TYPE_CHECKING: - assert event.object_id is not None - if event.object_id in added_ids: - return - player = mass.players.get(event.object_id) - if TYPE_CHECKING: - assert player is not None - if not player.expose_to_ha: - return - added_ids.add(event.object_id) - async_add_entities([MusicAssistantPlayer(mass, event.object_id)]) + def add_player(player_id: str) -> None: + """Handle add player.""" + async_add_entities([MusicAssistantPlayer(mass, player_id)]) - # register listener for new players - entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED)) - mass_players = [] - # add all current players - for player in mass.players: - if not player.expose_to_ha: - continue - added_ids.add(player.player_id) - mass_players.append(MusicAssistantPlayer(mass, player.player_id)) - - async_add_entities(mass_players) + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.MEDIA_PLAYER, add_player) # add platform service for play_media with advanced options platform = async_get_current_platform() @@ -212,11 +172,6 @@ async def async_setup_entry( func="_async_handle_get_queue", supports_response=SupportsResponse.ONLY, ) - platform.async_register_entity_service( - SERVICE_ADD_FAVORITE, - schema=None, - func="_async_handle_add_currently_playing_to_favorites", - ) class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): @@ -617,13 +572,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): ) return response - @catch_musicassistant_error - async def _async_handle_add_currently_playing_to_favorites(self) -> None: - """Handle action to add currently playing item to favorites.""" - await self.mass.players.add_currently_playing_to_favorites( - self.player_id, - ) - async def async_browse_media( self, media_content_type: MediaType | str | None = None, diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index d0da33f6da7..a3715ea2580 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -100,12 +100,6 @@ get_queue: supported_features: - media_player.MediaPlayerEntityFeature.PLAY_MEDIA -add_currently_playing_to_favorites: - target: - entity: - domain: media_player - integration: music_assistant - search: fields: config_entry_id: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index ad5c83cd743..c41bfa70d4c 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -31,6 +31,13 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, + "entity": { + "button": { + "favorite_now_playing": { + "name": "Favorite current song" + } + } + }, "issues": { "invalid_server_version": { "title": "The Music Assistant server is not the correct version", @@ -104,10 +111,6 @@ "name": "Get playerQueue details (advanced)", "description": "Retrieves the details of the currently active queue of a Music Assistant player." }, - "add_currently_playing_to_favorites": { - "name": "Add currently playing to favorites", - "description": "Adds the currently playing item on a Music Assistant player to the favorites." - }, "search": { "name": "Search Music Assistant", "description": "Performs a global search on the Music Assistant library and all providers.", diff --git a/tests/components/music_assistant/snapshots/test_button.ambr b/tests/components/music_assistant/snapshots/test_button.ambr new file mode 100644 index 00000000000..d128b0ad24e --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_button.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_button_entities[button.my_super_test_player_2_favorite_current_song-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.my_super_test_player_2_favorite_current_song', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:heart', + 'original_name': 'Favorite current song', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'favorite_now_playing', + 'unique_id': '00:00:00:00:00:02_favorite_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities[button.my_super_test_player_2_favorite_current_song-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Super Test Player 2 Favorite current song', + 'icon': 'mdi:heart', + }), + 'context': , + 'entity_id': 'button.my_super_test_player_2_favorite_current_song', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_entities[button.test_group_player_1_favorite_current_song-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_group_player_1_favorite_current_song', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:heart', + 'original_name': 'Favorite current song', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'favorite_now_playing', + 'unique_id': 'test_group_player_1_favorite_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities[button.test_group_player_1_favorite_current_song-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Group Player 1 Favorite current song', + 'icon': 'mdi:heart', + }), + 'context': , + 'entity_id': 'button.test_group_player_1_favorite_current_song', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_entities[button.test_player_1_favorite_current_song-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_player_1_favorite_current_song', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:heart', + 'original_name': 'Favorite current song', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'favorite_now_playing', + 'unique_id': '00:00:00:00:00:01_favorite_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities[button.test_player_1_favorite_current_song-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Player 1 Favorite current song', + 'icon': 'mdi:heart', + }), + 'context': , + 'entity_id': 'button.test_player_1_favorite_current_song', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/music_assistant/test_button.py b/tests/components/music_assistant/test_button.py new file mode 100644 index 00000000000..d64bb43e2de --- /dev/null +++ b/tests/components/music_assistant/test_button.py @@ -0,0 +1,48 @@ +"""Test Music Assistant button entities.""" + +from unittest.mock import MagicMock, call + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities + + +async def test_button_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + music_assistant_client: MagicMock, +) -> None: + """Test media player.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + snapshot_music_assistant_entities(hass, entity_registry, snapshot, Platform.BUTTON) + + +async def test_button_press_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test button press action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "button.my_super_test_player_2_favorite_current_song" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "music/favorites/add_item", + item="spotify://track/5d95dc5be77e4f7eb4939f62cfef527b", + ) diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index a03e1b5a7d5..eb1e64485c4 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -42,7 +42,6 @@ from homeassistant.components.music_assistant.media_player import ( ATTR_SOURCE_PLAYER, ATTR_URL, ATTR_USE_PRE_ANNOUNCE, - SERVICE_ADD_FAVORITE, SERVICE_GET_QUEUE, SERVICE_PLAY_ANNOUNCEMENT, SERVICE_PLAY_MEDIA_ADVANCED, @@ -648,45 +647,6 @@ async def test_media_player_select_source_action( ) -async def test_media_player_add_currently_playing_to_favorites_action( - hass: HomeAssistant, - music_assistant_client: MagicMock, -) -> None: - """Test media_player add_currently_playing_to_favorites action.""" - await setup_integration_from_fixtures(hass, music_assistant_client) - - entity_id = "media_player.my_super_test_player_2" - state = hass.states.get(entity_id) - assert state - await hass.services.async_call( - MASS_DOMAIN, - SERVICE_ADD_FAVORITE, - { - ATTR_ENTITY_ID: entity_id, - }, - blocking=True, - ) - assert music_assistant_client.send_command.call_count == 1 - assert music_assistant_client.send_command.call_args == call( - "music/favorites/add_item", - item="spotify://track/5d95dc5be77e4f7eb4939f62cfef527b", - ) - - # repeat test with a player that has no current item - entity_id = "media_player.test_player_1" - state = hass.states.get(entity_id) - assert state - with pytest.raises(HomeAssistantError, match="No current item to add to favorites"): - await hass.services.async_call( - MASS_DOMAIN, - SERVICE_ADD_FAVORITE, - { - ATTR_ENTITY_ID: entity_id, - }, - blocking=True, - ) - - async def test_media_player_supported_features( hass: HomeAssistant, music_assistant_client: MagicMock,