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
8 changed files with 331 additions and 51 deletions

View File

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

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": {
"play_media": { "service": "mdi:play" },
"play_announcement": { "service": "mdi:bullhorn" },

View File

@ -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
@ -120,25 +120,6 @@ SERVICE_TRANSFER_QUEUE = "transfer_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(
hass: HomeAssistant,
entry: MusicAssistantConfigEntry,
@ -146,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()

View File

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

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",
)