mirror of
https://github.com/home-assistant/core.git
synced 2025-06-24 17:11:53 +02:00
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:
committed by
GitHub
parent
e494f66c02
commit
673a2e35ad
@ -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."""
|
||||
|
53
homeassistant/components/music_assistant/button.py
Normal file
53
homeassistant/components/music_assistant/button.py
Normal 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)
|
28
homeassistant/components/music_assistant/helpers.py
Normal file
28
homeassistant/components/music_assistant/helpers.py
Normal 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
|
@ -1,4 +1,11 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"favorite_now_playing": {
|
||||
"default": "mdi:heart-plus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"play_media": { "service": "mdi:play" },
|
||||
"play_announcement": { "service": "mdi:bullhorn" },
|
||||
|
@ -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()
|
||||
|
@ -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",
|
||||
|
145
tests/components/music_assistant/snapshots/test_button.ambr
Normal file
145
tests/components/music_assistant/snapshots/test_button.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
48
tests/components/music_assistant/test_button.py
Normal file
48
tests/components/music_assistant/test_button.py
Normal 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",
|
||||
)
|
Reference in New Issue
Block a user