diff --git a/custom_components/mass/__init__.py b/custom_components/mass/__init__.py index 40834d0f..4c2cce7c 100644 --- a/custom_components/mass/__init__.py +++ b/custom_components/mass/__init__.py @@ -24,8 +24,8 @@ from music_assistant_models.enums import EventType from music_assistant_models.errors import MusicAssistantError +from .actions import register_actions from .const import DOMAIN, LOGGER -from .services import register_services if TYPE_CHECKING: from music_assistant_models.event import MassEvent @@ -50,8 +50,8 @@ class MusicAssistantEntryData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Music Assistant component.""" - # register our (custom) services - register_services(hass) + # register our (custom) actions/services + register_actions(hass) return True diff --git a/custom_components/mass/actions.py b/custom_components/mass/actions.py new file mode 100644 index 00000000..020467fe --- /dev/null +++ b/custom_components/mass/actions.py @@ -0,0 +1,185 @@ +"""Custom actions (previously known as services) for the Music Assistant integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError +from music_assistant_client.helpers import searchresults_as_compact_dict +from music_assistant_models.enums import MediaType + +from .const import DOMAIN + +if TYPE_CHECKING: + from music_assistant_client import MusicAssistantClient + + from . import MusicAssistantConfigEntry + +SERVICE_SEARCH = "search" +SERVICE_GET_QUEUE = "get_queue" +SERVICE_GET_LIBRARY = "get_library" +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_QUEUE_ID = "queue_id" + + +@callback +def get_music_assistant_client(hass: HomeAssistant) -> MusicAssistantClient: + """Get the (first) Music Assistant client from the (loaded) config entries.""" + entry: MusicAssistantConfigEntry + for entry in hass.config_entries.async_entries(DOMAIN, False, False): + if entry.state != ConfigEntryState.LOADED: + continue + return entry.runtime_data.mass + raise HomeAssistantError("Music Assistant is not loaded") + + +@callback +def register_actions(hass: HomeAssistant) -> None: + """Register custom actions.""" + register_search_action(hass) + register_get_queue_action(hass) + register_get_library_action(hass) + + +def register_search_action(hass: HomeAssistant) -> None: + """Register search action.""" + + async def handle_search(call: ServiceCall) -> ServiceResponse: + """Handle queue_command action.""" + mass = get_music_assistant_client(hass) + 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], + ) + # return limited result to prevent it being too verbose + return cast(ServiceResponse, searchresults_as_compact_dict(search_results)) + + hass.services.async_register( + DOMAIN, + SERVICE_SEARCH, + handle_search, + schema=vol.Schema( + { + 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, + ) + + +def register_get_queue_action(hass: HomeAssistant) -> None: + """Register get_queue action.""" + + async def handle_get_queue(call: ServiceCall) -> ServiceResponse: + """Handle get_queue action.""" + mass = get_music_assistant_client(hass) + queue_id = call.data.get(ATTR_QUEUE_ID) + if queue_id and (queue := mass.player_queues.get(queue_id)): + return cast(ServiceResponse, queue.to_dict()) + raise HomeAssistantError(f"Queue with ID {queue_id} not found") + + hass.services.async_register( + DOMAIN, + SERVICE_GET_QUEUE, + handle_get_queue, + schema=vol.Schema( + { + vol.Required(ATTR_QUEUE_ID): cv.string, + } + ), + supports_response=SupportsResponse.ONLY, + ) + + +def register_get_library_action(hass: HomeAssistant) -> None: + """Register get_library action.""" + + async def handle_get_library(call: ServiceCall) -> ServiceResponse: + """Handle get_library action.""" + mass = get_music_assistant_client(hass) + media_type = call.data[ATTR_MEDIA_TYPE] + base_params = { + "favorite": call.data.get("favorite"), + "search": call.data.get("search"), + "limit": call.data.get("limit"), + "offset": call.data.get("offset"), + "order_by": call.data.get("order_by"), + } + if media_type == MediaType.ALBUM: + library_result = await mass.music.get_library_albums( + **base_params, + album_types=call.data.get("album_type"), + ) + elif media_type == MediaType.ARTIST: + library_result = await mass.music.get_library_artists( + **base_params, + album_artists_only=call.data.get("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 HomeAssistantError(f"Unsupported media type {media_type}") + # result must be a dict so we return the media item (+s) as key + result = {f"{media_type.value}s": [item.to_dict() for item in library_result]} + return cast(ServiceResponse, result) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_LIBRARY, + handle_get_library, + schema=vol.Schema( + { + vol.Required(ATTR_MEDIA_TYPE): vol.Coerce(MediaType), + vol.Optional("favorite"): cv.boolean, + vol.Optional("search"): cv.string, + vol.Optional("limit"): cv.positive_int, + vol.Optional("offset"): int, + vol.Optional("order_by"): cv.string, + vol.Optional("album_types"): cv.ensure_list, + } + ), + supports_response=SupportsResponse.ONLY, + ) diff --git a/custom_components/mass/services.py b/custom_components/mass/services.py deleted file mode 100644 index e59fc705..00000000 --- a/custom_components/mass/services.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Custom services for the Music Assistant integration.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, cast - -import homeassistant.helpers.config_validation as cv -import voluptuous as vol -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, - callback, -) -from homeassistant.exceptions import HomeAssistantError -from music_assistant_client.helpers import searchresults_as_compact_dict -from music_assistant_models.enums import MediaType - -from .const import DOMAIN - -if TYPE_CHECKING: - from music_assistant_client import MusicAssistantClient - - from . import MusicAssistantConfigEntry - -SERVICE_SEARCH = "search" -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" - - -@callback -def get_music_assistant_client(hass: HomeAssistant) -> MusicAssistantClient: - """Get the (first) Music Assistant client from the (loaded) config entries.""" - entry: MusicAssistantConfigEntry - for entry in hass.config_entries.async_entries(DOMAIN, False, False): - if entry.state != ConfigEntryState.LOADED: - continue - return entry.runtime_data.mass - raise HomeAssistantError("Music Assistant is not loaded") - - -@callback -def register_services(hass: HomeAssistant) -> None: - """Register custom services.""" - - async def handle_search(call: ServiceCall) -> ServiceResponse: - """Handle queue_command service.""" - mass = get_music_assistant_client(hass) - 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], - ) - # return limited result to prevent it being too verbose - return cast(ServiceResponse, searchresults_as_compact_dict(search_results)) - - hass.services.async_register( - DOMAIN, - SERVICE_SEARCH, - handle_search, - schema=vol.Schema( - { - 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, - ) diff --git a/custom_components/mass/services.yaml b/custom_components/mass/services.yaml index cd298c19..bd247fca 100644 --- a/custom_components/mass/services.yaml +++ b/custom_components/mass/services.yaml @@ -18,6 +18,7 @@ play_media: example: "playlist" selector: select: + translation_key: media_type options: - artist - album @@ -35,9 +36,6 @@ play_media: selector: text: enqueue: - filter: - supported_features: - - media_player.MediaPlayerEntityFeature.MEDIA_ENQUEUE required: false selector: select: @@ -113,6 +111,7 @@ search: selector: select: multiple: true + translation_key: media_type options: - artist - album @@ -145,3 +144,101 @@ search: default: false selector: boolean: + +get_queue: + fields: + queue_id: + required: true + example: "00:11:22:33:44:55" + selector: + text: + +get_library: + fields: + media_type: + required: true + example: "playlist" + selector: + select: + translation_key: media_type + options: + - artist + - album + - playlist + - track + - radio + favorite: + required: false + example: "true" + default: false + selector: + boolean: + search: + required: false + example: "We Are The Champions" + selector: + text: + limit: + required: false + advanced: true + example: 25 + default: 25 + selector: + number: + min: 1 + max: 500 + step: 1 + offset: + required: false + advanced: true + example: 25 + default: 0 + selector: + number: + min: 1 + max: 1000000 + step: 1 + order_by: + required: false + example: "playlist" + 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: + required: false + example: "single" + selector: + select: + multiple: true + translation_key: album_type + options: + - album + - single + - compilation + - ep + - unknown + album_artists_only: + required: false + example: "true" + default: false + selector: + boolean: diff --git a/custom_components/mass/strings.json b/custom_components/mass/strings.json index bc1aec94..530841b0 100644 --- a/custom_components/mass/strings.json +++ b/custom_components/mass/strings.json @@ -122,6 +122,54 @@ "description": "Only include results that are in the library." } } + }, + "get_queue": { + "name": "Get PlayerQueue details (advanced)", + "description": "Get the full queue details of a Music Assistant Queue.", + "fields": { + "queue_id": { + "name": "Queue ID", + "description": "The Music Assistant Queue ID to request details for." + } + } + }, + "get_library": { + "name": "Get Library items", + "description": "Get items from a Music Assistant Library.", + "fields": { + "media_type": { + "name": "Media Type", + "description": "The MediaType 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": { @@ -133,6 +181,46 @@ "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" + } + }, + "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" + } } }, "options": { diff --git a/custom_components/mass/translations/en.json b/custom_components/mass/translations/en.json index 652eb0ad..530841b0 100644 --- a/custom_components/mass/translations/en.json +++ b/custom_components/mass/translations/en.json @@ -25,7 +25,6 @@ "unknown": "Unexpected error" }, "abort": { - "already_configured": "Music Assistant is already configured", "already_in_progress": "Configuration flow is already in progress", "reconfiguration_successful": "Successfully reconfigured the Music Assistant integration.", "cannot_connect": "Failed to connect" @@ -123,6 +122,54 @@ "description": "Only include results that are in the library." } } + }, + "get_queue": { + "name": "Get PlayerQueue details (advanced)", + "description": "Get the full queue details of a Music Assistant Queue.", + "fields": { + "queue_id": { + "name": "Queue ID", + "description": "The Music Assistant Queue ID to request details for." + } + } + }, + "get_library": { + "name": "Get Library items", + "description": "Get items from a Music Assistant Library.", + "fields": { + "media_type": { + "name": "Media Type", + "description": "The MediaType 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": { @@ -134,6 +181,46 @@ "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" + } + }, + "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" + } } }, "options": {