"""Actions for integration.""" from __future__ import annotations from typing import TYPE_CHECKING from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse, callback, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from music_assistant_models.errors import ( InvalidCommand, MediaNotFoundError, ProviderUnavailableError, ) from .const import ( ATTR_COMMAND, ATTR_DATA, ATTR_DURATION, ATTR_FAVORITE, ATTR_LIMIT, ATTR_LIMIT_AFTER, ATTR_LIMIT_BEFORE, ATTR_LOCAL_IMAGE_ENCODED, ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_IMAGE, ATTR_MEDIA_TITLE, ATTR_OFFSET, ATTR_PLAYER_ENTITY, ATTR_POSITION, ATTR_PROVIDERS, ATTR_QUEUE_ID, ATTR_QUEUE_ITEM_ID, ATTR_RELEASE_DATE, ATTR_VOLUME_LEVEL, CONF_DOWNLOAD_LOCAL, DEFAULT_QUEUE_ITEMS_LIMIT, DEFAULT_QUEUE_ITEMS_OFFSET, DOMAIN, LOGGER, SERVICE_GET_GROUP_VOLUME, SERVICE_GET_QUEUE_ITEMS, SERVICE_GET_RECOMMENDATIONS, SERVICE_MOVE_QUEUE_ITEM_DOWN, SERVICE_MOVE_QUEUE_ITEM_NEXT, SERVICE_MOVE_QUEUE_ITEM_UP, SERVICE_PLAY_QUEUE_ITEM, SERVICE_REMOVE_QUEUE_ITEM, SERVICE_SEND_COMMAND, SERVICE_SET_GROUP_VOLUME, ) from .controller import MassQueueController from .schemas import ( GET_GROUP_VOLUME_SERVICE_SCHEMA, GET_RECOMMENDATIONS_SERVICE_SCHEMA, MOVE_QUEUE_ITEM_DOWN_SERVICE_SCHEMA, MOVE_QUEUE_ITEM_NEXT_SERVICE_SCHEMA, MOVE_QUEUE_ITEM_UP_SERVICE_SCHEMA, PLAY_QUEUE_ITEM_SERVICE_SCHEMA, QUEUE_ITEM_SCHEMA, QUEUE_ITEMS_SERVICE_SCHEMA, REMOVE_QUEUE_ITEM_SERVICE_SCHEMA, SEND_COMMAND_SERVICE_SCHEMA, SET_GROUP_VOLUME_SERVICE_SCHEMA, TRACK_ITEM_SCHEMA, ) from .utils import ( find_image, parse_uri, ) if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient from . import MassQueueEntryData class MassQueueActions: """Class to manage Music Assistant actions without passing `hass` and `mass_client` each time.""" def __init__( self, hass: HomeAssistant, mass_client: MusicAssistantClient, config_entry: ConfigEntry, ): """Initialize class.""" self._hass: HomeAssistant = hass self._client: MusicAssistantClient = mass_client self._controller = MassQueueController(self._hass, self._client, config_entry) self._config_entry = config_entry self._download_local = config_entry.options.get(CONF_DOWNLOAD_LOCAL) def setup_controller(self): """Setup Music Assistant controller.""" self._controller.update_players() self._controller.subscribe_events() self._hass.loop.create_task(self._controller.update_queues()) @callback def register_actions(self) -> None: """Register actions with Home Assistant.""" self._hass.services.async_register( DOMAIN, SERVICE_GET_QUEUE_ITEMS, self.get_queue_items, schema=QUEUE_ITEMS_SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) self._hass.services.async_register( DOMAIN, SERVICE_PLAY_QUEUE_ITEM, self.play_queue_item, schema=PLAY_QUEUE_ITEM_SERVICE_SCHEMA, supports_response=SupportsResponse.NONE, ) self._hass.services.async_register( DOMAIN, SERVICE_REMOVE_QUEUE_ITEM, self.remove_queue_item, schema=REMOVE_QUEUE_ITEM_SERVICE_SCHEMA, supports_response=SupportsResponse.NONE, ) self._hass.services.async_register( DOMAIN, SERVICE_MOVE_QUEUE_ITEM_UP, self.move_queue_item_up, schema=MOVE_QUEUE_ITEM_UP_SERVICE_SCHEMA, supports_response=SupportsResponse.NONE, ) self._hass.services.async_register( DOMAIN, SERVICE_MOVE_QUEUE_ITEM_DOWN, self.move_queue_item_down, schema=MOVE_QUEUE_ITEM_DOWN_SERVICE_SCHEMA, supports_response=SupportsResponse.NONE, ) self._hass.services.async_register( DOMAIN, SERVICE_MOVE_QUEUE_ITEM_NEXT, self.move_queue_item_next, schema=MOVE_QUEUE_ITEM_NEXT_SERVICE_SCHEMA, supports_response=SupportsResponse.NONE, ) self._hass.services.async_register( DOMAIN, SERVICE_SEND_COMMAND, self.send_command, schema=SEND_COMMAND_SERVICE_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) self._hass.services.async_register( DOMAIN, SERVICE_GET_RECOMMENDATIONS, self.get_recommendations, schema=GET_RECOMMENDATIONS_SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) self._hass.services.async_register( DOMAIN, SERVICE_GET_GROUP_VOLUME, self.get_group_volume, schema=GET_GROUP_VOLUME_SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) self._hass.services.async_register( DOMAIN, SERVICE_SET_GROUP_VOLUME, self.set_group_volume, schema=SET_GROUP_VOLUME_SERVICE_SCHEMA, supports_response=SupportsResponse.NONE, ) def get_queue_id(self, entity_id: str): """Get the queue ID for a player.""" return self._hass.states.get(entity_id).attributes[ATTR_QUEUE_ID] async def get_queue_index(self, entity_id: str): """Get the current index of the queue.""" active_queue = await self.get_active_queue(entity_id) try: return active_queue.current_index or 0 except AttributeError: return 0 async def get_active_queue(self, entity_id: str): """Get active queue details.""" queue_id = self.get_queue_id(entity_id) return await self._client.player_queues.get_active_queue(queue_id) async def _format_queue_item(self, queue_item: dict) -> dict: """Format list of queue items for response.""" media = queue_item["media_item"] queue_item_id = queue_item["queue_item_id"] media_title = media["name"] media_album = media.get("album") media_album_name = "" if media_album is None else media_album.get("name", "") media_content_id = media["uri"] media_image = find_image(queue_item) or "" local_image_encoded = queue_item.get(ATTR_LOCAL_IMAGE_ENCODED) favorite = media["favorite"] artists = media["artists"] artist_names = [artist["name"] for artist in artists] media_artist = ", ".join(artist_names) response: ServiceResponse = QUEUE_ITEM_SCHEMA( { ATTR_QUEUE_ITEM_ID: queue_item_id, ATTR_MEDIA_TITLE: media_title, ATTR_MEDIA_ALBUM_NAME: media_album_name, ATTR_MEDIA_ARTIST: media_artist, ATTR_MEDIA_CONTENT_ID: media_content_id, ATTR_MEDIA_IMAGE: media_image, ATTR_FAVORITE: favorite, }, ) if local_image_encoded: response[ATTR_LOCAL_IMAGE_ENCODED] = local_image_encoded return response async def send_command(self, call: ServiceCall) -> ServiceResponse: """Sends command to Music Assistant and returns response.""" command = call.data[ATTR_COMMAND] data = call.data.get(ATTR_DATA) response = await self._controller.send_command(command, data) return {"response": response} async def get_recommendations(self, call: ServiceCall) -> ServiceResponse: """Pulls all recommendations for the providers given.""" providers = call.data.get(ATTR_PROVIDERS) return await self._controller.get_recommendations(providers) async def get_group_volume(self, call: ServiceCall) -> ServiceResponse: """Gets the group volume for a single player.""" entity_id = call.data.get(ATTR_PLAYER_ENTITY) queue_id = self.get_queue_id(entity_id) try: volume = await self._controller.get_grouped_volume(queue_id) except: # noqa: E722 volume = None return volume async def set_group_volume(self, call: ServiceCall) -> ServiceResponse: """Sets the group volume for a player.""" entity_id = call.data.get(ATTR_PLAYER_ENTITY) queue_id = self.get_queue_id(entity_id) volume_level = call.data.get(ATTR_VOLUME_LEVEL) await self._controller.set_grouped_volume(queue_id, volume_level) async def get_queue_items(self, call: ServiceCall) -> ServiceResponse: """Get all items in queue.""" entity_id = call.data[ATTR_PLAYER_ENTITY] queue_id = self.get_queue_id(entity_id) if queue_id is None: return {entity_id: []} offset = call.data.get(ATTR_OFFSET) limit = call.data.get(ATTR_LIMIT) limit_before = call.data.get(ATTR_LIMIT_BEFORE) limit_after = call.data.get(ATTR_LIMIT_AFTER) idx = await self.get_queue_index(entity_id) if limit_before: offset = idx - limit_before if limit_after: limit = limit_before + limit_after + 1 if limit_before else limit_after + 1 if offset is None: offset = idx + DEFAULT_QUEUE_ITEMS_OFFSET if limit is None: limit = DEFAULT_QUEUE_ITEMS_LIMIT offset = max(offset, 0) queue_items = await self._controller.player_queue(queue_id, limit, offset) response: ServiceResponse = { entity_id: [await self._format_queue_item(item) for item in queue_items], } return response async def play_queue_item(self, call: ServiceCall) -> ServiceResponse: """Play selected item in queue.""" entity_id = call.data[ATTR_PLAYER_ENTITY] queue_item_id = call.data[ATTR_QUEUE_ITEM_ID] queue_id = self.get_queue_id(entity_id) await self._client.send_command( "player_queues/play_index", queue_id=queue_id, index=queue_item_id, ) async def remove_queue_item(self, call: ServiceCall) -> ServiceResponse: """Remove selected item from queue.""" entity_id = call.data[ATTR_PLAYER_ENTITY] queue_item_id = call.data[ATTR_QUEUE_ITEM_ID] queue_id = self.get_queue_id(entity_id) await self._client.player_queues.queue_command_delete(queue_id, queue_item_id) async def move_queue_item_up(self, call: ServiceCall) -> ServiceResponse: """Move selected item up in queue.""" entity_id = call.data[ATTR_PLAYER_ENTITY] queue_item_id = call.data[ATTR_QUEUE_ITEM_ID] queue_id = self.get_queue_id(entity_id) await self._client.player_queues.queue_command_move_up(queue_id, queue_item_id) async def move_queue_item_down(self, call: ServiceCall) -> ServiceResponse: """Move selected item down in queue.""" entity_id = call.data[ATTR_PLAYER_ENTITY] queue_item_id = call.data[ATTR_QUEUE_ITEM_ID] queue_id = self.get_queue_id(entity_id) await self._client.player_queues.queue_command_move_down( queue_id, queue_item_id, ) async def move_queue_item_next(self, call: ServiceCall) -> ServiceResponse: """Move selected item next in queue.""" entity_id = call.data[ATTR_PLAYER_ENTITY] queue_item_id = call.data[ATTR_QUEUE_ITEM_ID] queue_id = self.get_queue_id(entity_id) await self._client.player_queues.queue_command_move_next( queue_id, queue_item_id, ) async def unfavorite_item(self, call: ServiceCall) -> ServiceResponse: """Unfavorites currently playing item in queue.""" entity_id = call.data[ATTR_PLAYER_ENTITY] attrs = self._hass.states.get(entity_id).attributes content_id = attrs.get(ATTR_MEDIA_CONTENT_ID) if not content_id: msg = f"Cannot find media with content id {content_id}" raise MediaNotFoundError(msg) provider = content_id.split("://")[0] if provider != "library": msg = f"Unfavorite can only apply to library media items, not from provider {provider}" raise InvalidCommand(msg) item_id = str(content_id.split("/")[-1]) await self._client.send_command( "music/favorites/remove_item", media_type="track", library_item_id=item_id, ) async def get_artist_details(self, artist_uri): """Retrieves the details for an artist.""" provider, item_id = parse_uri(artist_uri) LOGGER.debug(f"Getting artist details for provider {provider}") return await self._client.music.get_artist(item_id, provider) async def get_album_details(self, album_uri): """Retrieves the details for an album.""" provider, item_id = parse_uri(album_uri) LOGGER.debug(f"Getting album details for provider {provider}") return await self._client.music.get_album(item_id, provider) async def get_playlist_details(self, playlist_uri): """Retrieves the details for a playlist.""" provider, item_id = parse_uri(playlist_uri) LOGGER.debug(f"Getting album details for provider {provider}") return await self._client.music.get_playlist(item_id, provider) async def get_podcast_details(self, podcast_uri): """Retrieves the details for a podcast.""" provider, item_id = parse_uri(podcast_uri) LOGGER.debug(f"Getting podcast details for provider {provider}") return await self._client.music.get_podcast(item_id, provider) async def get_artist_tracks(self, artist_uri: str, page: int | None = None): """Retrieves a limited number of tracks from an artist.""" details = await self.get_artist_details(artist_uri) mappings = list(details.provider_mappings) if not len(mappings) > 0: msg = f"URI {artist_uri} returned no results!" raise ProviderUnavailableError(msg) mapping = mappings[0] item_id = mapping.item_id provider = mapping.provider_domain resp = ( await self._client.music.get_artist_tracks(item_id, provider) if not page else await self._client.music.get_artist_tracks(item_id, provider, page) ) return [self.format_track_item(item.to_dict()) for item in resp] async def get_album_tracks(self, album_uri: str, page: int | None = None): """Retrieves all tracks from an album.""" details = await self.get_album_details(album_uri) mappings = list(details.provider_mappings) if not len(mappings) > 0: msg = f"URI {album_uri} returned no results!" raise ProviderUnavailableError(msg) mapping = mappings[0] item_id = mapping.item_id provider = mapping.provider_domain resp = ( await self._client.music.get_album_tracks(item_id, provider) if not page else await self._client.music.get_album_tracks(item_id, provider, page) ) return [self.format_track_item(item.to_dict()) for item in resp] async def get_podcast_episodes(self, podcast_uri): """Retrieves all episodes for a podcast.""" provider, item_id = parse_uri(podcast_uri) LOGGER.debug( f"Getting podcast episodes for provider {provider}, item_id {item_id}", ) resp: list = await self._client.music.get_podcast_episodes(item_id, provider) formatted = [self.format_podcast_episode(item.to_dict()) for item in resp] formatted.sort(key=lambda x: x[ATTR_RELEASE_DATE], reverse=True) return formatted async def get_playlist_tracks(self, playlist_uri: str, page: int | None = None): """Retrieves all playlist items.""" provider, item_id = parse_uri(playlist_uri) LOGGER.debug( f"Getting playlist items for provider {provider}, item_id {item_id}", ) resp = ( await self._client.music.get_playlist_tracks(item_id, provider) if not page else await self._client.music.get_playlist_tracks(item_id, provider, page) ) return [self.format_playlist_track(item.to_dict()) for item in resp] def format_playlist_track(self, playlist_track: dict) -> TRACK_ITEM_SCHEMA: """Processes individual playlist tracks using format_track_item and adds position.""" result = self.format_track_item(playlist_track) result[ATTR_POSITION] = playlist_track["position"] return result def format_track_item(self, track_item: dict) -> TRACK_ITEM_SCHEMA: """Process an individual track item.""" result = self.format_item(track_item) media_album = track_item.get("album") media_album_name = "" if media_album is None else media_album.get("name", "") artists = track_item["artists"] artist_names = [artist["name"] for artist in artists] media_artist = ", ".join(artist_names) result[ATTR_MEDIA_ALBUM_NAME] = media_album_name result[ATTR_MEDIA_ARTIST] = media_artist return result def format_podcast_episode(self, podcast_episode: dict) -> TRACK_ITEM_SCHEMA: """Process an individual track item.""" result = self.format_item(podcast_episode) result[ATTR_RELEASE_DATE] = podcast_episode.get("metadata", {}).get( "release_date", ) return result def format_item(self, media_item: dict) -> TRACK_ITEM_SCHEMA: """Processes the individual items in a playlist.""" media_title = media_item.get("name") or "N/A" media_content_id = media_item["uri"] media_image = find_image(media_item) or "" local_image_encoded = media_item.get(ATTR_LOCAL_IMAGE_ENCODED) favorite = media_item["favorite"] duration = media_item["duration"] or 0 response: ServiceResponse = TRACK_ITEM_SCHEMA( { ATTR_MEDIA_TITLE: media_title, ATTR_MEDIA_CONTENT_ID: media_content_id, ATTR_DURATION: duration, ATTR_MEDIA_IMAGE: media_image, ATTR_FAVORITE: favorite, }, ) if local_image_encoded: response[ATTR_LOCAL_IMAGE_ENCODED] = local_image_encoded return response async def remove_playlist_tracks( self, playlist_id: str | int, positions_to_remove: list[int], ): """Removes one or more items from a playlist.""" await self._client.music.remove_playlist_tracks( playlist_id, positions_to_remove, ) @callback def get_music_assistant_client( hass: HomeAssistant, entity_id: str, ) -> MusicAssistantClient: """Get Music Assistant client from entity_id.""" registry = er.async_get(hass) entity = registry.async_get(entity_id) config_entry_id = entity.config_entry_id return _get_music_assistant_client(hass, config_entry_id) @callback def _get_music_assistant_client( hass: HomeAssistant, config_entry_id: str, ) -> MusicAssistantClient: """Get Music Assistant Client from config_entry_id.""" entry: MassQueueEntryData | None if not (entry := hass.config_entries.async_get_entry(config_entry_id)): exc = "Entry not found." raise ServiceValidationError(exc) if entry.state is not ConfigEntryState.LOADED: exc = "Entry not loaded" raise ServiceValidationError(exc) return entry.runtime_data.mass @callback async def setup_controller_and_actions( hass: HomeAssistant, mass_client: MusicAssistantClient, entry: ConfigEntry, ) -> MassQueueActions: """Initialize client and actions class, add actions to Home Assistant.""" actions = MassQueueActions(hass, mass_client, entry) actions.setup_controller() return actions