From 3485ce9c71c05d1c902d5cd40f0a789657280ea8 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 27 Nov 2024 17:43:48 +0100 Subject: [PATCH] Add actions to Music Assistant integration (#129515) Co-authored-by: Franck Nijhof Co-authored-by: Martin Hjelmare --- .../components/music_assistant/__init__.py | 7 +- .../components/music_assistant/icons.json | 7 + .../music_assistant/media_player.py | 80 +++++- .../components/music_assistant/services.yaml | 90 ++++++ .../components/music_assistant/strings.json | 73 +++++ .../music_assistant/test_media_player.py | 258 +++++++++++++++++- 6 files changed, 509 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/music_assistant/icons.json create mode 100644 homeassistant/components/music_assistant/services.yaml diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 9f0fc1aad27..22de510ebe3 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -28,13 +28,13 @@ from .const import DOMAIN, LOGGER if TYPE_CHECKING: from music_assistant_models.event import MassEvent -type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] - PLATFORMS = [Platform.MEDIA_PLAYER] CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 +type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] + @dataclass class MusicAssistantEntryData: @@ -47,7 +47,7 @@ class MusicAssistantEntryData: async def async_setup_entry( hass: HomeAssistant, entry: MusicAssistantConfigEntry ) -> bool: - """Set up from a config entry.""" + """Set up Music Assistant from a config entry.""" http_session = async_get_clientsession(hass, verify_ssl=False) mass_url = entry.data[CONF_URL] mass = MusicAssistantClient(mass_url, http_session) @@ -97,6 +97,7 @@ async def async_setup_entry( listen_task.cancel() raise ConfigEntryNotReady("Music Assistant client not ready") from err + # store the listen task and mass client in the entry data entry.runtime_data = MusicAssistantEntryData(mass, listen_task) # If the listen task is already failed, we need to raise ConfigEntryNotReady diff --git a/homeassistant/components/music_assistant/icons.json b/homeassistant/components/music_assistant/icons.json new file mode 100644 index 00000000000..7533dbb6dad --- /dev/null +++ b/homeassistant/components/music_assistant/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "play_media": { "service": "mdi:play" }, + "play_announcement": { "service": "mdi:bullhorn" }, + "transfer_queue": { "service": "mdi:transfer" } + } +} diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 8789bb36d33..07d6ddeee03 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -13,15 +13,18 @@ from music_assistant_models.enums import ( EventType, MediaType, PlayerFeature, + PlayerState as MassPlayerState, QueueOption, RepeatMode as MassRepeatMode, ) from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType, Track +import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( + ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_EXTRA, BrowseMedia, MediaPlayerDeviceClass, @@ -37,7 +40,11 @@ from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.util.dt import utc_from_timestamp from . import MusicAssistantConfigEntry @@ -79,6 +86,9 @@ QUEUE_OPTION_MAP = { MediaPlayerEnqueue.REPLACE: QueueOption.REPLACE, } +SERVICE_PLAY_MEDIA_ADVANCED = "play_media" +SERVICE_PLAY_ANNOUNCEMENT = "play_announcement" +SERVICE_TRANSFER_QUEUE = "transfer_queue" ATTR_RADIO_MODE = "radio_mode" ATTR_MEDIA_ID = "media_id" ATTR_MEDIA_TYPE = "media_type" @@ -138,6 +148,38 @@ async def async_setup_entry( async_add_entities(mass_players) + # add platform service for play_media with advanced options + platform = async_get_current_platform() + platform.async_register_entity_service( + SERVICE_PLAY_MEDIA_ADVANCED, + { + vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), + vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption), + vol.Optional(ATTR_ARTIST): cv.string, + vol.Optional(ATTR_ALBUM): cv.string, + vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool), + }, + "_async_handle_play_media", + ) + platform.async_register_entity_service( + SERVICE_PLAY_ANNOUNCEMENT, + { + vol.Required(ATTR_URL): cv.string, + vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool), + vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int), + }, + "_async_handle_play_announcement", + ) + platform.async_register_entity_service( + SERVICE_TRANSFER_QUEUE, + { + vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id, + vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool), + }, + "_async_handle_transfer_queue", + ) + class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): """Representation of MediaPlayerEntity from Music Assistant Player.""" @@ -376,6 +418,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): async def _async_handle_play_media( self, media_id: list[str], + artist: str | None = None, + album: str | None = None, enqueue: MediaPlayerEnqueue | QueueOption | None = None, radio_mode: bool | None = None, media_type: str | None = None, @@ -402,6 +446,14 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): elif await asyncio.to_thread(os.path.isfile, media_id_str): media_uris.append(media_id_str) continue + # last resort: search for media item by name/search + if item := await self.mass.music.get_item_by_name( + name=media_id_str, + artist=artist, + album=album, + media_type=MediaType(media_type) if media_type else None, + ): + media_uris.append(item.uri) if not media_uris: raise HomeAssistantError( @@ -435,6 +487,32 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self.player_id, url, use_pre_announce, announce_volume ) + @catch_musicassistant_error + async def _async_handle_transfer_queue( + self, source_player: str | None = None, auto_play: bool | None = None + ) -> None: + """Transfer the current queue to another player.""" + if not source_player: + # no source player given; try to find a playing player(queue) + for queue in self.mass.player_queues: + if queue.state == MassPlayerState.PLAYING: + source_queue_id = queue.queue_id + break + else: + raise HomeAssistantError( + "Source player not specified and no playing player found." + ) + else: + # resolve HA entity_id to MA player_id + entity_registry = er.async_get(self.hass) + if (entity := entity_registry.async_get(source_player)) is None: + raise HomeAssistantError("Source player not available.") + source_queue_id = entity.unique_id # unique_id is the MA player_id + target_queue_id = self.player_id + await self.mass.player_queues.transfer_queue( + source_queue_id, target_queue_id, auto_play + ) + 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 new file mode 100644 index 00000000000..00f895c4ef6 --- /dev/null +++ b/homeassistant/components/music_assistant/services.yaml @@ -0,0 +1,90 @@ +# Descriptions for Music Assistant custom services + +play_media: + target: + entity: + domain: media_player + integration: music_assistant + supported_features: + - media_player.MediaPlayerEntityFeature.PLAY_MEDIA + fields: + media_id: + required: true + example: "spotify://playlist/aabbccddeeff" + selector: + object: + media_type: + example: "playlist" + selector: + select: + translation_key: media_type + options: + - artist + - album + - playlist + - track + - radio + artist: + example: "Queen" + selector: + text: + album: + example: "News of the world" + selector: + text: + enqueue: + selector: + select: + options: + - "play" + - "replace" + - "next" + - "replace_next" + - "add" + translation_key: enqueue + radio_mode: + advanced: true + selector: + boolean: + +play_announcement: + target: + entity: + domain: media_player + integration: music_assistant + supported_features: + - media_player.MediaPlayerEntityFeature.PLAY_MEDIA + - media_player.MediaPlayerEntityFeature.MEDIA_ANNOUNCE + fields: + url: + required: true + example: "http://someremotesite.com/doorbell.mp3" + selector: + text: + use_pre_announce: + example: "true" + selector: + boolean: + announce_volume: + example: 75 + selector: + number: + min: 1 + max: 100 + step: 1 + +transfer_queue: + target: + entity: + domain: media_player + integration: music_assistant + fields: + source_player: + selector: + entity: + domain: media_player + integration: music_assistant + auto_play: + example: "true" + selector: + boolean: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index f15b0b1b306..cce7f9607c2 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -37,6 +37,70 @@ "description": "Check if there are updates available for the Music Assistant Server and/or integration." } }, + "services": { + "play_media": { + "name": "Play media", + "description": "Play media on a Music Assistant player with more fine-grained control options.", + "fields": { + "media_id": { + "name": "Media ID(s)", + "description": "URI or name of the item you want to play. Specify a list if you want to play/enqueue multiple items." + }, + "media_type": { + "name": "Media type", + "description": "The type of the content to play. Such as artist, album, track or playlist. Will be auto-determined if omitted." + }, + "enqueue": { + "name": "Enqueue", + "description": "If the content should be played now or added to the queue." + }, + "artist": { + "name": "Artist name", + "description": "When specifying a track or album by name in the Media ID field, you can optionally restrict results by this artist name." + }, + "album": { + "name": "Album name", + "description": "When specifying a track by name in the Media ID field, you can optionally restrict results by this album name." + }, + "radio_mode": { + "name": "Enable radio mode", + "description": "Enable radio mode to auto-generate a playlist based on the selection." + } + } + }, + "play_announcement": { + "name": "Play announcement", + "description": "Play announcement on a Music Assistant player with more fine-grained control options.", + "fields": { + "url": { + "name": "URL", + "description": "URL to the notification sound." + }, + "use_pre_announce": { + "name": "Use pre-announce", + "description": "Use pre-announcement sound for the announcement. Omit to use the player default." + }, + "announce_volume": { + "name": "Announce volume", + "description": "Use a forced volume level for the announcement. Omit to use player default." + } + } + }, + "transfer_queue": { + "name": "Transfer queue", + "description": "Transfer the player's queue to another player.", + "fields": { + "source_player": { + "name": "Source media player", + "description": "The source media player which has the queue you want to transfer. When omitted, the first playing player will be used." + }, + "auto_play": { + "name": "Auto play", + "description": "Start playing the queue on the target player. Omit to use the default behavior." + } + } + } + }, "selector": { "enqueue": { "options": { @@ -46,6 +110,15 @@ "replace": "Play now and clear queue", "replace_next": "Play next and clear queue" } + }, + "media_type": { + "options": { + "artist": "Artist", + "album": "Album", + "track": "Track", + "playlist": "Playlist", + "radio": "Radio" + } } } } diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 2054ce1e6aa..26ed5d1e538 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -2,9 +2,13 @@ from unittest.mock import MagicMock, call +from music_assistant_models.enums import MediaType, QueueOption +from music_assistant_models.media_items import Track +import pytest from syrupy import SnapshotAssertion from homeassistant.components.media_player import ( + ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_REPEAT, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SHUFFLE, @@ -13,6 +17,23 @@ from homeassistant.components.media_player import ( DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, ) +from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN +from homeassistant.components.music_assistant.media_player import ( + ATTR_ALBUM, + ATTR_ANNOUNCE_VOLUME, + ATTR_ARTIST, + ATTR_AUTO_PLAY, + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_RADIO_MODE, + ATTR_SOURCE_PLAYER, + ATTR_URL, + ATTR_USE_PRE_ANNOUNCE, + SERVICE_PLAY_ANNOUNCEMENT, + SERVICE_PLAY_MEDIA_ADVANCED, + SERVICE_TRANSFER_QUEUE, +) +from homeassistant.config_entries import HomeAssistantError from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, @@ -35,6 +56,15 @@ from homeassistant.helpers import entity_registry as er from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from tests.common import AsyncMock + +MOCK_TRACK = Track( + item_id="1", + provider="library", + name="Test Track", + provider_mappings={}, +) + async def test_media_player( hass: HomeAssistant, @@ -110,11 +140,11 @@ async def test_media_player_seek_action( ) -async def test_media_player_volume_action( +async def test_media_player_volume_set_action( hass: HomeAssistant, music_assistant_client: MagicMock, ) -> None: - """Test media_player entity volume action.""" + """Test media_player entity volume_set action.""" await setup_integration_from_fixtures(hass, music_assistant_client) entity_id = "media_player.test_player_1" mass_player_id = "00:00:00:00:00:01" @@ -261,3 +291,227 @@ async def test_media_player_clear_playlist_action( assert music_assistant_client.send_command.call_args == call( "player_queues/clear", queue_id=mass_player_id ) + + +async def test_media_player_play_media_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player (advanced) play_media action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + + # test simple play_media call with URI as media_id and no media type + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=False, + start_item=None, + ) + + # test simple play_media call with URI and enqueue specified + music_assistant_client.send_command.reset_mock() + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + ATTR_MEDIA_ENQUEUE: "add", + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=QueueOption.ADD, + radio_mode=False, + start_item=None, + ) + + # test basic play_media call with URL and radio mode specified + music_assistant_client.send_command.reset_mock() + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "spotify://track/1234", + ATTR_RADIO_MODE: True, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=["spotify://track/1234"], + option=None, + radio_mode=True, + start_item=None, + ) + + # test play_media call with media id and media type specified + music_assistant_client.send_command.reset_mock() + music_assistant_client.music.get_item = AsyncMock(return_value=MOCK_TRACK) + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "1", + ATTR_MEDIA_TYPE: "track", + }, + blocking=True, + ) + assert music_assistant_client.music.get_item.call_count == 1 + assert music_assistant_client.music.get_item.call_args == call( + MediaType.TRACK, "1", "library" + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=[MOCK_TRACK.uri], + option=None, + radio_mode=False, + start_item=None, + ) + + # test play_media call by name + music_assistant_client.send_command.reset_mock() + music_assistant_client.music.get_item_by_name = AsyncMock(return_value=MOCK_TRACK) + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_MEDIA_ADVANCED, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_ID: "test", + ATTR_ARTIST: "artist", + ATTR_ALBUM: "album", + }, + blocking=True, + ) + assert music_assistant_client.music.get_item_by_name.call_count == 1 + assert music_assistant_client.music.get_item_by_name.call_args == call( + name="test", + artist="artist", + album="album", + media_type=None, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/play_media", + queue_id=mass_player_id, + media=[MOCK_TRACK.uri], + option=None, + radio_mode=False, + start_item=None, + ) + + +async def test_media_player_play_announcement_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player play_announcement action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + mass_player_id = "00:00:00:00:00:01" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_PLAY_ANNOUNCEMENT, + { + ATTR_ENTITY_ID: entity_id, + ATTR_URL: "http://blah.com/announcement.mp3", + ATTR_USE_PRE_ANNOUNCE: True, + ATTR_ANNOUNCE_VOLUME: 50, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "players/cmd/play_announcement", + player_id=mass_player_id, + url="http://blah.com/announcement.mp3", + use_pre_announce=True, + volume_level=50, + ) + + +async def test_media_player_transfer_queue_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test media_player transfer_queu action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_player_1" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_TRANSFER_QUEUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SOURCE_PLAYER: "media_player.my_super_test_player_2", + ATTR_AUTO_PLAY: True, + }, + blocking=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "player_queues/transfer", + source_queue_id="00:00:00:00:00:02", + target_queue_id="00:00:00:00:00:01", + auto_play=True, + require_schema=25, + ) + # test again with invalid source player + music_assistant_client.send_command.reset_mock() + with pytest.raises(HomeAssistantError, match="Source player not available."): + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_TRANSFER_QUEUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_SOURCE_PLAYER: "media_player.blah_blah", + }, + blocking=True, + ) + # test again with no source player specified (which picks first playing playerqueue) + music_assistant_client.send_command.reset_mock() + await hass.services.async_call( + MASS_DOMAIN, + SERVICE_TRANSFER_QUEUE, + { + 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( + "player_queues/transfer", + source_queue_id="test_group_player_1", + target_queue_id="00:00:00:00:00:01", + auto_play=None, + require_schema=25, + )