Add button entity to Music Assistant to add currently playing item to favorites (#145626)

* Add action to Music Assistant to add currently playing item to favorites

* add test

* Convert to button entity

* review comments

* Update test_button.ambr

* Fix

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
Marcel van der Veldt 2025-06-23 20:39:46 +02:00 committed by GitHub
parent e494f66c02
commit 673a2e35ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 331 additions and 51 deletions

View File

@ -3,7 +3,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from music_assistant_client import MusicAssistantClient from music_assistant_client import MusicAssistantClient
@ -31,7 +32,7 @@ if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
PLATFORMS = [Platform.MEDIA_PLAYER] PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
CONNECT_TIMEOUT = 10 CONNECT_TIMEOUT = 10
LISTEN_READY_TIMEOUT = 30 LISTEN_READY_TIMEOUT = 30
@ -39,6 +40,7 @@ LISTEN_READY_TIMEOUT = 30
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData]
type PlayerAddCallback = Callable[[str], None]
@dataclass @dataclass
@ -47,6 +49,8 @@ class MusicAssistantEntryData:
mass: MusicAssistantClient mass: MusicAssistantClient
listen_task: asyncio.Task 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: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@ -122,6 +126,33 @@ async def async_setup_entry(
# initialize platforms # initialize platforms
await hass.config_entries.async_forward_entry_setups(entry, 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 # register listener for removed players
async def handle_player_removed(event: MassEvent) -> None: async def handle_player_removed(event: MassEvent) -> None:
"""Handle Mass Player Removed event.""" """Handle Mass Player Removed event."""

View File

@ -0,0 +1,53 @@
"""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",
)
@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)

View File

@ -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

View File

@ -1,4 +1,11 @@
{ {
"entity": {
"button": {
"favorite_now_playing": {
"default": "mdi:heart-plus"
}
}
},
"services": { "services": {
"play_media": { "service": "mdi:play" }, "play_media": { "service": "mdi:play" },
"play_announcement": { "service": "mdi:bullhorn" }, "play_announcement": { "service": "mdi:bullhorn" },

View File

@ -3,11 +3,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable, Coroutine, Mapping from collections.abc import Mapping
from contextlib import suppress from contextlib import suppress
import functools
import os 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.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import ( from music_assistant_models.enums import (
@ -18,7 +17,7 @@ from music_assistant_models.enums import (
QueueOption, QueueOption,
RepeatMode as MassRepeatMode, 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.event import MassEvent
from music_assistant_models.media_items import ItemMapping, MediaItemType, Track from music_assistant_models.media_items import ItemMapping, MediaItemType, Track
from music_assistant_models.player_queue import PlayerQueue from music_assistant_models.player_queue import PlayerQueue
@ -40,7 +39,7 @@ from homeassistant.components.media_player import (
SearchMediaQuery, SearchMediaQuery,
async_process_play_media_url, 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.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers import config_validation as cv, entity_registry as er
@ -76,6 +75,7 @@ from .const import (
DOMAIN, DOMAIN,
) )
from .entity import MusicAssistantEntity from .entity import MusicAssistantEntity
from .helpers import catch_musicassistant_error
from .media_browser import async_browse_media, async_search_media from .media_browser import async_browse_media, async_search_media
from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item
@ -120,25 +120,6 @@ SERVICE_TRANSFER_QUEUE = "transfer_queue"
SERVICE_GET_QUEUE = "get_queue" SERVICE_GET_QUEUE = "get_queue"
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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: MusicAssistantConfigEntry, entry: MusicAssistantConfigEntry,
@ -146,33 +127,13 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Music Assistant MediaPlayer(s) from Config Entry.""" """Set up Music Assistant MediaPlayer(s) from Config Entry."""
mass = entry.runtime_data.mass mass = entry.runtime_data.mass
added_ids = set()
async def handle_player_added(event: MassEvent) -> None: def add_player(player_id: str) -> None:
"""Handle Mass Player Added event.""" """Handle add player."""
if TYPE_CHECKING: async_add_entities([MusicAssistantPlayer(mass, player_id)])
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)])
# register listener for new players # register callback to add players when they are discovered
entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED)) entry.runtime_data.platform_handlers.setdefault(Platform.MEDIA_PLAYER, add_player)
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)
# add platform service for play_media with advanced options # add platform service for play_media with advanced options
platform = async_get_current_platform() platform = async_get_current_platform()

View File

@ -31,6 +31,13 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
} }
}, },
"entity": {
"button": {
"favorite_now_playing": {
"name": "Favorite current song"
}
}
},
"issues": { "issues": {
"invalid_server_version": { "invalid_server_version": {
"title": "The Music Assistant server is not the correct version", "title": "The Music Assistant server is not the correct version",

View File

@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Favorite current song',
'platform': 'music_assistant',
'previous_unique_id': None,
'suggested_object_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',
}),
'context': <ANY>,
'entity_id': 'button.my_super_test_player_2_favorite_current_song',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Favorite current song',
'platform': 'music_assistant',
'previous_unique_id': None,
'suggested_object_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',
}),
'context': <ANY>,
'entity_id': 'button.test_group_player_1_favorite_current_song',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Favorite current song',
'platform': 'music_assistant',
'previous_unique_id': None,
'suggested_object_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',
}),
'context': <ANY>,
'entity_id': 'button.test_player_1_favorite_current_song',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
})
# ---

View File

@ -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, SERVICE_PRESS
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,
SERVICE_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",
)