1 """MediaPlayer platform for Music Assistant integration."""
3 from __future__
import annotations
6 from collections.abc
import Awaitable, Callable, Coroutine, Mapping
7 from contextlib
import suppress
10 from typing
import TYPE_CHECKING, Any
12 from music_assistant_models.enums
import (
16 PlayerState
as MassPlayerState,
18 RepeatMode
as MassRepeatMode,
20 from music_assistant_models.errors
import MediaNotFoundError, MusicAssistantError
21 from music_assistant_models.event
import MassEvent
22 from music_assistant_models.media_items
import ItemMapping, MediaItemType, Track
23 import voluptuous
as vol
30 MediaPlayerDeviceClass,
33 MediaPlayerEntityFeature,
35 MediaType
as HAMediaType,
37 async_process_play_media_url,
46 async_get_current_platform,
50 from .
import MusicAssistantConfigEntry
51 from .const
import ATTR_ACTIVE_QUEUE, ATTR_MASS_PLAYER_TYPE, DOMAIN
52 from .entity
import MusicAssistantEntity
53 from .media_browser
import async_browse_media
56 from music_assistant_client
import MusicAssistantClient
57 from music_assistant_models.player
import Player
58 from music_assistant_models.player_queue
import PlayerQueue
60 SUPPORTED_FEATURES = (
61 MediaPlayerEntityFeature.PAUSE
62 | MediaPlayerEntityFeature.VOLUME_SET
63 | MediaPlayerEntityFeature.STOP
64 | MediaPlayerEntityFeature.PREVIOUS_TRACK
65 | MediaPlayerEntityFeature.NEXT_TRACK
66 | MediaPlayerEntityFeature.SHUFFLE_SET
67 | MediaPlayerEntityFeature.REPEAT_SET
68 | MediaPlayerEntityFeature.TURN_ON
69 | MediaPlayerEntityFeature.TURN_OFF
70 | MediaPlayerEntityFeature.PLAY
71 | MediaPlayerEntityFeature.PLAY_MEDIA
72 | MediaPlayerEntityFeature.VOLUME_STEP
73 | MediaPlayerEntityFeature.CLEAR_PLAYLIST
74 | MediaPlayerEntityFeature.BROWSE_MEDIA
75 | MediaPlayerEntityFeature.MEDIA_ENQUEUE
76 | MediaPlayerEntityFeature.MEDIA_ANNOUNCE
77 | MediaPlayerEntityFeature.SEEK
83 MediaPlayerEnqueue.ADD: QueueOption.ADD,
84 MediaPlayerEnqueue.NEXT: QueueOption.NEXT,
85 MediaPlayerEnqueue.PLAY: QueueOption.PLAY,
86 MediaPlayerEnqueue.REPLACE: QueueOption.REPLACE,
89 SERVICE_PLAY_MEDIA_ADVANCED =
"play_media"
90 SERVICE_PLAY_ANNOUNCEMENT =
"play_announcement"
91 SERVICE_TRANSFER_QUEUE =
"transfer_queue"
92 ATTR_RADIO_MODE =
"radio_mode"
93 ATTR_MEDIA_ID =
"media_id"
94 ATTR_MEDIA_TYPE =
"media_type"
95 ATTR_ARTIST =
"artist"
98 ATTR_USE_PRE_ANNOUNCE =
"use_pre_announce"
99 ATTR_ANNOUNCE_VOLUME =
"announce_volume"
100 ATTR_SOURCE_PLAYER =
"source_player"
101 ATTR_AUTO_PLAY =
"auto_play"
104 def catch_musicassistant_error[_R, **P](
105 func: Callable[..., Awaitable[_R]],
106 ) -> Callable[..., Coroutine[Any, Any, _R |
None]]:
107 """Check and log commands to players."""
109 @functools.wraps(func)
111 self: MusicAssistantPlayer, *args: P.args, **kwargs: P.kwargs
113 """Catch Music Assistant errors and convert to Home Assistant error."""
115 return await func(self, *args, **kwargs)
116 except MusicAssistantError
as err:
117 error_msg =
str(err)
or err.__class__.__name__
125 entry: MusicAssistantConfigEntry,
126 async_add_entities: AddEntitiesCallback,
128 """Set up Music Assistant MediaPlayer(s) from Config Entry."""
129 mass = entry.runtime_data.mass
132 async
def handle_player_added(event: MassEvent) ->
None:
133 """Handle Mass Player Added event."""
135 assert event.object_id
is not None
136 if event.object_id
in added_ids:
138 added_ids.add(event.object_id)
142 entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED))
145 for player
in mass.players:
146 added_ids.add(player.player_id)
153 platform.async_register_entity_service(
154 SERVICE_PLAY_MEDIA_ADVANCED,
156 vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]),
157 vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType),
158 vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption),
159 vol.Optional(ATTR_ARTIST): cv.string,
160 vol.Optional(ATTR_ALBUM): cv.string,
161 vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool),
163 "_async_handle_play_media",
165 platform.async_register_entity_service(
166 SERVICE_PLAY_ANNOUNCEMENT,
168 vol.Required(ATTR_URL): cv.string,
169 vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool),
170 vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int),
172 "_async_handle_play_announcement",
174 platform.async_register_entity_service(
175 SERVICE_TRANSFER_QUEUE,
177 vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id,
178 vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool),
180 "_async_handle_transfer_queue",
185 """Representation of MediaPlayerEntity from Music Assistant Player."""
188 _attr_media_image_remotely_accessible =
True
189 _attr_media_content_type = HAMediaType.MUSIC
191 def __init__(self, mass: MusicAssistantClient, player_id: str) ->
None:
192 """Initialize MediaPlayer entity."""
196 if PlayerFeature.SET_MEMBERS
in self.
playerplayer.supported_features:
198 if PlayerFeature.VOLUME_MUTE
in self.
playerplayer.supported_features:
204 """Register callbacks."""
209 async
def queue_time_updated(event: MassEvent) ->
None:
210 if event.object_id != self.
playerplayer.active_source:
212 if abs((self.
_prev_time_prev_time
or 0) - event.data) > 5:
220 EventType.QUEUE_TIME_UPDATED,
226 """Return the active queue for this player (if any)."""
227 if not self.
playerplayer.active_source:
229 return self.
massmass.player_queues.get(self.
playerplayer.active_source)
233 """Return additional state attributes."""
235 ATTR_MASS_PLAYER_TYPE: self.
playerplayer.type.value,
242 """Handle player updates."""
245 player = self.
playerplayer
248 if player.powered
and active_queue
is not None:
249 self.
_attr_state_attr_state = MediaPlayerState(active_queue.state.value)
250 if player.powered
and player.state
is not None:
251 self.
_attr_state_attr_state = MediaPlayerState(player.state.value)
253 self.
_attr_state_attr_state = MediaPlayerState(STATE_OFF)
254 group_members_entity_ids: list[str] = []
255 if player.group_childs:
257 entity_registry = er.async_get(self.
hasshass)
258 group_members_entity_ids = [
260 for child_id
in player.group_childs
262 entity_id := entity_registry.async_get_entity_id(
263 self.
platformplatform.domain, DOMAIN, child_id
271 player.volume_level / 100
if player.volume_level
is not None else None
277 @catch_musicassistant_error
279 """Send play command to device."""
280 await self.
massmass.players.player_command_play(self.
player_idplayer_id)
282 @catch_musicassistant_error
284 """Send pause command to device."""
285 await self.
massmass.players.player_command_pause(self.
player_idplayer_id)
287 @catch_musicassistant_error
289 """Send stop command to device."""
290 await self.
massmass.players.player_command_stop(self.
player_idplayer_id)
292 @catch_musicassistant_error
294 """Send next track command to device."""
295 await self.
massmass.players.player_command_next_track(self.
player_idplayer_id)
297 @catch_musicassistant_error
299 """Send previous track command to device."""
300 await self.
massmass.players.player_command_previous_track(self.
player_idplayer_id)
302 @catch_musicassistant_error
304 """Send seek command."""
305 position =
int(position)
306 await self.
massmass.players.player_command_seek(self.
player_idplayer_id, position)
308 @catch_musicassistant_error
310 """Mute the volume."""
311 await self.
massmass.players.player_command_volume_mute(self.
player_idplayer_id, mute)
313 @catch_musicassistant_error
315 """Send new volume_level to device."""
316 volume =
int(volume * 100)
317 await self.
massmass.players.player_command_volume_set(self.
player_idplayer_id, volume)
319 @catch_musicassistant_error
321 """Send new volume_level to device."""
322 await self.
massmass.players.player_command_volume_up(self.
player_idplayer_id)
324 @catch_musicassistant_error
326 """Send new volume_level to device."""
327 await self.
massmass.players.player_command_volume_down(self.
player_idplayer_id)
329 @catch_musicassistant_error
331 """Turn on device."""
332 await self.
massmass.players.player_command_power(self.
player_idplayer_id,
True)
334 @catch_musicassistant_error
336 """Turn off device."""
337 await self.
massmass.players.player_command_power(self.
player_idplayer_id,
False)
339 @catch_musicassistant_error
341 """Set shuffle state."""
344 await self.
massmass.player_queues.queue_command_shuffle(
348 @catch_musicassistant_error
350 """Set repeat state."""
353 await self.
massmass.player_queues.queue_command_repeat(
354 self.
active_queueactive_queue.queue_id, MassRepeatMode(repeat)
357 @catch_musicassistant_error
359 """Clear players playlist."""
361 assert self.
playerplayer.active_source
is not None
362 if queue := self.
massmass.player_queues.get(self.
playerplayer.active_source):
363 await self.
massmass.player_queues.queue_command_clear(queue.queue_id)
365 @catch_musicassistant_error
368 media_type: MediaType | str,
370 enqueue: MediaPlayerEnqueue |
None =
None,
371 announce: bool |
None =
None,
374 """Send the play_media command to the media player."""
375 if media_source.is_media_source_id(media_id):
377 sourced_media = await media_source.async_resolve_media(
380 media_id = sourced_media.url
386 use_pre_announce=kwargs[ATTR_MEDIA_EXTRA].
get(
"use_pre_announce"),
387 announce_volume=kwargs[ATTR_MEDIA_EXTRA].
get(
"announce_volume"),
395 media_type=media_type,
396 radio_mode=kwargs[ATTR_MEDIA_EXTRA].
get(ATTR_RADIO_MODE),
399 @catch_musicassistant_error
401 """Join `group_members` as a player group with the current player."""
402 player_ids: list[str] = []
403 entity_registry = er.async_get(self.
hasshass)
404 for child_entity_id
in group_members:
406 if not (entity_reg_entry := entity_registry.async_get(child_entity_id)):
409 player_ids.append(entity_reg_entry.unique_id)
410 await self.
massmass.players.player_command_group_many(self.
player_idplayer_id, player_ids)
412 @catch_musicassistant_error
414 """Remove this player from any group."""
415 await self.
massmass.players.player_command_ungroup(self.
player_idplayer_id)
417 @catch_musicassistant_error
421 artist: str |
None =
None,
422 album: str |
None =
None,
423 enqueue: MediaPlayerEnqueue | QueueOption |
None =
None,
424 radio_mode: bool |
None =
None,
425 media_type: str |
None =
None,
427 """Send the play_media command to the media player."""
428 media_uris: list[str] = []
429 item: MediaItemType | ItemMapping |
None =
None
431 for media_id_str
in media_id:
433 if "://" in media_id_str:
434 media_uris.append(media_id_str)
437 if media_type
and media_id_str.isnumeric():
438 with suppress(MediaNotFoundError):
439 item = await self.
massmass.music.get_item(
440 MediaType(media_type), media_id_str,
"library"
442 if isinstance(item, MediaItemType | ItemMapping)
and item.uri:
443 media_uris.append(item.uri)
446 elif await asyncio.to_thread(os.path.isfile, media_id_str):
447 media_uris.append(media_id_str)
450 if item := await self.
massmass.music.get_item_by_name(
454 media_type=MediaType(media_type)
if media_type
else None,
456 media_uris.append(item.uri)
460 f
"Could not resolve {media_id} to playable media item"
465 assert self.
playerplayer.active_source
is not None
466 if queue := self.
massmass.player_queues.get(self.
playerplayer.active_source):
467 queue_id = queue.queue_id
471 await self.
massmass.player_queues.play_media(
475 radio_mode=radio_mode
if radio_mode
else False,
478 @catch_musicassistant_error
482 use_pre_announce: bool |
None =
None,
483 announce_volume: int |
None =
None,
485 """Send the play_announcement command to the media player."""
486 await self.
massmass.players.play_announcement(
487 self.
player_idplayer_id, url, use_pre_announce, announce_volume
490 @catch_musicassistant_error
492 self, source_player: str |
None =
None, auto_play: bool |
None =
None
494 """Transfer the current queue to another player."""
495 if not source_player:
497 for queue
in self.
massmass.player_queues:
498 if queue.state == MassPlayerState.PLAYING:
499 source_queue_id = queue.queue_id
503 "Source player not specified and no playing player found."
507 entity_registry = er.async_get(self.
hasshass)
508 if (entity := entity_registry.async_get(source_player))
is None:
510 source_queue_id = entity.unique_id
511 target_queue_id = self.
player_idplayer_id
512 await self.
massmass.player_queues.transfer_queue(
513 source_queue_id, target_queue_id, auto_play
518 media_content_type: MediaType | str |
None =
None,
519 media_content_id: str |
None =
None,
521 """Implement the websocket media browsing helper."""
530 self, player: Player, queue: PlayerQueue |
None
532 """Update image URL for the active queue item."""
533 if queue
is None or queue.current_item
is None:
536 if image_url := self.
massmass.get_media_item_image_url(queue.current_item):
538 self.
massmass.server_url
not in image_url
545 self, player: Player, queue: PlayerQueue |
None
547 """Update media attributes for the active queue item."""
558 if queue
is None and player.current_media:
570 assert player.elapsed_time
is not None
574 if player.elapsed_time_last_updated
578 assert player.elapsed_time
is not None
579 self.
_prev_time_prev_time = player.elapsed_time
592 if not (cur_item := queue.current_item):
600 queue.elapsed_time_last_updated
602 self.
_prev_time_prev_time = queue.elapsed_time
605 if (stream_details := cur_item.streamdetails)
and stream_details.stream_title:
607 if " - " in stream_details.stream_title:
608 stream_title_parts = stream_details.stream_title.split(
" - ", 1)
615 if not (media_item := cur_item.media_item):
623 if media_item.media_type == MediaType.TRACK:
625 assert isinstance(media_item, Track)
627 if media_item.version:
632 media_item.album,
"artist_str",
None
636 self, queue_option: MediaPlayerEnqueue | QueueOption |
None
637 ) -> QueueOption |
None:
638 """Convert a QueueOption to a MediaPlayerEnqueue."""
639 if isinstance(queue_option, MediaPlayerEnqueue):
640 queue_option = QUEUE_OPTION_MAP.get(queue_option)
None async_on_update(self)
None async_write_ha_state(self)
None async_on_remove(self, CALLBACK_TYPE func)
web.Response get(self, web.Request request, str config_key)
Callable[[], None] subscribe(HomeAssistant hass, str topic, MessageCallbackType msg_callback, int qos=DEFAULT_QOS, str encoding="utf-8")