From 83f5ca5a303178b5ba2f08b48195c5fcd56f2c2f Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 23 Dec 2024 11:10:10 +0100 Subject: [PATCH] Add actions with response values to Music Assistant (#133521) Co-authored-by: Franck Nijhof Co-authored-by: OzGav Co-authored-by: Joost Lekkerkerker --- .../components/music_assistant/__init__.py | 12 + .../components/music_assistant/actions.py | 212 ++++++++++++++++++ .../components/music_assistant/const.py | 50 +++++ .../components/music_assistant/icons.json | 5 +- .../music_assistant/media_player.py | 73 ++++-- .../components/music_assistant/schemas.py | 182 +++++++++++++++ .../components/music_assistant/services.yaml | 143 ++++++++++++ .../components/music_assistant/strings.json | 111 +++++++++ tests/components/music_assistant/common.py | 3 +- .../snapshots/test_actions.ambr | 202 +++++++++++++++++ .../snapshots/test_media_player.ambr | 85 +++++++ .../music_assistant/test_actions.py | 68 ++++++ .../music_assistant/test_media_player.py | 24 ++ 13 files changed, 1155 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/music_assistant/actions.py create mode 100644 homeassistant/components/music_assistant/schemas.py create mode 100644 tests/components/music_assistant/snapshots/test_actions.ambr create mode 100644 tests/components/music_assistant/test_actions.py diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index 22de510ebe3..052f4f556c1 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -17,22 +17,28 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, async_delete_issue, ) +from .actions import register_actions from .const import DOMAIN, LOGGER if TYPE_CHECKING: from music_assistant_models.event import MassEvent + from homeassistant.helpers.typing import ConfigType + PLATFORMS = [Platform.MEDIA_PLAYER] CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] @@ -44,6 +50,12 @@ class MusicAssistantEntryData: listen_task: asyncio.Task +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Music Assistant component.""" + register_actions(hass) + return True + + async def async_setup_entry( hass: HomeAssistant, entry: MusicAssistantConfigEntry ) -> bool: diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py new file mode 100644 index 00000000000..f3297bf0a6f --- /dev/null +++ b/homeassistant/components/music_assistant/actions.py @@ -0,0 +1,212 @@ +"""Custom actions (previously known as services) for the Music Assistant integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.enums import MediaType +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_ALBUM_ARTISTS_ONLY, + ATTR_ALBUM_TYPE, + ATTR_ALBUMS, + ATTR_ARTISTS, + ATTR_CONFIG_ENTRY_ID, + ATTR_FAVORITE, + ATTR_ITEMS, + ATTR_LIBRARY_ONLY, + ATTR_LIMIT, + ATTR_MEDIA_TYPE, + ATTR_OFFSET, + ATTR_ORDER_BY, + ATTR_PLAYLISTS, + ATTR_RADIO, + ATTR_SEARCH, + ATTR_SEARCH_ALBUM, + ATTR_SEARCH_ARTIST, + ATTR_SEARCH_NAME, + ATTR_TRACKS, + DOMAIN, +) +from .schemas import ( + LIBRARY_RESULTS_SCHEMA, + SEARCH_RESULT_SCHEMA, + media_item_dict_from_mass_item, +) + +if TYPE_CHECKING: + from music_assistant_client import MusicAssistantClient + + from . import MusicAssistantConfigEntry + +SERVICE_SEARCH = "search" +SERVICE_GET_LIBRARY = "get_library" +DEFAULT_OFFSET = 0 +DEFAULT_LIMIT = 25 +DEFAULT_SORT_ORDER = "name" + + +@callback +def get_music_assistant_client( + hass: HomeAssistant, config_entry_id: str +) -> MusicAssistantClient: + """Get the Music Assistant client for the given config entry.""" + entry: MusicAssistantConfigEntry | None + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError("Entry not found") + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError("Entry not loaded") + return entry.runtime_data.mass + + +@callback +def register_actions(hass: HomeAssistant) -> None: + """Register custom actions.""" + hass.services.async_register( + DOMAIN, + SERVICE_SEARCH, + handle_search, + schema=vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_SEARCH_NAME): cv.string, + vol.Optional(ATTR_MEDIA_TYPE): vol.All( + cv.ensure_list, [vol.Coerce(MediaType)] + ), + vol.Optional(ATTR_SEARCH_ARTIST): cv.string, + vol.Optional(ATTR_SEARCH_ALBUM): cv.string, + vol.Optional(ATTR_LIMIT, default=5): vol.Coerce(int), + vol.Optional(ATTR_LIBRARY_ONLY, default=False): cv.boolean, + } + ), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_LIBRARY, + handle_get_library, + schema=vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Required(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), + vol.Optional(ATTR_FAVORITE): cv.boolean, + vol.Optional(ATTR_SEARCH): cv.string, + vol.Optional(ATTR_LIMIT): cv.positive_int, + vol.Optional(ATTR_OFFSET): int, + vol.Optional(ATTR_ORDER_BY): cv.string, + vol.Optional(ATTR_ALBUM_TYPE): list[MediaType], + vol.Optional(ATTR_ALBUM_ARTISTS_ONLY): cv.boolean, + } + ), + supports_response=SupportsResponse.ONLY, + ) + + +async def handle_search(call: ServiceCall) -> ServiceResponse: + """Handle queue_command action.""" + mass = get_music_assistant_client(call.hass, call.data[ATTR_CONFIG_ENTRY_ID]) + search_name = call.data[ATTR_SEARCH_NAME] + search_artist = call.data.get(ATTR_SEARCH_ARTIST) + search_album = call.data.get(ATTR_SEARCH_ALBUM) + if search_album and search_artist: + search_name = f"{search_artist} - {search_album} - {search_name}" + elif search_album: + search_name = f"{search_album} - {search_name}" + elif search_artist: + search_name = f"{search_artist} - {search_name}" + search_results = await mass.music.search( + search_query=search_name, + media_types=call.data.get(ATTR_MEDIA_TYPE, MediaType.ALL), + limit=call.data[ATTR_LIMIT], + library_only=call.data[ATTR_LIBRARY_ONLY], + ) + response: ServiceResponse = SEARCH_RESULT_SCHEMA( + { + ATTR_ARTISTS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.artists + ], + ATTR_ALBUMS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.albums + ], + ATTR_TRACKS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.tracks + ], + ATTR_PLAYLISTS: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.playlists + ], + ATTR_RADIO: [ + media_item_dict_from_mass_item(mass, item) + for item in search_results.radio + ], + } + ) + return response + + +async def handle_get_library(call: ServiceCall) -> ServiceResponse: + """Handle get_library action.""" + mass = get_music_assistant_client(call.hass, call.data[ATTR_CONFIG_ENTRY_ID]) + media_type = call.data[ATTR_MEDIA_TYPE] + limit = call.data.get(ATTR_LIMIT, DEFAULT_LIMIT) + offset = call.data.get(ATTR_OFFSET, DEFAULT_OFFSET) + order_by = call.data.get(ATTR_ORDER_BY, DEFAULT_SORT_ORDER) + base_params = { + "favorite": call.data.get(ATTR_FAVORITE), + "search": call.data.get(ATTR_SEARCH), + "limit": limit, + "offset": offset, + "order_by": order_by, + } + if media_type == MediaType.ALBUM: + library_result = await mass.music.get_library_albums( + **base_params, + album_types=call.data.get(ATTR_ALBUM_TYPE), + ) + elif media_type == MediaType.ARTIST: + library_result = await mass.music.get_library_artists( + **base_params, + album_artists_only=call.data.get(ATTR_ALBUM_ARTISTS_ONLY), + ) + elif media_type == MediaType.TRACK: + library_result = await mass.music.get_library_tracks( + **base_params, + ) + elif media_type == MediaType.RADIO: + library_result = await mass.music.get_library_radios( + **base_params, + ) + elif media_type == MediaType.PLAYLIST: + library_result = await mass.music.get_library_playlists( + **base_params, + ) + else: + raise ServiceValidationError(f"Unsupported media type {media_type}") + + response: ServiceResponse = LIBRARY_RESULTS_SCHEMA( + { + ATTR_ITEMS: [ + media_item_dict_from_mass_item(mass, item) for item in library_result + ], + ATTR_LIMIT: limit, + ATTR_OFFSET: offset, + ATTR_ORDER_BY: order_by, + ATTR_MEDIA_TYPE: media_type, + } + ) + return response diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index 6512f58b96c..1980c495278 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -14,5 +14,55 @@ ATTR_GROUP_PARENTS = "group_parents" ATTR_MASS_PLAYER_TYPE = "mass_player_type" ATTR_ACTIVE_QUEUE = "active_queue" ATTR_STREAM_TITLE = "stream_title" +ATTR_MEDIA_TYPE = "media_type" +ATTR_SEARCH_NAME = "name" +ATTR_SEARCH_ARTIST = "artist" +ATTR_SEARCH_ALBUM = "album" +ATTR_LIMIT = "limit" +ATTR_LIBRARY_ONLY = "library_only" +ATTR_FAVORITE = "favorite" +ATTR_SEARCH = "search" +ATTR_OFFSET = "offset" +ATTR_ORDER_BY = "order_by" +ATTR_ALBUM_TYPE = "album_type" +ATTR_ALBUM_ARTISTS_ONLY = "album_artists_only" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_URI = "uri" +ATTR_IMAGE = "image" +ATTR_VERSION = "version" +ATTR_ARTISTS = "artists" +ATTR_ALBUMS = "albums" +ATTR_TRACKS = "tracks" +ATTR_PLAYLISTS = "playlists" +ATTR_RADIO = "radio" +ATTR_ITEMS = "items" +ATTR_RADIO_MODE = "radio_mode" +ATTR_MEDIA_ID = "media_id" +ATTR_ARTIST = "artist" +ATTR_ALBUM = "album" +ATTR_URL = "url" +ATTR_USE_PRE_ANNOUNCE = "use_pre_announce" +ATTR_ANNOUNCE_VOLUME = "announce_volume" +ATTR_SOURCE_PLAYER = "source_player" +ATTR_AUTO_PLAY = "auto_play" +ATTR_QUEUE_ID = "queue_id" +ATTR_ACTIVE = "active" +ATTR_SHUFFLE_ENABLED = "shuffle_enabled" +ATTR_REPEAT_MODE = "repeat_mode" +ATTR_CURRENT_INDEX = "current_index" +ATTR_ELAPSED_TIME = "elapsed_time" +ATTR_CURRENT_ITEM = "current_item" +ATTR_NEXT_ITEM = "next_item" +ATTR_QUEUE_ITEM_ID = "queue_item_id" +ATTR_DURATION = "duration" +ATTR_MEDIA_ITEM = "media_item" +ATTR_STREAM_DETAILS = "stream_details" +ATTR_CONTENT_TYPE = "content_type" +ATTR_SAMPLE_RATE = "sample_rate" +ATTR_BIT_DEPTH = "bit_depth" +ATTR_STREAM_TITLE = "stream_title" +ATTR_PROVIDER = "provider" +ATTR_ITEM_ID = "item_id" + LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/music_assistant/icons.json b/homeassistant/components/music_assistant/icons.json index 7533dbb6dad..0fa64b8d273 100644 --- a/homeassistant/components/music_assistant/icons.json +++ b/homeassistant/components/music_assistant/icons.json @@ -2,6 +2,9 @@ "services": { "play_media": { "service": "mdi:play" }, "play_announcement": { "service": "mdi:bullhorn" }, - "transfer_queue": { "service": "mdi:transfer" } + "transfer_queue": { "service": "mdi:transfer" }, + "search": { "service": "mdi:magnify" }, + "get_queue": { "service": "mdi:playlist-music" }, + "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 7004f09aad5..9aa7498a2ee 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -36,8 +36,8 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) -from homeassistant.const import STATE_OFF -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_NAME, STATE_OFF +from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv @@ -48,9 +48,33 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.util.dt import utc_from_timestamp from . import MusicAssistantConfigEntry -from .const import ATTR_ACTIVE_QUEUE, ATTR_MASS_PLAYER_TYPE, DOMAIN +from .const import ( + ATTR_ACTIVE, + ATTR_ACTIVE_QUEUE, + ATTR_ALBUM, + ATTR_ANNOUNCE_VOLUME, + ATTR_ARTIST, + ATTR_AUTO_PLAY, + ATTR_CURRENT_INDEX, + ATTR_CURRENT_ITEM, + ATTR_ELAPSED_TIME, + ATTR_ITEMS, + ATTR_MASS_PLAYER_TYPE, + ATTR_MEDIA_ID, + ATTR_MEDIA_TYPE, + ATTR_NEXT_ITEM, + ATTR_QUEUE_ID, + ATTR_RADIO_MODE, + ATTR_REPEAT_MODE, + ATTR_SHUFFLE_ENABLED, + ATTR_SOURCE_PLAYER, + ATTR_URL, + ATTR_USE_PRE_ANNOUNCE, + DOMAIN, +) from .entity import MusicAssistantEntity from .media_browser import async_browse_media +from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient @@ -89,16 +113,7 @@ QUEUE_OPTION_MAP = { 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" -ATTR_ARTIST = "artist" -ATTR_ALBUM = "album" -ATTR_URL = "url" -ATTR_USE_PRE_ANNOUNCE = "use_pre_announce" -ATTR_ANNOUNCE_VOLUME = "announce_volume" -ATTR_SOURCE_PLAYER = "source_player" -ATTR_AUTO_PLAY = "auto_play" +SERVICE_GET_QUEUE = "get_queue" def catch_musicassistant_error[_R, **P]( @@ -179,6 +194,12 @@ async def async_setup_entry( }, "_async_handle_transfer_queue", ) + platform.async_register_entity_service( + SERVICE_GET_QUEUE, + schema=None, + func="_async_handle_get_queue", + supports_response=SupportsResponse.ONLY, + ) class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): @@ -513,6 +534,32 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): source_queue_id, target_queue_id, auto_play ) + @catch_musicassistant_error + async def _async_handle_get_queue(self) -> ServiceResponse: + """Handle get_queue action.""" + if not self.active_queue: + raise HomeAssistantError("No active queue found") + active_queue = self.active_queue + response: ServiceResponse = QUEUE_DETAILS_SCHEMA( + { + ATTR_QUEUE_ID: active_queue.queue_id, + ATTR_ACTIVE: active_queue.active, + ATTR_NAME: active_queue.display_name, + ATTR_ITEMS: active_queue.items, + ATTR_SHUFFLE_ENABLED: active_queue.shuffle_enabled, + ATTR_REPEAT_MODE: active_queue.repeat_mode.value, + ATTR_CURRENT_INDEX: active_queue.current_index, + ATTR_ELAPSED_TIME: active_queue.corrected_elapsed_time, + ATTR_CURRENT_ITEM: queue_item_dict_from_mass_item( + self.mass, active_queue.current_item + ), + ATTR_NEXT_ITEM: queue_item_dict_from_mass_item( + self.mass, active_queue.next_item + ), + } + ) + return response + async def async_browse_media( self, media_content_type: MediaType | str | None = None, diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py new file mode 100644 index 00000000000..9caae2ee0b4 --- /dev/null +++ b/homeassistant/components/music_assistant/schemas.py @@ -0,0 +1,182 @@ +"""Voluptuous schemas for Music Assistant integration service responses.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from music_assistant_models.enums import MediaType +import voluptuous as vol + +from homeassistant.const import ATTR_NAME +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_ACTIVE, + ATTR_ALBUM, + ATTR_ALBUMS, + ATTR_ARTISTS, + ATTR_BIT_DEPTH, + ATTR_CONTENT_TYPE, + ATTR_CURRENT_INDEX, + ATTR_CURRENT_ITEM, + ATTR_DURATION, + ATTR_ELAPSED_TIME, + ATTR_IMAGE, + ATTR_ITEM_ID, + ATTR_ITEMS, + ATTR_LIMIT, + ATTR_MEDIA_ITEM, + ATTR_MEDIA_TYPE, + ATTR_NEXT_ITEM, + ATTR_OFFSET, + ATTR_ORDER_BY, + ATTR_PLAYLISTS, + ATTR_PROVIDER, + ATTR_QUEUE_ID, + ATTR_QUEUE_ITEM_ID, + ATTR_RADIO, + ATTR_REPEAT_MODE, + ATTR_SAMPLE_RATE, + ATTR_SHUFFLE_ENABLED, + ATTR_STREAM_DETAILS, + ATTR_STREAM_TITLE, + ATTR_TRACKS, + ATTR_URI, + ATTR_VERSION, +) + +if TYPE_CHECKING: + from music_assistant_client import MusicAssistantClient + from music_assistant_models.media_items import ItemMapping, MediaItemType + from music_assistant_models.queue_item import QueueItem + +MEDIA_ITEM_SCHEMA = vol.Schema( + { + vol.Required(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), + vol.Required(ATTR_URI): cv.string, + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_VERSION): cv.string, + vol.Optional(ATTR_IMAGE, default=None): vol.Any(None, cv.string), + vol.Optional(ATTR_ARTISTS): [vol.Self], + vol.Optional(ATTR_ALBUM): vol.Self, + } +) + + +def media_item_dict_from_mass_item( + mass: MusicAssistantClient, + item: MediaItemType | ItemMapping | None, +) -> dict[str, Any] | None: + """Parse a Music Assistant MediaItem.""" + if not item: + return None + base = { + ATTR_MEDIA_TYPE: item.media_type, + ATTR_URI: item.uri, + ATTR_NAME: item.name, + ATTR_VERSION: item.version, + ATTR_IMAGE: mass.get_media_item_image_url(item), + } + if artists := getattr(item, "artists", None): + base[ATTR_ARTISTS] = [media_item_dict_from_mass_item(mass, x) for x in artists] + if album := getattr(item, "album", None): + base[ATTR_ALBUM] = media_item_dict_from_mass_item(mass, album) + return base + + +SEARCH_RESULT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ARTISTS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_ALBUMS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_TRACKS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_PLAYLISTS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_RADIO): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + }, +) + +LIBRARY_RESULTS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ITEMS): vol.All( + cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)] + ), + vol.Required(ATTR_LIMIT): int, + vol.Required(ATTR_OFFSET): int, + vol.Required(ATTR_ORDER_BY): str, + vol.Required(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), + } +) + +AUDIO_FORMAT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONTENT_TYPE): str, + vol.Required(ATTR_SAMPLE_RATE): int, + vol.Required(ATTR_BIT_DEPTH): int, + vol.Required(ATTR_PROVIDER): str, + vol.Required(ATTR_ITEM_ID): str, + } +) + +QUEUE_ITEM_SCHEMA = vol.Schema( + { + vol.Required(ATTR_QUEUE_ITEM_ID): cv.string, + vol.Required(ATTR_NAME): cv.string, + vol.Optional(ATTR_DURATION, default=None): vol.Any(None, int), + vol.Optional(ATTR_MEDIA_ITEM, default=None): vol.Any( + None, vol.Schema(MEDIA_ITEM_SCHEMA) + ), + vol.Optional(ATTR_STREAM_DETAILS): vol.Schema(AUDIO_FORMAT_SCHEMA), + vol.Optional(ATTR_STREAM_TITLE, default=None): vol.Any(None, cv.string), + } +) + + +def queue_item_dict_from_mass_item( + mass: MusicAssistantClient, + item: QueueItem | None, +) -> dict[str, Any] | None: + """Parse a Music Assistant QueueItem.""" + if not item: + return None + base = { + ATTR_QUEUE_ITEM_ID: item.queue_item_id, + ATTR_NAME: item.name, + ATTR_DURATION: item.duration, + ATTR_MEDIA_ITEM: media_item_dict_from_mass_item(mass, item.media_item), + } + if streamdetails := item.streamdetails: + base[ATTR_STREAM_TITLE] = streamdetails.stream_title + base[ATTR_STREAM_DETAILS] = { + ATTR_CONTENT_TYPE: streamdetails.audio_format.content_type.value, + ATTR_SAMPLE_RATE: streamdetails.audio_format.sample_rate, + ATTR_BIT_DEPTH: streamdetails.audio_format.bit_depth, + ATTR_PROVIDER: streamdetails.provider, + ATTR_ITEM_ID: streamdetails.item_id, + } + + return base + + +QUEUE_DETAILS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_QUEUE_ID): str, + vol.Required(ATTR_ACTIVE): bool, + vol.Required(ATTR_NAME): str, + vol.Required(ATTR_ITEMS): int, + vol.Required(ATTR_SHUFFLE_ENABLED): bool, + vol.Required(ATTR_REPEAT_MODE): str, + vol.Required(ATTR_CURRENT_INDEX): vol.Any(None, int), + vol.Required(ATTR_ELAPSED_TIME): vol.Coerce(int), + vol.Required(ATTR_CURRENT_ITEM): vol.Any(None, QUEUE_ITEM_SCHEMA), + vol.Required(ATTR_NEXT_ITEM): vol.Any(None, QUEUE_ITEM_SCHEMA), + } +) diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index 00f895c4ef6..73e8e2d7521 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -88,3 +88,146 @@ transfer_queue: example: "true" selector: boolean: + +get_queue: + target: + entity: + domain: media_player + integration: music_assistant + supported_features: + - media_player.MediaPlayerEntityFeature.PLAY_MEDIA + +search: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: music_assistant + name: + required: true + example: "We Are The Champions" + selector: + text: + media_type: + example: "playlist" + selector: + select: + multiple: true + translation_key: media_type + options: + - artist + - album + - playlist + - track + - radio + artist: + example: "Queen" + selector: + text: + album: + example: "News of the world" + selector: + text: + limit: + advanced: true + example: 25 + default: 5 + selector: + number: + min: 1 + max: 100 + step: 1 + library_only: + example: "true" + default: false + selector: + boolean: + +get_library: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: music_assistant + media_type: + required: true + example: "playlist" + selector: + select: + translation_key: media_type + options: + - artist + - album + - playlist + - track + - radio + favorite: + example: "true" + default: false + selector: + boolean: + search: + example: "We Are The Champions" + selector: + text: + limit: + advanced: true + example: 25 + default: 25 + selector: + number: + min: 1 + max: 500 + step: 1 + offset: + advanced: true + example: 25 + default: 0 + selector: + number: + min: 1 + max: 1000000 + step: 1 + order_by: + example: "random" + selector: + select: + translation_key: order_by + options: + - name + - name_desc + - sort_name + - sort_name_desc + - timestamp_added + - timestamp_added_desc + - last_played + - last_played_desc + - play_count + - play_count_desc + - year + - year_desc + - position + - position_desc + - artist_name + - artist_name_desc + - random + - random_play_count + album_type: + example: "single" + selector: + select: + multiple: true + translation_key: album_type + options: + - album + - single + - compilation + - ep + - unknown + album_artists_only: + example: "true" + default: false + selector: + boolean: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index cce7f9607c2..af366c94310 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -99,6 +99,86 @@ "description": "Start playing the queue on the target player. Omit to use the default behavior." } } + }, + "get_queue": { + "name": "Get playerQueue details (advanced)", + "description": "Get the details of the currently active queue of a Music Assistant player." + }, + "search": { + "name": "Search Music Assistant", + "description": "Perform a global search on the Music Assistant library and all providers.", + "fields": { + "config_entry_id": { + "name": "Music Assistant instance", + "description": "Select the Music Assistant instance to perform the search on." + }, + "name": { + "name": "Search name", + "description": "The name/title to search for." + }, + "media_type": { + "name": "Media type(s)", + "description": "The type of the content to search. Such as artist, album, track, radio, or playlist. All types if omitted." + }, + "artist": { + "name": "Artist name", + "description": "When specifying a track or album name in the name field, you can optionally restrict results by this artist name." + }, + "album": { + "name": "Album name", + "description": "When specifying a track name in the name field, you can optionally restrict results by this album name." + }, + "limit": { + "name": "Limit", + "description": "Maximum number of items to return (per media type)." + }, + "library_only": { + "name": "Only library items", + "description": "Only include results that are in the library." + } + } + }, + "get_library": { + "name": "Get Library items", + "description": "Get items from a Music Assistant library.", + "fields": { + "config_entry_id": { + "name": "[%key:component::music_assistant::services::search::fields::config_entry_id::name%]", + "description": "[%key:component::music_assistant::services::search::fields::config_entry_id::description%]" + }, + "media_type": { + "name": "Media type", + "description": "The media type for which to request details for." + }, + "favorite": { + "name": "Favorites only", + "description": "Filter items so only favorites items are returned." + }, + "search": { + "name": "Search", + "description": "Optional search string to search through this library." + }, + "limit": { + "name": "Limit", + "description": "Maximum number of items to return." + }, + "offset": { + "name": "Offset", + "description": "Offset to start the list from." + }, + "order_by": { + "name": "Order By", + "description": "Sort the list by this field." + }, + "album_type": { + "name": "Album type filter (albums library only)", + "description": "Filter albums by type." + }, + "album_artists_only": { + "name": "Enable album artists filter (only for artist library)", + "description": "Only return Album Artists when listing the Artists library items." + } + } } }, "selector": { @@ -119,6 +199,37 @@ "playlist": "Playlist", "radio": "Radio" } + }, + "order_by": { + "options": { + "name": "Name", + "name_desc": "Name (desc)", + "sort_name": "Sort name", + "sort_name_desc": "Sort name (desc)", + "timestamp_added": "Added", + "timestamp_added_desc": "Added (desc)", + "last_played": "Last played", + "last_played_desc": "Last played (desc)", + "play_count": "Play count", + "play_count_desc": "Play count (desc)", + "year": "Year", + "year_desc": "Year (desc)", + "position": "Position", + "position_desc": "Position (desc)", + "artist_name": "Artist name", + "artist_name_desc": "Artist name (desc)", + "random": "Random", + "random_play_count": "Random + least played" + } + }, + "album_type": { + "options": { + "album": "Album", + "single": "Single", + "ep": "EP", + "compilation": "Compilation", + "unknown": "Unknown" + } } } } diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index c8293b5622f..7c0f9df751a 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -30,7 +30,7 @@ def load_and_parse_fixture(fixture: str) -> dict[str, Any]: async def setup_integration_from_fixtures( hass: HomeAssistant, music_assistant_client: MagicMock, -) -> None: +) -> MockConfigEntry: """Set up MusicAssistant integration with fixture data.""" players = create_players_from_fixture() music_assistant_client.players._players = {x.player_id: x for x in players} @@ -65,6 +65,7 @@ async def setup_integration_from_fixtures( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + return config_entry def create_players_from_fixture() -> list[Player]: diff --git a/tests/components/music_assistant/snapshots/test_actions.ambr b/tests/components/music_assistant/snapshots/test_actions.ambr new file mode 100644 index 00000000000..6c30ffc512c --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_actions.ambr @@ -0,0 +1,202 @@ +# serializer version: 1 +# name: test_get_library_action + dict({ + 'items': list([ + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Traveller', + 'uri': 'library://album/463', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Chris Stapleton', + 'uri': 'library://artist/433', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Tennessee Whiskey', + 'uri': 'library://track/456', + 'version': '', + }), + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Thelma + Louise', + 'uri': 'library://album/471', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Bastille', + 'uri': 'library://artist/81', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Thelma + Louise', + 'uri': 'library://track/467', + 'version': '', + }), + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'HIStory - PAST, PRESENT AND FUTURE - BOOK I', + 'uri': 'library://album/486', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Michael Jackson', + 'uri': 'library://artist/30', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': "They Don't Care About Us", + 'uri': 'library://track/485', + 'version': '', + }), + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Better Dayz', + 'uri': 'library://album/487', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': '2Pac', + 'uri': 'library://artist/159', + 'version': '', + }), + dict({ + 'image': None, + 'media_type': , + 'name': 'The Outlawz', + 'uri': 'library://artist/451', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': "They Don't Give A F**** About Us", + 'uri': 'library://track/486', + 'version': '', + }), + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Things We Lost In The Fire', + 'uri': 'library://album/488', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Bastille', + 'uri': 'library://artist/81', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Things We Lost In The Fire', + 'uri': 'library://track/487', + 'version': 'TORN Remix', + }), + dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Doom Days', + 'uri': 'library://album/489', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Bastille', + 'uri': 'library://artist/81', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Those Nights', + 'uri': 'library://track/488', + 'version': '', + }), + ]), + 'limit': 25, + 'media_type': , + 'offset': 0, + 'order_by': 'name', + }) +# --- +# name: test_search_action + dict({ + 'albums': list([ + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'A Space Love Adventure', + 'uri': 'library://artist/289', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synth Punk EP', + 'uri': 'library://album/396', + 'version': '', + }), + dict({ + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'Various Artists', + 'uri': 'library://artist/96', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Synthwave (The 80S Revival)', + 'uri': 'library://album/95', + 'version': 'The 80S Revival', + }), + ]), + 'artists': list([ + ]), + 'playlists': list([ + ]), + 'radio': list([ + ]), + 'tracks': list([ + ]), + }) +# --- diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index e3d7a4a0cbc..6c5389dbd6a 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -188,3 +188,88 @@ 'state': 'off', }) # --- +# name: test_media_player_get_queue_action + dict({ + 'media_player.test_group_player_1': dict({ + 'active': True, + 'current_index': 26, + 'current_item': dict({ + 'duration': 536, + 'media_item': dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'Use Your Illusion I', + 'uri': 'spotify://album/0CxPbTRARqKUYighiEY9Sz', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': "Guns N' Roses", + 'uri': 'spotify://artist/3qm84nBOXUEQ2vnTfUTTFC', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'November Rain', + 'uri': 'spotify://track/3YRCqOhFifThpSRFJ1VWFM', + 'version': '', + }), + 'name': "Guns N' Roses - November Rain", + 'queue_item_id': '5d95dc5be77e4f7eb4939f62cfef527b', + 'stream_details': dict({ + 'bit_depth': 16, + 'content_type': 'ogg', + 'item_id': '3YRCqOhFifThpSRFJ1VWFM', + 'provider': 'spotify', + 'sample_rate': 44100, + }), + 'stream_title': None, + }), + 'items': 1094, + 'name': 'Test Group Player 1', + 'next_item': dict({ + 'duration': 207, + 'media_item': dict({ + 'album': dict({ + 'image': None, + 'media_type': , + 'name': 'La Folie', + 'uri': 'qobuz://album/0724353468859', + 'version': '', + }), + 'artists': list([ + dict({ + 'image': None, + 'media_type': , + 'name': 'The Stranglers', + 'uri': 'qobuz://artist/26779', + 'version': '', + }), + ]), + 'image': None, + 'media_type': , + 'name': 'Golden Brown', + 'uri': 'qobuz://track/1004735', + 'version': '', + }), + 'name': 'The Stranglers - Golden Brown', + 'queue_item_id': '990ae8f29cdf4fb588d679b115621f55', + 'stream_details': dict({ + 'bit_depth': 16, + 'content_type': 'flac', + 'item_id': '1004735', + 'provider': 'qobuz', + 'sample_rate': 44100, + }), + 'stream_title': None, + }), + 'queue_id': 'test_group_player_1', + 'repeat_mode': 'all', + 'shuffle_enabled': True, + }), + }) +# --- diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py new file mode 100644 index 00000000000..4d3917091c1 --- /dev/null +++ b/tests/components/music_assistant/test_actions.py @@ -0,0 +1,68 @@ +"""Test Music Assistant actions.""" + +from unittest.mock import AsyncMock, MagicMock + +from music_assistant_models.media_items import SearchResults +from syrupy import SnapshotAssertion + +from homeassistant.components.music_assistant.actions import ( + SERVICE_GET_LIBRARY, + SERVICE_SEARCH, +) +from homeassistant.components.music_assistant.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_FAVORITE, + ATTR_MEDIA_TYPE, + ATTR_SEARCH_NAME, + DOMAIN as MASS_DOMAIN, +) +from homeassistant.core import HomeAssistant + +from .common import create_library_albums_from_fixture, setup_integration_from_fixtures + + +async def test_search_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test music assistant search action.""" + entry = await setup_integration_from_fixtures(hass, music_assistant_client) + + music_assistant_client.music.search = AsyncMock( + return_value=SearchResults( + albums=create_library_albums_from_fixture(), + ) + ) + response = await hass.services.async_call( + MASS_DOMAIN, + SERVICE_SEARCH, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_SEARCH_NAME: "test", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +async def test_get_library_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test music assistant get_library action.""" + entry = await setup_integration_from_fixtures(hass, music_assistant_client) + response = await hass.services.async_call( + MASS_DOMAIN, + SERVICE_GET_LIBRARY, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_FAVORITE: False, + ATTR_MEDIA_TYPE: "track", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot diff --git a/tests/components/music_assistant/test_media_player.py b/tests/components/music_assistant/test_media_player.py index 13716b6a479..25dfcd22c72 100644 --- a/tests/components/music_assistant/test_media_player.py +++ b/tests/components/music_assistant/test_media_player.py @@ -6,6 +6,7 @@ from music_assistant_models.enums import MediaType, QueueOption from music_assistant_models.media_items import Track import pytest from syrupy import SnapshotAssertion +from syrupy.filters import paths from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, @@ -32,6 +33,7 @@ from homeassistant.components.music_assistant.media_player import ( ATTR_SOURCE_PLAYER, ATTR_URL, ATTR_USE_PRE_ANNOUNCE, + SERVICE_GET_QUEUE, SERVICE_PLAY_ANNOUNCEMENT, SERVICE_PLAY_MEDIA_ADVANCED, SERVICE_TRANSFER_QUEUE, @@ -583,3 +585,25 @@ async def test_media_player_transfer_queue_action( auto_play=None, require_schema=25, ) + + +async def test_media_player_get_queue_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test media_player get_queue action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "media_player.test_group_player_1" + response = await hass.services.async_call( + MASS_DOMAIN, + SERVICE_GET_QUEUE, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + return_response=True, + ) + # no call is made, this info comes from the cached queue data + assert music_assistant_client.send_command.call_count == 0 + assert response == snapshot(exclude=paths(f"{entity_id}.elapsed_time"))