Added Alexa Music
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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."]
|
||||
}
|
||||
@@ -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]),
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 l’URL de votre serveur Music Assistant déjà en cours d’exécution. Si le serveur n’est pas encore installé, vous devez l’installer 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 n’est 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 l’inté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 n’apparaît pour vos lecteurs."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"invalid_server_version": {
|
||||
"title": "La version du serveur Music Assistant n’est pas compatible",
|
||||
"description": "Vérifiez si des mises à jour sont disponibles pour le serveur et/ou l’intégration Music Assistant."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_queue_items": {
|
||||
"name": "Obtenir les éléments de file d’attente",
|
||||
"description": "Retourne la liste des éléments actuellement présents dans la file d’attente d’un 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 d’un 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 d’un 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 d’un 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 d’un 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 d’un 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 à l’API 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 d’entrée de config",
|
||||
"description": "Identifiant d’entrée de configuration pour l’instance 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 d’un 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 d’un groupe de lecteurs.",
|
||||
"fields": {
|
||||
"entity": {
|
||||
"name": "Entité",
|
||||
"description": "Entité media_player Music Assistant."
|
||||
},
|
||||
"volume_level": {
|
||||
"name": "Niveau sonore",
|
||||
"description": "Niveau sonore à appliquer au lecteur."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user