Added Alexa Music

This commit is contained in:
2026-07-17 10:12:15 -04:00
parent 92c5268dc8
commit 28a8cb98f6
757 changed files with 151171 additions and 85450 deletions
+230
View File
@@ -0,0 +1,230 @@
# ty:ignore[unresolved-import]
"""Initialize component."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import TYPE_CHECKING
from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from music_assistant_client import MusicAssistantClient
from music_assistant_client.exceptions import (
CannotConnect,
InvalidServerVersion,
MusicAssistantClientException,
)
from music_assistant_models.errors import (
ActionUnavailable,
AuthenticationFailed,
AuthenticationRequired,
InvalidToken,
MusicAssistantError,
)
from .actions import (
MassQueueActions,
get_music_assistant_client,
setup_controller_and_actions,
)
from .const import CONF_TOKEN, DOMAIN, LOGGER
from .services import register_actions
from .websocket_commands import (
api_download_and_encode_image,
api_download_images,
api_get_entity_info,
api_get_user_info,
)
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, Event
PLATFORMS = []
CONNECT_TIMEOUT = 10
LISTEN_READY_TIMEOUT = 30
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantQueueEntryData]
@dataclass
class MusicAssistantQueueEntryData:
"""Hold Mass data for the config entry."""
mass: MusicAssistantClient
actions: MassQueueActions
listen_task: asyncio.Task
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ARG001
"""Set up the Music Assistant component."""
return True
async def async_setup_entry( # noqa: PLR0915
hass: HomeAssistant,
entry: MusicAssistantConfigEntry,
) -> bool:
"""Set up Music Assistant from a config entry."""
http_session = async_get_clientsession(hass, verify_ssl=False)
mass_url = entry.data[CONF_URL]
# Get token from config entry (for schema >= AUTH_SCHEMA_VERSION)
token = entry.data.get(CONF_TOKEN)
mass = MusicAssistantClient(mass_url, http_session, token=token)
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await mass.connect()
except (TimeoutError, CannotConnect) as err:
exc = f"Failed to connect to music assistant server {mass_url}"
raise ConfigEntryNotReady(
exc,
) from err
except InvalidServerVersion as err:
async_create_issue(
hass,
DOMAIN,
"invalid_server_version",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="invalid_server_version",
)
exc = f"Invalid server version: {err}"
raise ConfigEntryNotReady(exc) from err
except (AuthenticationRequired, AuthenticationFailed, InvalidToken) as err:
exc = f"Authentication failed for {mass_url}: {err}"
raise ConfigEntryAuthFailed(exc) from err
except MusicAssistantClientException as err:
exc = f"Failed to connect to music assistant server {mass_url}: {err}"
raise ConfigEntryNotReady(exc) from err
except MusicAssistantError as err:
LOGGER.exception("Failed to connect to music assistant server", exc_info=err)
exc = f"Unknown error connecting to the Music Assistant server {mass_url}"
raise ConfigEntryNotReady(
exc,
) from err
async_delete_issue(hass, DOMAIN, "invalid_server_version")
async def on_hass_stop(event: Event) -> None: # noqa: ARG001
"""Handle incoming stop event from Home Assistant."""
await mass.disconnect()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop),
)
# launch the music assistant client listen task in the background
# use the init_ready event to wait until initialization is done
init_ready = asyncio.Event()
listen_task = asyncio.create_task(_client_listen(hass, entry, mass, init_ready))
try:
async with asyncio.timeout(LISTEN_READY_TIMEOUT):
await init_ready.wait()
except TimeoutError as err:
listen_task.cancel()
exc = "Music Assistant client not ready"
raise ConfigEntryNotReady(exc) from err
# store the listen task and mass client in the entry data
actions = await setup_controller_and_actions(hass, mass, entry)
register_actions(hass)
entry.runtime_data = MusicAssistantQueueEntryData(mass, actions, listen_task)
websocket_api.async_register_command(hass, api_download_images)
websocket_api.async_register_command(hass, api_download_and_encode_image)
websocket_api.async_register_command(hass, api_get_entity_info)
websocket_api.async_register_command(hass, api_get_user_info)
# If the listen task is already failed, we need to raise ConfigEntryNotReady
if listen_task.done() and (listen_error := listen_task.exception()) is not None:
await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
try:
await mass.disconnect()
finally:
raise ConfigEntryNotReady(listen_error) from listen_error
# initialize platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def _client_listen(
hass: HomeAssistant,
entry: ConfigEntry,
mass: MusicAssistantClient,
init_ready: asyncio.Event,
) -> None:
"""Listen with the client."""
try:
await mass.start_listening(init_ready)
except MusicAssistantError as err:
if entry.state != ConfigEntryState.LOADED:
raise
LOGGER.error("Failed to listen: %s", err)
except Exception as err: # pylint: disable=broad-except
# We need to guard against unknown exceptions to not crash this task.
if entry.state != ConfigEntryState.LOADED:
raise
LOGGER.exception("Unexpected exception: %s", err)
if not hass.is_stopping:
LOGGER.debug("Disconnected from server. Reloading integration")
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
mass_entry_data: MusicAssistantQueueEntryData = entry.runtime_data
mass_entry_data.listen_task.cancel()
await mass_entry_data.mass.disconnect()
return unload_ok
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: ConfigEntry,
device_entry: dr.DeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
player_id = next(
(
identifier[1]
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
),
None,
)
if player_id is None:
# this should not be possible at all, but guard it anyways
return False
mass = get_music_assistant_client(hass, config_entry.entry_id)
if mass.players.get(player_id) is None:
# player is already removed on the server, this is an orphaned device
return True
# try to remove the player from the server
try:
await mass.config.remove_player_config(player_id)
except ActionUnavailable:
return False
else:
return True
+529
View File
@@ -0,0 +1,529 @@
"""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
+494
View File
@@ -0,0 +1,494 @@
# ty:ignore[unresolved-import]
"""Config flow for integration."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_URL
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.config_entry_oauth2_flow import (
_encode_jwt,
async_get_redirect_uri,
)
from music_assistant_client import MusicAssistantClient
from music_assistant_client.auth_helpers import create_long_lived_token, get_server_info
from music_assistant_client.exceptions import (
CannotConnect,
InvalidServerVersion,
MusicAssistantClientException,
)
from music_assistant_models.api import ServerInfoMessage
from music_assistant_models.errors import AuthenticationFailed, InvalidToken
from .const import (
AUTH_SCHEMA_VERSION,
CONF_DOWNLOAD_LOCAL,
CONF_TOKEN,
DOMAIN,
HASSIO_DISCOVERY_SCHEMA_VERSION,
LOGGER,
)
if TYPE_CHECKING:
from collections.abc import Mapping
from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
DEFAULT_URL = "http://mass.local:8095"
DEFAULT_TITLE = "Music Assistant Queue Items"
DEFAULT_DOWNLOAD_LOCAL = False
STEP_AUTH_TOKEN_SCHEMA = vol.Schema({vol.Required(CONF_TOKEN): str})
def _parse_zeroconf_server_info(properties: dict[str, str]) -> ServerInfoMessage:
"""Parse zeroconf properties to ServerInfoMessage."""
return ServerInfoMessage(
server_id=properties["server_id"],
server_version=properties["server_version"],
schema_version=int(properties["schema_version"]),
min_supported_schema_version=int(properties["min_supported_schema_version"]),
base_url=properties["base_url"],
homeassistant_addon=properties["homeassistant_addon"].lower() == "true",
onboard_done=properties["onboard_done"].lower() == "true",
)
def get_manual_schema(user_input: dict[str, Any] | None) -> vol.Schema:
"""Return a schema for the manual step."""
default_url = (
user_input.get(CONF_URL, DEFAULT_URL)
if type(user_input) is dict
else DEFAULT_URL
)
return vol.Schema(
{
vol.Required(CONF_URL, default=default_url): str,
},
)
async def _get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage:
"""Validate the user input allows us to connect."""
session = aiohttp_client.async_get_clientsession(hass)
return await get_server_info(server_url=url, aiohttp_session=session)
async def _test_connection(hass: HomeAssistant, url: str, token: str) -> None:
"""Test connection to MA server with given URL and token."""
session = aiohttp_client.async_get_clientsession(hass)
async with MusicAssistantClient(
server_url=url,
aiohttp_session=session,
token=token,
) as client:
# Just executing any command to test the connection.
# If auth is required and the token is invalid, this will raise.
await client.send_command("info")
class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for MusicAssistant."""
VERSION = 1
def __init__(self) -> None:
"""Set up flow instance."""
self.url: str | None = None
self.token: str | None = None
self.server_info: ServerInfoMessage | None = None
async def async_step_user(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle a manual configuration."""
errors: dict[str, str] = {}
if user_input is not None:
self.url = user_input[CONF_URL]
try:
server_info = await _get_server_info(self.hass, self.url)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidServerVersion:
errors["base"] = "invalid_server_version"
except MusicAssistantClientException:
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self.server_info = server_info
await self.async_set_unique_id(
server_info.server_id,
raise_on_progress=False,
)
self._abort_if_unique_id_configured(updates={CONF_URL: self.url})
if server_info.schema_version >= AUTH_SCHEMA_VERSION:
return await self.async_step_auth()
return self.async_create_entry(
title=DEFAULT_TITLE,
data={
CONF_URL: self.url,
},
options={CONF_DOWNLOAD_LOCAL: DEFAULT_DOWNLOAD_LOCAL},
)
suggested_values = user_input
if suggested_values is None:
suggested_values = {CONF_URL: DEFAULT_URL}
form = get_manual_schema(user_input)
schema = self.add_suggested_values_to_schema(
form,
suggested_values,
)
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors,
)
async def async_step_hassio(
self,
discovery_info: HassioServiceInfo,
) -> ConfigFlowResult:
"""Handle Home Assistant add-on discovery.
This flow is triggered by the Music Assistant add-on.
"""
# Build URL from add-on discovery info
# The add-on exposes the API on port 8095, but also hosts an internal-only
# webserver (default at port 8094) for the Home Assistant integration to connect to.
# The info where the internal API is exposed is passed via discovery_info
host = discovery_info.config["host"]
port = discovery_info.config["port"]
self.url = f"http://{host}:{port}"
try:
server_info = await _get_server_info(self.hass, self.url)
except CannotConnect:
return self.async_abort(reason="cannot_connect")
except InvalidServerVersion:
return self.async_abort(reason="invalid_server_version")
except MusicAssistantClientException:
LOGGER.exception("Unexpected exception during add-on discovery")
return self.async_abort(reason="unknown")
if not server_info.onboard_done:
return self.async_abort(reason="server_not_ready")
# We trust the token from hassio discovery and validate it during setup
self.token = discovery_info.config["auth_token"]
self.server_info = server_info
# Check if there's an existing entry
if entry := await self.async_set_unique_id(server_info.server_id):
# Update the entry with new URL and token
if self.hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_URL: self.url, CONF_TOKEN: self.token},
) and entry.state in (
ConfigEntryState.LOADED,
ConfigEntryState.SETUP_ERROR,
ConfigEntryState.SETUP_RETRY,
):
self.hass.config_entries.async_schedule_reload(entry.entry_id)
# Abort since entry already exists
return self.async_abort(reason="already_configured")
return await self.async_step_hassio_confirm()
async def async_step_hassio_confirm(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Confirm the add-on discovery."""
if TYPE_CHECKING:
assert self.url is not None
if user_input is not None:
data = {CONF_URL: self.url}
if self.token:
data[CONF_TOKEN] = self.token
return self.async_create_entry(
title=DEFAULT_TITLE,
data=data,
)
self._set_confirm_only()
return self.async_show_form(step_id="hassio_confirm")
async def async_step_zeroconf(
self,
discovery_info: ZeroconfServiceInfo,
) -> ConfigFlowResult:
"""Handle a zeroconf discovery for a Music Assistant server."""
try:
# Parse zeroconf properties (strings) to ServerInfoMessage
server_info = _parse_zeroconf_server_info(discovery_info.properties)
except (LookupError, KeyError, ValueError):
return self.async_abort(reason="invalid_discovery_info")
if server_info.schema_version >= HASSIO_DISCOVERY_SCHEMA_VERSION:
# Ignore servers running as Home Assistant add-on
# (they should be discovered through hassio discovery instead)
if server_info.homeassistant_addon:
LOGGER.debug("Ignoring add-on server in zeroconf discovery")
return self.async_abort(reason="already_discovered_addon")
# Ignore servers that have not completed onboarding yet
if not server_info.onboard_done:
LOGGER.debug("Ignoring server that hasn't completed onboarding")
return self.async_abort(reason="server_not_ready")
self.url = server_info.base_url
self.server_info = server_info
await self.async_set_unique_id(server_info.server_id)
self._abort_if_unique_id_configured(updates={CONF_URL: self.url})
try:
await _get_server_info(self.hass, self.url)
except CannotConnect:
return self.async_abort(reason="cannot_connect")
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle user-confirmation of discovered server."""
if TYPE_CHECKING:
assert self.url is not None
assert self.server_info is not None
if user_input is not None:
# Check if authentication is required for this server
if self.server_info.schema_version >= AUTH_SCHEMA_VERSION:
# Redirect to browser-based authentication
return await self.async_step_auth()
# Old server, no auth needed
return self.async_create_entry(
title=DEFAULT_TITLE,
data={
CONF_URL: self.url,
},
options={CONF_DOWNLOAD_LOCAL: DEFAULT_DOWNLOAD_LOCAL},
)
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"url": self.url},
)
async def async_step_auth(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle authentication via redirect to MA login."""
if TYPE_CHECKING:
assert self.url is not None
# Check if we're returning from the external auth step with a token
if user_input is not None:
if "error" in user_input:
return self.async_abort(reason="auth_error")
# OAuth2 callback sends token as "code" parameter
if "code" in user_input:
self.token = user_input["code"]
return self.async_external_step_done(next_step_id="finish_auth")
# Check if we can use external auth (redirect flow)
try:
redirect_uri = async_get_redirect_uri(self.hass)
except RuntimeError:
# No current request context or missing required headers
return await self.async_step_auth_manual()
# Use OAuth2 callback URL with JWT-encoded state
state = _encode_jwt(
self.hass,
{"flow_id": self.flow_id, "redirect_uri": redirect_uri},
)
# Music Assistant server will redirect to: {redirect_uri}?state={state}&code={token}
params = urlencode(
{
"return_url": f"{redirect_uri}?state={state}",
"device_name": "Home Assistant",
},
)
login_url = f"{self.url}/login?{params}"
return self.async_external_step(step_id="auth", url=login_url)
async def async_step_finish_auth(
self,
user_input: dict[str, Any] | None = None, # noqa: ARG002
) -> ConfigFlowResult:
"""Finish authentication after receiving token."""
if TYPE_CHECKING:
assert self.url is not None
assert self.token is not None
# Exchange session token for long-lived token
# The login flow gives us a session token (short expiration)
session = aiohttp_client.async_get_clientsession(self.hass)
try:
LOGGER.debug("Creating long-lived token")
long_lived_token = await create_long_lived_token(
self.url,
self.token,
"Home Assistant",
aiohttp_session=session,
)
LOGGER.debug("Successfully created long-lived token")
except (TimeoutError, CannotConnect):
return self.async_abort(reason="cannot_connect")
except (AuthenticationFailed, InvalidToken) as err:
LOGGER.error("Authentication failed: %s", err)
return self.async_abort(reason="auth_failed")
except InvalidServerVersion as err:
LOGGER.error("Invalid server version: %s", err)
return self.async_abort(reason="invalid_server_version")
except MusicAssistantClientException:
LOGGER.exception("Unexpected exception during connection test")
return self.async_abort(reason="unknown")
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
reauth_entry,
data={CONF_URL: self.url, CONF_TOKEN: long_lived_token},
)
# Connection has been validated by creating a long-lived token
return self.async_create_entry(
title=DEFAULT_TITLE,
data={CONF_URL: self.url, CONF_TOKEN: long_lived_token},
)
async def async_step_auth_manual(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle manual token entry as fallback."""
if TYPE_CHECKING:
assert self.url is not None
errors: dict[str, str] = {}
if user_input is not None:
self.token = user_input[CONF_TOKEN]
try:
# Test the connection with the provided token
await _test_connection(self.hass, self.url, self.token)
except CannotConnect:
return self.async_abort(reason="cannot_connect")
except InvalidServerVersion:
return self.async_abort(reason="invalid_server_version")
except (AuthenticationFailed, InvalidToken):
errors["base"] = "auth_failed"
except MusicAssistantClientException:
LOGGER.exception("Unexpected exception during manual auth")
return self.async_abort(reason="unknown")
else:
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data={CONF_URL: self.url, CONF_TOKEN: self.token},
)
return self.async_create_entry(
title=DEFAULT_TITLE,
data={CONF_URL: self.url, CONF_TOKEN: self.token},
)
return self.async_show_form(
step_id="auth_manual",
data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}),
description_placeholders={"url": self.url},
errors=errors,
)
async def async_step_reauth(
self,
entry_data: Mapping[str, Any],
) -> ConfigFlowResult:
"""Handle reauth when token is invalid or expired."""
self.url = entry_data[CONF_URL]
# Show confirmation before redirecting to auth
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if TYPE_CHECKING:
assert self.url is not None
if user_input is not None:
# Redirect to auth flow
return await self.async_step_auth()
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={"url": self.url},
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlowHandler:
"""Gets the options flow for this handler."""
LOGGER.debug("Starting options flow.")
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(OptionsFlowWithReload):
"""Options config flow for Music Assistant Queue Actions."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options handler."""
self._config_entry = config_entry
self._download_local = config_entry.options.get(
CONF_DOWNLOAD_LOCAL,
DEFAULT_DOWNLOAD_LOCAL,
)
async def async_step_init(self, user_input=None) -> ConfigFlowResult:
"""Manage options."""
if user_input is not None:
LOGGER.debug("User input is not none, submitting data")
entry = self.async_create_entry(data=self.config_entry.options | user_input)
LOGGER.debug(f"Created entry {entry} ({dir(entry)})")
return entry
LOGGER.debug("User input is none, showing form...")
default_download = self._download_local
data_schema = vol.Schema(
{
vol.Required(CONF_DOWNLOAD_LOCAL, default=default_download): bool,
},
)
return self.async_show_form(
step_id="init",
data_schema=data_schema,
)
+70
View File
@@ -0,0 +1,70 @@
"""Constants for the NEW_NAME integration."""
import logging
AUTH_SCHEMA_VERSION = 28
HASSIO_DISCOVERY_SCHEMA_VERSION = 28
CONF_TOKEN = "token" # noqa: S105
DOMAIN = "mass_queue"
DEFAULT_NAME = "Music Assistant Queue Items"
SERVICE_CLEAR_QUEUE_FROM_HERE = "clear_queue_from_here"
SERVICE_GET_GROUP_VOLUME = "get_group_volume"
SERVICE_GET_ALBUM = "get_album"
SERVICE_GET_ALBUM_TRACKS = "get_album_tracks"
SERVICE_GET_ARTIST = "get_artist"
SERVICE_GET_ARTIST_TRACKS = "get_artist_tracks"
SERVICE_GET_PLAYLIST = "get_playlist"
SERVICE_GET_PLAYLIST_TRACKS = "get_playlist_tracks"
SERVICE_GET_PODCAST = "get_podcast"
SERVICE_GET_PODCAST_EPISODES = "get_podcast_episodes"
SERVICE_GET_QUEUE_ITEMS = "get_queue_items"
SERVICE_GET_RECOMMENDATIONS = "get_recommendations"
SERVICE_PLAY_QUEUE_ITEM = "play_queue_item"
SERVICE_MOVE_QUEUE_ITEM_DOWN = "move_queue_item_down"
SERVICE_MOVE_QUEUE_ITEM_NEXT = "move_queue_item_next"
SERVICE_MOVE_QUEUE_ITEM_UP = "move_queue_item_up"
SERVICE_REMOVE_PLAYLIST_TRACKS = "remove_playlist_tracks"
SERVICE_REMOVE_QUEUE_ITEM = "remove_queue_item"
SERVICE_SEND_COMMAND = "send_command"
SERVICE_SET_GROUP_VOLUME = "set_group_volume"
SERVICE_UNFAVORITE_CURRENT_ITEM = "unfavorite_current_item"
ATTR_COMMAND = "command"
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
ATTR_DATA = "data"
ATTR_DURATION = "duration"
ATTR_FAVORITE = "favorite"
ATTR_LIMIT = "limit"
ATTR_LIMIT_AFTER = "limit_after"
ATTR_LIMIT_BEFORE = "limit_before"
ATTR_LOCAL_IMAGE_ENCODED = "local_image_encoded"
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_MEDIA_TITLE = "media_title"
ATTR_OFFSET = "offset"
ATTR_PAGE = "page"
ATTR_PLAYER_ENTITY = "entity"
ATTR_PLAYLIST_ID = "playlist_id"
ATTR_URI = "uri"
ATTR_POSITION = "position"
ATTR_POSITIONS_TO_REMOVE = "positions_to_remove"
ATTR_PROVIDERS = "providers"
ATTR_QUEUE_ID = "active_queue"
ATTR_QUEUE_ITEM_ID = "queue_item_id"
ATTR_QUEUE_ITEMS = "queue_items"
ATTR_RELEASE_DATE = "release_date"
ATTR_VOLUME_LEVEL = "volume_level"
CONF_DOWNLOAD_LOCAL = "download_local"
LOGGER = logging.getLogger(__package__)
DEFAULT_QUEUE_ITEMS_LIMIT = 500
DEFAULT_QUEUE_ITEMS_OFFSET = -5
MUSIC_ASSISTANT_EVENT_DOMAIN = "mass_music_assistant"
MASS_QUEUE_EVENT_DOMAIN = "mass_queue"
+411
View File
@@ -0,0 +1,411 @@
# ty:ignore[unresolved-import]
"""Controller for queues, players cache."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from homeassistant.core import callback
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from music_assistant_client import MusicAssistantClient
from music_assistant_models.enums import EventType
from .const import (
CONF_DOWNLOAD_LOCAL,
DEFAULT_QUEUE_ITEMS_LIMIT,
DEFAULT_QUEUE_ITEMS_OFFSET,
LOGGER,
MASS_QUEUE_EVENT_DOMAIN,
MUSIC_ASSISTANT_EVENT_DOMAIN,
)
from .utils import (
download_and_encode_image,
find_image,
format_queue_updated_event_data,
generate_image_url_from_image_data,
get_queue_id_from_player_data,
)
class MassQueueController:
"""Controller to hold methods, handle events, and control caches of players and queues."""
def __init__(
self,
hass: HomeAssistant,
mass_client: MusicAssistantClient,
config_entry: ConfigEntry,
):
"""Initialize class."""
self._client = mass_client
self._hass = hass
self.players = Players(hass)
self.queues = Queues(hass, mass_client, config_entry)
self._config_entry = config_entry
self._download_local = config_entry.options.get(CONF_DOWNLOAD_LOCAL)
# Events
def subscribe_events(self):
"""Subscribe to Music Assistant events."""
self._client.subscribe(self.on_queue_update_event, EventType.QUEUE_UPDATED)
self._client.subscribe(
self.on_queue_items_update_event,
EventType.QUEUE_ITEMS_UPDATED,
)
self._client.subscribe(self.on_player_event, EventType.PLAYER_UPDATED)
def send_ha_event(self, event_data):
"""Send event to Home Assistant."""
LOGGER.debug(
f"Sending event type {MUSIC_ASSISTANT_EVENT_DOMAIN}, data {event_data}",
)
self._hass.bus.async_fire(MUSIC_ASSISTANT_EVENT_DOMAIN, event_data)
def on_queue_update_event(self, event):
"""Callback when queue update event is received."""
LOGGER.debug("Got updated queue.")
event_type = event.event
event_object_id = event.object_id
event_data = event.data
event_queue_id = event_data.get("queue_id")
self._hass.loop.create_task(self.update_queue_items(event_queue_id))
if event_data is None:
LOGGER.error(f"Event data is empty! Event: {event}")
return
data = format_queue_updated_event_data(event_data)
ha_event_data = {"type": event_type, "object_id": event_object_id, "data": data}
self.send_ha_event(ha_event_data)
def on_queue_items_update_event(self, event):
"""Callback when queue items update event is received."""
LOGGER.debug("Got updated queue items.")
event_type = event.event
event_object_id = event.object_id
event_data = event.data
event_queue_id = event_data.get("queue_id")
self._hass.loop.create_task(self.update_queue_items(event_queue_id))
if event_data is None:
LOGGER.error(f"Event data is empty! Event: {event}")
return
data = format_queue_updated_event_data(event_data)
ha_event_data = {"type": event_type, "object_id": event_object_id, "data": data}
self.send_ha_event(ha_event_data)
def on_player_event(self, event):
"""Callback when player event is received."""
event_type = event.event
event_object_id = event.object_id
event_data = event.data
event_player = event_data["player_id"]
self.update_player_queue(event_player)
if event_data is None:
LOGGER.error(f"Event data is empty! Event: {event}")
return
ha_event_data = {
"type": event_type,
"object_id": event_object_id,
"data": event.data,
}
self.send_ha_event(ha_event_data)
# All players
def get_all_players(self):
"""Get all Music Assistant players."""
players = self._client.players.players
result = {}
for player_data in players:
player_id = player_data.player_id
queue_id = get_queue_id_from_player_data(player_data)
result[player_id] = queue_id
return result
def update_players(self):
"""Update all Music Assistant players."""
LOGGER.debug("Updating all players.")
players = self.get_all_players()
self.players.batch_add(players)
# Individual players
def update_player_queue(self, player_id: str):
"""Update queue items for single Music Assistant queue."""
LOGGER.debug(f"Updating player {player_id}.")
player = self._client.players.get(player_id)
if player is None:
self.players.remove(player_id)
queue_id = get_queue_id_from_player_data(player)
self.players.update(player_id, queue_id)
async def send_command(self, command: str, data: dict | None = None):
"""Sends command to Music Assistant and returns response."""
data = data or {}
return await self._client.send_command(command, require_schema=None, **data)
async def get_recommendations(self, providers: list | None = None):
"""Pulls all recommendations."""
recs = await self._client.music.recommendations()
if not providers:
return recs
rec_providers = []
for rec in recs:
if rec.provider not in rec_providers:
rec_providers.append(rec.provider)
used_rec_providers = [
rec_provider
for rec_provider in rec_providers
for provider in providers
if rec_provider.startswith(provider)
]
return [rec for rec in recs if rec.provider in used_rec_providers]
async def get_grouped_volume(self, player_id: str):
"""Get the grouped volume for a given player."""
return self._client.players.get(player_id).group_volume
async def set_grouped_volume(self, player_id: str, volume_level: int):
"""Sets the grouped volume for a given player."""
await self._client.players.set_player_group_volume(player_id, volume_level)
async def get_player_queue(self, player_id: str):
"""Gets queue items for single Music Assistant queue."""
player = self._client.players.get(player_id)
queue_id = get_queue_id_from_player_data(player)
return await self.get_queue(queue_id)
# All queues
async def get_all_queues(self):
"""Gets queue items for all Music Assistant queues."""
queue_ids = [q.queue_id for q in self._client.player_queues.player_queues]
return {queue_id: await self.get_queue(queue_id) for queue_id in queue_ids}
async def update_queues(self):
"""Update queue items for all Music Assistant queues."""
LOGGER.debug("Updating all queues.")
queues = await self.get_all_queues()
self.queues.batch_add(queues)
# Individual queues
async def player_queue(
self,
queue_id: str,
limit: int = DEFAULT_QUEUE_ITEMS_LIMIT,
offset: int = DEFAULT_QUEUE_ITEMS_OFFSET,
):
"""Get the cached queue items for a single queue."""
queue = self.queues.get(queue_id)
if offset == -1:
try:
offset = await self.get_queue_index(queue_id) - 5
except IndexError:
offset = 0
offset = max(offset, 0)
return queue[offset : offset + limit] if queue else []
async def update_queue_items(self, queue_id: str):
"""Update the queue items for a single queue."""
LOGGER.debug(f"Updating queue {queue_id}.")
queue = await self.get_queue(queue_id)
self.queues.update(queue_id, queue)
async def get_queue(
self,
queue_id: str,
limit: int = DEFAULT_QUEUE_ITEMS_LIMIT,
offset: int = DEFAULT_QUEUE_ITEMS_OFFSET,
):
"""Get the queue items for a single queue."""
if offset == -1:
try:
offset = await self.get_queue_index(queue_id) - 5
except IndexError:
offset = 0
offset = max(offset, 0)
# HA 2025.12 Fix: `get_player_queue_items` replaced with `get_queue_items`
try:
return await self._client.player_queues.get_queue_items(
queue_id=queue_id,
limit=limit,
offset=offset,
)
except AttributeError:
return await self._client.player_queues.get_player_queue_items(
queue_id=queue_id,
limit=limit,
offset=offset,
)
async def get_active_queue(self, queue_id: str):
"""Get the active queue for a single queue."""
return await self._client.player_queues.get_active_queue(queue_id)
async def get_queue_index(self, queue_id: str):
"""Get the active queue index for a single queue."""
active_queue = await self.get_active_queue(queue_id)
return active_queue.current_index
class Players:
"""Class to hold all player caches."""
def __init__(self, hass: HomeAssistant, players: dict | None = None):
"""Initialize class."""
self.players = players if players is not None else {}
self._hass = hass
def get(self, player_id):
"""Returns cached player records."""
return self.players.get(player_id)
def add(self, player_id: str, queue_id: str | None):
"""Adds a single player."""
self.players[player_id] = queue_id
event_data = {
"type": "player_added",
"data": {"player_id": player_id, "queue_id": queue_id},
}
self.send_ha_event(event_data)
def batch_add(self, players: dict):
"""Adds multiple players at once."""
for k, v in players.items():
self.players[k] = v
event_data = {"type": "player_added", "data": {"players": players}}
self.send_ha_event(event_data)
def remove(self, player_id: str):
"""Removes a single player."""
if player_id in self.players:
self.players.pop(player_id)
event_data = {
"type": "player_removed",
"data": {
"player_id": player_id,
},
}
self.send_ha_event(event_data)
def update(self, player_id: str, queue_id: str):
"""Updates the queue ID of a single player."""
if player_id not in self.players:
return
current_queue_id = self.players[player_id]
if current_queue_id == queue_id:
pass
self.players[player_id] = queue_id
event_data = {
"type": "player_updated",
"data": {"player_id": player_id, "queue_id": queue_id},
}
self.send_ha_event(event_data)
def send_ha_event(self, event_data):
"""Send event to Home Assistant."""
LOGGER.debug(
f"Sending event type {MASS_QUEUE_EVENT_DOMAIN}, data {event_data}",
)
self._hass.bus.async_fire(MASS_QUEUE_EVENT_DOMAIN, event_data)
class Queues:
"""Class to hold all queue caches."""
def __init__(
self,
hass: HomeAssistant,
client: MusicAssistantClient,
config_entry: ConfigEntry,
queues: dict | None = None,
):
"""Initialize class."""
self.queues = self.batch_add(queues) if queues else {}
self._hass = hass
self._config_entry = config_entry
self._download_local = config_entry.options.get(CONF_DOWNLOAD_LOCAL)
self._client = client
def get(self, queue_id):
"""Returns cached queue records."""
return self.queues.get(queue_id, [])
def add(self, queue_id: str, queue_items: int):
"""Adds a single queue."""
self.process_queue_images(queue_items, queue_id)
event_data = {"type": "queue_added", "data": {"queue_id": queue_id}}
self.send_ha_event(event_data)
def batch_add(self, queues):
"""Adds multiple queues at once."""
for k, v in queues.items():
self.process_queue_images(v, k)
event_data = {"type": "queues_added", "data": {"queue_id": list(queues.keys())}}
self.send_ha_event(event_data)
def update(self, queue_id, queue_items):
"""Updates queue items in record."""
self.queues[queue_id] = self.process_queue_images(queue_items, queue_id)
event_data = {"type": "queue_updated", "data": {"queue_id": queue_id}}
self.send_ha_event(event_data)
def remove(self, queue_id):
"""Removes queue from record."""
if queue_id not in self.queues:
return
self.queues.pop(queue_id)
event_data = {"type": "queue_removed", "data": {"queue_id": queue_id}}
self.send_ha_event(event_data)
def send_ha_event(self, event_data):
"""Send event to Home Assistant."""
LOGGER.debug(
f"Sending event type {MASS_QUEUE_EVENT_DOMAIN}, data {event_data}",
)
self._hass.bus.async_fire(MASS_QUEUE_EVENT_DOMAIN, event_data)
async def process_image_single_item(self, queue_item: dict):
"""Processes the images from a single item."""
media_image = find_image(queue_item)
if media_image:
queue_item["media_image"] = media_image
else:
queue_item["media_image"] = ""
if self._download_local:
try:
LOGGER.debug("Expected to download locally.")
img_data = queue_item["media_item"]["metadata"]["images"][0]
url = generate_image_url_from_image_data(img_data, self._client)
LOGGER.debug(f"Downloading URL {url}")
result = await download_and_encode_image(url)
LOGGER.debug("Downloaded and setting")
queue_item["local_image_encoded"] = result
except Exception as e: # noqa: BLE001
LOGGER.debug(
f"Received error {e} when downloading image for queue item: {queue_item}",
)
else:
LOGGER.debug("No media image found but not expected to download.")
return queue_item
async def _process_queue_images(self, queue_items: list, queue_id: str):
"""Helper to process all images in a given queue."""
items = [item if type(item) is dict else item.to_dict() for item in queue_items]
try:
result = await asyncio.gather(
*[self.process_image_single_item(item) for item in items],
)
except: # noqa: E722
LOGGER.error(f"Unable to process queue items {items}!")
result = items
self.queues[queue_id] = result
return result
@callback
def process_queue_images(self, queue_items: list, queue_id: str):
"""Processes all images in a given queue."""
loop = self._hass.loop
LOGGER.debug(f"Processing queue: {queue_items}")
loop.create_task(self._process_queue_images(queue_items, queue_id))
+25
View File
@@ -0,0 +1,25 @@
{
"services": {
"get_album": { "service": "mdi:album"},
"get_album_tracks": { "service": "mdi:album"},
"get_artist": { "service": "mdi:account-music"},
"get_artist_tracks": { "service": "mdi:account-music"},
"get_playlist": { "service": "mdi:playlist-music"},
"get_playlist_tracks": { "service": "mdi:playlist-music"},
"get_podcast": { "service": "mdi:podcast"},
"get_podcast_episodes": { "service": "mdi:podcast"},
"get_queue_items": { "service": "mdi:playlist-music" },
"move_queue_item_down": { "service": "mdi:arrow-down" },
"move_queue_item_next": { "service": "mdi:arrow-collapse-up" },
"move_queue_item_up": { "service": "mdi:arrow-up" },
"play_queue_item": { "service": "mdi:play" },
"remove_queue_item": { "service": "mdi:close" },
"send_command": { "service": "mdi:send" },
"unfavorite_current_item": { "service": "mdi:heart-remove" },
"get_recommendations": { "service": "mdi:creation"},
"get_group_volume": { "service": "mdi:speaker-multiple"},
"set_group_volume": { "service": "mdi:speaker-multiple"},
"clear_queue_from_here": { "service": "mdi:playlist-remove"},
"remove_playlist_tracks": { "service": "mdi:playlist-remove"}
}
}
@@ -0,0 +1,16 @@
{
"domain": "mass_queue",
"name": "Music Assistant Queue Actions",
"codeowners": ["@droans"],
"config_flow": true,
"dependencies": ["auth", "music_assistant"],
"documentation": "https://www.github.com/droans/mass_queue",
"homekit": {},
"integration_type": "service",
"iot_class": "local_push",
"issue_tracker": "https://github.com/droans/mass_queue/issues",
"requirements": ["music-assistant-client"],
"ssdp": [],
"version": "0.10.2",
"zeroconf": ["_mass._tcp.local."]
}
+181
View File
@@ -0,0 +1,181 @@
# ty:ignore[unresolved-import]
"""Schemas."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_COMMAND,
ATTR_CONFIG_ENTRY_ID,
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_PAGE,
ATTR_PLAYER_ENTITY,
ATTR_PLAYLIST_ID,
ATTR_POSITION,
ATTR_POSITIONS_TO_REMOVE,
ATTR_PROVIDERS,
ATTR_QUEUE_ITEM_ID,
ATTR_QUEUE_ITEMS,
ATTR_URI,
ATTR_VOLUME_LEVEL,
)
CLEAR_QUEUE_FROM_HERE_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_PLAYER_ENTITY): str,
},
)
GET_GROUP_VOLUME_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_PLAYER_ENTITY): str,
},
)
QUEUE_ITEM_SCHEMA = vol.Schema(
{
vol.Required(ATTR_QUEUE_ITEM_ID): str,
vol.Required(ATTR_MEDIA_TITLE): str,
vol.Required(ATTR_MEDIA_ALBUM_NAME): str,
vol.Required(ATTR_MEDIA_ARTIST): str,
vol.Required(ATTR_MEDIA_CONTENT_ID): str,
vol.Required(ATTR_MEDIA_IMAGE): str,
vol.Required(ATTR_FAVORITE): bool,
vol.Optional(ATTR_LOCAL_IMAGE_ENCODED): str,
},
)
TRACK_ITEM_SCHEMA = vol.Schema(
{
vol.Required(ATTR_MEDIA_TITLE): str,
vol.Optional(ATTR_MEDIA_ALBUM_NAME): str,
vol.Optional(ATTR_MEDIA_ARTIST): str,
vol.Required(ATTR_MEDIA_CONTENT_ID): str,
vol.Required(ATTR_MEDIA_IMAGE): str,
vol.Required(ATTR_FAVORITE): bool,
vol.Required(ATTR_DURATION): vol.Any(int, None),
vol.Optional(ATTR_LOCAL_IMAGE_ENCODED): str,
vol.Optional(ATTR_POSITION): str,
},
)
QUEUE_DETAILS_SCHEMA = vol.Schema(
{
vol.Required(ATTR_QUEUE_ITEMS): vol.All(
cv.ensure_list,
[vol.Schema(QUEUE_ITEM_SCHEMA)],
),
},
)
QUEUE_ITEMS_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_PLAYER_ENTITY): str,
vol.Optional(ATTR_OFFSET): int,
vol.Optional(ATTR_LIMIT): int,
vol.Optional(ATTR_LIMIT_BEFORE): int,
vol.Optional(ATTR_LIMIT_AFTER): int,
},
)
PLAY_QUEUE_ITEM_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_PLAYER_ENTITY): str,
vol.Required(ATTR_QUEUE_ITEM_ID): str,
},
)
REMOVE_QUEUE_ITEM_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_PLAYER_ENTITY): str,
vol.Required(ATTR_QUEUE_ITEM_ID): str,
},
)
MOVE_QUEUE_ITEM_UP_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_PLAYER_ENTITY): str,
vol.Required(ATTR_QUEUE_ITEM_ID): str,
},
)
MOVE_QUEUE_ITEM_DOWN_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_PLAYER_ENTITY): str,
vol.Required(ATTR_QUEUE_ITEM_ID): str,
},
)
MOVE_QUEUE_ITEM_NEXT_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_PLAYER_ENTITY): str,
vol.Required(ATTR_QUEUE_ITEM_ID): str,
},
)
SEND_COMMAND_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_COMMAND): str,
vol.Optional(ATTR_DATA, default={}): dict,
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
},
)
GET_TRACKS_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
vol.Required(ATTR_URI): str,
vol.Optional(ATTR_PAGE): int,
},
)
GET_PODCAST_EPISODES_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
vol.Required(ATTR_URI): str,
},
)
GET_DATA_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
vol.Required(ATTR_URI): str,
},
)
GET_RECOMMENDATIONS_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_PLAYER_ENTITY): str,
vol.Optional(ATTR_PROVIDERS): [str],
},
)
UNFAVORITE_CURRENT_ITEM_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_PLAYER_ENTITY): str,
},
)
SET_GROUP_VOLUME_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_PLAYER_ENTITY): str,
vol.Required(ATTR_VOLUME_LEVEL): int,
},
)
REMOVE_PLAYLIST_TRACKS_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): str,
vol.Required(ATTR_PLAYLIST_ID): vol.Any(int, str),
vol.Required(ATTR_POSITIONS_TO_REMOVE): vol.Any(int, [int], str, [str]),
},
)
+439
View File
@@ -0,0 +1,439 @@
# ty:ignore[unresolved-import]
"""Service actions for mass_queue."""
from __future__ import annotations
from homeassistant.core import (
ServiceCall,
SupportsResponse,
callback,
)
from .const import (
ATTR_CONFIG_ENTRY_ID,
ATTR_PAGE,
ATTR_PLAYER_ENTITY,
ATTR_PLAYLIST_ID,
ATTR_POSITIONS_TO_REMOVE,
ATTR_QUEUE_ITEM_ID,
ATTR_URI,
DOMAIN,
LOGGER,
SERVICE_CLEAR_QUEUE_FROM_HERE,
SERVICE_GET_ALBUM,
SERVICE_GET_ALBUM_TRACKS,
SERVICE_GET_ARTIST,
SERVICE_GET_ARTIST_TRACKS,
SERVICE_GET_GROUP_VOLUME,
SERVICE_GET_PLAYLIST,
SERVICE_GET_PLAYLIST_TRACKS,
SERVICE_GET_PODCAST,
SERVICE_GET_PODCAST_EPISODES,
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_PLAYLIST_TRACKS,
SERVICE_REMOVE_QUEUE_ITEM,
SERVICE_SEND_COMMAND,
SERVICE_SET_GROUP_VOLUME,
SERVICE_UNFAVORITE_CURRENT_ITEM,
)
from .schemas import (
CLEAR_QUEUE_FROM_HERE_SERVICE_SCHEMA,
GET_DATA_SERVICE_SCHEMA,
GET_GROUP_VOLUME_SERVICE_SCHEMA,
GET_PODCAST_EPISODES_SERVICE_SCHEMA,
GET_RECOMMENDATIONS_SERVICE_SCHEMA,
GET_TRACKS_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_ITEMS_SERVICE_SCHEMA,
REMOVE_PLAYLIST_TRACKS_SERVICE_SCHEMA,
REMOVE_QUEUE_ITEM_SERVICE_SCHEMA,
SEND_COMMAND_SERVICE_SCHEMA,
SET_GROUP_VOLUME_SERVICE_SCHEMA,
UNFAVORITE_CURRENT_ITEM_SERVICE_SCHEMA,
)
from .utils import get_entity_actions_controller, process_recommendations
@callback
def register_actions(hass) -> None:
"""Registers actions with Home Assistant."""
hass.services.async_register(
DOMAIN,
SERVICE_GET_QUEUE_ITEMS,
get_queue_items,
schema=QUEUE_ITEMS_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_MOVE_QUEUE_ITEM_DOWN,
move_queue_item_down,
schema=MOVE_QUEUE_ITEM_DOWN_SERVICE_SCHEMA,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
SERVICE_MOVE_QUEUE_ITEM_NEXT,
move_queue_item_next,
schema=MOVE_QUEUE_ITEM_NEXT_SERVICE_SCHEMA,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
SERVICE_MOVE_QUEUE_ITEM_UP,
move_queue_item_up,
schema=MOVE_QUEUE_ITEM_UP_SERVICE_SCHEMA,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
SERVICE_PLAY_QUEUE_ITEM,
play_queue_item,
schema=PLAY_QUEUE_ITEM_SERVICE_SCHEMA,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
SERVICE_REMOVE_QUEUE_ITEM,
remove_queue_item,
schema=REMOVE_QUEUE_ITEM_SERVICE_SCHEMA,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
SERVICE_SEND_COMMAND,
send_command,
schema=SEND_COMMAND_SERVICE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)
hass.services.async_register(
DOMAIN,
SERVICE_UNFAVORITE_CURRENT_ITEM,
unfavorite_current_item,
schema=UNFAVORITE_CURRENT_ITEM_SERVICE_SCHEMA,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_RECOMMENDATIONS,
get_recommendations,
schema=GET_RECOMMENDATIONS_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_GROUP_VOLUME,
get_group_volume,
schema=GET_GROUP_VOLUME_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_SET_GROUP_VOLUME,
set_group_volume,
schema=SET_GROUP_VOLUME_SERVICE_SCHEMA,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
SERVICE_CLEAR_QUEUE_FROM_HERE,
clear_queue_from_here,
schema=CLEAR_QUEUE_FROM_HERE_SERVICE_SCHEMA,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_PLAYLIST_TRACKS,
get_playlist_tracks,
schema=GET_TRACKS_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_ALBUM_TRACKS,
get_album_tracks,
schema=GET_TRACKS_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_ARTIST_TRACKS,
get_artist_tracks,
schema=GET_TRACKS_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_PODCAST_EPISODES,
get_podcast_episodes,
schema=GET_PODCAST_EPISODES_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_ALBUM,
get_album,
schema=GET_DATA_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_ARTIST,
get_artist,
schema=GET_DATA_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_PLAYLIST,
get_playlist,
schema=GET_DATA_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_PODCAST,
get_podcast,
schema=GET_DATA_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_REMOVE_PLAYLIST_TRACKS,
remove_playlist_tracks,
schema=REMOVE_PLAYLIST_TRACKS_SERVICE_SCHEMA,
supports_response=SupportsResponse.NONE,
)
async def get_queue_items(call: ServiceCall):
"""Service wrapper to get queue items."""
entity_id = call.data[ATTR_PLAYER_ENTITY]
hass = call.hass
actions = get_entity_actions_controller(hass, entity_id)
return await actions.get_queue_items(call)
async def move_queue_item_down(call: ServiceCall):
"""Service wrapper to move queue item down."""
entity_id = call.data[ATTR_PLAYER_ENTITY]
hass = call.hass
actions = get_entity_actions_controller(hass, entity_id)
return await actions.move_queue_item_down(call)
async def move_queue_item_next(call: ServiceCall):
"""Service wrapper to move queue item next."""
entity_id = call.data[ATTR_PLAYER_ENTITY]
hass = call.hass
actions = get_entity_actions_controller(hass, entity_id)
return await actions.move_queue_item_next(call)
async def move_queue_item_up(call: ServiceCall):
"""Service wrapper to move queue item up."""
entity_id = call.data[ATTR_PLAYER_ENTITY]
hass = call.hass
actions = get_entity_actions_controller(hass, entity_id)
return await actions.move_queue_item_up(call)
async def play_queue_item(call: ServiceCall):
"""Service wrapper to play a queue item."""
entity_id = call.data[ATTR_PLAYER_ENTITY]
hass = call.hass
actions = get_entity_actions_controller(hass, entity_id)
return await actions.play_queue_item(call)
async def remove_queue_item(call: ServiceCall):
"""Service wrapper to remove a queue item."""
entity_id = call.data[ATTR_PLAYER_ENTITY]
hass = call.hass
actions = get_entity_actions_controller(hass, entity_id)
return await actions.remove_queue_item(call)
async def send_command(call: ServiceCall):
"""Service wrapper to send command to Music Assistant."""
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
hass = call.hass
entry = hass.config_entries.async_get_entry(entry_id)
actions = entry.runtime_data.actions
return await actions.send_command(call)
async def unfavorite_current_item(call: ServiceCall):
"""Service wrapper to unfavorite currently playing item."""
entity_id = call.data[ATTR_PLAYER_ENTITY]
hass = call.hass
actions = get_entity_actions_controller(hass, entity_id)
await actions.unfavorite_item(call)
async def get_recommendations(call: ServiceCall):
"""Service wrapper to get recommendations from providers."""
entity_id = call.data[ATTR_PLAYER_ENTITY]
hass = call.hass
actions = get_entity_actions_controller(hass, entity_id)
result = await actions.get_recommendations(call)
return {"response": process_recommendations(result)}
async def get_group_volume(call: ServiceCall):
"""Service wrapper to get grouped volume."""
entity_id = call.data[ATTR_PLAYER_ENTITY]
hass = call.hass
actions = get_entity_actions_controller(hass, entity_id)
result = await actions.get_group_volume(call)
return {"volume_level": result}
async def set_group_volume(call: ServiceCall):
"""Service wrapper to set grouped volume."""
entity_id = call.data[ATTR_PLAYER_ENTITY]
hass = call.hass
actions = get_entity_actions_controller(hass, entity_id)
await actions.set_group_volume(call)
def filter_queue_after(queue, current_idx):
"""Returns all items after the current active track."""
if current_idx == len(queue):
return []
return queue[current_idx + 1 :]
async def clear_queue_from_here(call: ServiceCall):
"""Service wrapper to clear queue from point."""
entity_id = call.data[ATTR_PLAYER_ENTITY]
hass = call.hass
actions = get_entity_actions_controller(hass, entity_id)
current_idx = await actions.get_queue_index(entity_id)
LOGGER.debug(f"Current Index: {current_idx}")
queue_id = actions.get_queue_id(entity_id)
LOGGER.debug(f"Queue ID: {queue_id}")
queue = actions._controller.queues.get(queue_id)
LOGGER.debug(f"Queue length: {len(queue)}")
client = actions._client
if len(queue) == current_idx:
return
items = queue[current_idx + 1 :]
LOGGER.debug(f"Filtered length: {len(items)}")
LOGGER.debug(f"First item to remove {items[0]}")
LOGGER.debug(f"Last item to remove {items[-1]}")
for item in items:
queue_item_id = item[ATTR_QUEUE_ITEM_ID]
await client.player_queues.queue_command_delete(queue_id, queue_item_id)
async def get_album_tracks(call: ServiceCall):
"""Gets all tracks in an album."""
config_entry = call.data[ATTR_CONFIG_ENTRY_ID]
uri = call.data[ATTR_URI]
page = call.data.get(ATTR_PAGE)
hass = call.hass
entry = hass.config_entries.async_get_entry(config_entry)
actions = entry.runtime_data.actions
return {
"tracks": await actions.get_album_tracks(uri, page),
}
async def get_artist_tracks(call: ServiceCall):
"""Gets all tracks for an artist."""
config_entry = call.data[ATTR_CONFIG_ENTRY_ID]
uri = call.data[ATTR_URI]
hass = call.hass
entry = hass.config_entries.async_get_entry(config_entry)
actions = entry.runtime_data.actions
return {
"tracks": await actions.get_artist_tracks(uri),
}
async def get_playlist_tracks(call: ServiceCall):
"""Gets all tracks in a playlist."""
config_entry = call.data[ATTR_CONFIG_ENTRY_ID]
uri = call.data[ATTR_URI]
page = call.data.get(ATTR_PAGE)
hass = call.hass
entry = hass.config_entries.async_get_entry(config_entry)
actions = entry.runtime_data.actions
return {
"tracks": await actions.get_playlist_tracks(uri, page),
}
async def get_podcast_episodes(call: ServiceCall):
"""Gets all episodes for a podcast."""
config_entry = call.data[ATTR_CONFIG_ENTRY_ID]
uri = call.data[ATTR_URI]
hass = call.hass
entry = hass.config_entries.async_get_entry(config_entry)
actions = entry.runtime_data.actions
return {
"episodes": await actions.get_podcast_episodes(uri),
}
async def get_album(call: ServiceCall):
"""Returns the details about an album from the server."""
config_entry = call.data[ATTR_CONFIG_ENTRY_ID]
uri = call.data[ATTR_URI]
hass = call.hass
entry = hass.config_entries.async_get_entry(config_entry)
actions = entry.runtime_data.actions
return (await actions.get_album_details(uri)).to_dict()
async def get_artist(call: ServiceCall):
"""Returns the details about an artist from the server."""
config_entry = call.data[ATTR_CONFIG_ENTRY_ID]
uri = call.data[ATTR_URI]
hass = call.hass
entry = hass.config_entries.async_get_entry(config_entry)
actions = entry.runtime_data.actions
return (await actions.get_artist_details(uri)).to_dict()
async def get_playlist(call: ServiceCall):
"""Returns the details about a playlist from the server."""
config_entry = call.data[ATTR_CONFIG_ENTRY_ID]
uri = call.data[ATTR_URI]
hass = call.hass
entry = hass.config_entries.async_get_entry(config_entry)
actions = entry.runtime_data.actions
return (await actions.get_playlist_details(uri)).to_dict()
async def get_podcast(call: ServiceCall):
"""Returns the details about a podcast from the server."""
config_entry = call.data[ATTR_CONFIG_ENTRY_ID]
uri = call.data[ATTR_URI]
hass = call.hass
entry = hass.config_entries.async_get_entry(config_entry)
actions = entry.runtime_data.actions
return (await actions.get_podcast_details(uri)).to_dict()
async def remove_playlist_tracks(call: ServiceCall):
"""Removes one or more items from a playlist."""
config_entry = call.data[ATTR_CONFIG_ENTRY_ID]
playlist = call.data[ATTR_PLAYLIST_ID]
positions = call.data[ATTR_POSITIONS_TO_REMOVE]
if isinstance(positions, int):
positions = [positions]
positions = [int(position) for position in positions]
hass = call.hass
entry = hass.config_entries.async_get_entry(config_entry)
actions = entry.runtime_data.actions
await actions.remove_playlist_tracks(playlist, positions)
+383
View File
@@ -0,0 +1,383 @@
get_queue_items:
fields:
limit:
name: Limit
description: Limit on the number of items in queue to return
required: false
example: 500
selector:
number:
min: 1
max: 1000
step: 1
offset:
name: Offset
description: Location in queue to start where zero equals the first item in queue, not the current item.
required: false
advanced: true
example: 50
selector:
number:
min: 1
max: 1000
step: 1
limit_before:
name: Limit Before
description: Number of items to pull before current active item in queue.
required: false
example: 5
default: 5
selector:
number:
min: 1
max: 1000
step: 1
limit_after:
name: Limit After
description: Number of items to pull after current active item in queue.
required: false
example: 50
default: 100
selector:
number:
min: 1
max: 1000
step: 1
entity:
name: Entity
description: Music Assistant Media Player Entity
required: true
selector:
entity:
domain: media_player
integration: music_assistant
remove_queue_item:
fields:
queue_item_id:
name: Queue Item ID
required: true
selector:
text:
entity:
name: Entity
description: Music Assistant Media Player Entity
required: true
selector:
entity:
domain: media_player
integration: music_assistant
move_queue_item_up:
fields:
queue_item_id:
name: Queue Item ID
required: true
selector:
text:
entity:
name: Entity
description: Music Assistant Media Player Entity
required: true
selector:
entity:
domain: media_player
integration: music_assistant
move_queue_item_down:
fields:
queue_item_id:
name: Queue Item ID
required: true
selector:
text:
entity:
name: Entity
description: Music Assistant Media Player Entity
required: true
selector:
entity:
domain: media_player
integration: music_assistant
move_queue_item_next:
fields:
queue_item_id:
name: Queue Item ID
required: true
selector:
text:
entity:
name: Entity
description: Music Assistant Media Player Entity
required: true
selector:
entity:
domain: media_player
integration: music_assistant
play_queue_item:
fields:
queue_item_id:
name: Queue Item ID
required: true
selector:
text:
entity:
name: Entity
description: Music Assistant Media Player Entity
required: true
selector:
entity:
domain: media_player
integration: music_assistant
send_command:
fields:
command:
name: Command
description: Command to send to Music Assistant
required: true
selector:
text:
data:
name: Data
description: Command data to send
required: false
default: {}
selector:
object:
config_entry_id:
name: Config Entry ID
required: true
selector:
config_entry:
integration: mass_queue
unfavorite_current_item:
fields:
entity:
name: Entity
description: Music Assistant Media Player Entity
required: true
selector:
entity:
domain: media_player
integration: music_assistant
get_recommendations:
fields:
entity:
name: Entity
description: Music Assistant Media Player Entity
required: true
selector:
entity:
domain: media_player
integration: music_assistant
providers:
name: Providers
description: Limit recommendations to the specified providers.
required: false
selector:
text:
multiple: true
get_group_volume:
fields:
entity:
name: Entity
description: Music Assistant Media Player Entity
required: true
selector:
entity:
domain: media_player
integration: music_assistant
set_group_volume:
fields:
entity:
name: Entity
description: Music Assistant Media Player Entity
required: true
selector:
entity:
domain: media_player
integration: music_assistant
volume_level:
name: Volume Level
description: Volume level to set the player to.
required: true
selector:
number:
min: 1
max: 100
step: 1
unit_of_measurement: "%"
mode: slider
clear_queue_from_here:
fields:
entity:
name: Entity
description: Music Assistant Media Player Entity
required: true
selector:
entity:
domain: media_player
integration: music_assistant
get_playlist_tracks:
fields:
config_entry_id:
name: Config Entry ID
required: true
selector:
config_entry:
integration: mass_queue
uri:
name: Playlist URI
description: URI for the playlist
selector:
text:
example: "library://playlist/12"
required: true
page:
selector:
number:
min: 0
max: 1000
step: 1
mode: box
required: false
example: 0
get_album_tracks:
fields:
config_entry_id:
name: Config Entry ID
required: true
selector:
config_entry:
integration: mass_queue
uri:
name: Album URI
description: URI for the album
selector:
text:
example: "library://album/12"
required: true
page:
selector:
number:
min: 0
max: 1000
step: 1
mode: box
required: false
example: 0
get_artist_tracks:
fields:
config_entry_id:
name: Config Entry ID
required: true
selector:
config_entry:
integration: mass_queue
uri:
name: Artist URI
description: URI for the artist
selector:
text:
example: "library://artist/12"
required: true
page:
selector:
number:
min: 0
max: 1000
step: 1
mode: box
required: false
example: 0
get_podcast_episodes:
fields:
config_entry_id:
name: Config Entry ID
required: true
selector:
config_entry:
integration: mass_queue
uri:
name: Podcast URI
description: URI for the podcast
selector:
text:
example: "library://podcast/12"
required: true
get_album:
fields:
config_entry_id:
name: Config Entry ID
required: true
selector:
config_entry:
integration: mass_queue
uri:
name: Album URI
description: URI for the Album
selector:
text:
example: "library://album/12"
required: true
get_artist:
fields:
config_entry_id:
name: Config Entry ID
required: true
selector:
config_entry:
integration: mass_queue
uri:
name: Artist URI
description: URI for the artist
selector:
text:
example: "library://artist/12"
required: true
get_playlist:
fields:
config_entry_id:
name: Config Entry ID
required: true
selector:
config_entry:
integration: mass_queue
uri:
name: Playlist URI
description: URI for the playlist
selector:
text:
example: "library://playlist/12"
required: true
get_podcast:
fields:
config_entry_id:
name: Config Entry ID
required: true
selector:
config_entry:
integration: mass_queue
uri:
name: Podcast URI
description: URI for the podcast
selector:
text:
example: "library://podcast/12"
required: true
remove_playlist_tracks:
fields:
config_entry_id:
name: Config Entry ID
required: true
selector:
config_entry:
integration: mass_queue
playlist_id:
required: true
selector:
text:
positions_to_remove:
required: true
selector:
text:
multiple: true
+389
View File
@@ -0,0 +1,389 @@
{
"config": {
"step": {
"auth_manual": {
"data": {
"token": "Long-lived access token"
},
"data_description": {
"token": "Create a long-lived access token in your Music Assistant server settings and paste it here"
},
"title": "Enter long-lived access token"
},
"init": {
"data": {
"url": "URL of the Music Assistant server"
}
},
"manual": {
"title": "Manually add Music Assistant server",
"description": "Enter the URL to your already running Music Assistant server. If you do not have the Music Assistant server running, you should install it first.",
"data": {
"url": "URL of the Music Assistant server"
}
},
"discovery_confirm": {
"description": "Do you want to add the Music Assistant server `{url}` to Home Assistant?",
"title": "Discovered Music Assistant server"
},
"hassio_confirm": {
"description": "Do you want to add the Music Assistant server to Home Assistant?",
"title": "Discovered Music Assistant add-on"
},
"reauth_confirm": {
"description": "The authentication token for Music Assistant server `{url}` is no longer valid. Please re-authenticate to continue using the integration.",
"title": "Reauthentication required"
}
},
"error": {
"auth_failed": "[%key:component::music_assistant::config::abort::auth_failed%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_server_version": "[%key:component::music_assistant::config::abort::invalid_server_version%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"auth_error": "Authentication error, please try again",
"auth_failed": "Authentication failed, please try again",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_server_version": "The Music Assistant server is not the correct version",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"options": {
"step": {
"init": {
"data": {
"download_local": "Attempt fallback support for local media images. May cause performance issues. Only enable if you cannot see media images for your players."
}
}
},
"error": {
"invalid_det_rules": "Invalid state detection rules"
}
},
"issues": {
"invalid_server_version": {
"title": "The Music Assistant server is not the correct version",
"description": "Check if there are updates available for the Music Assistant server and/or integration."
}
},
"services": {
"get_queue_items": {
"name": "Get Queue Items",
"description": "Returns a list of the current items in a player's queue.",
"fields": {
"limit": {
"name": "Limit",
"description": "Limit on the number of items in queue to return."
},
"offset": {
"name": "Offset",
"description": "Location in queue to start where zero equals the first item in queue, not the current item.."
},
"limit_before": {
"name": "Limit Before",
"description": "Number of items to pull before current active item in queue.."
},
"limit_after": {
"name": "Limit After",
"description": "Number of items to pull after current active item in queue.."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"remove_queue_item": {
"name": "Remove Queue Item",
"description": "Removes an item from the current active queue for a player.",
"fields": {
"queue_item_id": {
"name": "Queue Item ID",
"description": "ID for the item to remove."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"move_queue_item_up": {
"name": "Move Queue Item Up",
"description": "Moves an item up in a player's queue.",
"fields": {
"queue_item_id": {
"name": "Queue Item ID",
"description": "ID for the item to move up."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"move_queue_item_down": {
"name": "Move Queue Item Down",
"description": "Moves an item down in a player's queue.",
"fields": {
"queue_item_id": {
"name": "Queue Item ID",
"description": "ID for the item to move down."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"move_queue_item_next": {
"name": "Move Queue Item Next",
"description": "Moves an item next in a player's queue.",
"fields": {
"queue_item_id": {
"name": "Queue Item ID",
"description": "ID for the item to move next."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"play_queue_item": {
"name": "Play Queue Item",
"description": "Plays an item that's currently in a player queue.",
"fields": {
"queue_item_id": {
"name": "Queue Item ID",
"description": "ID for the item to play."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"send_command": {
"name": "Send Command",
"description": "Sends a command to the Music Assistant API.",
"fields": {
"command": {
"name": "Command",
"description": "Command to send to Music Assistant."
},
"data": {
"name": "Data",
"description": "Command data to send."
},
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
}
}
},
"unfavorite_current_item": {
"name": "Unfavorite Current Item",
"description": "Unfavorites the currently playing item in a player queue.",
"fields": {
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"get_recommendations": {
"name": "Get Recommendations",
"description": "Get recommendations from your music providers.",
"fields": {
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
},
"providers": {
"name": "Providers",
"description": "Limit recommendations to the specified providers."
}
}
},
"get_group_volume": {
"name": "Get Group Volume",
"description": "Returns the volume for a player group.",
"fields": {
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"set_group_volume": {
"name": "Set Group Volume",
"description": "Sets the volume for a player group.",
"fields": {
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
},
"volume_level": {
"name": "Volume Level",
"description": "Volume level to set the player to."
}
}
},
"clear_queue_from_here": {
"name": "Clear Queue From Here",
"description": "Clears all items before/after the current track in a queue.",
"fields": {
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"get_playlist_tracks": {
"name": "Get Playlist Tracks",
"description": "Returns all tracks for the playlist given.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Playlist URI",
"description": "URI for the playlist."
},
"page": {
"name": "Page",
"description": "Page of results to return. If not provided, returns all."
}
}
},
"get_album_tracks": {
"name": "Get Album Tracks",
"description": "Returns all tracks for the album given.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Album URI",
"description": "URI for the album."
},
"page": {
"name": "Page",
"description": "Page of results to return. If not provided, returns all."
}
}
},
"get_artist_tracks": {
"name": "Get Artist Tracks",
"description": "Returns all tracks for the artist given.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Artist URI",
"description": "URI for the artist."
},
"page": {
"name": "Page",
"description": "Page of results to return. If not provided, returns all."
}
}
},
"get_podcast_episodes": {
"name": "Get Podcast Episodes",
"description": "Returns all episodes for the podcast given.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Podcast URI",
"description": "URI for the podcast."
}
}
},
"get_playlist": {
"name": "Get Playlist",
"description": "Returns information about a playlist from the server.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Playlist URI",
"description": "URI for the playlist."
}
}
},
"get_album": {
"name": "Get Album",
"description": "Returns information about an album from the server.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Album URI",
"description": "URI for the album."
}
}
},
"get_artist": {
"name": "Get Artist",
"description": "Returns information about an artist from the server.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Artist URI",
"description": "URI for the artist."
}
}
},
"get_podcast": {
"name": "Get Podcast",
"description": "Returns information about a podcast from the server.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Podcast URI",
"description": "URI for the podcast."
}
}
},
"remove_playlist_tracks": {
"name": "Remove Playlist Tracks",
"description": "Removes one or more tracks from a playlist based on position.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"playlist_id": {
"name": "Playlist ID",
"description": "ID of the playlist."
},
"positions_to_remove": {
"name": "Positions to remove",
"description": "Position(s) of items to remove from the playlist."
}
}
}
}
}
@@ -0,0 +1,365 @@
{
"config": {
"step": {
"init": {
"data": {
"url": "URL of the Music Assistant server"
}
},
"manual": {
"title": "Manually add Music Assistant server",
"description": "Enter the URL to your already running Music Assistant server for Music Assistant Queue Actions to use. If you do not have the Music Assistant server running, you should install it first.",
"data": {
"url": "URL of the Music Assistant server"
}
},
"discovery_confirm": {
"description": "Do you want to add Music Assistant Queue Actions (via the Music Assistant server `{url}`) to Home Assistant?",
"title": "Discovered Music Assistant server"
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_server_version": "The Music Assistant server is not the correct version",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reconfiguration_successful": "Successfully reconfigured the Music Assistant Queue Action integration.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"options": {
"step": {
"init": {
"data": {
"download_local": "Attempt fallback support for local media images. May cause performance issues. Only enable if you cannot see media images for your players."
}
}
}
},
"issues": {
"invalid_server_version": {
"title": "The Music Assistant server is not the correct version",
"description": "Check if there are updates available for the Music Assistant server and/or MA Queue Actions integration."
}
},
"services": {
"get_queue_items": {
"name": "Get Queue Items",
"description": "Returns a list of the current items in a player's queue.",
"fields": {
"limit": {
"name": "Limit",
"description": "Limit on the number of items in queue to return."
},
"offset": {
"name": "Offset",
"description": "Location in queue to start where zero equals the first item in queue, not the current item.."
},
"limit_before": {
"name": "Limit Before",
"description": "Number of items to pull before current active item in queue.."
},
"limit_after": {
"name": "Limit After",
"description": "Number of items to pull after current active item in queue.."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"remove_queue_item": {
"name": "Remove Queue Item",
"description": "Removes an item from the current active queue for a player.",
"fields": {
"queue_item_id": {
"name": "Queue Item ID",
"description": "ID for the item to remove."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"move_queue_item_up": {
"name": "Move Queue Item Up",
"description": "Moves an item up in a player's queue.",
"fields": {
"queue_item_id": {
"name": "Queue Item ID",
"description": "ID for the item to move up."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"move_queue_item_down": {
"name": "Move Queue Item Down",
"description": "Moves an item down in a player's queue.",
"fields": {
"queue_item_id": {
"name": "Queue Item ID",
"description": "ID for the item to move down."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"move_queue_item_next": {
"name": "Move Queue Item Next",
"description": "Moves an item next in a player's queue.",
"fields": {
"queue_item_id": {
"name": "Queue Item ID",
"description": "ID for the item to move next."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"play_queue_item": {
"name": "Play Queue Item",
"description": "Plays an item that's currently in a player queue.",
"fields": {
"queue_item_id": {
"name": "Queue Item ID",
"description": "ID for the item to play."
},
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"send_command": {
"name": "Send Command",
"description": "Sends a command to the Music Assistant API.",
"fields": {
"command": {
"name": "Command",
"description": "Command to send to Music Assistant."
},
"data": {
"name": "Data",
"description": "Command data to send."
},
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
}
}
},
"unfavorite_current_item": {
"name": "Unfavorite Current Item",
"description": "Unfavorites the currently playing item in a player queue.",
"fields": {
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"get_recommendations": {
"name": "Get Recommendations",
"description": "Get recommendations from your music providers.",
"fields": {
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
},
"providers": {
"name": "Providers",
"description": "Limit recommendations to the specified providers."
}
}
},
"get_group_volume": {
"name": "Get Group Volume",
"description": "Returns the volume for a player group.",
"fields": {
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"set_group_volume": {
"name": "Set Group Volume",
"description": "Sets the volume for a player group.",
"fields": {
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
},
"volume_level": {
"name": "Volume Level",
"description": "Volume level to set the player to."
}
}
},
"clear_queue_from_here": {
"name": "Clear Queue From Here",
"description": "Clears all items before/after the current track in a queue.",
"fields": {
"entity": {
"name": "Entity",
"description": "Music Assistant Media Player Entity."
}
}
},
"get_playlist_tracks": {
"name": "Get Playlist Tracks",
"description": "Returns all tracks for the playlist given.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Playlist URI",
"description": "URI for the playlist."
},
"page": {
"name": "Page",
"description": "Page of results to return. If not provided, returns all."
}
}
},
"get_album_tracks": {
"name": "Get Album Tracks",
"description": "Returns all tracks for the album given.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Album URI",
"description": "URI for the album."
},
"page": {
"name": "Page",
"description": "Page of results to return. If not provided, returns all."
}
}
},
"get_artist_tracks": {
"name": "Get Artist Tracks",
"description": "Returns all tracks for the artist given.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Artist URI",
"description": "URI for the artist."
},
"page": {
"name": "Page",
"description": "Page of results to return. If not provided, returns all."
}
}
},
"get_podcast_episodes": {
"name": "Get Podcast Episodes",
"description": "Returns all episodes for the podcast given.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Podcast URI",
"description": "URI for the podcast."
}
}
},
"get_playlist": {
"name": "Get Playlist",
"description": "Returns information about a playlist from the server.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Playlist URI",
"description": "URI for the playlist."
}
}
},
"get_album": {
"name": "Get Album",
"description": "Returns information about an album from the server.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Album URI",
"description": "URI for the album."
}
}
},
"get_artist": {
"name": "Get Artist",
"description": "Returns information about an artist from the server.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Artist URI",
"description": "URI for the artist."
}
}
},
"get_podcast": {
"name": "Get Podcast",
"description": "Returns information about a podcast from the server.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"uri": {
"name": "Podcast URI",
"description": "URI for the podcast."
}
}
},
"remove_playlist_tracks": {
"name": "Remove Playlist Tracks",
"description": "Removes one or more tracks from a playlist based on position.",
"fields": {
"config_entry_id": {
"name": "Config Entry ID",
"description": "Config Entry ID for the Music Assistant Queue Items instance."
},
"playlist_id": {
"name": "Playlist ID",
"description": "ID of the playlist."
},
"positions_to_remove": {
"name": "Positions to remove",
"description": "Position(s) of items to remove from the playlist."
}
}
}
}
}
@@ -0,0 +1,213 @@
{
"config": {
"step": {
"init": {
"data": {
"url": "URL du serveur Music Assistant"
}
},
"manual": {
"title": "Ajouter manuellement le serveur Music Assistant",
"description": "Saisissez lURL de votre serveur Music Assistant déjà en cours dexécution. Si le serveur nest pas encore installé, vous devez linstaller avant de poursuivre.",
"data": {
"url": "URL du serveur Music Assistant"
}
},
"discovery_confirm": {
"description": "Souhaitez-vous ajouter le serveur Music Assistant « {url} » à Home Assistant ?",
"title": "Serveur Music Assistant découvert"
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_server_version": "La version du serveur Music Assistant nest pas compatible",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reconfiguration_successful": "Réconfiguration de lintégration Music Assistant réussie.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"options": {
"step": {
"init": {
"data": {
"download_local": "Active une solution de secours pour télécharger les images locales des médias. Peut entraîner des problèmes de performances. À activer uniquement si aucune image napparaît pour vos lecteurs."
}
}
}
},
"issues": {
"invalid_server_version": {
"title": "La version du serveur Music Assistant nest pas compatible",
"description": "Vérifiez si des mises à jour sont disponibles pour le serveur et/ou lintégration Music Assistant."
}
},
"services": {
"get_queue_items": {
"name": "Obtenir les éléments de file dattente",
"description": "Retourne la liste des éléments actuellement présents dans la file dattente dun lecteur.",
"fields": {
"limit": {
"name": "Limite",
"description": "Limite sur le nombre d’éléments de la file à renvoyer."
},
"offset": {
"name": "Décalage",
"description": "Position de départ dans la file où zéro correspond au premier élément, et non à l’élément en cours."
},
"limit_before": {
"name": "Limite avant",
"description": "Nombre d’éléments à récupérer avant l’élément actif actuel."
},
"limit_after": {
"name": "Limite après",
"description": "Nombre d’éléments à récupérer après l’élément actif actuel."
},
"entity": {
"name": "Entité",
"description": "Entité media_player Music Assistant."
}
}
},
"remove_queue_item": {
"name": "Supprimer un élément de file",
"description": "Supprime un élément de la file active dun lecteur.",
"fields": {
"queue_item_id": {
"name": "ID d’élément",
"description": "Identifiant de l’élément à supprimer."
},
"entity": {
"name": "Entité",
"description": "Entité media_player Music Assistant."
}
}
},
"move_queue_item_up": {
"name": "Monter un élément",
"description": "Monte un élément dans la file dun lecteur.",
"fields": {
"queue_item_id": {
"name": "ID d’élément",
"description": "Identifiant de l’élément à monter."
},
"entity": {
"name": "Entité",
"description": "Entité media_player Music Assistant."
}
}
},
"move_queue_item_down": {
"name": "Descendre un élément",
"description": "Descend un élément dans la file dun lecteur.",
"fields": {
"queue_item_id": {
"name": "ID d’élément",
"description": "Identifiant de l’élément à descendre."
},
"entity": {
"name": "Entité",
"description": "Entité media_player Music Assistant."
}
}
},
"move_queue_item_next": {
"name": "Lire ensuite",
"description": "Place un élément en lecture suivante dans la file dun lecteur.",
"fields": {
"queue_item_id": {
"name": "ID d’élément",
"description": "Identifiant de l’élément à lire ensuite."
},
"entity": {
"name": "Entité",
"description": "Entité media_player Music Assistant."
}
}
},
"play_queue_item": {
"name": "Lire un élément",
"description": "Lit un élément actuellement présent dans la file dun lecteur.",
"fields": {
"queue_item_id": {
"name": "ID d’élément",
"description": "Identifiant de l’élément à lire."
},
"entity": {
"name": "Entité",
"description": "Entité media_player Music Assistant."
}
}
},
"send_command": {
"name": "Envoyer une commande",
"description": "Envoie une commande à lAPI Music Assistant.",
"fields": {
"command": {
"name": "Commande",
"description": "Commande à envoyer à Music Assistant."
},
"data": {
"name": "Données",
"description": "Données à transmettre avec la commande."
},
"config_entry_id": {
"name": "ID dentrée de config",
"description": "Identifiant dentrée de configuration pour linstance Mass Queue Items."
}
}
},
"unfavorite_current_item": {
"name": "Retirer des favoris",
"description": "Retire l’élément en cours de la liste des favoris.",
"fields": {
"entity": {
"name": "Entité",
"description": "Entité media_player Music Assistant."
}
}
},
"get_recommendations": {
"name": "Obtenir des recommandations",
"description": "Récupère des recommandations auprès de vos fournisseurs de musique.",
"fields": {
"entity": {
"name": "Entité",
"description": "Entité media_player Music Assistant."
},
"providers": {
"name": "Fournisseurs",
"description": "Limiter les recommandations aux fournisseurs indiqués."
}
}
},
"get_group_volume": {
"name": "Obtenir le volume du groupe",
"description": "Retourne le volume dun groupe de lecteurs.",
"fields": {
"entity": {
"name": "Entité",
"description": "Entité media_player Music Assistant."
}
}
},
"set_group_volume": {
"name": "Définir le volume du groupe",
"description": "Définit le volume dun groupe de lecteurs.",
"fields": {
"entity": {
"name": "Entité",
"description": "Entité media_player Music Assistant."
},
"volume_level": {
"name": "Niveau sonore",
"description": "Niveau sonore à appliquer au lecteur."
}
}
}
}
}
+381
View File
@@ -0,0 +1,381 @@
# ty:ignore[unresolved-import]
"""Utilities."""
from __future__ import annotations
import base64
import urllib.parse
from typing import TYPE_CHECKING
from aiocache import cached
from aiocache.serializers import PickleSerializer
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import async_get_hass, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from music_assistant_client import MusicAssistantClient
from . import MassQueueEntryData
from .const import ATTR_QUEUE_ID, LOGGER
@callback
def _get_config_entry(
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
def get_mass_queue_entry(hass, entity_id):
"""Gets the actions for the selected entity."""
mass_entry = get_mass_entry(hass, entity_id)
unique_id = mass_entry.unique_id
return find_mass_queue_entry_from_unique_id(hass, unique_id)
def get_entity_actions_controller(hass, entity_id):
"""Gets the actions for the selected entity."""
mass_queue_entry = get_mass_queue_entry(hass, entity_id)
return mass_queue_entry.runtime_data.actions
def get_mass_client(hass, entity_id):
"""Gets the actions for the selected entity."""
mass_queue_entry = get_mass_queue_entry(hass, entity_id)
return mass_queue_entry.runtime_data.mass
def get_mass_entry(hass, entity_id):
"""Helper function to pull MA Config Entry."""
config_id = _get_mass_entity_config_entry_id(hass, entity_id)
return _get_config_entry(hass, config_id)
def _get_mass_entity_config_entry_id(hass, entity_id):
"""Helper to grab config entry ID from entity ID."""
registry = er.async_get(hass)
return registry.async_get(entity_id).config_entry_id
def find_mass_queue_entry_from_unique_id(hass: HomeAssistant, unique_id: str):
"""Finds the mass_queue entry for the given MA URL."""
entries = _get_mass_queue_entries(hass)
for entry in entries:
if entry.unique_id == unique_id:
return entry
msg = f"Cannot find entry for Music Assistant Queue Actions with unique ID {unique_id}. Are the integrations for Music Assistant and Music Assistant Queue Actions configured?"
raise ServiceValidationError(msg)
def _get_mass_queue_entries(hass):
"""Gets all entries for mass_queue domain."""
entries = hass.config_entries.async_entries()
return [entry for entry in entries if entry.domain == "mass_queue"]
def format_event_data_queue_item(queue_item):
"""Format event data results for usage by controller."""
if queue_item is None:
return None
if queue_item.get("queue_id") is None:
return queue_item
item_cp = queue_item.copy()
if "streamdetails" in item_cp:
item_cp.pop("streamdetails")
if "media_item" in item_cp:
item_cp.pop("media_item")
return item_cp
def format_queue_updated_event_data(event: dict):
"""Format queue updated results for usage by controller."""
event_data = event.copy()
event_data["current_item"] = format_event_data_queue_item(
event_data.get("current_item"),
)
event_data["next_item"] = format_event_data_queue_item(event_data.get("next_item"))
return event_data
def get_queue_id_from_player_data(player_data):
"""Force as dict if not already."""
data = player_data.to_dict() if type(player_data) is not dict else player_data
current_media = data.get("current_media", None)
if current_media is None:
return None
return current_media.get("queue_id")
def return_image_or_none(img_data: dict | None, remotely_accessible: bool):
"""Returns None if image is not present or not remotely accessible."""
if type(img_data) is dict:
img = img_data.get("path")
remote = img_data.get("remotely_accessible")
if remote or not remotely_accessible:
return img
return None
def search_image_list(images: list, remotely_accessible: bool):
"""Checks through a list of image data and attempts to find an image."""
result = None
for item in images:
image = return_image_or_none(item, remotely_accessible)
if image is not None:
result = image
break
return result
def find_image_from_image(data: dict, remotely_accessible: bool):
"""Attempts to find the image via the image key."""
img_data = data.get("image")
return return_image_or_none(img_data, remotely_accessible)
def find_image_from_metadata(data: dict, remotely_accessible: bool):
"""Attempts to find the image via the metadata key."""
metadata = data.get("metadata", {})
img_data = metadata.get("images")
if img_data is None:
return None
return search_image_list(img_data, remotely_accessible)
def find_image_from_album(data: dict, remotely_accessible: bool):
"""Attempts to find the image via the album key."""
album = data.get("album") or {}
metadata = album.get("metadata") or {}
img_data = metadata.get("images")
if img_data is None:
return None
return search_image_list(img_data, remotely_accessible)
def find_image_from_artists(data: dict, remotely_accessible: bool):
"""Attempts to find the image via the artists key."""
artist = data.get("artist", {})
img_data = artist.get("image") or []
img_data += artist.get("metadata", {})
if len(img_data):
return search_image_list(img_data, remotely_accessible)
if isinstance(img_data, dict):
return return_image_or_none(img_data, remotely_accessible)
return None
def find_image(data: dict, remotely_accessible: bool = True):
"""Returns None if image is not present or not remotely accessible."""
media_item = data.get("media_item", data)
from_image = find_image_from_image(data, remotely_accessible)
from_metadata = find_image_from_metadata(media_item, remotely_accessible)
from_album = find_image_from_album(data, remotely_accessible)
from_artists = find_image_from_artists(data, remotely_accessible)
return from_image or from_metadata or from_album or from_artists
def _get_recommendation_item_image_from_metadata(item: dict):
try:
images = item["metadata"]["images"]
accessible = [image for image in images if image["remotely_accessible"]]
if accessible:
return accessible[0]["path"]
except: # noqa: E722 S110
pass
return ""
def _get_recommendation_item_image_from_image(item: dict):
try:
image_data = item["image"]
accessible = image_data["remotely_accessible"]
if accessible:
return image_data["path"]
except: # noqa: E722 S110
pass
return ""
def _get_recommendation_item_image(item: dict):
meta_img = _get_recommendation_item_image_from_metadata(item)
img_img = _get_recommendation_item_image_from_image(item)
if len(meta_img):
return meta_img
return img_img
def process_recommendation_section_item(item: dict):
"""Process and reformat a single recommendation item."""
LOGGER.debug(f"Got section item: {item}")
return {
"item_id": item["item_id"],
"name": item["name"],
"sort_name": item["sort_name"],
"uri": item["uri"],
"media_type": item["media_type"],
"image": _get_recommendation_item_image(item),
}
def process_recommendation_section_items(items: list):
"""Process and reformat items for a single recommendation section."""
return [process_recommendation_section_item(item) for item in items]
def process_recommendation_section(section):
"""Process and reformat a single recommendation section."""
LOGGER.debug(f"Got section: {section}")
section = section.to_dict()
return {
"item_id": section["item_id"],
"provider": section["provider"],
"sort_name": section["sort_name"],
"name": section["name"],
"uri": section["uri"],
"icon": section["icon"],
"image": section["image"],
"items": process_recommendation_section_items(section["items"]),
}
def process_recommendations(recs: list):
"""Process and reformat items all recommendation sections."""
result = []
for rec in recs:
processed = process_recommendation_section(rec)
if len(processed["items"]):
result.append(processed)
return result
def generate_image_url_from_image_data(image_data: dict, client):
"""Generates an image URL from `image_data`."""
img_path = image_data["path"]
provider = image_data["provider"]
base_url = "" if img_path.startswith("http") else client.server_url
img = urllib.parse.quote_plus(urllib.parse.quote_plus(img_path))
return f"{base_url}/imageproxy?provider={provider}&size=256&format=png&path={img}"
async def download_single_image_from_image_data(
image_data: dict,
entity_id,
hass,
session,
):
"""Downloads a single image from Music Assistant and returns the base64 encoded string."""
entry = get_mass_entry(hass, entity_id)
client = entry.runtime_data.mass
url = generate_image_url_from_image_data(image_data, client)
try:
req = await session.get(url)
read = await req.content.read()
return f"data:image;base64,{base64.b64encode(read).decode('utf-8')}"
except: # noqa: E722
LOGGER.error(f"Unable to get image with data {image_data}")
return None
@cached(serializer=PickleSerializer())
async def download_and_encode_image(url: str):
"""Downloads and encodes a single image from the given URL."""
hass = async_get_hass()
session = aiohttp_client.async_get_clientsession(hass)
req = await session.get(url)
read = await req.content.read()
return f"data:image;base64,{base64.b64encode(read).decode('utf-8')}"
async def get_user_info(hass: HomeAssistant, entity_id: str, username: str):
"""Returns the user information for the given username."""
client = get_mass_client(hass, entity_id)
users = await client.auth.list_users()
LOGGER.debug(f"Client: {client}")
LOGGER.debug(f"Users: {users}")
return [user.to_dict() for user in users if user.username == username][0]
def get_entity_info(hass: HomeAssistant, entity_id: str):
"""Gets the server and client info for a given player."""
client = get_mass_client(hass, entity_id)
state = hass.states.get(entity_id)
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
dev_id = entity_registry.async_get(entity_id).device_id
dev = device_registry.async_get(dev_id)
identifiers = dev.identifiers
player_id = [_id[1] for _id in identifiers if _id[0] == "music_assistant"][0]
player = client.players.get(player_id)
mass_entry_id = _get_mass_entity_config_entry_id(hass, entity_id)
mass_queue_id = get_mass_queue_entry(hass, entity_id).entry_id
queue_id = state.attributes.get(ATTR_QUEUE_ID)
server_url = client.server_info.base_url
ws_url = client.connection.ws_server_url
config_url = dev.configuration_url
manufacturer = dev.manufacturer
model = dev.model
available = player.available
can_group_with = player.can_group_with
ip_address = player.device_info.ip_address
features = list(player.supported_features)
name = player.name
provider = player.provider
synced_to = player.synced_to
player_type = player.type
return {
"available": available,
"can_group_with": can_group_with,
"connection": {
"configuration_url": config_url,
"url": ip_address,
},
"entries": {
"music_assistant": mass_entry_id,
"mass_queue": mass_queue_id,
},
"features": features,
"manufacturer": manufacturer,
"model": model,
"name": name,
"player_id": player_id,
"provider": provider,
"queue_id": queue_id,
"server": {
"connection": {
"url": server_url,
"websocket": ws_url,
},
},
"synced_to": synced_to,
"type": player_type,
}
def parse_uri(uri):
"""Parse a URI and split to provider and item ID."""
provider = uri.split("://")[0]
item_id = uri.split("/")[-1]
return [provider, item_id]
@@ -0,0 +1,127 @@
# ty:ignore[unresolved-import]
"""Music Assistant Queue Actions Websocket Commands."""
from __future__ import annotations
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.helpers import aiohttp_client
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from .const import LOGGER
from .utils import (
download_and_encode_image,
download_single_image_from_image_data,
get_entity_info,
get_user_info,
)
@websocket_api.websocket_command(
{
vol.Required("type"): "mass_queue/get_info",
vol.Required("entity_id"): str,
},
)
def api_get_entity_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Returns Music Assistant player information on a given player."""
LOGGER.debug(f"Got message: {msg}")
entity_id = msg["entity_id"]
result = get_entity_info(hass, entity_id)
LOGGER.debug(f"Sending result {result}")
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): "mass_queue/download_and_encode_image",
vol.Required("url"): str,
},
)
@websocket_api.async_response
async def api_download_and_encode_image(
hass: HomeAssistant, # noqa: ARG001
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Download images and return them as b64 encoded."""
LOGGER.debug(f"Got message: {msg}")
url = msg["url"]
result = await download_and_encode_image(url)
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): "mass_queue/encode_images",
vol.Required("entity_id"): str,
vol.Required("images"): list,
},
)
@websocket_api.async_response
async def api_download_images(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Download images and return them as b64 encoded."""
LOGGER.debug(f"Received message: {msg}")
session = aiohttp_client.async_get_clientsession(hass)
images = msg["images"]
result = []
entity_id = msg["entity_id"]
for image in images:
img = await download_single_image_from_image_data(
image,
entity_id,
hass,
session,
)
image["encoded"] = img
result.append(image)
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): "mass_queue/get_playlist_items",
vol.Required("playlist_uri"): str,
},
)
@websocket_api.async_response
async def get_playlist_items(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Retrieves all playlist items."""
@websocket_api.websocket_command(
{
vol.Required("type"): "mass_queue/get_user_info",
vol.Required("entity_id"): str,
vol.Required("username"): str,
},
)
@websocket_api.async_response
async def api_get_user_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Returns the information for a given user."""
entity_id = msg["entity_id"]
username = msg["username"]
LOGGER.debug(f"Received message {msg}")
result = await get_user_info(hass, entity_id, username)
LOGGER.debug(f"Sending result {result}")
connection.send_result(msg["id"], result)