"""Handle player related endpoints for Music Assistant.""" from __future__ import annotations from contextlib import suppress from typing import TYPE_CHECKING from music_assistant_models.enums import EventType, MediaType from music_assistant_models.errors import ( MusicAssistantError, PlayerCommandFailed, PlayerUnavailableError, ) from music_assistant_models.player import Player, PlayerMedia, PlayerOptionValueType, PlayerSource from music_assistant_models.player_control import PlayerControl if TYPE_CHECKING: from collections.abc import Iterator from music_assistant_models.event import MassEvent from .client import MusicAssistantClient class Players: """Player related endpoints/data for Music Assistant.""" def __init__(self, client: MusicAssistantClient) -> None: """Handle Initialization.""" self.client = client # subscribe to player events client.subscribe( self._handle_event, ( EventType.PLAYER_ADDED, EventType.PLAYER_REMOVED, EventType.PLAYER_UPDATED, ), ) # the initial items are retrieved after connect self._players: dict[str, Player] = {} @property def players(self) -> list[Player]: """Return all players.""" return list(self._players.values()) def __iter__(self) -> Iterator[Player]: """Iterate over (available) players.""" return iter(self._players.values()) def get(self, player_id: str) -> Player | None: """Return Player by ID (or None if not found).""" return self._players.get(player_id) def __getitem__(self, player_id: str) -> Player: """Return Player by ID.""" return self._players[player_id] # Player related endpoints/commands async def stop(self, player_id: str) -> None: """Send STOP command to given player (directly).""" await self.client.send_command("players/cmd/stop", player_id=player_id) async def play(self, player_id: str) -> None: """Send PLAY command to given player (directly).""" await self.client.send_command("players/cmd/play", player_id=player_id) async def pause(self, player_id: str) -> None: """Send PAUSE command to given player (directly).""" await self.client.send_command("players/cmd/pause", player_id=player_id) async def play_pause(self, player_id: str) -> None: """Send PLAY_PAUSE (toggle) command to given player (directly).""" await self.client.send_command("players/cmd/pause", player_id=player_id) async def power(self, player_id: str, powered: bool) -> None: """Send POWER command to given player.""" await self.client.send_command("players/cmd/power", player_id=player_id, powered=powered) async def volume_set(self, player_id: str, volume_level: int) -> None: """Send VOLUME SET command to given player.""" await self.client.send_command( "players/cmd/volume_set", player_id=player_id, volume_level=volume_level ) async def volume_up(self, player_id: str) -> None: """Send VOLUME UP command to given player.""" await self.client.send_command("players/cmd/volume_up", player_id=player_id) async def volume_down(self, player_id: str) -> None: """Send VOLUME DOWN command to given player.""" await self.client.send_command("players/cmd/volume_down", player_id=player_id) async def volume_mute(self, player_id: str, muted: bool) -> None: """Send VOLUME MUTE command to given player.""" await self.client.send_command("players/cmd/volume_mute", player_id=player_id, muted=muted) async def seek(self, player_id: str, position: int) -> None: """Handle SEEK command for given player (directly). - player_id: player_id of the player to handle the command. - position: position in seconds to seek to in the current playing item. """ await self.client.send_command("players/cmd/seek", player_id=player_id, position=position) async def next_track(self, player_id: str) -> None: """Handle NEXT TRACK command for given player.""" await self.client.send_command("players/cmd/next", player_id=player_id) async def previous_track(self, player_id: str) -> None: """Handle PREVIOUS TRACK command for given player.""" await self.client.send_command("players/cmd/previous", player_id=player_id) async def select_source(self, player_id: str, source: str) -> None: """ Handle SELECT SOURCE command on given player. - player_id: player_id of the player to handle the command. - source: The ID of the source that needs to be activated/selected. """ await self.client.send_command( "players/cmd/select_source", player_id=player_id, source=source ) async def select_sound_mode(self, player_id: str, sound_mode: str) -> None: """ Handle SELECT SOUND MODE command on given player. - player_id: player_id of the player to handle the command - sound_mode: The ID of the sound mode that needs to be activated/selected. """ await self.client.send_command( "players/cmd/select_sound_mode", player_id=player_id, sound_mode=sound_mode ) async def set_option( self, player_id: str, option_key: str, option_value: PlayerOptionValueType ) -> None: """ Handle SET_OPTION command on given player. - player_id: player_id of the player to handle the command - option_key: The ID of the player option that needs to be activated/selected. - option_value: The new value of the player option """ await self.client.send_command( "players/cmd/set_option", player_id=player_id, option_key=option_key, option_value=option_value, ) async def group(self, player_id: str, target_player: str) -> None: """Handle GROUP command for given player. Join/add the given player(id) to the given (leader) player/sync group. If the target player itself is already synced to another player, this may fail. If the player can not be synced with the given target player, this may fail. - player_id: player_id of the player to handle the command. - target_player: player_id of the syncgroup leader or group player. """ await self.client.send_command( "players/cmd/group", player_id=player_id, target_player=target_player ) async def ungroup(self, player_id: str) -> None: """Handle UNGROUP command for given player. Remove the given player from any (sync)groups it currently is synced to. If the player is not currently grouped to any other player, this will silently be ignored. - player_id: player_id of the player to handle the command. """ await self.client.send_command("players/cmd/ungroup", player_id=player_id) async def group_many(self, target_player: str, child_player_ids: list[str]) -> None: """Join given player(s) to target player.""" await self.client.send_command( "players/cmd/group_many", target_player=target_player, child_player_ids=child_player_ids ) async def ungroup_many(self, player_ids: list[str]) -> None: """Handle UNGROUP command for all the given players.""" await self.client.send_command("players/cmd/ungroup_many", player_ids=player_ids) async def play_announcement( self, player_id: str, url: str, pre_announce: bool | None = None, volume_level: int | None = None, pre_announce_url: str | None = None, ) -> None: """Handle playback of an announcement (url) on given player.""" await self.client.send_command( "players/cmd/play_announcement", player_id=player_id, url=url, pre_announce=pre_announce, volume_level=volume_level, pre_announce_url=pre_announce_url, ) # PlayerGroup related endpoints/commands async def group_volume(self, player_id: str, volume_level: int) -> None: """ Send VOLUME_SET command to given playergroup. Will send the new (average) volume level to group child's. - player_id: player_id of the playergroup to handle the command. - volume_level: volume level (0..100) to set on the player. """ await self.client.send_command( "players/cmd/group_volume", player_id=player_id, volume_level=volume_level ) async def group_volume_up(self, player_id: str) -> None: """Send VOLUME_UP command to given playergroup.""" await self.client.send_command("players/cmd/group_volume_up", player_id=player_id) async def group_volume_down(self, player_id: str) -> None: """Send VOLUME_DOWN command to given playergroup.""" await self.client.send_command("players/cmd/group_volume_down", player_id=player_id) async def add_currently_playing_to_favorites(self, player_id: str) -> None: """ Add the currently playing item/track on given player to the favorites. This tries to resolve the currently playing media to an actual media item and add that to the favorites in the library. Will raise an error if the player is not currently playing anything or if the currently playing media can not be resolved to a media item. """ assert self.client.server_info # for type checking if self.client.server_info.schema_version >= 27: # if the server supports the new favorites endpoint, use that await self.client.send_command( "players/add_currently_playing_to_favorites", player_id=player_id ) return # Fallback implementation for older server versions. # TODO: remove this after a while, once all/most servers are updated # guard for unknown player - which should not happen, but just in case if not (player := self._players.get(player_id)): raise PlayerUnavailableError(f"Player {player_id} not found") # handle mass player queue active if mass_queue := await self.client.player_queues.get_active_queue(player_id): if not (current_item := mass_queue.current_item) or not current_item.media_item: raise PlayerCommandFailed("No current item to add to favorites") # if we're playing a radio station, try to resolve the currently playing track if current_item.media_item.media_type == MediaType.RADIO: if not ( (streamdetails := mass_queue.current_item.streamdetails) and (stream_title := streamdetails.stream_title) and " - " in stream_title ): # no stream title available, so we can't resolve the track # this can happen if the radio station does not provide metadata # or there's a commercial break # Possible future improvement could be to actually detect the song with a # shazam-like approach. raise PlayerCommandFailed("No current item to add to favorites") # send the streamtitle into a global search query search_artist, search_title_title = stream_title.split(" - ", 1) if track := await self.client.music.get_track_by_name( search_title_title, search_artist ): # we found a track, so add it to the favorites await self.client.music.add_item_to_favorites(track) return # we could not resolve the track, so raise an error raise PlayerCommandFailed("No current item to add to favorites") # else: any other media item, just add it to the favorites directly await self.client.music.add_item_to_favorites(current_item.media_item) return # guard for player with no active source if not player.active_source: raise PlayerCommandFailed("Player has no active source") # handle other source active using the current_media with uri if current_media := player.current_media: # prefer the uri of the current media item if current_media.uri: with suppress(MusicAssistantError): await self.client.music.add_item_to_favorites(current_media.uri) return # fallback to search based on artist and title (and album if available) if current_media.artist and current_media.title: # noqa: SIM102 if track := await self.client.music.get_track_by_name( current_media.title, current_media.artist, current_media.album, ): # we found a track, so add it to the favorites await self.client.music.add_item_to_favorites(track) return # if we reach here, we could not resolve the currently playing item raise PlayerCommandFailed("No current item to add to favorites") async def resume( self, player_id: str, source: str | None = None, media: PlayerMedia | None = None, ) -> None: """Send RESUME command to given player. Resume (or restart) playback on the player.""" await self.client.send_command( "players/cmd/resume", player_id=player_id, source=source, media=media, ) async def set_members( self, target_player: str, player_ids_to_add: list[str] | None = None, player_ids_to_remove: list[str] | None = None, ) -> None: """Join/unjoin given player(s) to/from target player.""" await self.client.send_command( "players/cmd/set_members", target_player=target_player, player_ids_to_add=player_ids_to_add, player_ids_to_remove=player_ids_to_remove, ) async def create_group_player( self, provider: str, name: str, members: list[str], dynamic: bool | None = None, ) -> Player: """Create a new (permanent) Group Player.""" return Player.from_dict( await self.client.send_command( "players/create_group_player", provider=provider, name=name, members=members, dynamic=dynamic, ) ) async def get_by_name(self, name: str) -> Player: """Return PlayerState by name.""" return Player.from_dict( await self.client.send_command( "players/get_by_name", name=name, ) ) async def player_control(self, control_id: str) -> PlayerControl: """Return PlayerControl by control_id.""" return PlayerControl.from_dict( await self.client.send_command( "players/player_control", control_id=control_id, ) ) async def player_controls(self) -> list[PlayerControl]: """Return all registered playercontrols.""" return [ PlayerControl.from_dict(item) for item in await self.client.send_command( "players/player_controls", ) ] async def plugin_source(self, source_id: str) -> PlayerSource: """Return PluginSource by source_id.""" return PlayerSource.from_dict( await self.client.send_command( "players/plugin_source", source_id=source_id, ) ) async def plugin_sources(self) -> list[PlayerSource]: """Return all available plugin sources.""" return [ PlayerSource.from_dict(item) for item in await self.client.send_command( "players/plugin_sources", ) ] async def remove(self, player_id: str) -> None: """Remove a player from a provider.""" await self.client.send_command( "players/remove", player_id=player_id, ) async def remove_group_player(self, player_id: str) -> None: """Remove a group player.""" await self.client.send_command( "players/remove_group_player", player_id=player_id, ) async def _get_players(self) -> list[Player]: """Fetch all Players from the server.""" return [Player.from_dict(item) for item in await self.client.send_command("players/all")] async def fetch_state(self) -> None: """Fetch initial state once the server is connected.""" for player in await self._get_players(): self._players[player.player_id] = player def _handle_event(self, event: MassEvent) -> None: """Handle incoming player event.""" if event.event in (EventType.PLAYER_ADDED, EventType.PLAYER_UPDATED): # Player events always have an object id assert event.object_id self._players[event.object_id] = Player.from_dict(event.data) return if event.event == EventType.PLAYER_REMOVED: # Player events always have an object id assert event.object_id self._players.pop(event.object_id, None) # Backward compatibility aliases (deprecated) player_command_stop = stop player_command_play = play player_command_pause = pause player_command_play_pause = play_pause player_command_power = power player_command_volume_set = volume_set player_command_volume_up = volume_up player_command_volume_down = volume_down player_command_volume_mute = volume_mute player_command_seek = seek player_command_next_track = next_track player_command_previous_track = previous_track player_command_select_source = select_source player_command_group = group player_command_ungroup = ungroup player_command_group_many = group_many player_command_ungroup_many = ungroup_many set_player_group_volume = group_volume player_command_group_volume_up = group_volume_up player_command_group_volume_down = group_volume_down