Files
HomeAssistantVS/custom_components/mass_queue/actions.py
T
2026-07-17 10:12:15 -04:00

530 lines
20 KiB
Python

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