Add actions with response values to Music Assistant (#133521)

Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: OzGav <gavnosp@hotmail.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
pull/133823/head
Marcel van der Veldt 2024-12-23 11:10:10 +01:00 committed by GitHub
parent 1f8f85d6eb
commit 83f5ca5a30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1155 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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),
}
)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,202 @@
# serializer version: 1
# name: test_get_library_action
dict({
'items': list([
dict({
'album': dict({
'image': None,
'media_type': <MediaType.ALBUM: 'album'>,
'name': 'Traveller',
'uri': 'library://album/463',
'version': '',
}),
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'Chris Stapleton',
'uri': 'library://artist/433',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.TRACK: 'track'>,
'name': 'Tennessee Whiskey',
'uri': 'library://track/456',
'version': '',
}),
dict({
'album': dict({
'image': None,
'media_type': <MediaType.ALBUM: 'album'>,
'name': 'Thelma + Louise',
'uri': 'library://album/471',
'version': '',
}),
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'Bastille',
'uri': 'library://artist/81',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.TRACK: 'track'>,
'name': 'Thelma + Louise',
'uri': 'library://track/467',
'version': '',
}),
dict({
'album': dict({
'image': None,
'media_type': <MediaType.ALBUM: 'album'>,
'name': 'HIStory - PAST, PRESENT AND FUTURE - BOOK I',
'uri': 'library://album/486',
'version': '',
}),
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'Michael Jackson',
'uri': 'library://artist/30',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.TRACK: 'track'>,
'name': "They Don't Care About Us",
'uri': 'library://track/485',
'version': '',
}),
dict({
'album': dict({
'image': None,
'media_type': <MediaType.ALBUM: 'album'>,
'name': 'Better Dayz',
'uri': 'library://album/487',
'version': '',
}),
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': '2Pac',
'uri': 'library://artist/159',
'version': '',
}),
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'The Outlawz',
'uri': 'library://artist/451',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.TRACK: 'track'>,
'name': "They Don't Give A F**** About Us",
'uri': 'library://track/486',
'version': '',
}),
dict({
'album': dict({
'image': None,
'media_type': <MediaType.ALBUM: 'album'>,
'name': 'Things We Lost In The Fire',
'uri': 'library://album/488',
'version': '',
}),
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'Bastille',
'uri': 'library://artist/81',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.TRACK: 'track'>,
'name': 'Things We Lost In The Fire',
'uri': 'library://track/487',
'version': 'TORN Remix',
}),
dict({
'album': dict({
'image': None,
'media_type': <MediaType.ALBUM: 'album'>,
'name': 'Doom Days',
'uri': 'library://album/489',
'version': '',
}),
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'Bastille',
'uri': 'library://artist/81',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.TRACK: 'track'>,
'name': 'Those Nights',
'uri': 'library://track/488',
'version': '',
}),
]),
'limit': 25,
'media_type': <MediaType.TRACK: 'track'>,
'offset': 0,
'order_by': 'name',
})
# ---
# name: test_search_action
dict({
'albums': list([
dict({
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'A Space Love Adventure',
'uri': 'library://artist/289',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.ALBUM: 'album'>,
'name': 'Synth Punk EP',
'uri': 'library://album/396',
'version': '',
}),
dict({
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'Various Artists',
'uri': 'library://artist/96',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.ALBUM: 'album'>,
'name': 'Synthwave (The 80S Revival)',
'uri': 'library://album/95',
'version': 'The 80S Revival',
}),
]),
'artists': list([
]),
'playlists': list([
]),
'radio': list([
]),
'tracks': list([
]),
})
# ---

View File

@ -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': <MediaType.ALBUM: 'album'>,
'name': 'Use Your Illusion I',
'uri': 'spotify://album/0CxPbTRARqKUYighiEY9Sz',
'version': '',
}),
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': "Guns N' Roses",
'uri': 'spotify://artist/3qm84nBOXUEQ2vnTfUTTFC',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.TRACK: 'track'>,
'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': <MediaType.ALBUM: 'album'>,
'name': 'La Folie',
'uri': 'qobuz://album/0724353468859',
'version': '',
}),
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'The Stranglers',
'uri': 'qobuz://artist/26779',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.TRACK: 'track'>,
'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,
}),
})
# ---

View File

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

View File

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