Add actions to Music Assistant integration (#129515)

Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/131746/head
Marcel van der Veldt 2024-11-27 17:43:48 +01:00 committed by GitHub
parent 3eb483c1b0
commit 3485ce9c71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 509 additions and 6 deletions

View File

@ -28,13 +28,13 @@ from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
from music_assistant_models.event import MassEvent
type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData]
PLATFORMS = [Platform.MEDIA_PLAYER]
CONNECT_TIMEOUT = 10
LISTEN_READY_TIMEOUT = 30
type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData]
@dataclass
class MusicAssistantEntryData:
@ -47,7 +47,7 @@ class MusicAssistantEntryData:
async def async_setup_entry(
hass: HomeAssistant, entry: MusicAssistantConfigEntry
) -> bool:
"""Set up from a config entry."""
"""Set up Music Assistant from a config entry."""
http_session = async_get_clientsession(hass, verify_ssl=False)
mass_url = entry.data[CONF_URL]
mass = MusicAssistantClient(mass_url, http_session)
@ -97,6 +97,7 @@ async def async_setup_entry(
listen_task.cancel()
raise ConfigEntryNotReady("Music Assistant client not ready") from err
# store the listen task and mass client in the entry data
entry.runtime_data = MusicAssistantEntryData(mass, listen_task)
# If the listen task is already failed, we need to raise ConfigEntryNotReady

View File

@ -0,0 +1,7 @@
{
"services": {
"play_media": { "service": "mdi:play" },
"play_announcement": { "service": "mdi:bullhorn" },
"transfer_queue": { "service": "mdi:transfer" }
}
}

View File

@ -13,15 +13,18 @@ from music_assistant_models.enums import (
EventType,
MediaType,
PlayerFeature,
PlayerState as MassPlayerState,
QueueOption,
RepeatMode as MassRepeatMode,
)
from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError
from music_assistant_models.event import MassEvent
from music_assistant_models.media_items import ItemMapping, MediaItemType, Track
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_EXTRA,
BrowseMedia,
MediaPlayerDeviceClass,
@ -37,7 +40,11 @@ from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_current_platform,
)
from homeassistant.util.dt import utc_from_timestamp
from . import MusicAssistantConfigEntry
@ -79,6 +86,9 @@ QUEUE_OPTION_MAP = {
MediaPlayerEnqueue.REPLACE: QueueOption.REPLACE,
}
SERVICE_PLAY_MEDIA_ADVANCED = "play_media"
SERVICE_PLAY_ANNOUNCEMENT = "play_announcement"
SERVICE_TRANSFER_QUEUE = "transfer_queue"
ATTR_RADIO_MODE = "radio_mode"
ATTR_MEDIA_ID = "media_id"
ATTR_MEDIA_TYPE = "media_type"
@ -138,6 +148,38 @@ async def async_setup_entry(
async_add_entities(mass_players)
# add platform service for play_media with advanced options
platform = async_get_current_platform()
platform.async_register_entity_service(
SERVICE_PLAY_MEDIA_ADVANCED,
{
vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType),
vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption),
vol.Optional(ATTR_ARTIST): cv.string,
vol.Optional(ATTR_ALBUM): cv.string,
vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool),
},
"_async_handle_play_media",
)
platform.async_register_entity_service(
SERVICE_PLAY_ANNOUNCEMENT,
{
vol.Required(ATTR_URL): cv.string,
vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool),
vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int),
},
"_async_handle_play_announcement",
)
platform.async_register_entity_service(
SERVICE_TRANSFER_QUEUE,
{
vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id,
vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool),
},
"_async_handle_transfer_queue",
)
class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
"""Representation of MediaPlayerEntity from Music Assistant Player."""
@ -376,6 +418,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
async def _async_handle_play_media(
self,
media_id: list[str],
artist: str | None = None,
album: str | None = None,
enqueue: MediaPlayerEnqueue | QueueOption | None = None,
radio_mode: bool | None = None,
media_type: str | None = None,
@ -402,6 +446,14 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
elif await asyncio.to_thread(os.path.isfile, media_id_str):
media_uris.append(media_id_str)
continue
# last resort: search for media item by name/search
if item := await self.mass.music.get_item_by_name(
name=media_id_str,
artist=artist,
album=album,
media_type=MediaType(media_type) if media_type else None,
):
media_uris.append(item.uri)
if not media_uris:
raise HomeAssistantError(
@ -435,6 +487,32 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
self.player_id, url, use_pre_announce, announce_volume
)
@catch_musicassistant_error
async def _async_handle_transfer_queue(
self, source_player: str | None = None, auto_play: bool | None = None
) -> None:
"""Transfer the current queue to another player."""
if not source_player:
# no source player given; try to find a playing player(queue)
for queue in self.mass.player_queues:
if queue.state == MassPlayerState.PLAYING:
source_queue_id = queue.queue_id
break
else:
raise HomeAssistantError(
"Source player not specified and no playing player found."
)
else:
# resolve HA entity_id to MA player_id
entity_registry = er.async_get(self.hass)
if (entity := entity_registry.async_get(source_player)) is None:
raise HomeAssistantError("Source player not available.")
source_queue_id = entity.unique_id # unique_id is the MA player_id
target_queue_id = self.player_id
await self.mass.player_queues.transfer_queue(
source_queue_id, target_queue_id, auto_play
)
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,

View File

@ -0,0 +1,90 @@
# Descriptions for Music Assistant custom services
play_media:
target:
entity:
domain: media_player
integration: music_assistant
supported_features:
- media_player.MediaPlayerEntityFeature.PLAY_MEDIA
fields:
media_id:
required: true
example: "spotify://playlist/aabbccddeeff"
selector:
object:
media_type:
example: "playlist"
selector:
select:
translation_key: media_type
options:
- artist
- album
- playlist
- track
- radio
artist:
example: "Queen"
selector:
text:
album:
example: "News of the world"
selector:
text:
enqueue:
selector:
select:
options:
- "play"
- "replace"
- "next"
- "replace_next"
- "add"
translation_key: enqueue
radio_mode:
advanced: true
selector:
boolean:
play_announcement:
target:
entity:
domain: media_player
integration: music_assistant
supported_features:
- media_player.MediaPlayerEntityFeature.PLAY_MEDIA
- media_player.MediaPlayerEntityFeature.MEDIA_ANNOUNCE
fields:
url:
required: true
example: "http://someremotesite.com/doorbell.mp3"
selector:
text:
use_pre_announce:
example: "true"
selector:
boolean:
announce_volume:
example: 75
selector:
number:
min: 1
max: 100
step: 1
transfer_queue:
target:
entity:
domain: media_player
integration: music_assistant
fields:
source_player:
selector:
entity:
domain: media_player
integration: music_assistant
auto_play:
example: "true"
selector:
boolean:

View File

@ -37,6 +37,70 @@
"description": "Check if there are updates available for the Music Assistant Server and/or integration."
}
},
"services": {
"play_media": {
"name": "Play media",
"description": "Play media on a Music Assistant player with more fine-grained control options.",
"fields": {
"media_id": {
"name": "Media ID(s)",
"description": "URI or name of the item you want to play. Specify a list if you want to play/enqueue multiple items."
},
"media_type": {
"name": "Media type",
"description": "The type of the content to play. Such as artist, album, track or playlist. Will be auto-determined if omitted."
},
"enqueue": {
"name": "Enqueue",
"description": "If the content should be played now or added to the queue."
},
"artist": {
"name": "Artist name",
"description": "When specifying a track or album by name in the Media ID field, you can optionally restrict results by this artist name."
},
"album": {
"name": "Album name",
"description": "When specifying a track by name in the Media ID field, you can optionally restrict results by this album name."
},
"radio_mode": {
"name": "Enable radio mode",
"description": "Enable radio mode to auto-generate a playlist based on the selection."
}
}
},
"play_announcement": {
"name": "Play announcement",
"description": "Play announcement on a Music Assistant player with more fine-grained control options.",
"fields": {
"url": {
"name": "URL",
"description": "URL to the notification sound."
},
"use_pre_announce": {
"name": "Use pre-announce",
"description": "Use pre-announcement sound for the announcement. Omit to use the player default."
},
"announce_volume": {
"name": "Announce volume",
"description": "Use a forced volume level for the announcement. Omit to use player default."
}
}
},
"transfer_queue": {
"name": "Transfer queue",
"description": "Transfer the player's queue to another player.",
"fields": {
"source_player": {
"name": "Source media player",
"description": "The source media player which has the queue you want to transfer. When omitted, the first playing player will be used."
},
"auto_play": {
"name": "Auto play",
"description": "Start playing the queue on the target player. Omit to use the default behavior."
}
}
}
},
"selector": {
"enqueue": {
"options": {
@ -46,6 +110,15 @@
"replace": "Play now and clear queue",
"replace_next": "Play next and clear queue"
}
},
"media_type": {
"options": {
"artist": "Artist",
"album": "Album",
"track": "Track",
"playlist": "Playlist",
"radio": "Radio"
}
}
}
}

View File

@ -2,9 +2,13 @@
from unittest.mock import MagicMock, call
from music_assistant_models.enums import MediaType, QueueOption
from music_assistant_models.media_items import Track
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_REPEAT,
ATTR_MEDIA_SEEK_POSITION,
ATTR_MEDIA_SHUFFLE,
@ -13,6 +17,23 @@ from homeassistant.components.media_player import (
DOMAIN as MEDIA_PLAYER_DOMAIN,
SERVICE_CLEAR_PLAYLIST,
)
from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN
from homeassistant.components.music_assistant.media_player import (
ATTR_ALBUM,
ATTR_ANNOUNCE_VOLUME,
ATTR_ARTIST,
ATTR_AUTO_PLAY,
ATTR_MEDIA_ID,
ATTR_MEDIA_TYPE,
ATTR_RADIO_MODE,
ATTR_SOURCE_PLAYER,
ATTR_URL,
ATTR_USE_PRE_ANNOUNCE,
SERVICE_PLAY_ANNOUNCEMENT,
SERVICE_PLAY_MEDIA_ADVANCED,
SERVICE_TRANSFER_QUEUE,
)
from homeassistant.config_entries import HomeAssistantError
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_MEDIA_NEXT_TRACK,
@ -35,6 +56,15 @@ from homeassistant.helpers import entity_registry as er
from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities
from tests.common import AsyncMock
MOCK_TRACK = Track(
item_id="1",
provider="library",
name="Test Track",
provider_mappings={},
)
async def test_media_player(
hass: HomeAssistant,
@ -110,11 +140,11 @@ async def test_media_player_seek_action(
)
async def test_media_player_volume_action(
async def test_media_player_volume_set_action(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test media_player entity volume action."""
"""Test media_player entity volume_set action."""
await setup_integration_from_fixtures(hass, music_assistant_client)
entity_id = "media_player.test_player_1"
mass_player_id = "00:00:00:00:00:01"
@ -261,3 +291,227 @@ async def test_media_player_clear_playlist_action(
assert music_assistant_client.send_command.call_args == call(
"player_queues/clear", queue_id=mass_player_id
)
async def test_media_player_play_media_action(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test media_player (advanced) play_media action."""
await setup_integration_from_fixtures(hass, music_assistant_client)
entity_id = "media_player.test_player_1"
mass_player_id = "00:00:00:00:00:01"
state = hass.states.get(entity_id)
assert state
# test simple play_media call with URI as media_id and no media type
await hass.services.async_call(
MASS_DOMAIN,
SERVICE_PLAY_MEDIA_ADVANCED,
{
ATTR_ENTITY_ID: entity_id,
ATTR_MEDIA_ID: "spotify://track/1234",
},
blocking=True,
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"player_queues/play_media",
queue_id=mass_player_id,
media=["spotify://track/1234"],
option=None,
radio_mode=False,
start_item=None,
)
# test simple play_media call with URI and enqueue specified
music_assistant_client.send_command.reset_mock()
await hass.services.async_call(
MASS_DOMAIN,
SERVICE_PLAY_MEDIA_ADVANCED,
{
ATTR_ENTITY_ID: entity_id,
ATTR_MEDIA_ID: "spotify://track/1234",
ATTR_MEDIA_ENQUEUE: "add",
},
blocking=True,
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"player_queues/play_media",
queue_id=mass_player_id,
media=["spotify://track/1234"],
option=QueueOption.ADD,
radio_mode=False,
start_item=None,
)
# test basic play_media call with URL and radio mode specified
music_assistant_client.send_command.reset_mock()
await hass.services.async_call(
MASS_DOMAIN,
SERVICE_PLAY_MEDIA_ADVANCED,
{
ATTR_ENTITY_ID: entity_id,
ATTR_MEDIA_ID: "spotify://track/1234",
ATTR_RADIO_MODE: True,
},
blocking=True,
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"player_queues/play_media",
queue_id=mass_player_id,
media=["spotify://track/1234"],
option=None,
radio_mode=True,
start_item=None,
)
# test play_media call with media id and media type specified
music_assistant_client.send_command.reset_mock()
music_assistant_client.music.get_item = AsyncMock(return_value=MOCK_TRACK)
await hass.services.async_call(
MASS_DOMAIN,
SERVICE_PLAY_MEDIA_ADVANCED,
{
ATTR_ENTITY_ID: entity_id,
ATTR_MEDIA_ID: "1",
ATTR_MEDIA_TYPE: "track",
},
blocking=True,
)
assert music_assistant_client.music.get_item.call_count == 1
assert music_assistant_client.music.get_item.call_args == call(
MediaType.TRACK, "1", "library"
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"player_queues/play_media",
queue_id=mass_player_id,
media=[MOCK_TRACK.uri],
option=None,
radio_mode=False,
start_item=None,
)
# test play_media call by name
music_assistant_client.send_command.reset_mock()
music_assistant_client.music.get_item_by_name = AsyncMock(return_value=MOCK_TRACK)
await hass.services.async_call(
MASS_DOMAIN,
SERVICE_PLAY_MEDIA_ADVANCED,
{
ATTR_ENTITY_ID: entity_id,
ATTR_MEDIA_ID: "test",
ATTR_ARTIST: "artist",
ATTR_ALBUM: "album",
},
blocking=True,
)
assert music_assistant_client.music.get_item_by_name.call_count == 1
assert music_assistant_client.music.get_item_by_name.call_args == call(
name="test",
artist="artist",
album="album",
media_type=None,
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"player_queues/play_media",
queue_id=mass_player_id,
media=[MOCK_TRACK.uri],
option=None,
radio_mode=False,
start_item=None,
)
async def test_media_player_play_announcement_action(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test media_player play_announcement action."""
await setup_integration_from_fixtures(hass, music_assistant_client)
entity_id = "media_player.test_player_1"
mass_player_id = "00:00:00:00:00:01"
state = hass.states.get(entity_id)
assert state
await hass.services.async_call(
MASS_DOMAIN,
SERVICE_PLAY_ANNOUNCEMENT,
{
ATTR_ENTITY_ID: entity_id,
ATTR_URL: "http://blah.com/announcement.mp3",
ATTR_USE_PRE_ANNOUNCE: True,
ATTR_ANNOUNCE_VOLUME: 50,
},
blocking=True,
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"players/cmd/play_announcement",
player_id=mass_player_id,
url="http://blah.com/announcement.mp3",
use_pre_announce=True,
volume_level=50,
)
async def test_media_player_transfer_queue_action(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test media_player transfer_queu action."""
await setup_integration_from_fixtures(hass, music_assistant_client)
entity_id = "media_player.test_player_1"
state = hass.states.get(entity_id)
assert state
await hass.services.async_call(
MASS_DOMAIN,
SERVICE_TRANSFER_QUEUE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_SOURCE_PLAYER: "media_player.my_super_test_player_2",
ATTR_AUTO_PLAY: True,
},
blocking=True,
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"player_queues/transfer",
source_queue_id="00:00:00:00:00:02",
target_queue_id="00:00:00:00:00:01",
auto_play=True,
require_schema=25,
)
# test again with invalid source player
music_assistant_client.send_command.reset_mock()
with pytest.raises(HomeAssistantError, match="Source player not available."):
await hass.services.async_call(
MASS_DOMAIN,
SERVICE_TRANSFER_QUEUE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_SOURCE_PLAYER: "media_player.blah_blah",
},
blocking=True,
)
# test again with no source player specified (which picks first playing playerqueue)
music_assistant_client.send_command.reset_mock()
await hass.services.async_call(
MASS_DOMAIN,
SERVICE_TRANSFER_QUEUE,
{
ATTR_ENTITY_ID: entity_id,
},
blocking=True,
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"player_queues/transfer",
source_queue_id="test_group_player_1",
target_queue_id="00:00:00:00:00:01",
auto_play=None,
require_schema=25,
)