From: Marcel van der Veldt Date: Wed, 1 Oct 2025 11:11:26 +0000 (+0200) Subject: Various Playergroup fixes (#2444) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=72c279d65ee42e17c78364b7e2ffe786e4b49867;p=music-assistant-server.git Various Playergroup fixes (#2444) --- diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index 33e609d0..a5e79c0a 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -170,6 +170,18 @@ class MusicController(CoreController): if self.database: await self.database.close() + async def on_provider_loaded(self, provider: MusicProvider) -> None: + """Handle logic when a provider is loaded.""" + await self.schedule_provider_sync(provider.instance_id) + + async def on_provider_unload(self, provider: MusicProvider) -> None: + """Handle logic when a provider is (about to get) unloaded.""" + # make sure to stop any running sync tasks first + for sync_task in self.in_progress_syncs: + if sync_task.provider_instance == provider.instance_id: + if sync_task.task: + sync_task.task.cancel() + @property def providers(self) -> list[MusicProvider]: """Return all loaded/running MusicProviders (instances).""" diff --git a/music_assistant/controllers/players.py b/music_assistant/controllers/players.py deleted file mode 100644 index f81b91d9..00000000 --- a/music_assistant/controllers/players.py +++ /dev/null @@ -1,2060 +0,0 @@ -""" -MusicAssistant PlayerController. - -Handles all logic to control supported players, -which are provided by Player Providers. - -Note that the PlayerController has a concept of a 'player' and a 'playerstate'. -The Player is the actual object that is provided by the provider, -which incorporates the actual state of the player (e.g. volume, state, etc) -and functions for controlling the player (e.g. play, pause, etc). - -The playerstate is the (final) state of the player, including any user customizations -and transformations that are applied to the player. -The playerstate is the object that is exposed to the outside world (via the API). -""" - -from __future__ import annotations - -import asyncio -import functools -import time -from contextlib import suppress -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, cast - -from music_assistant_models.constants import ( - PLAYER_CONTROL_FAKE, - PLAYER_CONTROL_NATIVE, - PLAYER_CONTROL_NONE, -) -from music_assistant_models.enums import ( - EventType, - MediaType, - PlaybackState, - PlayerFeature, - PlayerType, - ProviderFeature, - ProviderType, -) -from music_assistant_models.errors import ( - AlreadyRegisteredError, - MusicAssistantError, - PlayerCommandFailed, - PlayerUnavailableError, - ProviderUnavailableError, - UnsupportedFeaturedException, -) -from music_assistant_models.player_control import PlayerControl # noqa: TC002 - -from music_assistant.constants import ( - ANNOUNCE_ALERT_FILE, - ATTR_ANNOUNCEMENT_IN_PROGRESS, - ATTR_FAKE_MUTE, - ATTR_FAKE_POWER, - ATTR_FAKE_VOLUME, - ATTR_GROUP_MEMBERS, - ATTR_LAST_POLL, - ATTR_PREVIOUS_VOLUME, - CONF_AUTO_PLAY, - CONF_ENTRY_ANNOUNCE_VOLUME, - CONF_ENTRY_ANNOUNCE_VOLUME_MAX, - CONF_ENTRY_ANNOUNCE_VOLUME_MIN, - CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, - CONF_ENTRY_TTS_PRE_ANNOUNCE, - CONF_PLAYER_DSP, - CONF_PLAYERS, - CONF_PRE_ANNOUNCE_CHIME_URL, -) -from music_assistant.controllers.streams import AnnounceData -from music_assistant.helpers.api import api_command -from music_assistant.helpers.tags import async_parse_tags -from music_assistant.helpers.throttle_retry import Throttler -from music_assistant.helpers.util import TaskManager, validate_announcement_chime_url -from music_assistant.models.core_controller import CoreController -from music_assistant.models.player import Player, PlayerMedia, PlayerState -from music_assistant.models.player_provider import PlayerProvider -from music_assistant.models.plugin import PluginProvider, PluginSource - -if TYPE_CHECKING: - from collections.abc import Awaitable, Callable, Coroutine, Iterator - - from music_assistant_models.config_entries import CoreConfig, PlayerConfig - from music_assistant_models.player_queue import PlayerQueue - -CACHE_CATEGORY_PLAYER_POWER = 1 - - -_PlayerControllerT = TypeVar("_PlayerControllerT", bound="PlayerController") -_R = TypeVar("_R") -_P = ParamSpec("_P") - - -def handle_player_command[PlayerControllerT: "PlayerController", **P, R]( - func: Callable[Concatenate[_PlayerControllerT, _P], Awaitable[_R]], -) -> Callable[Concatenate[_PlayerControllerT, _P], Coroutine[Any, Any, _R | None]]: - """Check and log commands to players.""" - - @functools.wraps(func) - async def wrapper(self: _PlayerControllerT, *args: _P.args, **kwargs: _P.kwargs) -> _R | None: - """Log and handle_player_command commands to players.""" - player_id = kwargs["player_id"] if "player_id" in kwargs else args[0] - if (player := self._players.get(player_id)) is None or not player.available: - # player not existent - self.logger.warning( - "Ignoring command %s for unavailable player %s", - func.__name__, - player_id, - ) - return - - self.logger.debug( - "Handling command %s for player %s", - func.__name__, - player.display_name, - ) - try: - await func(self, *args, **kwargs) - except Exception as err: - raise PlayerCommandFailed(str(err)) from err - - return wrapper - - -class PlayerController(CoreController): - """Controller holding all logic to control registered players.""" - - domain: str = "players" - - def __init__(self, *args, **kwargs) -> None: - """Initialize core controller.""" - super().__init__(*args, **kwargs) - self._players: dict[str, Player] = {} - self._controls: dict[str, PlayerControl] = {} - self.manifest.name = "Player Controller" - self.manifest.description = ( - "Music Assistant's core controller which manages all players from all providers." - ) - self.manifest.icon = "speaker-multiple" - self._poll_task: asyncio.Task | None = None - self._player_throttlers: dict[str, Throttler] = {} - self._announce_locks: dict[str, asyncio.Lock] = {} - - async def setup(self, config: CoreConfig) -> None: - """Async initialize of module.""" - self._poll_task = self.mass.create_task(self._poll_players()) - - async def close(self) -> None: - """Cleanup on exit.""" - if self._poll_task and not self._poll_task.done(): - self._poll_task.cancel() - - @property - def providers(self) -> list[PlayerProvider]: - """Return all loaded/running MusicProviders.""" - return self.mass.get_providers(ProviderType.PLAYER) # type: ignore=return-value - - def all( - self, - return_unavailable: bool = True, - return_disabled: bool = False, - provider_filter: str | None = None, - ) -> list[Player]: - """ - Return all registered players. - - :param return_unavailable [bool]: Include unavailable players. - :param return_disabled [bool]: Include disabled players. - :param provider_filter [str]: Optional filter by provider lookup key. - - :return: List of Player objects. - """ - return [ - player - for player in self._players.values() - if (player.available or return_unavailable) - and (player.enabled or return_disabled) - and (provider_filter is None or player.provider.lookup_key == provider_filter) - ] - - @api_command("players/all") - def all_states( - self, - return_unavailable: bool = True, - return_disabled: bool = False, - provider_filter: str | None = None, - ) -> list[PlayerState]: - """ - Return PlayerState for all registered players. - - :param return_unavailable [bool]: Include unavailable players. - :param return_disabled [bool]: Include disabled players. - :param provider_filter [str]: Optional filter by provider lookup key. - - :return: List of PlayerState objects. - """ - return [ - player.state - for player in self.all( - return_unavailable=return_unavailable, - return_disabled=return_disabled, - provider_filter=provider_filter, - ) - ] - - def get( - self, - player_id: str, - raise_unavailable: bool = False, - ) -> Player | None: - """ - Return Player by player_id. - - :param player_id [str]: ID of the player. - :param raise_unavailable [bool]: Raise if player is unavailable. - - :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True. - :return: Player object or None. - """ - if player := self._players.get(player_id): - if (not player.available or not player.enabled) and raise_unavailable: - msg = f"Player {player_id} is not available" - raise PlayerUnavailableError(msg) - return player - if raise_unavailable: - msg = f"Player {player_id} is not available" - raise PlayerUnavailableError(msg) - return None - - @api_command("players/get") - def get_state( - self, - player_id: str, - raise_unavailable: bool = False, - ) -> PlayerState | None: - """ - Return PlayerState by player_id. - - :param player_id [str]: ID of the player. - :param raise_unavailable [bool]: Raise if player is unavailable. - - :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True. - :return: Player object or None. - """ - if player := self.get(player_id, raise_unavailable): - return player.state - return None - - def get_player_by_name(self, name: str) -> Player | None: - """ - Return Player by name. - - :param name: Name of the player. - :return: Player object or None. - """ - return next((x for x in self._players.values() if x.name == name), None) - - @api_command("players/get_by_name") - def get_player_state_by_name(self, name: str) -> PlayerState | None: - """ - Return PlayerState by name. - - :param name: Name of the player. - :return: PlayerState object or None. - """ - if player := self.get_player_by_name(name): - return player.state - return None - - @api_command("players/player_controls") - def player_controls( - self, - ) -> list[PlayerControl]: - """Return all registered playercontrols.""" - return list(self._controls.values()) - - @api_command("players/player_control") - def get_player_control( - self, - control_id: str, - ) -> PlayerControl | None: - """ - Return PlayerControl by control_id. - - :param control_id: ID of the player control. - :return: PlayerControl object or None. - """ - if control := self._controls.get(control_id): - return control - return None - - @api_command("players/plugin_sources") - def get_plugin_sources(self) -> list[PluginSource]: - """Return all available plugin sources.""" - return [ - plugin_prov.get_source() - for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN) - if isinstance(plugin_prov, PluginProvider) - and ProviderFeature.AUDIO_SOURCE in plugin_prov.supported_features - ] - - @api_command("players/plugin_source") - def get_plugin_source( - self, - source_id: str, - ) -> PluginSource | None: - """ - Return PluginSource by source_id. - - :param source_id: ID of the plugin source. - :return: PluginSource object or None. - """ - for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN): - assert isinstance(plugin_prov, PluginProvider) # for type checking - if ProviderFeature.AUDIO_SOURCE not in plugin_prov.supported_features: - continue - if (source := plugin_prov.get_source()) and source.id == source_id: - return source - return None - - # Player commands - - @api_command("players/cmd/stop") - @handle_player_command - async def cmd_stop(self, player_id: str) -> None: - """Send STOP command to given player. - - - player_id: player_id of the player to handle the command. - """ - player = self._get_player_with_redirect(player_id) - # Redirect to queue controller if it is active - if active_queue := self.get_active_queue(player): - await self.mass.player_queues.stop(active_queue.queue_id) - return - # handle command on player directly - async with self._player_throttlers[player.player_id]: - await player.stop() - - @api_command("players/cmd/play") - @handle_player_command - async def cmd_play(self, player_id: str) -> None: - """Send PLAY (unpause) command to given player. - - - player_id: player_id of the player to handle the command. - """ - player = self._get_player_with_redirect(player_id) - if player.playback_state == PlaybackState.PLAYING: - self.logger.info( - "Ignore PLAY request to player %s: player is already playing", player.display_name - ) - return - # Redirect to queue controller if it is active - if active_queue := self.get_active_queue(player): - await self.mass.player_queues.play(active_queue.queue_id) - return - # handle command on player directly - async with self._player_throttlers[player.player_id]: - await player.play() - - @api_command("players/cmd/pause") - @handle_player_command - async def cmd_pause(self, player_id: str) -> None: - """Send PAUSE command to given player. - - - player_id: player_id of the player to handle the command. - """ - player = self._get_player_with_redirect(player_id) - # Redirect to queue controller if it is active - if active_queue := self.get_active_queue(player): - await self.mass.player_queues.pause(active_queue.queue_id) - return - if PlayerFeature.PAUSE not in player.supported_features: - # if player does not support pause, we need to send stop - self.logger.debug( - "Player %s does not support pause, using STOP instead", - player.display_name, - ) - await self.cmd_stop(player.player_id) - return - # handle command on player directly - await player.pause() - - @api_command("players/cmd/play_pause") - async def cmd_play_pause(self, player_id: str) -> None: - """Toggle play/pause on given player. - - - player_id: player_id of the player to handle the command. - """ - player = self._get_player_with_redirect(player_id) - if player.playback_state == PlaybackState.PLAYING: - await self.cmd_pause(player.player_id) - else: - await self.cmd_play(player.player_id) - - @api_command("players/cmd/seek") - async def cmd_seek(self, player_id: str, position: int) -> None: - """Handle SEEK command for given player. - - - player_id: player_id of the player to handle the command. - - position: position in seconds to seek to in the current playing item. - """ - player = self._get_player_with_redirect(player_id) - # Redirect to queue controller if it is active - if active_queue := self.get_active_queue(player): - await self.mass.player_queues.seek(active_queue.queue_id, position) - return - if PlayerFeature.SEEK not in player.supported_features: - msg = f"Player {player.display_name} does not support seeking" - raise UnsupportedFeaturedException(msg) - # handle command on player directly - await player.seek(position) - - @api_command("players/cmd/next") - async def cmd_next_track(self, player_id: str) -> None: - """Handle NEXT TRACK command for given player.""" - player = self._get_player_with_redirect(player_id) - active_source_id = player.active_source or player.player_id - - # Redirect to queue controller if it is active - if active_queue := self.get_active_queue(player): - await self.mass.player_queues.next(active_queue.queue_id) - return - - if PlayerFeature.NEXT_PREVIOUS in player.supported_features: - # player has some other source active and native next/previous support - active_source = next((x for x in player.source_list if x.id == active_source_id), None) - if active_source and active_source.can_next_previous: - await player.next_track() - return - msg = "This action is (currently) unavailable for this source." - raise PlayerCommandFailed(msg) - - msg = f"Player {player.display_name} does not support skipping to the next track." - raise UnsupportedFeaturedException(msg) - - @api_command("players/cmd/previous") - async def cmd_previous_track(self, player_id: str) -> None: - """Handle PREVIOUS TRACK command for given player.""" - player = self._get_player_with_redirect(player_id) - active_source_id = player.active_source or player.player_id - # Redirect to queue controller if it is active - if active_queue := self.get_active_queue(player): - await self.mass.player_queues.previous(active_queue.queue_id) - return - - if PlayerFeature.NEXT_PREVIOUS in player.supported_features: - # player has some other source active and native next/previous support - active_source = next((x for x in player.source_list if x.id == active_source_id), None) - if active_source and active_source.can_next_previous: - await player.previous_track() - return - msg = "This action is (currently) unavailable for this source." - raise PlayerCommandFailed(msg) - - msg = f"Player {player.display_name} does not support skipping to the previous track." - raise UnsupportedFeaturedException(msg) - - @api_command("players/cmd/power") - @handle_player_command - async def cmd_power(self, player_id: str, powered: bool, skip_update: bool = False) -> None: - """Send POWER command to given player. - - - player_id: player_id of the player to handle the command. - - powered: bool if player should be powered on or off. - """ - player = self.get(player_id, True) - assert player is not None # for type checking - player_state = player.state - - if player_state.powered == powered: - self.logger.debug( - "Ignoring power %s command for player %s: already in state %s", - "ON" if powered else "OFF", - player_state.name, - "ON" if player_state.powered else "OFF", - ) - return # nothing to do - - # ungroup player at power off - player_was_synced = player.synced_to is not None - if player.type == PlayerType.PLAYER and not powered: - # ungroup player if it is synced (or is a sync leader itself) - # NOTE: ungroup will be ignored if the player is not grouped or synced - await self.cmd_ungroup(player_id) - - # always stop player at power off - if ( - not powered - and not player_was_synced - and player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) - ): - await self.cmd_stop(player_id) - # short sleep: allow the stop command to process and prevent race conditions - await asyncio.sleep(0.2) - - # power off all synced childs when player is a sync leader - elif not powered and player.type == PlayerType.PLAYER and player.group_members: - async with TaskManager(self.mass) as tg: - for member in self.iter_group_members(player, True): - if member.power_control == PLAYER_CONTROL_NONE: - continue - tg.create_task(self.cmd_power(member.player_id, False)) - - # handle actual power command - if player.power_control == PLAYER_CONTROL_NONE: - raise UnsupportedFeaturedException( - f"Player {player.display_name} does not support power control" - ) - if player.power_control == PLAYER_CONTROL_NATIVE: - # player supports power command natively: forward to player provider - async with self._player_throttlers[player_id]: - await player.power(powered) - elif player.power_control == PLAYER_CONTROL_FAKE: - # user wants to use fake power control - so we (optimistically) update the state - # and store the state in the cache - player.extra_data[ATTR_FAKE_POWER] = powered - await self.mass.cache.set( - key=player_id, - data=powered, - provider=self.domain, - category=CACHE_CATEGORY_PLAYER_POWER, - ) - else: - # handle external player control - player_control = self._controls.get(player.power_control) - control_name = player_control.name if player_control else player.power_control - self.logger.debug("Redirecting power command to PlayerControl %s", control_name) - if not player_control or not player_control.supports_power: - raise UnsupportedFeaturedException( - f"Player control {control_name} is not available" - ) - if powered: - assert player_control.power_on is not None # for type checking - await player_control.power_on() - else: - assert player_control.power_off is not None # for type checking - await player_control.power_off() - - # always optimistically set the power state to update the UI - # as fast as possible and prevent race conditions - player_state.powered = powered - # reset active source on power off - if not powered: - player_state.active_source = None - - if not skip_update: - player.update_state() - - # handle 'auto play on power on' feature - if ( - not player.active_group - and powered - and player.config.get_value(CONF_AUTO_PLAY) - and player.active_source in (None, player_id) - and not player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS) - ): - await self.mass.player_queues.resume(player_id) - - @api_command("players/cmd/volume_set") - @handle_player_command - async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: - """Send VOLUME_SET command to given player. - - - player_id: player_id of the player to handle the command. - - volume_level: volume level (0..100) to set on the player. - """ - player = self.get(player_id, True) - assert player is not None # for type checker - if player.type == PlayerType.GROUP: - # redirect to special group volume control - await self.cmd_group_volume(player_id, volume_level) - return - - if player.volume_control == PLAYER_CONTROL_NONE: - raise UnsupportedFeaturedException( - f"Player {player.display_name} does not support volume control" - ) - - if player.mute_control != PLAYER_CONTROL_NONE and player.volume_muted: - # if player is muted, we unmute it first - self.logger.debug( - "Unmuting player %s before setting volume", - player.display_name, - ) - await self.cmd_volume_mute(player_id, False) - - if player.volume_control == PLAYER_CONTROL_NATIVE: - # player supports volume command natively: forward to player - async with self._player_throttlers[player_id]: - await player.volume_set(volume_level) - return - if player.volume_control == PLAYER_CONTROL_FAKE: - # user wants to use fake volume control - so we (optimistically) update the state - # and store the state in the cache - player.extra_data[ATTR_FAKE_VOLUME] = volume_level - # trigger update - player.update_state() - return - # else: handle external player control - player_control = self._controls.get(player.volume_control) - control_name = player_control.name if player_control else player.volume_control - self.logger.debug("Redirecting volume command to PlayerControl %s", control_name) - if not player_control or not player_control.supports_volume: - raise UnsupportedFeaturedException(f"Player control {control_name} is not available") - async with self._player_throttlers[player_id]: - assert player_control.volume_set is not None - await player_control.volume_set(volume_level) - - @api_command("players/cmd/volume_up") - @handle_player_command - async def cmd_volume_up(self, player_id: str) -> None: - """Send VOLUME_UP command to given player. - - - player_id: player_id of the player to handle the command. - """ - if not (player := self.get(player_id)): - return - current_volume = player.volume_state or 0 - if current_volume < 5 or current_volume > 95: - step_size = 1 - elif current_volume < 20 or current_volume > 80: - step_size = 2 - else: - step_size = 5 - new_volume = min(100, current_volume + step_size) - await self.cmd_volume_set(player_id, new_volume) - - @api_command("players/cmd/volume_down") - @handle_player_command - async def cmd_volume_down(self, player_id: str) -> None: - """Send VOLUME_DOWN command to given player. - - - player_id: player_id of the player to handle the command. - """ - if not (player := self.get(player_id)): - return - current_volume = player.volume_state or 0 - if current_volume < 5 or current_volume > 95: - step_size = 1 - elif current_volume < 20 or current_volume > 80: - step_size = 2 - else: - step_size = 5 - new_volume = max(0, current_volume - step_size) - await self.cmd_volume_set(player_id, new_volume) - - @api_command("players/cmd/group_volume") - @handle_player_command - async def cmd_group_volume( - self, - player_id: str, - volume_level: int, - ) -> None: - """ - Handle adjusting the overall/group volume to a playergroup (or synced players). - - Will set a new (overall) volume level to a group player or syncgroup. - - :param group_player: dedicated group player or syncleader to handle the command. - :param volume_level: volume level (0..100) to set to the group. - """ - player = self.get(player_id, True) - assert player is not None # for type checker - if player.type == PlayerType.GROUP or player.group_members: - # dedicated group player or sync leader - await self.set_group_volume(player, volume_level) - return - if player.synced_to and (sync_leader := self.get(player.synced_to)): - # redirect to sync leader - await self.set_group_volume(sync_leader, volume_level) - return - # treat as normal player volume change - await self.cmd_volume_set(player_id, volume_level) - - @api_command("players/cmd/group_volume_up") - @handle_player_command - async def cmd_group_volume_up(self, player_id: str) -> None: - """Send VOLUME_UP command to given playergroup. - - - player_id: player_id of the player to handle the command. - """ - group_player = self.get(player_id, True) - assert group_player - cur_volume = group_player.group_volume - if cur_volume < 5 or cur_volume > 95: - step_size = 1 - elif cur_volume < 20 or cur_volume > 80: - step_size = 2 - else: - step_size = 5 - new_volume = min(100, cur_volume + step_size) - await self.cmd_group_volume(player_id, new_volume) - - @api_command("players/cmd/group_volume_down") - @handle_player_command - async def cmd_group_volume_down(self, player_id: str) -> None: - """Send VOLUME_DOWN command to given playergroup. - - - player_id: player_id of the player to handle the command. - """ - group_player = self.get(player_id, True) - assert group_player - cur_volume = group_player.group_volume - if cur_volume < 5 or cur_volume > 95: - step_size = 1 - elif cur_volume < 20 or cur_volume > 80: - step_size = 2 - else: - step_size = 5 - new_volume = max(0, cur_volume - step_size) - await self.cmd_group_volume(player_id, new_volume) - - @api_command("players/cmd/volume_mute") - @handle_player_command - async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: - """Send VOLUME_MUTE command to given player. - - - player_id: player_id of the player to handle the command. - - muted: bool if player should be muted. - """ - player = self.get(player_id, True) - assert player - if player.mute_control == PLAYER_CONTROL_NONE: - raise UnsupportedFeaturedException( - f"Player {player.display_name} does not support muting" - ) - if player.mute_control == PLAYER_CONTROL_NATIVE: - # player supports mute command natively: forward to player - async with self._player_throttlers[player_id]: - await player.volume_mute(muted) - elif player.mute_control == PLAYER_CONTROL_FAKE: - # user wants to use fake mute control - so we use volume instead - self.logger.debug( - "Using volume for muting for player %s", - player.display_name, - ) - if muted: - player.extra_data[ATTR_PREVIOUS_VOLUME] = player.volume_state - player.extra_data[ATTR_FAKE_MUTE] = True - await self.cmd_volume_set(player_id, 0) - else: - player._attr_volume_muted = False - prev_volume = player.extra_data.get(ATTR_PREVIOUS_VOLUME, 1) - player.extra_data[ATTR_FAKE_MUTE] = False - await self.cmd_volume_set(player_id, prev_volume) - else: - # handle external player control - player_control = self._controls.get(player.mute_control) - control_name = player_control.name if player_control else player.mute_control - self.logger.debug("Redirecting mute command to PlayerControl %s", control_name) - if not player_control or not player_control.supports_mute: - raise UnsupportedFeaturedException( - f"Player control {control_name} is not available" - ) - async with self._player_throttlers[player_id]: - assert player_control.mute_set is not None - await player_control.mute_set(muted) - - @api_command("players/cmd/play_announcement") - async def play_announcement( - self, - player_id: str, - url: str, - pre_announce: bool | str | None = None, - volume_level: int | None = None, - pre_announce_url: str | None = None, - ) -> None: - """ - Handle playback of an announcement (url) on given player. - - - player_id: player_id of the player to handle the command. - - url: URL of the announcement to play. - - pre_announce: optional bool if pre-announce should be used. - - volume_level: optional volume level to set for the announcement. - - pre_announce_url: optional custom URL to use for the pre-announce chime. - """ - player = self.get(player_id, True) - assert player is not None # for type checking - if not url.startswith("http"): - raise PlayerCommandFailed("Only URLs are supported for announcements") - if ( - pre_announce - and pre_announce_url - and not validate_announcement_chime_url(pre_announce_url) - ): - raise PlayerCommandFailed("Invalid pre-announce chime URL specified.") - # prevent multiple announcements at the same time to the same player with a lock - if player_id not in self._announce_locks: - self._announce_locks[player_id] = lock = asyncio.Lock() - else: - lock = self._announce_locks[player_id] - async with lock: - try: - # mark announcement_in_progress on player - player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = True - # determine if the player has native announcements support - native_announce_support = ( - PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features - ) - # determine pre-announce from (group)player config - if pre_announce is None and "tts" in url: - conf_pre_announce = self.mass.config.get_raw_player_config_value( - player_id, - CONF_ENTRY_TTS_PRE_ANNOUNCE.key, - CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value, - ) - pre_announce = cast("bool", conf_pre_announce) - if pre_announce_url is None: - if conf_pre_announce_url := self.mass.config.get_raw_player_config_value( - player_id, - CONF_PRE_ANNOUNCE_CHIME_URL, - ): - # player default custom chime url - pre_announce_url = cast("str", conf_pre_announce_url) - else: - # use global default chime url - pre_announce_url = ANNOUNCE_ALERT_FILE - # if player type is group with all members supporting announcements, - # we forward the request to each individual player - if player.type == PlayerType.GROUP and ( - all( - PlayerFeature.PLAY_ANNOUNCEMENT in x.supported_features - for x in self.iter_group_members(player) - ) - ): - # forward the request to each individual player - async with TaskManager(self.mass) as tg: - for group_member in player.group_members: - tg.create_task( - self.play_announcement( - group_member, - url=url, - pre_announce=pre_announce, - volume_level=volume_level, - pre_announce_url=pre_announce_url, - ) - ) - return - self.logger.info( - "Playback announcement to player %s (with pre-announce: %s): %s", - player.display_name, - pre_announce, - url, - ) - # create a PlayerMedia object for the announcement so - # we can send a regular play-media call downstream - announce_data = AnnounceData( - announcement_url=url, - pre_announce=pre_announce, - pre_announce_url=pre_announce_url, - ) - announcement = PlayerMedia( - uri=self.mass.streams.get_announcement_url(player_id, url, announce_data), - media_type=MediaType.ANNOUNCEMENT, - title="Announcement", - custom_data=announce_data, - ) - # handle native announce support - if native_announce_support: - announcement_volume = self.get_announcement_volume(player_id, volume_level) - await player.play_announcement(announcement, announcement_volume) - return - # use fallback/default implementation - await self._play_announcement(player, announcement, volume_level) - finally: - player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = False - - @handle_player_command - async def play_media(self, player_id: str, media: PlayerMedia) -> None: - """Handle PLAY MEDIA on given player. - - - player_id: player_id of the player to handle the command. - - media: The Media that needs to be played on the player. - """ - player = self._get_player_with_redirect(player_id) - # power on the player if needed - if player.powered is False and player.power_control != PLAYER_CONTROL_NONE: - await self.cmd_power(player.player_id, True) - await player.play_media(media) - - @api_command("players/cmd/select_source") - 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. - """ - player = self.get(player_id, True) - assert player is not None # for type checking - if player.synced_to or player.active_group: - raise PlayerCommandFailed(f"Player {player.display_name} is currently grouped") - # check if player is already playing and source is different - # in that case we need to stop the player first - prev_source = player.active_source - if prev_source and source != prev_source: - if player.playback_state != PlaybackState.IDLE: - await self.cmd_stop(player_id) - await asyncio.sleep(0.5) # small delay to allow stop to process - player.active_source = None - player.current_media = None - # check if source is a pluginsource - # in that case the source id is the instance_id of the plugin provider - if plugin_prov := self.mass.get_provider(source): - await self._handle_select_plugin_source(player, plugin_prov) - return - # check if source is a mass queue - # this can be used to restore the queue after a source switch - if mass_queue := self.mass.player_queues.get(source): - await self.mass.player_queues.play(mass_queue.queue_id) - return - # basic check if player supports source selection - if PlayerFeature.SELECT_SOURCE not in player.supported_features: - raise UnsupportedFeaturedException( - f"Player {player.display_name} does not support source selection" - ) - # basic check if source is valid for player - if not any(x for x in player.source_list if x.id == source): - raise PlayerCommandFailed( - f"{source} is an invalid source for player {player.display_name}" - ) - # forward to player - await player.select_source(source) - - async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: - """ - Handle enqueuing of a next media item on the player. - - :param player_id: player_id of the player to handle the command. - :param media: The Media that needs to be enqueued on the player. - :raises UnsupportedFeaturedException: if the player does not support enqueueing. - :raises PlayerUnavailableError: if the player is not available. - """ - player = self.get(player_id, raise_unavailable=True) - assert player is not None # for type checking - if PlayerFeature.ENQUEUE not in player.supported_features: - raise UnsupportedFeaturedException( - f"Player {player.display_name} does not support enqueueing" - ) - async with self._player_throttlers[player_id]: - await player.enqueue_next_media(media) - - @api_command("players/cmd/set_members") - async def cmd_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. - - Will add the given player(s) to the target player (sync leader or group player). - - :param target_player: player_id of the syncgroup leader or group player. - :param player_ids_to_add: List of player_id's to add to the target player. - :param player_ids_to_remove: List of player_id's to remove from the target player. - - :raises UnsupportedFeaturedException: if the target player does not support grouping. - :raises PlayerUnavailableError: if the target player is not available. - """ - parent_player: Player | None = self.get(target_player, True) - assert parent_player is not None # for type checking - if PlayerFeature.SET_MEMBERS not in parent_player.supported_features: - msg = f"Player {parent_player.name} does not support group commands" - raise UnsupportedFeaturedException(msg) - - if parent_player.synced_to: - # guard edge case: player already synced to another player - raise PlayerCommandFailed( - f"Player {parent_player.name} is already synced to another player on its own, " - "you need to ungroup it first before you can join other players to it.", - ) - - # filter all player ids on compatibility and availability - final_player_ids_to_add: list[str] = [] - for child_player_id in player_ids_to_add or []: - if child_player_id == target_player: - continue - if child_player_id in final_player_ids_to_add: - continue - if not (child_player := self.get(child_player_id)) or not child_player.available: - self.logger.warning("Player %s is not available", child_player_id) - continue - - # check if player can be synced/grouped with the target player - if not ( - child_player_id in parent_player.can_group_with - or child_player.provider.lookup_key in parent_player.can_group_with - or "*" in parent_player.can_group_with - ): - raise UnsupportedFeaturedException( - f"Player {child_player.name} can not be grouped with {parent_player.name}" - ) - - if ( - child_player.synced_to - and child_player.synced_to == target_player - and child_player_id in parent_player.group_members - ): - continue # already synced to this target - - # Check if player is already part of another group and try to automatically ungroup it - # first. If that fails, power off the group - if child_player.active_group and child_player.active_group != target_player: - if ( - other_group := self.get(child_player.active_group) - ) and PlayerFeature.SET_MEMBERS in other_group.supported_features: - self.logger.warning( - "Player %s is already part of another group (%s), " - "removing from that group first", - child_player.name, - child_player.active_group, - ) - if child_player.player_id in other_group.static_group_members: - self.logger.warning( - "Player %s is a static member of group %s: removing is not possible, " - "powering the group off instead", - child_player.name, - child_player.active_group, - ) - await self.cmd_power(child_player.active_group, False) - else: - await other_group.set_members(player_ids_to_remove=[child_player.player_id]) - else: - self.logger.warning( - "Player %s is already part of another group (%s), powering it off first", - child_player.name, - child_player.active_group, - ) - await self.cmd_power(child_player.active_group, False) - elif child_player.synced_to and child_player.synced_to != target_player: - self.logger.warning( - "Player %s is already synced to another player, ungrouping first", - child_player.name, - ) - await self.cmd_ungroup(child_player.player_id) - - # power on the player if needed - if not child_player.powered and child_player.power_control != PLAYER_CONTROL_NONE: - await self.cmd_power(child_player.player_id, True, skip_update=True) - # if we reach here, all checks passed - final_player_ids_to_add.append(child_player_id) - - final_player_ids_to_remove: list[str] = [] - if player_ids_to_remove: - static_members = set(parent_player.static_group_members) - for child_player_id in player_ids_to_remove: - if child_player_id == target_player: - raise UnsupportedFeaturedException( - f"Cannot remove {parent_player.name} from itself as a member!" - ) - if child_player_id not in parent_player.group_members: - continue - if child_player_id in static_members: - raise UnsupportedFeaturedException( - f"Cannot remove {child_player_id} from {parent_player.name} " - "as it is a static member of this group" - ) - final_player_ids_to_remove.append(child_player_id) - - # forward command to the player after all (base) sanity checks - async with self._player_throttlers[target_player]: - await parent_player.set_members( - player_ids_to_add=final_player_ids_to_add or None, - player_ids_to_remove=final_player_ids_to_remove or None, - ) - - @api_command("players/cmd/group") - @handle_player_command - async def cmd_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. - - :param player_id: player_id of the player to handle the command. - :param target_player: player_id of the syncgroup leader or group player. - - :raises UnsupportedFeaturedException: if the target player does not support grouping. - :raises PlayerCommandFailed: if the target player is already synced to another player. - :raises PlayerUnavailableError: if the target player is not available. - :raises PlayerCommandFailed: if the player is already grouped to another player. - """ - await self.cmd_set_members(target_player, player_ids_to_add=[player_id]) - - @api_command("players/cmd/group_many") - async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None: - """ - Join given player(s) to target player. - - Will add the given player(s) to the target player (sync leader or group player). - NOTE: This is a (deprecated) alias for cmd_set_members. - """ - await self.cmd_set_members(target_player, player_ids_to_add=child_player_ids) - - @api_command("players/cmd/ungroup") - @handle_player_command - async def cmd_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. - - NOTE: This is a (deprecated) alias for cmd_set_members. - """ - if not (player := self.get(player_id)): - self.logger.warning("Player %s is not available", player_id) - return - - if ( - player.active_group - and (group_player := self.get(player.active_group)) - and (PlayerFeature.SET_MEMBERS in group_player.supported_features) - ): - # the player is part of a (permanent) groupplayer and the user tries to ungroup - if player_id in group_player.static_group_members: - raise UnsupportedFeaturedException( - f"Player {player.name} is a static member of group {group_player.name} " - "and cannot be removed from that group!" - ) - await group_player.set_members(player_ids_to_remove=[player_id]) - return - - if player.synced_to and (synced_player := self.get(player.synced_to)): - # player is a sync member - await synced_player.set_members(player_ids_to_remove=[player_id]) - return - - if not (player.synced_to or player.group_members): - return # nothing to do - - if PlayerFeature.SET_MEMBERS not in player.supported_features: - self.logger.warning("Player %s does not support (un)group commands", player.name) - return - - # forward command to the player once all checks passed - await player.ungroup() - - @api_command("players/cmd/ungroup_many") - async def cmd_ungroup_many(self, player_ids: list[str]) -> None: - """Handle UNGROUP command for all the given players.""" - for player_id in list(player_ids): - await self.cmd_ungroup(player_id) - - @api_command("players/create_group_player") - async def create_group_player( - self, provider: str, name: str, members: list[str], dynamic: bool = True - ): - """ - Create a new (permanent) Group Player. - - :param provider: The provider to create the group player for - :param name: Name of the new group player - :param members: List of player ids to add to the group - :param dynamic: Whether the group is dynamic (members can change) - """ - if not (provider_instance := self.mass.get_provider(provider)): - raise ProviderUnavailableError(f"Provider {provider} not found") - provider_instance.check_feature(ProviderFeature.CREATE_GROUP_PLAYER) - provider_instance = cast("PlayerProvider", provider_instance) - # create the group player - return await provider_instance.create_group_player(name, members, dynamic) - - @api_command("players/remove_group_player") - async def remove_group_player(self, player_id: str) -> None: - """ - Remove a group player. - - :param player_id: ID of the group player to remove. - """ - if not (player := self.get(player_id)): - # we simply permanently delete the player by wiping its config - self.mass.config.remove(f"players/{player_id}") - return - if player.type != PlayerType.GROUP: - raise UnsupportedFeaturedException( - f"Player {player.display_name} is not a group player" - ) - player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER) - await player.provider.remove_group_player(player_id) - - @api_command("players/add_currently_playing_to_favorites") - 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. - """ - player = self._get_player_with_redirect(player_id) - # handle mass player queue active - if mass_queue := self.get_active_queue(player): - 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) - # strip off any additional comments in the title (such as from Radio Paradise) - search_title_title = search_title_title.split(" | ")[0].strip() - if track := await self.mass.music.get_track_by_name( - search_title_title, search_artist - ): - # we found a track, so add it to the favorites - await self.mass.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.mass.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.mass.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: - if track := await self.mass.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.mass.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 register(self, player: Player) -> None: - """Register a player on the Player Controller.""" - if self.mass.closing: - return - player_id = player.player_id - - if player_id in self._players: - msg = f"Player {player_id} is already registered!" - raise AlreadyRegisteredError(msg) - - # ignore disabled players - if not player.enabled: - return - - # register throttler for this player - self._player_throttlers[player_id] = Throttler(1, 0.05) - - # restore 'fake' power state from cache if available - cached_value = await self.mass.cache.get( - key=player.player_id, - provider=self.domain, - category=CACHE_CATEGORY_PLAYER_POWER, - default=False, - ) - if cached_value is not None: - player.extra_data[ATTR_FAKE_POWER] = cached_value - - # finally actually register it - self._players[player_id] = player - - # ensure we fetch and set the latest/full config for the player - player_config = await self.mass.config.get_player_config(player_id) - player.set_config(player_config) - # call hook after the player is registered and config is set - await player.on_config_updated() - # always call update to fix special attributes like display name, group volume etc. - player.update_state() - - self.logger.info( - "Player registered: %s/%s", - player_id, - player.display_name, - ) - # signal event that a player was added - self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player) - - # register playerqueue for this player - await self.mass.player_queues.on_player_register(player) - - async def register_or_update(self, player: Player) -> None: - """Register a new player on the controller or update existing one.""" - if self.mass.closing: - return - - if player.player_id in self._players: - self._players[player.player_id] = player - player.update_state() - return - - await self.register(player) - - def trigger_player_update(self, player_id: str, force_update: bool = False) -> None: - """Trigger an update for the given player.""" - if self.mass.closing: - return - player = self.get(player_id, True) - assert player is not None # for type checker - self.mass.loop.call_soon(player.update_state, force_update) - - async def unregister(self, player_id: str, permanent: bool = False) -> None: - """ - Unregister a player from the player controller. - - Called (by a PlayerProvider) when a player is removed - or no longer available (for a longer period of time). - - This will remove the player from the player controller and - optionally remove the player's config from the mass config. - - - player_id: player_id of the player to unregister. - - permanent: if True, remove the player permanently by deleting - the player's config from the mass config. If False, the player config will not be removed, - allowing for re-registration (with the same config) later. - - If the player is not registered, this will silently be ignored. - """ - player = self._players.get(player_id) - if player is None: - return - await self._cleanup_player_memberships(player_id) - del self._players[player_id] - self.logger.info("Player removed: %s", player.name) - self.mass.player_queues.on_player_remove(player_id, permanent=permanent) - await player.on_unload() - if permanent: - self.delete_player_config(player_id) - self.mass.signal_event(EventType.PLAYER_REMOVED, player_id) - - @api_command("players/remove") - async def remove(self, player_id: str) -> None: - """ - Remove a player from a provider. - - Can only be called when a PlayerProvider supports ProviderFeature.REMOVE_PLAYER. - """ - player = self.get(player_id) - if player is None: - # we simply permanently delete the player config since it is not registered - self.delete_player_config(player_id) - return - if player.type == PlayerType.GROUP: - # Handle group player removal - await player.provider.remove_group_player(player_id) - return - player.provider.check_feature(ProviderFeature.REMOVE_PLAYER) - await player.provider.remove_player(player_id) - # check for group memberships that need to be updated - if player.active_group and (group_player := self.mass.players.get(player.active_group)): - # try to remove from the group - with suppress(UnsupportedFeaturedException, PlayerCommandFailed): - await group_player.set_members( - player_ids_to_remove=[player_id], - ) - # We removed the player and can now clean up its config - self.delete_player_config(player_id) - - def delete_player_config(self, player_id: str) -> None: - """ - Permanently delete a player's configuration. - - Should only be called for players that are not registered by the player controller. - """ - # we simply permanently delete the player by wiping its config - conf_key = f"{CONF_PLAYERS}/{player_id}" - dsp_conf_key = f"{CONF_PLAYER_DSP}/{player_id}" - for key in (conf_key, dsp_conf_key): - self.mass.config.remove(key) - - def signal_player_state_update( - self, - player: Player, - changed_values: dict[str, tuple[Any, Any]], - force_update: bool = False, - skip_forward: bool = False, - ) -> None: - """ - Signal a player state update. - - Called by a Player when its state has changed. - This will update the player state in the controller and signal the event bus. - """ - player_id = player.player_id - if self.mass.closing: - return - - # ignore updates for disabled players - if not player.enabled and "enabled" not in changed_values: - return - - if len(changed_values) == 0 and not force_update: - # nothing changed - return - - # always signal update to the playerqueue - self.mass.player_queues.on_player_update(player, changed_values) - - if changed_values.keys() == {"elapsed_time"} and not force_update: - # ignore elapsed_time only changes - prev_value = changed_values["elapsed_time"][0] or 0 - new_value = changed_values["elapsed_time"][1] or 0 - if abs(prev_value - new_value) < 30: - # ignore small changes in elapsed time - return - - # handle DSP reload of the leader when grouping/ungrouping - if ATTR_GROUP_MEMBERS in changed_values: - prev_group_members, new_group_members = changed_values[ATTR_GROUP_MEMBERS] - self._handle_group_dsp_change(player, prev_group_members or [], new_group_members) - - if ATTR_GROUP_MEMBERS in changed_values: - # Removed group members also need to be updated since they are no longer part - # of this group and are available for playback again - prev_group_members = changed_values[ATTR_GROUP_MEMBERS][0] or [] - new_group_members = changed_values[ATTR_GROUP_MEMBERS][1] or [] - removed_members = set(prev_group_members) - set(new_group_members) - for player_id in removed_members: - if removed_player := self.get(player_id): - removed_player.update_state() - - became_inactive = False - if "available" in changed_values: - became_inactive = changed_values["available"][1] is False - if not became_inactive and "enabled" in changed_values: - became_inactive = changed_values["enabled"][1] is False - if became_inactive and (player.active_group or player.synced_to): - self.mass.create_task(self._cleanup_player_memberships(player.player_id)) - - # signal player update on the eventbus - self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player) - - if skip_forward and not force_update: - return - - # update/signal group player(s) child's when group updates - for child_player in self.iter_group_members(player, exclude_self=True): - child_player.update_state() - # update/signal group player(s) when child updates - for group_player in self._get_player_groups(player, powered_only=False): - group_player.update_state() - # update/signal manually synced to player when child updates - if (synced_to := player.synced_to) and (synced_to_player := self.get(synced_to)): - synced_to_player.update_state() - # update/signal active groups when a group member updates - if (active_group := player.active_group) and ( - active_group_player := self.get(active_group) - ): - active_group_player.update_state() - - async def register_player_control(self, player_control: PlayerControl) -> None: - """Register a new PlayerControl on the controller.""" - if self.mass.closing: - return - control_id = player_control.id - - if control_id in self._controls: - msg = f"PlayerControl {control_id} is already registered" - raise AlreadyRegisteredError(msg) - - # make sure that the playercontrol's provider is set to the instance_id - prov = self.mass.get_provider(player_control.provider) - if not prov or prov.instance_id != player_control.provider: - raise RuntimeError(f"Invalid provider ID given: {player_control.provider}") - - self._controls[control_id] = player_control - - self.logger.info( - "PlayerControl registered: %s/%s", - control_id, - player_control.name, - ) - - # always call update to update any attached players etc. - self.update_player_control(player_control.id) - - async def register_or_update_player_control(self, player_control: PlayerControl) -> None: - """Register a new playercontrol on the controller or update existing one.""" - if self.mass.closing: - return - if player_control.id in self._controls: - self._controls[player_control.id] = player_control - self.update_player_control(player_control.id) - return - await self.register_player_control(player_control) - - def update_player_control(self, control_id: str) -> None: - """Update playercontrol state.""" - if self.mass.closing: - return - # update all players that are using this control - for player in self._players.values(): - if control_id in (player.power_control, player.volume_control, player.mute_control): - self.mass.loop.call_soon(player.update_state) - - def remove_player_control(self, control_id: str) -> None: - """Remove a player_control from the player manager.""" - control = self._controls.pop(control_id, None) - if control is None: - return - self._controls.pop(control_id, None) - self.logger.info("PlayerControl removed: %s", control.name) - - def get_player_provider(self, player_id: str) -> PlayerProvider: - """Return PlayerProvider for given player.""" - player = self._players[player_id] - assert player # for type checker - return player.provider - - def get_active_queue(self, player: Player) -> PlayerQueue | None: - """Return the current active queue for a player (if any).""" - # account for player that is synced (sync child) - if player.synced_to and player.synced_to != player.player_id: - if sync_leader := self.get(player.synced_to): - return self.get_active_queue(sync_leader) - # handle active group player - if player.active_group and player.active_group != player.player_id: - if group_player := self.get(player.active_group): - return self.get_active_queue(group_player) - # active_source may be filled queue id (or None) - active_source = player.active_source or player.player_id - if active_queue := self.mass.player_queues.get(active_source): - return active_queue - return None - - async def set_group_volume(self, group_player: Player, volume_level: int) -> None: - """Handle adjusting the overall/group volume to a playergroup (or synced players).""" - cur_volume = group_player.state.group_volume - volume_dif = volume_level - cur_volume - coros = [] - # handle group volume by only applying the volume to powered members - for child_player in self.iter_group_members( - group_player, only_powered=True, exclude_self=False - ): - if child_player.volume_control == PLAYER_CONTROL_NONE: - continue - cur_child_volume = child_player.volume_level or 0 - new_child_volume = int(cur_child_volume + volume_dif) - new_child_volume = max(0, new_child_volume) - new_child_volume = min(100, new_child_volume) - coros.append(self.cmd_volume_set(child_player.player_id, new_child_volume)) - await asyncio.gather(*coros) - - def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None: - """Get the (player specific) volume for a announcement.""" - volume_strategy = self.mass.config.get_raw_player_config_value( - player_id, - CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key, - CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value, - ) - volume_strategy_volume = self.mass.config.get_raw_player_config_value( - player_id, - CONF_ENTRY_ANNOUNCE_VOLUME.key, - CONF_ENTRY_ANNOUNCE_VOLUME.default_value, - ) - if volume_strategy == "none": - return None - volume_level = volume_override - if volume_level is None and volume_strategy == "absolute": - volume_level = volume_strategy_volume - elif volume_level is None and volume_strategy == "relative": - player = self.get(player_id) - volume_level = player.volume_level + volume_strategy_volume - elif volume_level is None and volume_strategy == "percentual": - player = self.get(player_id) - percentual = (player.volume_level / 100) * volume_strategy_volume - volume_level = player.volume_level + percentual - if volume_level is not None: - announce_volume_min = self.mass.config.get_raw_player_config_value( - player_id, - CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key, - CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value, - ) - volume_level = max(announce_volume_min, volume_level) - announce_volume_max = self.mass.config.get_raw_player_config_value( - player_id, - CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key, - CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value, - ) - volume_level = min(announce_volume_max, volume_level) - # ensure the result is an integer - return None if volume_level is None else int(volume_level) - - def iter_group_members( - self, - group_player: Player, - only_powered: bool = False, - only_playing: bool = False, - active_only: bool = False, - exclude_self: bool = True, - ) -> Iterator[Player]: - """Get (child) players attached to a group player or syncgroup.""" - for child_id in list(group_player.group_members): - if child_player := self.get(child_id, False): - if not child_player.available or not child_player.enabled: - continue - if only_powered and child_player.powered is False: - continue - if active_only and child_player.active_group != group_player.player_id: - continue - if exclude_self and child_player.player_id == group_player.player_id: - continue - if only_playing and child_player.playback_state not in ( - PlaybackState.PLAYING, - PlaybackState.PAUSED, - ): - continue - yield child_player - - async def wait_for_state( - self, - player: Player, - wanted_state: PlaybackState, - timeout: float = 60.0, - minimal_time: float = 0, - ) -> None: - """Wait for the given player to reach the given state.""" - start_timestamp = time.time() - self.logger.debug( - "Waiting for player %s to reach state %s", player.display_name, wanted_state - ) - try: - async with asyncio.timeout(timeout): - while player.playback_state != wanted_state: - await asyncio.sleep(0.1) - - except TimeoutError: - self.logger.debug( - "Player %s did not reach state %s within the timeout of %s seconds", - player.display_name, - wanted_state, - timeout, - ) - elapsed_time = round(time.time() - start_timestamp, 2) - if elapsed_time < minimal_time: - self.logger.debug( - "Player %s reached state %s too soon (%s vs %s seconds) - add fallback sleep...", - player.display_name, - wanted_state, - elapsed_time, - minimal_time, - ) - await asyncio.sleep(minimal_time - elapsed_time) - else: - self.logger.debug( - "Player %s reached state %s within %s seconds", - player.display_name, - wanted_state, - elapsed_time, - ) - - async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None: - """Call (by config manager) when the configuration of a player changes.""" - player_disabled = "enabled" in changed_keys and not config.enabled - # signal player provider that the player got enabled/disabled - if player_provider := self.mass.get_provider(config.provider): - assert isinstance(player_provider, PlayerProvider) # for type checking - if "enabled" in changed_keys and not config.enabled: - player_provider.on_player_disabled(config.player_id) - elif "enabled" in changed_keys and config.enabled: - player_provider.on_player_enabled(config.player_id) - # ensure player state gets updated with any updated config - if not (player := self.get(config.player_id)): - return # guard against player not being registered (yet) - player.set_config(config) - await player.on_config_updated() - player.update_state() - resume_queue: PlayerQueue | None = ( - self.mass.player_queues.get(player.active_source) if player.active_source else None - ) - if player_disabled: - # edge case: ensure that the player is powered off if the player gets disabled - if player.power_control != PLAYER_CONTROL_NONE: - await self.cmd_power(config.player_id, False) - elif player.playback_state != PlaybackState.IDLE: - await self.cmd_stop(config.player_id) - player.available = False - # if the PlayerQueue was playing, restart playback - # TODO: add property to ConfigEntry if it requires a restart of playback on change - elif not player_disabled and resume_queue and resume_queue.state == PlaybackState.PLAYING: - # always stop first to ensure the player uses the new config - await self.mass.player_queues.stop(resume_queue.queue_id) - self.mass.call_later(1, self.mass.player_queues.resume, resume_queue.queue_id, False) - - async def on_player_dsp_change(self, player_id: str) -> None: - """Call (by config manager) when the DSP settings of a player change.""" - # signal player provider that the config changed - if not (player := self.get(player_id)): - return - if player.playback_state == PlaybackState.PLAYING: - self.logger.info("Restarting playback of Player %s after DSP change", player_id) - # this will restart the queue stream/playback - if player.mass_queue_active: - self.mass.call_later(0, self.mass.player_queues.resume, player.active_source, False) - return - # if the player is not using a queue, we need to stop and start playback - await self.cmd_stop(player_id) - await self.cmd_play(player_id) - - async def _cleanup_player_memberships(self, player_id: str) -> None: - """Ensure a player is detached from any groups or syncgroups.""" - if not (player := self.get(player_id)): - return - - if ( - player.active_group - and (group := self.get(player.active_group)) - and group.supports_feature(PlayerFeature.SET_MEMBERS) - ): - # Ungroup the player if its part of an active group, this will ignore - # static_group_members since that is only checked when using cmd_set_members - with suppress(UnsupportedFeaturedException, PlayerCommandFailed): - await group.set_members(player_ids_to_remove=[player_id]) - elif player.synced_to and player.supports_feature(PlayerFeature.SET_MEMBERS): - # Remove the player if it was synced, otherwise it will still show as - # synced to the other player after it gets registered again - with suppress(UnsupportedFeaturedException, PlayerCommandFailed): - await player.ungroup() - - def _get_player_with_redirect(self, player_id: str) -> Player: - """Get player with check if playback related command should be redirected.""" - player = self.get(player_id, True) - assert player is not None # for type checking - if player.synced_to and (sync_leader := self.get(player.synced_to)): - self.logger.info( - "Player %s is synced to %s and can not accept " - "playback related commands itself, " - "redirected the command to the sync leader.", - player.name, - sync_leader.name, - ) - return sync_leader - if player.active_group and (active_group := self.get(player.active_group)): - self.logger.info( - "Player %s is part of a playergroup and can not accept " - "playback related commands itself, " - "redirected the command to the group leader.", - player.name, - ) - return active_group - return player - - def _get_player_groups( - self, player: Player, available_only: bool = True, powered_only: bool = False - ) -> Iterator[Player]: - """Return all groupplayers the given player belongs to.""" - for _player in self.all(return_unavailable=not available_only): - if _player.player_id == player.player_id: - continue - if _player.type != PlayerType.GROUP: - continue - if powered_only and _player.powered is False: - continue - if player.player_id in _player.group_members: - yield _player - - async def _play_announcement( # noqa: PLR0915 - self, - player: Player, - announcement: PlayerMedia, - volume_level: int | None = None, - ) -> None: - """Handle (default/fallback) implementation of the play announcement feature. - - This default implementation will; - - stop playback of the current media (if needed) - - power on the player (if needed) - - raise the volume a bit - - play the announcement (from given url) - - wait for the player to finish playing - - restore the previous power and volume - - restore playback (if needed and if possible) - - This default implementation will only be used if the player - (provider) has no native support for the PLAY_ANNOUNCEMENT feature. - """ - prev_power = player.powered - prev_state = player.playback_state - prev_synced_to = player.synced_to - prev_group = self.get(player.active_group) if player.active_group else None - prev_source = player.active_source - prev_queue = self.get_active_queue(player) - prev_media = player.current_media - prev_media_name = prev_media.title or prev_media.uri if prev_media else None - if prev_synced_to: - # ungroup player if its currently synced - self.logger.debug( - "Announcement to player %s - ungrouping player from %s...", - player.display_name, - prev_synced_to, - ) - await self.cmd_ungroup(player.player_id) - elif prev_group: - # if the player is part of a group player, we need to ungroup it - if PlayerFeature.SET_MEMBERS in prev_group.supported_features: - self.logger.debug( - "Announcement to player %s - ungrouping from group player %s...", - player.display_name, - prev_group.display_name, - ) - await prev_group.set_members(player_ids_to_remove=[player.player_id]) - else: - # if the player is part of a group player that does not support ungrouping, - # we need to power off the groupplayer instead - self.logger.debug( - "Announcement to player %s - turning off group player %s...", - player.display_name, - prev_group.display_name, - ) - await self.cmd_power(player.player_id, False) - elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): - # normal/standalone player: stop player if its currently playing - self.logger.debug( - "Announcement to player %s - stop existing content (%s)...", - player.display_name, - prev_media_name, - ) - await self.cmd_stop(player.player_id) - # wait for the player to stop - await self.wait_for_state(player, PlaybackState.IDLE, 10, 0.4) - # adjust volume if needed - # in case of a (sync) group, we need to do this for all child players - prev_volumes: dict[str, int] = {} - async with TaskManager(self.mass) as tg: - for volume_player_id in player.group_members or (player.player_id,): - if not (volume_player := self.get(volume_player_id)): - continue - # catch any players that have a different source active - if ( - volume_player.active_source - not in ( - player.active_source, - volume_player.player_id, - None, - ) - and volume_player.playback_state == PlaybackState.PLAYING - ): - self.logger.warning( - "Detected announcement to playergroup %s while group member %s is playing " - "other content, this may lead to unexpected behavior.", - player.display_name, - volume_player.display_name, - ) - tg.create_task(self.cmd_stop(volume_player.player_id)) - if volume_player.volume_control == PLAYER_CONTROL_NONE: - continue - if (prev_volume := volume_player.volume_level) is None: - continue - announcement_volume = self.get_announcement_volume(volume_player_id, volume_level) - if announcement_volume is None: - continue - temp_volume = announcement_volume or player.volume_level - if temp_volume != prev_volume: - prev_volumes[volume_player_id] = prev_volume - self.logger.debug( - "Announcement to player %s - setting temporary volume (%s)...", - volume_player.display_name, - announcement_volume, - ) - tg.create_task( - self.cmd_volume_set(volume_player.player_id, announcement_volume) - ) - # play the announcement - self.logger.debug( - "Announcement to player %s - playing the announcement on the player...", - player.display_name, - ) - await self.play_media(player_id=player.player_id, media=announcement) - # wait for the player(s) to play - await self.wait_for_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1) - # wait for the player to stop playing - if not announcement.duration: - media_info = await async_parse_tags( - announcement.custom_data["url"], require_duration=True - ) - announcement.duration = media_info.duration - await self.wait_for_state( - player, - PlaybackState.IDLE, - timeout=announcement.duration + 6, - minimal_time=announcement.duration, - ) - self.logger.debug( - "Announcement to player %s - restore previous state...", player.display_name - ) - # restore volume - async with TaskManager(self.mass) as tg: - for volume_player_id, prev_volume in prev_volumes.items(): - tg.create_task(self.cmd_volume_set(volume_player_id, prev_volume)) - await asyncio.sleep(0.2) - player.current_media = prev_media - player.active_source = prev_source - # either power off the player or resume playing - if not prev_power and player.power_control != PLAYER_CONTROL_NONE: - await self.cmd_power(player.player_id, False) - return - elif prev_synced_to: - await self.cmd_group(player.player_id, prev_synced_to) - elif prev_group: - if PlayerFeature.SET_MEMBERS in prev_group.supported_features: - self.logger.debug( - "Announcement to player %s - grouping back to group player %s...", - player.display_name, - prev_group.display_name, - ) - await prev_group.set_members(player_ids_to_add=[player.player_id]) - elif prev_state == PlaybackState.PLAYING: - # if the player is part of a group player that does not support set_members, - # we need to restart the groupplayer - self.logger.debug( - "Announcement to player %s - restarting playback on group player %s...", - player.display_name, - prev_group.display_name, - ) - await self.cmd_play(prev_group.player_id) - elif prev_queue and prev_state == PlaybackState.PLAYING: - await self.mass.player_queues.resume(prev_queue.queue_id, True) - await self.wait_for_state(player, PlaybackState.PLAYING, 5) - elif prev_state == PlaybackState.PLAYING: - # player was playing something else - try to resume that here - for source in player.source_list_state: - if source.id == prev_source and not source.passive: - await player.select_source(source.id) - break - else: - # no source found, try to resume the previous media - await self.cmd_play(player.player_id) - - async def _poll_players(self) -> None: - """Background task that polls players for updates.""" - while True: - for player in list(self._players.values()): - # if the player is playing, update elapsed time every tick - # to ensure the queue has accurate details - player_playing = player.playback_state == PlaybackState.PLAYING - if player_playing: - self.mass.loop.call_soon( - self.mass.player_queues.on_player_update, - player, - {"corrected_elapsed_time": player.corrected_elapsed_time}, - ) - # Poll player; - if not player.needs_poll: - continue - try: - last_poll: float = player.extra_data[ATTR_LAST_POLL] - except KeyError: - last_poll = 0.0 - if (self.mass.loop.time() - last_poll) < player.poll_interval: - continue - player.extra_data[ATTR_LAST_POLL] = self.mass.loop.time() - try: - await player.poll() - except Exception as err: - self.logger.warning( - "Error while requesting latest state from player %s: %s", - player.display_name, - str(err), - exc_info=err if self.logger.isEnabledFor(10) else None, - ) - finally: - # always update player state - self.mass.loop.call_soon(player.update_state) - await asyncio.sleep(1) - - async def _handle_select_plugin_source( - self, player: Player, plugin_prov: PluginProvider - ) -> None: - """Handle playback/select of given plugin source on player.""" - plugin_source = plugin_prov.get_source() - stream_url = await self.mass.streams.get_plugin_source_url( - plugin_source.id, player.player_id - ) - await self.play_media( - player_id=player.player_id, - media=PlayerMedia( - uri=stream_url, - media_type=MediaType.PLUGIN_SOURCE, - title=plugin_source.name, - custom_data={ - "provider": plugin_prov.instance_id, - "source_id": plugin_source.id, - "player_id": player.player_id, - "audio_format": plugin_source.audio_format, - }, - ), - ) - # trigger player update to ensure the source is set - self.trigger_player_update(player.player_id) - - def _handle_group_dsp_change( - self, player: Player, prev_group_members: list[str], new_group_members: list[str] - ) -> None: - """Handle DSP reload when group membership changes.""" - prev_child_count = len(prev_group_members) - new_child_count = len(new_group_members) - is_player_group = player.type == PlayerType.GROUP - - # handle special case for PlayerGroups: since there are no leaders, - # DSP still always work with a single player in the group. - multi_device_dsp_threshold = 1 if is_player_group else 0 - - prev_is_multiple_devices = prev_child_count > multi_device_dsp_threshold - new_is_multiple_devices = new_child_count > multi_device_dsp_threshold - - if prev_is_multiple_devices == new_is_multiple_devices: - return # no change in multi-device status - - supports_multi_device_dsp = PlayerFeature.MULTI_DEVICE_DSP in player.supported_features - - dsp_enabled: bool - if player.type == PlayerType.GROUP: - # Since player groups do not have leaders, we will use the only child - # that was in the group before and after the change - if prev_is_multiple_devices: - if childs := new_group_members: - # We shrank the group from multiple players to a single player - # So the now only child will control the DSP - dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled - else: - dsp_enabled = False - elif childs := prev_group_members: - # We grew the group from a single player to multiple players, - # let's see if the previous single player had DSP enabled - dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled - else: - dsp_enabled = False - else: - dsp_enabled = self.mass.config.get_player_dsp_config(player.player_id).enabled - - if dsp_enabled and not supports_multi_device_dsp: - # We now know that the group configuration has changed so: - # - multi-device DSP is not supported - # - we switched from a group with multiple players to a single player - # (or vice versa) - # - the leader has DSP enabled - self.mass.create_task(self.mass.players.on_player_dsp_change(player.player_id)) - - def __iter__(self) -> Iterator[Player]: - """Iterate over all players.""" - return iter(self._players.values()) diff --git a/music_assistant/controllers/players/__init__.py b/music_assistant/controllers/players/__init__.py new file mode 100644 index 00000000..455198a9 --- /dev/null +++ b/music_assistant/controllers/players/__init__.py @@ -0,0 +1,21 @@ +""" +MusicAssistant PlayerController. + +Handles all logic to control supported players, +which are provided by Player Providers. + +Note that the PlayerController has a concept of a 'player' and a 'playerstate'. +The Player is the actual object that is provided by the provider, +which incorporates the actual state of the player (e.g. volume, state, etc) +and functions for controlling the player (e.g. play, pause, etc). + +The playerstate is the (final) state of the player, including any user customizations +and transformations that are applied to the player. +The playerstate is the object that is exposed to the outside world (via the API). +""" + +from __future__ import annotations + +from .player_controller import PlayerController + +__all__ = ["PlayerController"] diff --git a/music_assistant/controllers/players/player_controller.py b/music_assistant/controllers/players/player_controller.py new file mode 100644 index 00000000..0c260c2c --- /dev/null +++ b/music_assistant/controllers/players/player_controller.py @@ -0,0 +1,2081 @@ +""" +MusicAssistant PlayerController. + +Handles all logic to control supported players, +which are provided by Player Providers. + +Note that the PlayerController has a concept of a 'player' and a 'playerstate'. +The Player is the actual object that is provided by the provider, +which incorporates the actual state of the player (e.g. volume, state, etc) +and functions for controlling the player (e.g. play, pause, etc). + +The playerstate is the (final) state of the player, including any user customizations +and transformations that are applied to the player. +The playerstate is the object that is exposed to the outside world (via the API). +""" + +from __future__ import annotations + +import asyncio +import functools +import time +from contextlib import suppress +from typing import TYPE_CHECKING, Any, Concatenate, TypedDict, cast + +from music_assistant_models.constants import ( + PLAYER_CONTROL_FAKE, + PLAYER_CONTROL_NATIVE, + PLAYER_CONTROL_NONE, +) +from music_assistant_models.enums import ( + EventType, + MediaType, + PlaybackState, + PlayerFeature, + PlayerType, + ProviderFeature, + ProviderType, +) +from music_assistant_models.errors import ( + AlreadyRegisteredError, + MusicAssistantError, + PlayerCommandFailed, + PlayerUnavailableError, + ProviderUnavailableError, + UnsupportedFeaturedException, +) +from music_assistant_models.player_control import PlayerControl # noqa: TC002 + +from music_assistant.constants import ( + ANNOUNCE_ALERT_FILE, + ATTR_ANNOUNCEMENT_IN_PROGRESS, + ATTR_FAKE_MUTE, + ATTR_FAKE_POWER, + ATTR_FAKE_VOLUME, + ATTR_GROUP_MEMBERS, + ATTR_LAST_POLL, + ATTR_PREVIOUS_VOLUME, + CONF_AUTO_PLAY, + CONF_ENTRY_ANNOUNCE_VOLUME, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, + CONF_ENTRY_TTS_PRE_ANNOUNCE, + CONF_PLAYER_DSP, + CONF_PLAYERS, + CONF_PRE_ANNOUNCE_CHIME_URL, +) +from music_assistant.helpers.api import api_command +from music_assistant.helpers.tags import async_parse_tags +from music_assistant.helpers.throttle_retry import Throttler +from music_assistant.helpers.util import TaskManager, validate_announcement_chime_url +from music_assistant.models.core_controller import CoreController +from music_assistant.models.player import Player, PlayerMedia, PlayerState +from music_assistant.models.player_provider import PlayerProvider +from music_assistant.models.plugin import PluginProvider, PluginSource + +from .sync_groups import SyncGroupController, SyncGroupPlayer + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Coroutine, Iterator + + from music_assistant_models.config_entries import CoreConfig, PlayerConfig + from music_assistant_models.player_queue import PlayerQueue + +CACHE_CATEGORY_PLAYER_POWER = 1 + + +class AnnounceData(TypedDict): + """Announcement data.""" + + announcement_url: str + pre_announce: bool + pre_announce_url: str + + +def handle_player_command[PlayerControllerT: "PlayerController", **P, R]( + func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]], +) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]: + """Check and log commands to players.""" + + @functools.wraps(func) + async def wrapper(self: PlayerControllerT, *args: P.args, **kwargs: P.kwargs) -> R | None: + """Log and handle_player_command commands to players.""" + player_id = kwargs["player_id"] if "player_id" in kwargs else args[0] + if (player := self._players.get(player_id)) is None or not player.available: + # player not existent + self.logger.warning( + "Ignoring command %s for unavailable player %s", + func.__name__, + player_id, + ) + return + + self.logger.debug( + "Handling command %s for player %s", + func.__name__, + player.display_name, + ) + try: + await func(self, *args, **kwargs) + except Exception as err: + raise PlayerCommandFailed(str(err)) from err + + return wrapper + + +class PlayerController(CoreController): + """Controller holding all logic to control registered players.""" + + domain: str = "players" + + def __init__(self, *args, **kwargs) -> None: + """Initialize core controller.""" + super().__init__(*args, **kwargs) + self._players: dict[str, Player] = {} + self._controls: dict[str, PlayerControl] = {} + self.manifest.name = "Player Controller" + self.manifest.description = ( + "Music Assistant's core controller which manages all players from all providers." + ) + self.manifest.icon = "speaker-multiple" + self._poll_task: asyncio.Task | None = None + self._player_throttlers: dict[str, Throttler] = {} + self._announce_locks: dict[str, asyncio.Lock] = {} + self._sync_groups: SyncGroupController = SyncGroupController(self) + + async def setup(self, config: CoreConfig) -> None: + """Async initialize of module.""" + self._poll_task = self.mass.create_task(self._poll_players()) + + async def close(self) -> None: + """Cleanup on exit.""" + if self._poll_task and not self._poll_task.done(): + self._poll_task.cancel() + + async def on_provider_loaded(self, provider: PlayerProvider) -> None: + """Handle logic when a provider is loaded.""" + if ProviderFeature.SYNC_PLAYERS in provider.supported_features: + await self._sync_groups.on_provider_loaded(provider) + + async def on_provider_unload(self, provider: PlayerProvider) -> None: + """Handle logic when a provider is (about to get) unloaded.""" + if ProviderFeature.SYNC_PLAYERS in provider.supported_features: + await self._sync_groups.on_provider_unload(provider) + + @property + def providers(self) -> list[PlayerProvider]: + """Return all loaded/running MusicProviders.""" + return self.mass.get_providers(ProviderType.PLAYER) # type: ignore=return-value + + def all( + self, + return_unavailable: bool = True, + return_disabled: bool = False, + provider_filter: str | None = None, + return_sync_groups: bool = True, + ) -> list[Player]: + """ + Return all registered players. + + :param return_unavailable [bool]: Include unavailable players. + :param return_disabled [bool]: Include disabled players. + :param provider_filter [str]: Optional filter by provider lookup key. + + :return: List of Player objects. + """ + return [ + player + for player in self._players.values() + if (player.available or return_unavailable) + and (player.enabled or return_disabled) + and (provider_filter is None or player.provider.lookup_key == provider_filter) + and (return_sync_groups or not isinstance(player, SyncGroupPlayer)) + ] + + @api_command("players/all") + def all_states( + self, + return_unavailable: bool = True, + return_disabled: bool = False, + provider_filter: str | None = None, + ) -> list[PlayerState]: + """ + Return PlayerState for all registered players. + + :param return_unavailable [bool]: Include unavailable players. + :param return_disabled [bool]: Include disabled players. + :param provider_filter [str]: Optional filter by provider lookup key. + + :return: List of PlayerState objects. + """ + return [ + player.state + for player in self.all( + return_unavailable=return_unavailable, + return_disabled=return_disabled, + provider_filter=provider_filter, + ) + ] + + def get( + self, + player_id: str, + raise_unavailable: bool = False, + ) -> Player | None: + """ + Return Player by player_id. + + :param player_id [str]: ID of the player. + :param raise_unavailable [bool]: Raise if player is unavailable. + + :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True. + :return: Player object or None. + """ + if player := self._players.get(player_id): + if (not player.available or not player.enabled) and raise_unavailable: + msg = f"Player {player_id} is not available" + raise PlayerUnavailableError(msg) + return player + if raise_unavailable: + msg = f"Player {player_id} is not available" + raise PlayerUnavailableError(msg) + return None + + @api_command("players/get") + def get_state( + self, + player_id: str, + raise_unavailable: bool = False, + ) -> PlayerState | None: + """ + Return PlayerState by player_id. + + :param player_id [str]: ID of the player. + :param raise_unavailable [bool]: Raise if player is unavailable. + + :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True. + :return: Player object or None. + """ + if player := self.get(player_id, raise_unavailable): + return player.state + return None + + def get_player_by_name(self, name: str) -> Player | None: + """ + Return Player by name. + + :param name: Name of the player. + :return: Player object or None. + """ + return next((x for x in self._players.values() if x.name == name), None) + + @api_command("players/get_by_name") + def get_player_state_by_name(self, name: str) -> PlayerState | None: + """ + Return PlayerState by name. + + :param name: Name of the player. + :return: PlayerState object or None. + """ + if player := self.get_player_by_name(name): + return player.state + return None + + @api_command("players/player_controls") + def player_controls( + self, + ) -> list[PlayerControl]: + """Return all registered playercontrols.""" + return list(self._controls.values()) + + @api_command("players/player_control") + def get_player_control( + self, + control_id: str, + ) -> PlayerControl | None: + """ + Return PlayerControl by control_id. + + :param control_id: ID of the player control. + :return: PlayerControl object or None. + """ + if control := self._controls.get(control_id): + return control + return None + + @api_command("players/plugin_sources") + def get_plugin_sources(self) -> list[PluginSource]: + """Return all available plugin sources.""" + return [ + plugin_prov.get_source() + for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN) + if isinstance(plugin_prov, PluginProvider) + and ProviderFeature.AUDIO_SOURCE in plugin_prov.supported_features + ] + + @api_command("players/plugin_source") + def get_plugin_source( + self, + source_id: str, + ) -> PluginSource | None: + """ + Return PluginSource by source_id. + + :param source_id: ID of the plugin source. + :return: PluginSource object or None. + """ + for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN): + assert isinstance(plugin_prov, PluginProvider) # for type checking + if ProviderFeature.AUDIO_SOURCE not in plugin_prov.supported_features: + continue + if (source := plugin_prov.get_source()) and source.id == source_id: + return source + return None + + # Player commands + + @api_command("players/cmd/stop") + @handle_player_command + async def cmd_stop(self, player_id: str) -> None: + """Send STOP command to given player. + + - player_id: player_id of the player to handle the command. + """ + player = self._get_player_with_redirect(player_id) + # Redirect to queue controller if it is active + if active_queue := self.get_active_queue(player): + await self.mass.player_queues.stop(active_queue.queue_id) + return + # handle command on player directly + async with self._player_throttlers[player.player_id]: + await player.stop() + + @api_command("players/cmd/play") + @handle_player_command + async def cmd_play(self, player_id: str) -> None: + """Send PLAY (unpause) command to given player. + + - player_id: player_id of the player to handle the command. + """ + player = self._get_player_with_redirect(player_id) + if player.playback_state == PlaybackState.PLAYING: + self.logger.info( + "Ignore PLAY request to player %s: player is already playing", player.display_name + ) + return + # Redirect to queue controller if it is active + if active_queue := self.get_active_queue(player): + await self.mass.player_queues.play(active_queue.queue_id) + return + # handle command on player directly + async with self._player_throttlers[player.player_id]: + await player.play() + + @api_command("players/cmd/pause") + @handle_player_command + async def cmd_pause(self, player_id: str) -> None: + """Send PAUSE command to given player. + + - player_id: player_id of the player to handle the command. + """ + player = self._get_player_with_redirect(player_id) + # Redirect to queue controller if it is active + if active_queue := self.get_active_queue(player): + await self.mass.player_queues.pause(active_queue.queue_id) + return + if PlayerFeature.PAUSE not in player.supported_features: + # if player does not support pause, we need to send stop + self.logger.debug( + "Player %s does not support pause, using STOP instead", + player.display_name, + ) + await self.cmd_stop(player.player_id) + return + # handle command on player directly + await player.pause() + + @api_command("players/cmd/play_pause") + async def cmd_play_pause(self, player_id: str) -> None: + """Toggle play/pause on given player. + + - player_id: player_id of the player to handle the command. + """ + player = self._get_player_with_redirect(player_id) + if player.playback_state == PlaybackState.PLAYING: + await self.cmd_pause(player.player_id) + else: + await self.cmd_play(player.player_id) + + @api_command("players/cmd/seek") + async def cmd_seek(self, player_id: str, position: int) -> None: + """Handle SEEK command for given player. + + - player_id: player_id of the player to handle the command. + - position: position in seconds to seek to in the current playing item. + """ + player = self._get_player_with_redirect(player_id) + # Redirect to queue controller if it is active + if active_queue := self.get_active_queue(player): + await self.mass.player_queues.seek(active_queue.queue_id, position) + return + if PlayerFeature.SEEK not in player.supported_features: + msg = f"Player {player.display_name} does not support seeking" + raise UnsupportedFeaturedException(msg) + # handle command on player directly + await player.seek(position) + + @api_command("players/cmd/next") + async def cmd_next_track(self, player_id: str) -> None: + """Handle NEXT TRACK command for given player.""" + player = self._get_player_with_redirect(player_id) + active_source_id = player.active_source or player.player_id + + # Redirect to queue controller if it is active + if active_queue := self.get_active_queue(player): + await self.mass.player_queues.next(active_queue.queue_id) + return + + if PlayerFeature.NEXT_PREVIOUS in player.supported_features: + # player has some other source active and native next/previous support + active_source = next((x for x in player.source_list if x.id == active_source_id), None) + if active_source and active_source.can_next_previous: + await player.next_track() + return + msg = "This action is (currently) unavailable for this source." + raise PlayerCommandFailed(msg) + + msg = f"Player {player.display_name} does not support skipping to the next track." + raise UnsupportedFeaturedException(msg) + + @api_command("players/cmd/previous") + async def cmd_previous_track(self, player_id: str) -> None: + """Handle PREVIOUS TRACK command for given player.""" + player = self._get_player_with_redirect(player_id) + active_source_id = player.active_source or player.player_id + # Redirect to queue controller if it is active + if active_queue := self.get_active_queue(player): + await self.mass.player_queues.previous(active_queue.queue_id) + return + + if PlayerFeature.NEXT_PREVIOUS in player.supported_features: + # player has some other source active and native next/previous support + active_source = next((x for x in player.source_list if x.id == active_source_id), None) + if active_source and active_source.can_next_previous: + await player.previous_track() + return + msg = "This action is (currently) unavailable for this source." + raise PlayerCommandFailed(msg) + + msg = f"Player {player.display_name} does not support skipping to the previous track." + raise UnsupportedFeaturedException(msg) + + @api_command("players/cmd/power") + @handle_player_command + async def cmd_power(self, player_id: str, powered: bool, skip_update: bool = False) -> None: + """Send POWER command to given player. + + - player_id: player_id of the player to handle the command. + - powered: bool if player should be powered on or off. + """ + player = self.get(player_id, True) + assert player is not None # for type checking + player_state = player.state + + if player_state.powered == powered: + self.logger.debug( + "Ignoring power %s command for player %s: already in state %s", + "ON" if powered else "OFF", + player_state.name, + "ON" if player_state.powered else "OFF", + ) + return # nothing to do + + # ungroup player at power off + player_was_synced = player.synced_to is not None + if player.type == PlayerType.PLAYER and not powered: + # ungroup player if it is synced (or is a sync leader itself) + # NOTE: ungroup will be ignored if the player is not grouped or synced + await self.cmd_ungroup(player_id) + + # always stop player at power off + if ( + not powered + and not player_was_synced + and player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) + ): + await self.cmd_stop(player_id) + # short sleep: allow the stop command to process and prevent race conditions + await asyncio.sleep(0.2) + + # power off all synced childs when player is a sync leader + elif not powered and player.type == PlayerType.PLAYER and player.group_members: + async with TaskManager(self.mass) as tg: + for member in self.iter_group_members(player, True): + if member.power_control == PLAYER_CONTROL_NONE: + continue + tg.create_task(self.cmd_power(member.player_id, False)) + + # handle actual power command + if player.power_control == PLAYER_CONTROL_NONE: + raise UnsupportedFeaturedException( + f"Player {player.display_name} does not support power control" + ) + if player.power_control == PLAYER_CONTROL_NATIVE: + # player supports power command natively: forward to player provider + async with self._player_throttlers[player_id]: + await player.power(powered) + elif player.power_control == PLAYER_CONTROL_FAKE: + # user wants to use fake power control - so we (optimistically) update the state + # and store the state in the cache + player.extra_data[ATTR_FAKE_POWER] = powered + await self.mass.cache.set( + key=player_id, + data=powered, + provider=self.domain, + category=CACHE_CATEGORY_PLAYER_POWER, + ) + else: + # handle external player control + player_control = self._controls.get(player.power_control) + control_name = player_control.name if player_control else player.power_control + self.logger.debug("Redirecting power command to PlayerControl %s", control_name) + if not player_control or not player_control.supports_power: + raise UnsupportedFeaturedException( + f"Player control {control_name} is not available" + ) + if powered: + assert player_control.power_on is not None # for type checking + await player_control.power_on() + else: + assert player_control.power_off is not None # for type checking + await player_control.power_off() + + # always optimistically set the power state to update the UI + # as fast as possible and prevent race conditions + player_state.powered = powered + # reset active source on power off + if not powered: + player_state.active_source = None + + if not skip_update: + player.update_state() + + # handle 'auto play on power on' feature + if ( + not player.active_group + and powered + and player.config.get_value(CONF_AUTO_PLAY) + and player.active_source in (None, player_id) + and not player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS) + ): + await self.mass.player_queues.resume(player_id) + + @api_command("players/cmd/volume_set") + @handle_player_command + async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: + """Send VOLUME_SET command to given player. + + - player_id: player_id of the player to handle the command. + - volume_level: volume level (0..100) to set on the player. + """ + player = self.get(player_id, True) + assert player is not None # for type checker + if player.type == PlayerType.GROUP: + # redirect to special group volume control + await self.cmd_group_volume(player_id, volume_level) + return + + if player.volume_control == PLAYER_CONTROL_NONE: + raise UnsupportedFeaturedException( + f"Player {player.display_name} does not support volume control" + ) + + if player.mute_control != PLAYER_CONTROL_NONE and player.volume_muted: + # if player is muted, we unmute it first + self.logger.debug( + "Unmuting player %s before setting volume", + player.display_name, + ) + await self.cmd_volume_mute(player_id, False) + + if player.volume_control == PLAYER_CONTROL_NATIVE: + # player supports volume command natively: forward to player + async with self._player_throttlers[player_id]: + await player.volume_set(volume_level) + return + if player.volume_control == PLAYER_CONTROL_FAKE: + # user wants to use fake volume control - so we (optimistically) update the state + # and store the state in the cache + player.extra_data[ATTR_FAKE_VOLUME] = volume_level + # trigger update + player.update_state() + return + # else: handle external player control + player_control = self._controls.get(player.volume_control) + control_name = player_control.name if player_control else player.volume_control + self.logger.debug("Redirecting volume command to PlayerControl %s", control_name) + if not player_control or not player_control.supports_volume: + raise UnsupportedFeaturedException(f"Player control {control_name} is not available") + async with self._player_throttlers[player_id]: + assert player_control.volume_set is not None + await player_control.volume_set(volume_level) + + @api_command("players/cmd/volume_up") + @handle_player_command + async def cmd_volume_up(self, player_id: str) -> None: + """Send VOLUME_UP command to given player. + + - player_id: player_id of the player to handle the command. + """ + if not (player := self.get(player_id)): + return + current_volume = player.volume_state or 0 + if current_volume < 5 or current_volume > 95: + step_size = 1 + elif current_volume < 20 or current_volume > 80: + step_size = 2 + else: + step_size = 5 + new_volume = min(100, current_volume + step_size) + await self.cmd_volume_set(player_id, new_volume) + + @api_command("players/cmd/volume_down") + @handle_player_command + async def cmd_volume_down(self, player_id: str) -> None: + """Send VOLUME_DOWN command to given player. + + - player_id: player_id of the player to handle the command. + """ + if not (player := self.get(player_id)): + return + current_volume = player.volume_state or 0 + if current_volume < 5 or current_volume > 95: + step_size = 1 + elif current_volume < 20 or current_volume > 80: + step_size = 2 + else: + step_size = 5 + new_volume = max(0, current_volume - step_size) + await self.cmd_volume_set(player_id, new_volume) + + @api_command("players/cmd/group_volume") + @handle_player_command + async def cmd_group_volume( + self, + player_id: str, + volume_level: int, + ) -> None: + """ + Handle adjusting the overall/group volume to a playergroup (or synced players). + + Will set a new (overall) volume level to a group player or syncgroup. + + :param group_player: dedicated group player or syncleader to handle the command. + :param volume_level: volume level (0..100) to set to the group. + """ + player = self.get(player_id, True) + assert player is not None # for type checker + if player.type == PlayerType.GROUP or player.group_members: + # dedicated group player or sync leader + await self.set_group_volume(player, volume_level) + return + if player.synced_to and (sync_leader := self.get(player.synced_to)): + # redirect to sync leader + await self.set_group_volume(sync_leader, volume_level) + return + # treat as normal player volume change + await self.cmd_volume_set(player_id, volume_level) + + @api_command("players/cmd/group_volume_up") + @handle_player_command + async def cmd_group_volume_up(self, player_id: str) -> None: + """Send VOLUME_UP command to given playergroup. + + - player_id: player_id of the player to handle the command. + """ + group_player = self.get(player_id, True) + assert group_player + cur_volume = group_player.group_volume + if cur_volume < 5 or cur_volume > 95: + step_size = 1 + elif cur_volume < 20 or cur_volume > 80: + step_size = 2 + else: + step_size = 5 + new_volume = min(100, cur_volume + step_size) + await self.cmd_group_volume(player_id, new_volume) + + @api_command("players/cmd/group_volume_down") + @handle_player_command + async def cmd_group_volume_down(self, player_id: str) -> None: + """Send VOLUME_DOWN command to given playergroup. + + - player_id: player_id of the player to handle the command. + """ + group_player = self.get(player_id, True) + assert group_player + cur_volume = group_player.group_volume + if cur_volume < 5 or cur_volume > 95: + step_size = 1 + elif cur_volume < 20 or cur_volume > 80: + step_size = 2 + else: + step_size = 5 + new_volume = max(0, cur_volume - step_size) + await self.cmd_group_volume(player_id, new_volume) + + @api_command("players/cmd/volume_mute") + @handle_player_command + async def cmd_volume_mute(self, player_id: str, muted: bool) -> None: + """Send VOLUME_MUTE command to given player. + + - player_id: player_id of the player to handle the command. + - muted: bool if player should be muted. + """ + player = self.get(player_id, True) + assert player + if player.mute_control == PLAYER_CONTROL_NONE: + raise UnsupportedFeaturedException( + f"Player {player.display_name} does not support muting" + ) + if player.mute_control == PLAYER_CONTROL_NATIVE: + # player supports mute command natively: forward to player + async with self._player_throttlers[player_id]: + await player.volume_mute(muted) + elif player.mute_control == PLAYER_CONTROL_FAKE: + # user wants to use fake mute control - so we use volume instead + self.logger.debug( + "Using volume for muting for player %s", + player.display_name, + ) + if muted: + player.extra_data[ATTR_PREVIOUS_VOLUME] = player.volume_state + player.extra_data[ATTR_FAKE_MUTE] = True + await self.cmd_volume_set(player_id, 0) + else: + player._attr_volume_muted = False + prev_volume = player.extra_data.get(ATTR_PREVIOUS_VOLUME, 1) + player.extra_data[ATTR_FAKE_MUTE] = False + await self.cmd_volume_set(player_id, prev_volume) + else: + # handle external player control + player_control = self._controls.get(player.mute_control) + control_name = player_control.name if player_control else player.mute_control + self.logger.debug("Redirecting mute command to PlayerControl %s", control_name) + if not player_control or not player_control.supports_mute: + raise UnsupportedFeaturedException( + f"Player control {control_name} is not available" + ) + async with self._player_throttlers[player_id]: + assert player_control.mute_set is not None + await player_control.mute_set(muted) + + @api_command("players/cmd/play_announcement") + async def play_announcement( + self, + player_id: str, + url: str, + pre_announce: bool | str | None = None, + volume_level: int | None = None, + pre_announce_url: str | None = None, + ) -> None: + """ + Handle playback of an announcement (url) on given player. + + - player_id: player_id of the player to handle the command. + - url: URL of the announcement to play. + - pre_announce: optional bool if pre-announce should be used. + - volume_level: optional volume level to set for the announcement. + - pre_announce_url: optional custom URL to use for the pre-announce chime. + """ + player = self.get(player_id, True) + assert player is not None # for type checking + if not url.startswith("http"): + raise PlayerCommandFailed("Only URLs are supported for announcements") + if ( + pre_announce + and pre_announce_url + and not validate_announcement_chime_url(pre_announce_url) + ): + raise PlayerCommandFailed("Invalid pre-announce chime URL specified.") + # prevent multiple announcements at the same time to the same player with a lock + if player_id not in self._announce_locks: + self._announce_locks[player_id] = lock = asyncio.Lock() + else: + lock = self._announce_locks[player_id] + async with lock: + try: + # mark announcement_in_progress on player + player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = True + # determine if the player has native announcements support + native_announce_support = ( + PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features + ) + # determine pre-announce from (group)player config + if pre_announce is None and "tts" in url: + conf_pre_announce = self.mass.config.get_raw_player_config_value( + player_id, + CONF_ENTRY_TTS_PRE_ANNOUNCE.key, + CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value, + ) + pre_announce = cast("bool", conf_pre_announce) + if pre_announce_url is None: + if conf_pre_announce_url := self.mass.config.get_raw_player_config_value( + player_id, + CONF_PRE_ANNOUNCE_CHIME_URL, + ): + # player default custom chime url + pre_announce_url = cast("str", conf_pre_announce_url) + else: + # use global default chime url + pre_announce_url = ANNOUNCE_ALERT_FILE + # if player type is group with all members supporting announcements, + # we forward the request to each individual player + if player.type == PlayerType.GROUP and ( + all( + PlayerFeature.PLAY_ANNOUNCEMENT in x.supported_features + for x in self.iter_group_members(player) + ) + ): + # forward the request to each individual player + async with TaskManager(self.mass) as tg: + for group_member in player.group_members: + tg.create_task( + self.play_announcement( + group_member, + url=url, + pre_announce=pre_announce, + volume_level=volume_level, + pre_announce_url=pre_announce_url, + ) + ) + return + self.logger.info( + "Playback announcement to player %s (with pre-announce: %s): %s", + player.display_name, + pre_announce, + url, + ) + # create a PlayerMedia object for the announcement so + # we can send a regular play-media call downstream + announce_data = AnnounceData( + announcement_url=url, + pre_announce=pre_announce, + pre_announce_url=pre_announce_url, + ) + announcement = PlayerMedia( + uri=self.mass.streams.get_announcement_url(player_id, url, announce_data), + media_type=MediaType.ANNOUNCEMENT, + title="Announcement", + custom_data=announce_data, + ) + # handle native announce support + if native_announce_support: + announcement_volume = self.get_announcement_volume(player_id, volume_level) + await player.play_announcement(announcement, announcement_volume) + return + # use fallback/default implementation + await self._play_announcement(player, announcement, volume_level) + finally: + player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = False + + @handle_player_command + async def play_media(self, player_id: str, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on given player. + + - player_id: player_id of the player to handle the command. + - media: The Media that needs to be played on the player. + """ + player = self._get_player_with_redirect(player_id) + # power on the player if needed + if player.powered is False and player.power_control != PLAYER_CONTROL_NONE: + await self.cmd_power(player.player_id, True) + await player.play_media(media) + + @api_command("players/cmd/select_source") + 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. + """ + player = self.get(player_id, True) + assert player is not None # for type checking + if player.synced_to or player.active_group: + raise PlayerCommandFailed(f"Player {player.display_name} is currently grouped") + # check if player is already playing and source is different + # in that case we need to stop the player first + prev_source = player.active_source + if prev_source and source != prev_source: + if player.playback_state != PlaybackState.IDLE: + await self.cmd_stop(player_id) + await asyncio.sleep(0.5) # small delay to allow stop to process + player.state.active_source = None + player.state.current_media = None + # check if source is a pluginsource + # in that case the source id is the instance_id of the plugin provider + if plugin_prov := self.mass.get_provider(source): + await self._handle_select_plugin_source(player, plugin_prov) + return + # check if source is a mass queue + # this can be used to restore the queue after a source switch + if mass_queue := self.mass.player_queues.get(source): + await self.mass.player_queues.play(mass_queue.queue_id) + return + # basic check if player supports source selection + if PlayerFeature.SELECT_SOURCE not in player.supported_features: + raise UnsupportedFeaturedException( + f"Player {player.display_name} does not support source selection" + ) + # basic check if source is valid for player + if not any(x for x in player.source_list if x.id == source): + raise PlayerCommandFailed( + f"{source} is an invalid source for player {player.display_name}" + ) + # forward to player + await player.select_source(source) + + async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None: + """ + Handle enqueuing of a next media item on the player. + + :param player_id: player_id of the player to handle the command. + :param media: The Media that needs to be enqueued on the player. + :raises UnsupportedFeaturedException: if the player does not support enqueueing. + :raises PlayerUnavailableError: if the player is not available. + """ + player = self.get(player_id, raise_unavailable=True) + assert player is not None # for type checking + if PlayerFeature.ENQUEUE not in player.supported_features: + raise UnsupportedFeaturedException( + f"Player {player.display_name} does not support enqueueing" + ) + async with self._player_throttlers[player_id]: + await player.enqueue_next_media(media) + + @api_command("players/cmd/set_members") + async def cmd_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. + + Will add the given player(s) to the target player (sync leader or group player). + + :param target_player: player_id of the syncgroup leader or group player. + :param player_ids_to_add: List of player_id's to add to the target player. + :param player_ids_to_remove: List of player_id's to remove from the target player. + + :raises UnsupportedFeaturedException: if the target player does not support grouping. + :raises PlayerUnavailableError: if the target player is not available. + """ + parent_player: Player | None = self.get(target_player, True) + assert parent_player is not None # for type checking + if PlayerFeature.SET_MEMBERS not in parent_player.supported_features: + msg = f"Player {parent_player.name} does not support group commands" + raise UnsupportedFeaturedException(msg) + + if parent_player.synced_to: + # guard edge case: player already synced to another player + raise PlayerCommandFailed( + f"Player {parent_player.name} is already synced to another player on its own, " + "you need to ungroup it first before you can join other players to it.", + ) + + # filter all player ids on compatibility and availability + final_player_ids_to_add: list[str] = [] + for child_player_id in player_ids_to_add or []: + if child_player_id == target_player: + continue + if child_player_id in final_player_ids_to_add: + continue + if not (child_player := self.get(child_player_id)) or not child_player.available: + self.logger.warning("Player %s is not available", child_player_id) + continue + + # check if player can be synced/grouped with the target player + if not ( + child_player_id in parent_player.can_group_with + or child_player.provider.lookup_key in parent_player.can_group_with + or "*" in parent_player.can_group_with + ): + raise UnsupportedFeaturedException( + f"Player {child_player.name} can not be grouped with {parent_player.name}" + ) + + if ( + child_player.synced_to + and child_player.synced_to == target_player + and child_player_id in parent_player.group_members + ): + continue # already synced to this target + + # Check if player is already part of another group and try to automatically ungroup it + # first. If that fails, power off the group + if child_player.active_group and child_player.active_group != target_player: + if ( + other_group := self.get(child_player.active_group) + ) and PlayerFeature.SET_MEMBERS in other_group.supported_features: + self.logger.warning( + "Player %s is already part of another group (%s), " + "removing from that group first", + child_player.name, + child_player.active_group, + ) + if child_player.player_id in other_group.static_group_members: + self.logger.warning( + "Player %s is a static member of group %s: removing is not possible, " + "powering the group off instead", + child_player.name, + child_player.active_group, + ) + await self.cmd_power(child_player.active_group, False) + else: + await other_group.set_members(player_ids_to_remove=[child_player.player_id]) + else: + self.logger.warning( + "Player %s is already part of another group (%s), powering it off first", + child_player.name, + child_player.active_group, + ) + await self.cmd_power(child_player.active_group, False) + elif child_player.synced_to and child_player.synced_to != target_player: + self.logger.warning( + "Player %s is already synced to another player, ungrouping first", + child_player.name, + ) + await self.cmd_ungroup(child_player.player_id) + + # power on the player if needed + if not child_player.powered and child_player.power_control != PLAYER_CONTROL_NONE: + await self.cmd_power(child_player.player_id, True, skip_update=True) + # if we reach here, all checks passed + final_player_ids_to_add.append(child_player_id) + + final_player_ids_to_remove: list[str] = [] + if player_ids_to_remove: + static_members = set(parent_player.static_group_members) + for child_player_id in player_ids_to_remove: + if child_player_id == target_player: + raise UnsupportedFeaturedException( + f"Cannot remove {parent_player.name} from itself as a member!" + ) + if child_player_id not in parent_player.group_members: + continue + if child_player_id in static_members: + raise UnsupportedFeaturedException( + f"Cannot remove {child_player_id} from {parent_player.name} " + "as it is a static member of this group" + ) + final_player_ids_to_remove.append(child_player_id) + + # forward command to the player after all (base) sanity checks + async with self._player_throttlers[target_player]: + await parent_player.set_members( + player_ids_to_add=final_player_ids_to_add or None, + player_ids_to_remove=final_player_ids_to_remove or None, + ) + + @api_command("players/cmd/group") + @handle_player_command + async def cmd_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. + + :param player_id: player_id of the player to handle the command. + :param target_player: player_id of the syncgroup leader or group player. + + :raises UnsupportedFeaturedException: if the target player does not support grouping. + :raises PlayerCommandFailed: if the target player is already synced to another player. + :raises PlayerUnavailableError: if the target player is not available. + :raises PlayerCommandFailed: if the player is already grouped to another player. + """ + await self.cmd_set_members(target_player, player_ids_to_add=[player_id]) + + @api_command("players/cmd/group_many") + async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None: + """ + Join given player(s) to target player. + + Will add the given player(s) to the target player (sync leader or group player). + NOTE: This is a (deprecated) alias for cmd_set_members. + """ + await self.cmd_set_members(target_player, player_ids_to_add=child_player_ids) + + @api_command("players/cmd/ungroup") + @handle_player_command + async def cmd_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. + + NOTE: This is a (deprecated) alias for cmd_set_members. + """ + if not (player := self.get(player_id)): + self.logger.warning("Player %s is not available", player_id) + return + + if ( + player.active_group + and (group_player := self.get(player.active_group)) + and (PlayerFeature.SET_MEMBERS in group_player.supported_features) + ): + # the player is part of a (permanent) groupplayer and the user tries to ungroup + if player_id in group_player.static_group_members: + raise UnsupportedFeaturedException( + f"Player {player.name} is a static member of group {group_player.name} " + "and cannot be removed from that group!" + ) + await group_player.set_members(player_ids_to_remove=[player_id]) + return + + if player.synced_to and (synced_player := self.get(player.synced_to)): + # player is a sync member + await synced_player.set_members(player_ids_to_remove=[player_id]) + return + + if not (player.synced_to or player.group_members): + return # nothing to do + + if PlayerFeature.SET_MEMBERS not in player.supported_features: + self.logger.warning("Player %s does not support (un)group commands", player.name) + return + + # forward command to the player once all checks passed + await player.ungroup() + + @api_command("players/cmd/ungroup_many") + async def cmd_ungroup_many(self, player_ids: list[str]) -> None: + """Handle UNGROUP command for all the given players.""" + for player_id in list(player_ids): + await self.cmd_ungroup(player_id) + + @api_command("players/create_group_player") + async def create_group_player( + self, provider: str, name: str, members: list[str], dynamic: bool = True + ): + """ + Create a new (permanent) Group Player. + + :param provider: The provider(id) to create the group player for + :param name: Name of the new group player + :param members: List of player ids to add to the group + :param dynamic: Whether the group is dynamic (members can change) + """ + if not (provider_instance := self.mass.get_provider(provider)): + raise ProviderUnavailableError(f"Provider {provider} not found") + provider_instance = cast("PlayerProvider", provider_instance) + if ProviderFeature.CREATE_GROUP_PLAYER in provider_instance.supported_features: + return await provider_instance.create_group_player(name, members, dynamic) + if ProviderFeature.SYNC_PLAYERS in provider_instance.supported_features: + # provider supports syncing but not dedicated group players + # create a sync group instead + return await self._sync_groups.create_group_player( + provider_instance, name, members, dynamic=dynamic + ) + raise UnsupportedFeaturedException( + f"Provider {provider} does not support creating group players" + ) + + @api_command("players/remove_group_player") + async def remove_group_player(self, player_id: str) -> None: + """ + Remove a group player. + + :param player_id: ID of the group player to remove. + """ + if not (player := self.get(player_id)): + # we simply permanently delete the player by wiping its config + self.mass.config.remove(f"players/{player_id}") + return + if player.type != PlayerType.GROUP: + raise UnsupportedFeaturedException( + f"Player {player.display_name} is not a group player" + ) + player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER) + await player.provider.remove_group_player(player_id) + + @api_command("players/add_currently_playing_to_favorites") + 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. + """ + player = self._get_player_with_redirect(player_id) + # handle mass player queue active + if mass_queue := self.get_active_queue(player): + 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) + # strip off any additional comments in the title (such as from Radio Paradise) + search_title_title = search_title_title.split(" | ")[0].strip() + if track := await self.mass.music.get_track_by_name( + search_title_title, search_artist + ): + # we found a track, so add it to the favorites + await self.mass.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.mass.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.mass.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: + if track := await self.mass.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.mass.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 register(self, player: Player) -> None: + """Register a player on the Player Controller.""" + if self.mass.closing: + return + player_id = player.player_id + + if player_id in self._players: + msg = f"Player {player_id} is already registered!" + raise AlreadyRegisteredError(msg) + + # ignore disabled players + if not player.enabled: + return + + # register throttler for this player + self._player_throttlers[player_id] = Throttler(1, 0.05) + + # restore 'fake' power state from cache if available + cached_value = await self.mass.cache.get( + key=player.player_id, + provider=self.domain, + category=CACHE_CATEGORY_PLAYER_POWER, + default=False, + ) + if cached_value is not None: + player.extra_data[ATTR_FAKE_POWER] = cached_value + + # finally actually register it + self._players[player_id] = player + + # ensure we fetch and set the latest/full config for the player + player_config = await self.mass.config.get_player_config(player_id) + player.set_config(player_config) + # call hook after the player is registered and config is set + await player.on_config_updated() + # always call update to fix special attributes like display name, group volume etc. + player.update_state() + + self.logger.info( + "Player registered: %s/%s", + player_id, + player.display_name, + ) + # signal event that a player was added + self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player) + + # register playerqueue for this player + await self.mass.player_queues.on_player_register(player) + + async def register_or_update(self, player: Player) -> None: + """Register a new player on the controller or update existing one.""" + if self.mass.closing: + return + + if player.player_id in self._players: + self._players[player.player_id] = player + player.update_state() + return + + await self.register(player) + + def trigger_player_update(self, player_id: str, force_update: bool = False) -> None: + """Trigger an update for the given player.""" + if self.mass.closing: + return + player = self.get(player_id, True) + assert player is not None # for type checker + self.mass.loop.call_soon(player.update_state, force_update) + + async def unregister(self, player_id: str, permanent: bool = False) -> None: + """ + Unregister a player from the player controller. + + Called (by a PlayerProvider) when a player is removed + or no longer available (for a longer period of time). + + This will remove the player from the player controller and + optionally remove the player's config from the mass config. + + - player_id: player_id of the player to unregister. + - permanent: if True, remove the player permanently by deleting + the player's config from the mass config. If False, the player config will not be removed, + allowing for re-registration (with the same config) later. + + If the player is not registered, this will silently be ignored. + """ + player = self._players.get(player_id) + if player is None: + return + await self._cleanup_player_memberships(player_id) + del self._players[player_id] + self.logger.info("Player removed: %s", player.name) + self.mass.player_queues.on_player_remove(player_id, permanent=permanent) + await player.on_unload() + if permanent: + self.delete_player_config(player_id) + self.mass.signal_event(EventType.PLAYER_REMOVED, player_id) + + @api_command("players/remove") + async def remove(self, player_id: str) -> None: + """ + Remove a player from a provider. + + Can only be called when a PlayerProvider supports ProviderFeature.REMOVE_PLAYER. + """ + player = self.get(player_id) + if player is None: + # we simply permanently delete the player config since it is not registered + self.delete_player_config(player_id) + return + if player.type == PlayerType.GROUP: + # Handle group player removal + await player.provider.remove_group_player(player_id) + return + player.provider.check_feature(ProviderFeature.REMOVE_PLAYER) + await player.provider.remove_player(player_id) + # check for group memberships that need to be updated + if player.active_group and (group_player := self.mass.players.get(player.active_group)): + # try to remove from the group + with suppress(UnsupportedFeaturedException, PlayerCommandFailed): + await group_player.set_members( + player_ids_to_remove=[player_id], + ) + # We removed the player and can now clean up its config + self.delete_player_config(player_id) + + def delete_player_config(self, player_id: str) -> None: + """ + Permanently delete a player's configuration. + + Should only be called for players that are not registered by the player controller. + """ + # we simply permanently delete the player by wiping its config + conf_key = f"{CONF_PLAYERS}/{player_id}" + dsp_conf_key = f"{CONF_PLAYER_DSP}/{player_id}" + for key in (conf_key, dsp_conf_key): + self.mass.config.remove(key) + + def signal_player_state_update( + self, + player: Player, + changed_values: dict[str, tuple[Any, Any]], + force_update: bool = False, + skip_forward: bool = False, + ) -> None: + """ + Signal a player state update. + + Called by a Player when its state has changed. + This will update the player state in the controller and signal the event bus. + """ + player_id = player.player_id + if self.mass.closing: + return + + # ignore updates for disabled players + if not player.enabled and "enabled" not in changed_values: + return + + if len(changed_values) == 0 and not force_update: + # nothing changed + return + + # always signal update to the playerqueue + self.mass.player_queues.on_player_update(player, changed_values) + + if changed_values.keys() == {"elapsed_time"} and not force_update: + # ignore elapsed_time only changes + prev_value = changed_values["elapsed_time"][0] or 0 + new_value = changed_values["elapsed_time"][1] or 0 + if abs(prev_value - new_value) < 30: + # ignore small changes in elapsed time + return + + # handle DSP reload of the leader when grouping/ungrouping + if ATTR_GROUP_MEMBERS in changed_values: + prev_group_members, new_group_members = changed_values[ATTR_GROUP_MEMBERS] + self._handle_group_dsp_change(player, prev_group_members or [], new_group_members) + + if ATTR_GROUP_MEMBERS in changed_values: + # Removed group members also need to be updated since they are no longer part + # of this group and are available for playback again + prev_group_members = changed_values[ATTR_GROUP_MEMBERS][0] or [] + new_group_members = changed_values[ATTR_GROUP_MEMBERS][1] or [] + removed_members = set(prev_group_members) - set(new_group_members) + for player_id in removed_members: + if removed_player := self.get(player_id): + removed_player.update_state() + + became_inactive = False + if "available" in changed_values: + became_inactive = changed_values["available"][1] is False + if not became_inactive and "enabled" in changed_values: + became_inactive = changed_values["enabled"][1] is False + if became_inactive and (player.active_group or player.synced_to): + self.mass.create_task(self._cleanup_player_memberships(player.player_id)) + + # signal player update on the eventbus + self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player) + + if skip_forward and not force_update: + return + + # update/signal group player(s) child's when group updates + for child_player in self.iter_group_members(player, exclude_self=True): + child_player.update_state() + # update/signal group player(s) when child updates + for group_player in self._get_player_groups(player, powered_only=False): + group_player.update_state() + # update/signal manually synced to player when child updates + if (synced_to := player.synced_to) and (synced_to_player := self.get(synced_to)): + synced_to_player.update_state() + # update/signal active groups when a group member updates + if (active_group := player.active_group) and ( + active_group_player := self.get(active_group) + ): + active_group_player.update_state() + + async def register_player_control(self, player_control: PlayerControl) -> None: + """Register a new PlayerControl on the controller.""" + if self.mass.closing: + return + control_id = player_control.id + + if control_id in self._controls: + msg = f"PlayerControl {control_id} is already registered" + raise AlreadyRegisteredError(msg) + + # make sure that the playercontrol's provider is set to the instance_id + prov = self.mass.get_provider(player_control.provider) + if not prov or prov.instance_id != player_control.provider: + raise RuntimeError(f"Invalid provider ID given: {player_control.provider}") + + self._controls[control_id] = player_control + + self.logger.info( + "PlayerControl registered: %s/%s", + control_id, + player_control.name, + ) + + # always call update to update any attached players etc. + self.update_player_control(player_control.id) + + async def register_or_update_player_control(self, player_control: PlayerControl) -> None: + """Register a new playercontrol on the controller or update existing one.""" + if self.mass.closing: + return + if player_control.id in self._controls: + self._controls[player_control.id] = player_control + self.update_player_control(player_control.id) + return + await self.register_player_control(player_control) + + def update_player_control(self, control_id: str) -> None: + """Update playercontrol state.""" + if self.mass.closing: + return + # update all players that are using this control + for player in self._players.values(): + if control_id in (player.power_control, player.volume_control, player.mute_control): + self.mass.loop.call_soon(player.update_state) + + def remove_player_control(self, control_id: str) -> None: + """Remove a player_control from the player manager.""" + control = self._controls.pop(control_id, None) + if control is None: + return + self._controls.pop(control_id, None) + self.logger.info("PlayerControl removed: %s", control.name) + + def get_player_provider(self, player_id: str) -> PlayerProvider: + """Return PlayerProvider for given player.""" + player = self._players[player_id] + assert player # for type checker + return player.provider + + def get_active_queue(self, player: Player) -> PlayerQueue | None: + """Return the current active queue for a player (if any).""" + # account for player that is synced (sync child) + if player.synced_to and player.synced_to != player.player_id: + if sync_leader := self.get(player.synced_to): + return self.get_active_queue(sync_leader) + # handle active group player + if player.active_group and player.active_group != player.player_id: + if group_player := self.get(player.active_group): + return self.get_active_queue(group_player) + # active_source may be filled queue id (or None) + active_source = player.active_source or player.player_id + if active_queue := self.mass.player_queues.get(active_source): + return active_queue + return None + + async def set_group_volume(self, group_player: Player, volume_level: int) -> None: + """Handle adjusting the overall/group volume to a playergroup (or synced players).""" + cur_volume = group_player.state.group_volume + volume_dif = volume_level - cur_volume + coros = [] + # handle group volume by only applying the volume to powered members + for child_player in self.iter_group_members( + group_player, only_powered=True, exclude_self=False + ): + if child_player.volume_control == PLAYER_CONTROL_NONE: + continue + cur_child_volume = child_player.volume_level or 0 + new_child_volume = int(cur_child_volume + volume_dif) + new_child_volume = max(0, new_child_volume) + new_child_volume = min(100, new_child_volume) + coros.append(self.cmd_volume_set(child_player.player_id, new_child_volume)) + await asyncio.gather(*coros) + + def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None: + """Get the (player specific) volume for a announcement.""" + volume_strategy = self.mass.config.get_raw_player_config_value( + player_id, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value, + ) + volume_strategy_volume = self.mass.config.get_raw_player_config_value( + player_id, + CONF_ENTRY_ANNOUNCE_VOLUME.key, + CONF_ENTRY_ANNOUNCE_VOLUME.default_value, + ) + if volume_strategy == "none": + return None + volume_level = volume_override + if volume_level is None and volume_strategy == "absolute": + volume_level = volume_strategy_volume + elif volume_level is None and volume_strategy == "relative": + player = self.get(player_id) + volume_level = player.volume_level + volume_strategy_volume + elif volume_level is None and volume_strategy == "percentual": + player = self.get(player_id) + percentual = (player.volume_level / 100) * volume_strategy_volume + volume_level = player.volume_level + percentual + if volume_level is not None: + announce_volume_min = self.mass.config.get_raw_player_config_value( + player_id, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value, + ) + volume_level = max(announce_volume_min, volume_level) + announce_volume_max = self.mass.config.get_raw_player_config_value( + player_id, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value, + ) + volume_level = min(announce_volume_max, volume_level) + # ensure the result is an integer + return None if volume_level is None else int(volume_level) + + def iter_group_members( + self, + group_player: Player, + only_powered: bool = False, + only_playing: bool = False, + active_only: bool = False, + exclude_self: bool = True, + ) -> Iterator[Player]: + """Get (child) players attached to a group player or syncgroup.""" + for child_id in list(group_player.group_members): + if child_player := self.get(child_id, False): + if not child_player.available or not child_player.enabled: + continue + if only_powered and child_player.powered is False: + continue + if active_only and child_player.active_group != group_player.player_id: + continue + if exclude_self and child_player.player_id == group_player.player_id: + continue + if only_playing and child_player.playback_state not in ( + PlaybackState.PLAYING, + PlaybackState.PAUSED, + ): + continue + yield child_player + + async def wait_for_state( + self, + player: Player, + wanted_state: PlaybackState, + timeout: float = 60.0, + minimal_time: float = 0, + ) -> None: + """Wait for the given player to reach the given state.""" + start_timestamp = time.time() + self.logger.debug( + "Waiting for player %s to reach state %s", player.display_name, wanted_state + ) + try: + async with asyncio.timeout(timeout): + while player.playback_state != wanted_state: + await asyncio.sleep(0.1) + + except TimeoutError: + self.logger.debug( + "Player %s did not reach state %s within the timeout of %s seconds", + player.display_name, + wanted_state, + timeout, + ) + elapsed_time = round(time.time() - start_timestamp, 2) + if elapsed_time < minimal_time: + self.logger.debug( + "Player %s reached state %s too soon (%s vs %s seconds) - add fallback sleep...", + player.display_name, + wanted_state, + elapsed_time, + minimal_time, + ) + await asyncio.sleep(minimal_time - elapsed_time) + else: + self.logger.debug( + "Player %s reached state %s within %s seconds", + player.display_name, + wanted_state, + elapsed_time, + ) + + async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None: + """Call (by config manager) when the configuration of a player changes.""" + player_disabled = "enabled" in changed_keys and not config.enabled + # signal player provider that the player got enabled/disabled + if player_provider := self.mass.get_provider(config.provider): + assert isinstance(player_provider, PlayerProvider) # for type checking + if "enabled" in changed_keys and not config.enabled: + player_provider.on_player_disabled(config.player_id) + elif "enabled" in changed_keys and config.enabled: + player_provider.on_player_enabled(config.player_id) + # ensure player state gets updated with any updated config + if not (player := self.get(config.player_id)): + return # guard against player not being registered (yet) + player.set_config(config) + await player.on_config_updated() + player.update_state() + resume_queue: PlayerQueue | None = ( + self.mass.player_queues.get(player.active_source) if player.active_source else None + ) + if player_disabled: + # edge case: ensure that the player is powered off if the player gets disabled + if player.power_control != PLAYER_CONTROL_NONE: + await self.cmd_power(config.player_id, False) + elif player.playback_state != PlaybackState.IDLE: + await self.cmd_stop(config.player_id) + # if the PlayerQueue was playing, restart playback + # TODO: add property to ConfigEntry if it requires a restart of playback on change + elif not player_disabled and resume_queue and resume_queue.state == PlaybackState.PLAYING: + # always stop first to ensure the player uses the new config + await self.mass.player_queues.stop(resume_queue.queue_id) + self.mass.call_later(1, self.mass.player_queues.resume, resume_queue.queue_id, False) + + async def on_player_dsp_change(self, player_id: str) -> None: + """Call (by config manager) when the DSP settings of a player change.""" + # signal player provider that the config changed + if not (player := self.get(player_id)): + return + if player.playback_state == PlaybackState.PLAYING: + self.logger.info("Restarting playback of Player %s after DSP change", player_id) + # this will restart the queue stream/playback + if player.mass_queue_active: + self.mass.call_later(0, self.mass.player_queues.resume, player.active_source, False) + return + # if the player is not using a queue, we need to stop and start playback + await self.cmd_stop(player_id) + await self.cmd_play(player_id) + + async def _cleanup_player_memberships(self, player_id: str) -> None: + """Ensure a player is detached from any groups or syncgroups.""" + if not (player := self.get(player_id)): + return + + if ( + player.active_group + and (group := self.get(player.active_group)) + and group.supports_feature(PlayerFeature.SET_MEMBERS) + ): + # Ungroup the player if its part of an active group, this will ignore + # static_group_members since that is only checked when using cmd_set_members + with suppress(UnsupportedFeaturedException, PlayerCommandFailed): + await group.set_members(player_ids_to_remove=[player_id]) + elif player.synced_to and player.supports_feature(PlayerFeature.SET_MEMBERS): + # Remove the player if it was synced, otherwise it will still show as + # synced to the other player after it gets registered again + with suppress(UnsupportedFeaturedException, PlayerCommandFailed): + await player.ungroup() + + def _get_player_with_redirect(self, player_id: str) -> Player: + """Get player with check if playback related command should be redirected.""" + player = self.get(player_id, True) + assert player is not None # for type checking + if player.synced_to and (sync_leader := self.get(player.synced_to)): + self.logger.info( + "Player %s is synced to %s and can not accept " + "playback related commands itself, " + "redirected the command to the sync leader.", + player.name, + sync_leader.name, + ) + return sync_leader + if player.active_group and (active_group := self.get(player.active_group)): + self.logger.info( + "Player %s is part of a playergroup and can not accept " + "playback related commands itself, " + "redirected the command to the group leader.", + player.name, + ) + return active_group + return player + + def _get_player_groups( + self, player: Player, available_only: bool = True, powered_only: bool = False + ) -> Iterator[Player]: + """Return all groupplayers the given player belongs to.""" + for _player in self.all(return_unavailable=not available_only): + if _player.player_id == player.player_id: + continue + if _player.type != PlayerType.GROUP: + continue + if powered_only and _player.powered is False: + continue + if player.player_id in _player.group_members: + yield _player + + async def _play_announcement( # noqa: PLR0915 + self, + player: Player, + announcement: PlayerMedia, + volume_level: int | None = None, + ) -> None: + """Handle (default/fallback) implementation of the play announcement feature. + + This default implementation will; + - stop playback of the current media (if needed) + - power on the player (if needed) + - raise the volume a bit + - play the announcement (from given url) + - wait for the player to finish playing + - restore the previous power and volume + - restore playback (if needed and if possible) + + This default implementation will only be used if the player + (provider) has no native support for the PLAY_ANNOUNCEMENT feature. + """ + prev_power = player.powered + prev_state = player.playback_state + prev_synced_to = player.synced_to + prev_group = self.get(player.active_group) if player.active_group else None + prev_source = player.active_source + prev_queue = self.get_active_queue(player) + prev_media = player.current_media + prev_media_name = prev_media.title or prev_media.uri if prev_media else None + if prev_synced_to: + # ungroup player if its currently synced + self.logger.debug( + "Announcement to player %s - ungrouping player from %s...", + player.display_name, + prev_synced_to, + ) + await self.cmd_ungroup(player.player_id) + elif prev_group: + # if the player is part of a group player, we need to ungroup it + if PlayerFeature.SET_MEMBERS in prev_group.supported_features: + self.logger.debug( + "Announcement to player %s - ungrouping from group player %s...", + player.display_name, + prev_group.display_name, + ) + await prev_group.set_members(player_ids_to_remove=[player.player_id]) + else: + # if the player is part of a group player that does not support ungrouping, + # we need to power off the groupplayer instead + self.logger.debug( + "Announcement to player %s - turning off group player %s...", + player.display_name, + prev_group.display_name, + ) + await self.cmd_power(player.player_id, False) + elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): + # normal/standalone player: stop player if its currently playing + self.logger.debug( + "Announcement to player %s - stop existing content (%s)...", + player.display_name, + prev_media_name, + ) + await self.cmd_stop(player.player_id) + # wait for the player to stop + await self.wait_for_state(player, PlaybackState.IDLE, 10, 0.4) + # adjust volume if needed + # in case of a (sync) group, we need to do this for all child players + prev_volumes: dict[str, int] = {} + async with TaskManager(self.mass) as tg: + for volume_player_id in player.group_members or (player.player_id,): + if not (volume_player := self.get(volume_player_id)): + continue + # catch any players that have a different source active + if ( + volume_player.active_source + not in ( + player.active_source, + volume_player.player_id, + None, + ) + and volume_player.playback_state == PlaybackState.PLAYING + ): + self.logger.warning( + "Detected announcement to playergroup %s while group member %s is playing " + "other content, this may lead to unexpected behavior.", + player.display_name, + volume_player.display_name, + ) + tg.create_task(self.cmd_stop(volume_player.player_id)) + if volume_player.volume_control == PLAYER_CONTROL_NONE: + continue + if (prev_volume := volume_player.volume_level) is None: + continue + announcement_volume = self.get_announcement_volume(volume_player_id, volume_level) + if announcement_volume is None: + continue + temp_volume = announcement_volume or player.volume_level + if temp_volume != prev_volume: + prev_volumes[volume_player_id] = prev_volume + self.logger.debug( + "Announcement to player %s - setting temporary volume (%s)...", + volume_player.display_name, + announcement_volume, + ) + tg.create_task( + self.cmd_volume_set(volume_player.player_id, announcement_volume) + ) + # play the announcement + self.logger.debug( + "Announcement to player %s - playing the announcement on the player...", + player.display_name, + ) + await self.play_media(player_id=player.player_id, media=announcement) + # wait for the player(s) to play + await self.wait_for_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1) + # wait for the player to stop playing + if not announcement.duration: + media_info = await async_parse_tags( + announcement.custom_data["url"], require_duration=True + ) + announcement.duration = media_info.duration + await self.wait_for_state( + player, + PlaybackState.IDLE, + timeout=announcement.duration + 6, + minimal_time=announcement.duration, + ) + self.logger.debug( + "Announcement to player %s - restore previous state...", player.display_name + ) + # restore volume + async with TaskManager(self.mass) as tg: + for volume_player_id, prev_volume in prev_volumes.items(): + tg.create_task(self.cmd_volume_set(volume_player_id, prev_volume)) + await asyncio.sleep(0.2) + player.current_media = prev_media + player.active_source = prev_source + # either power off the player or resume playing + if not prev_power and player.power_control != PLAYER_CONTROL_NONE: + await self.cmd_power(player.player_id, False) + return + elif prev_synced_to: + await self.cmd_group(player.player_id, prev_synced_to) + elif prev_group: + if PlayerFeature.SET_MEMBERS in prev_group.supported_features: + self.logger.debug( + "Announcement to player %s - grouping back to group player %s...", + player.display_name, + prev_group.display_name, + ) + await prev_group.set_members(player_ids_to_add=[player.player_id]) + elif prev_state == PlaybackState.PLAYING: + # if the player is part of a group player that does not support set_members, + # we need to restart the groupplayer + self.logger.debug( + "Announcement to player %s - restarting playback on group player %s...", + player.display_name, + prev_group.display_name, + ) + await self.cmd_play(prev_group.player_id) + elif prev_queue and prev_state == PlaybackState.PLAYING: + await self.mass.player_queues.resume(prev_queue.queue_id, True) + await self.wait_for_state(player, PlaybackState.PLAYING, 5) + elif prev_state == PlaybackState.PLAYING: + # player was playing something else - try to resume that here + for source in player.source_list_state: + if source.id == prev_source and not source.passive: + await player.select_source(source.id) + break + else: + # no source found, try to resume the previous media + await self.cmd_play(player.player_id) + + async def _poll_players(self) -> None: + """Background task that polls players for updates.""" + while True: + for player in list(self._players.values()): + # if the player is playing, update elapsed time every tick + # to ensure the queue has accurate details + player_playing = player.playback_state == PlaybackState.PLAYING + if player_playing: + self.mass.loop.call_soon( + self.mass.player_queues.on_player_update, + player, + {"corrected_elapsed_time": player.corrected_elapsed_time}, + ) + # Poll player; + if not player.needs_poll: + continue + try: + last_poll: float = player.extra_data[ATTR_LAST_POLL] + except KeyError: + last_poll = 0.0 + if (self.mass.loop.time() - last_poll) < player.poll_interval: + continue + player.extra_data[ATTR_LAST_POLL] = self.mass.loop.time() + try: + await player.poll() + except Exception as err: + self.logger.warning( + "Error while requesting latest state from player %s: %s", + player.display_name, + str(err), + exc_info=err if self.logger.isEnabledFor(10) else None, + ) + await asyncio.sleep(1) + + async def _handle_select_plugin_source( + self, player: Player, plugin_prov: PluginProvider + ) -> None: + """Handle playback/select of given plugin source on player.""" + plugin_source = plugin_prov.get_source() + stream_url = await self.mass.streams.get_plugin_source_url( + plugin_source.id, player.player_id + ) + await self.play_media( + player_id=player.player_id, + media=PlayerMedia( + uri=stream_url, + media_type=MediaType.PLUGIN_SOURCE, + title=plugin_source.name, + custom_data={ + "provider": plugin_prov.instance_id, + "source_id": plugin_source.id, + "player_id": player.player_id, + "audio_format": plugin_source.audio_format, + }, + ), + ) + # trigger player update to ensure the source is set + self.trigger_player_update(player.player_id) + + def _handle_group_dsp_change( + self, player: Player, prev_group_members: list[str], new_group_members: list[str] + ) -> None: + """Handle DSP reload when group membership changes.""" + prev_child_count = len(prev_group_members) + new_child_count = len(new_group_members) + is_player_group = player.type == PlayerType.GROUP + + # handle special case for PlayerGroups: since there are no leaders, + # DSP still always work with a single player in the group. + multi_device_dsp_threshold = 1 if is_player_group else 0 + + prev_is_multiple_devices = prev_child_count > multi_device_dsp_threshold + new_is_multiple_devices = new_child_count > multi_device_dsp_threshold + + if prev_is_multiple_devices == new_is_multiple_devices: + return # no change in multi-device status + + supports_multi_device_dsp = PlayerFeature.MULTI_DEVICE_DSP in player.supported_features + + dsp_enabled: bool + if player.type == PlayerType.GROUP: + # Since player groups do not have leaders, we will use the only child + # that was in the group before and after the change + if prev_is_multiple_devices: + if childs := new_group_members: + # We shrank the group from multiple players to a single player + # So the now only child will control the DSP + dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled + else: + dsp_enabled = False + elif childs := prev_group_members: + # We grew the group from a single player to multiple players, + # let's see if the previous single player had DSP enabled + dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled + else: + dsp_enabled = False + else: + dsp_enabled = self.mass.config.get_player_dsp_config(player.player_id).enabled + + if dsp_enabled and not supports_multi_device_dsp: + # We now know that the group configuration has changed so: + # - multi-device DSP is not supported + # - we switched from a group with multiple players to a single player + # (or vice versa) + # - the leader has DSP enabled + self.mass.create_task(self.mass.players.on_player_dsp_change(player.player_id)) + + def __iter__(self) -> Iterator[Player]: + """Iterate over all players.""" + return iter(self._players.values()) diff --git a/music_assistant/controllers/players/sync_groups.py b/music_assistant/controllers/players/sync_groups.py new file mode 100644 index 00000000..0d95ca9e --- /dev/null +++ b/music_assistant/controllers/players/sync_groups.py @@ -0,0 +1,590 @@ +""" +Controller for (provider specific) SyncGroup players. + +A SyncGroup player is a virtual player that automatically groups multiple players +together in a sync group, where one player is the sync leader +and the other players are synced to that leader. +""" + +from __future__ import annotations + +import asyncio +from copy import deepcopy +from typing import TYPE_CHECKING, cast + +import shortuuid +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption +from music_assistant_models.constants import PLAYER_CONTROL_NONE +from music_assistant_models.enums import ( + ConfigEntryType, + PlaybackState, + PlayerFeature, + PlayerType, + ProviderFeature, +) +from music_assistant_models.errors import UnsupportedFeaturedException +from music_assistant_models.player import DeviceInfo, PlayerMedia, PlayerSource +from propcache import under_cached_property as cached_property + +from music_assistant.constants import ( + CONF_CROSSFADE_DURATION, + CONF_DYNAMIC_GROUP_MEMBERS, + CONF_ENABLE_ICY_METADATA, + CONF_FLOW_MODE, + CONF_GROUP_MEMBERS, + CONF_HTTP_PROFILE, + CONF_OUTPUT_CODEC, + CONF_SAMPLE_RATES, + CONF_SMART_FADES_MODE, + SYNCGROUP_PREFIX, +) +from music_assistant.models.player import GroupPlayer, Player + +if TYPE_CHECKING: + from music_assistant.models.player_provider import PlayerProvider + + from .player_controller import PlayerController + + +SUPPORT_DYNAMIC_LEADER = { + # providers that support dynamic leader selection in a syncgroup + # meaning that if you would remove the current leader from the group, + # the provider will automatically select a new leader from the remaining members + # and the music keeps playing uninterrupted. + "airplay", + "squeezelite", + "resonate", + # TODO: Get this working with Sonos as well (need to handle range requests) +} + +OPTIONAL_FEATURES = { + PlayerFeature.ENQUEUE, + PlayerFeature.GAPLESS_PLAYBACK, + PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE, + PlayerFeature.NEXT_PREVIOUS, + PlayerFeature.PAUSE, + PlayerFeature.PLAY_ANNOUNCEMENT, + PlayerFeature.SEEK, + PlayerFeature.SELECT_SOURCE, + PlayerFeature.VOLUME_MUTE, +} + + +class SyncGroupPlayer(GroupPlayer): + """Helper class for a (provider specific) SyncGroup player.""" + + _attr_type: PlayerType = PlayerType.GROUP + sync_leader: Player | None = None + """The active sync leader player for this syncgroup.""" + + @cached_property + def is_dynamic(self) -> bool: + """Return if the player is a dynamic group player.""" + return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False)) + + def __init__( + self, + provider: PlayerProvider, + player_id: str, + ) -> None: + """Initialize GroupPlayer instance.""" + super().__init__(provider, player_id) + self._attr_name = self.config.name or f"SyncGroup {player_id}" + self._attr_available = True + self._attr_powered = False # group players are always powered off by default + self._attr_active_source = None + self._attr_device_info = DeviceInfo(model="Sync Group", manufacturer=provider.name) + self._attr_supported_features = { + PlayerFeature.POWER, + PlayerFeature.VOLUME_SET, + } + + async def on_config_updated(self) -> None: + """Handle logic when the player is loaded or updated.""" + # Config is only available after the player was registered + static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])) + self._attr_static_group_members = static_members.copy() + if not self.powered: + self._attr_group_members = static_members.copy() + if self.is_dynamic: + self._attr_supported_features.add(PlayerFeature.SET_MEMBERS) + else: + self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS) + + @property + def supported_features(self) -> set[PlayerFeature]: + """Return the supported features of the player.""" + if self.sync_leader: + base_features = self._attr_supported_features.copy() + # add features supported by the sync leader + for feature in OPTIONAL_FEATURES: + if feature in self.sync_leader.supported_features: + base_features.add(feature) + return base_features + return self._attr_supported_features + + @property + def playback_state(self) -> PlaybackState: + """Return the current playback state of the player.""" + if self.power_state: + return self.sync_leader.playback_state if self.sync_leader else PlaybackState.IDLE + else: + return PlaybackState.IDLE + + @cached_property + def flow_mode(self) -> bool: + """ + Return if the player needs flow mode. + + Will by default be set to True if the player does not support PlayerFeature.ENQUEUE + or has a flow mode config entry set to True. + """ + if leader := self.sync_leader: + return leader.flow_mode + return False + + @property + def elapsed_time(self) -> float | None: + """Return the elapsed time in (fractional) seconds of the current track (if any).""" + return self.sync_leader.elapsed_time if self.sync_leader else None + + @property + def elapsed_time_last_updated(self) -> float | None: + """Return when the elapsed time was last updated.""" + return self.sync_leader.elapsed_time_last_updated if self.sync_leader else None + + @property + def current_media(self) -> PlayerMedia | None: + """Return the current media item (if any) loaded in the player.""" + return self.sync_leader.current_media if self.sync_leader else self._attr_current_media + + @property + def active_source(self) -> str | None: + """Return the active source id (if any) of the player.""" + return self._attr_active_source + + @property + def source_list(self) -> list[PlayerSource]: + """Return list of available (native) sources for this player.""" + if self.sync_leader: + return self.sync_leader.source_list + return [] + + @property + def can_group_with(self) -> set[str]: + """ + Return the id's of players this player can group with. + + This should return set of player_id's this player can group/sync with + or just the provider's instance_id if all players can group with each other. + """ + if self.is_dynamic and (leader := self.sync_leader): + return leader.can_group_with + elif self.is_dynamic: + return {self.provider.lookup_key} + else: + return set() + + async def get_config_entries(self) -> list[ConfigEntry]: + """Return all (provider/player specific) Config Entries for the given player (if any).""" + entries: list[ConfigEntry] = [ + # default entries for player groups + *await super().get_config_entries(), + # add syncgroup specific entries + ConfigEntry( + key=CONF_GROUP_MEMBERS, + type=ConfigEntryType.STRING, + multi_value=True, + label="Group members", + default_value=[], + description="Select all players you want to be part of this group", + required=False, # needed for dynamic members (which allows empty members list) + options=[ + ConfigValueOption(x.display_name, x.player_id) + for x in self.provider.players + if x.type != PlayerType.GROUP + ], + ), + ConfigEntry( + key="dynamic_members", + type=ConfigEntryType.BOOLEAN, + label="Enable dynamic members", + description="Allow (un)joining members dynamically, so the group more or less " + "behaves the same like manually syncing players together, " + "with the main difference being that the group player will hold the queue.", + default_value=False, + required=False, + ), + ] + # combine base group entries with (base) player entries for this player type + child_player = next((x for x in self.provider.players if x.type == PlayerType.PLAYER), None) + if child_player: + allowed_conf_entries = ( + CONF_HTTP_PROFILE, + CONF_ENABLE_ICY_METADATA, + CONF_CROSSFADE_DURATION, + CONF_OUTPUT_CODEC, + CONF_FLOW_MODE, + CONF_SAMPLE_RATES, + CONF_SMART_FADES_MODE, + ) + child_config_entries = await child_player.get_config_entries() + entries.extend( + [entry for entry in child_config_entries if entry.key in allowed_conf_entries] + ) + return entries + + async def stop(self) -> None: + """Send STOP command to given player.""" + if sync_leader := self.sync_leader: + await sync_leader.stop() + + async def play(self) -> None: + """Send PLAY command to given player.""" + if sync_leader := self.sync_leader: + await sync_leader.play() + + async def pause(self) -> None: + """Send PAUSE command to given player.""" + if sync_leader := self.sync_leader: + await sync_leader.pause() + + async def power(self, powered: bool) -> None: + """Handle POWER command to group player.""" + prev_power = self._attr_powered + if powered == prev_power: + # no change + return + + # always stop at power off + if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): + await self.stop() + + # optimistically set the group state + + self._attr_powered = powered + self.update_state() + + if not prev_power and powered: + # ensure static members are present when powering on + for static_group_member in self._attr_static_group_members: + member_player = self.mass.players.get(static_group_member) + if not member_player or not member_player.available or not member_player.enabled: + if static_group_member in self._attr_group_members: + self._attr_group_members.remove(static_group_member) + continue + if static_group_member not in self._attr_group_members: + self._attr_group_members.append(static_group_member) + # Select sync leader and handle turn on + new_leader = self._select_sync_leader() + # handle TURN_ON of the group player by turning on all members + for member in self.mass.players.iter_group_members( + self, only_powered=False, active_only=False + ): + await self._handle_member_collisions(member) + if not member.powered and member.power_control != PLAYER_CONTROL_NONE: + await member.power(True) + # Set up the sync group with the new leader + await self._handle_leader_transition(new_leader) + elif prev_power and not powered: + # handle TURN_OFF of the group player by dissolving group and turning off all members + await self._dissolve_syncgroup() + # turn off all group members + for member in self.mass.players.iter_group_members( + self, only_powered=True, active_only=True + ): + if member.powered and member.power_control != PLAYER_CONTROL_NONE: + await member.power(False) + + if not powered: + # Reset to unfiltered static members list when powered off + # (the frontend will hide unavailable members) + self._attr_group_members = self._attr_static_group_members.copy() + self._attr_active_source = None + # and clear the sync leader + self.sync_leader = None + self.update_state() + + async def volume_set(self, volume_level: int) -> None: + """Send VOLUME_SET command to given player.""" + # group volume is already handled in the player manager + + async def play_media(self, media: PlayerMedia) -> None: + """Handle PLAY MEDIA on given player.""" + # power on (which will also resync if needed) + await self.power(True) + # simply forward the command to the sync leader + if sync_leader := self.sync_leader: + await sync_leader.play_media(media) + self._attr_current_media = deepcopy(media) + self._attr_active_source = media.source_id + self.update_state() + else: + raise RuntimeError("an empty group cannot play media, consider adding members first") + + async def enqueue_next_media(self, media: PlayerMedia) -> None: + """Handle enqueuing of a next media item on the player.""" + if sync_leader := self.sync_leader: + await sync_leader.enqueue_next_media(media) + + async def set_members( + self, + player_ids_to_add: list[str] | None = None, + player_ids_to_remove: list[str] | None = None, + ) -> None: + """Handle SET_MEMBERS command on the player.""" + if not self.is_dynamic: + raise UnsupportedFeaturedException( + f"Group {self.display_name} does not allow dynamically adding/removing members!" + ) + # handle additions + final_players_to_add: list[str] = [] + for player_id in player_ids_to_add or []: + if player_id in self._attr_group_members: + continue + if player_id == self.player_id: + raise UnsupportedFeaturedException( + f"Cannot add {self.display_name} to itself as a member!" + ) + self._attr_group_members.append(player_id) + final_players_to_add.append(player_id) + # handle removals + final_players_to_remove: list[str] = [] + for player_id in player_ids_to_remove or []: + if player_id not in self._attr_group_members: + continue + if player_id == self.player_id: + raise UnsupportedFeaturedException( + f"Cannot remove {self.display_name} from itself as a member!" + ) + self._attr_group_members.remove(player_id) + final_players_to_remove.append(player_id) + self.update_state() + if not self.powered: + # Don't need to do anything else if the group is powered off + # The syncing will be done once powered on + return + next_leader = self._select_sync_leader() + prev_leader = self.sync_leader + + if prev_leader and next_leader is None: + # Edge case: we no longer have any members in the group (and thus no leader) + await self._handle_leader_transition(None) + elif prev_leader != next_leader: + # Edge case: we had changed the leader (or just got one) + await self._handle_leader_transition(next_leader) + elif self.sync_leader and (player_ids_to_add or player_ids_to_remove): + # if the group still has the same leader, we need to (re)sync the members + # Handle collisions for newly added players + for player_id in final_players_to_add: + if player := self.mass.players.get(player_id): + await self._handle_member_collisions(player) + + await self.sync_leader.set_members( + player_ids_to_add=final_players_to_add, + player_ids_to_remove=final_players_to_remove, + ) + + async def _form_syncgroup(self) -> None: + """Form syncgroup by syncing all (possible) members.""" + if self.sync_leader is None: + # This is an empty group, leader will be selected once a member is added + self._attr_group_members = [] + self.update_state() + return + # ensure the sync leader is first in the list + self._attr_group_members = [ + self.sync_leader.player_id, + *[x for x in self._attr_group_members if x != self.sync_leader.player_id], + ] + self.update_state() + members_to_sync: list[str] = [] + for member in self.mass.players.iter_group_members(self, active_only=False): + # Handle collisions before attempting to sync + await self._handle_member_collisions(member) + + if member.synced_to and member.synced_to != self.sync_leader.player_id: + # ungroup first + await member.ungroup() + if member.player_id == self.sync_leader.player_id: + # skip sync leader + continue + if ( + member.synced_to == self.sync_leader.player_id + and member.player_id in self.sync_leader.group_members + ): + # already synced + continue + members_to_sync.append(member.player_id) + if members_to_sync: + await self.sync_leader.set_members(members_to_sync) + + async def _dissolve_syncgroup(self) -> None: + """Dissolve the current syncgroup by ungrouping all members and restoring leader queue.""" + if sync_leader := self.sync_leader: + # dissolve the temporary syncgroup from the sync leader + sync_children = [x for x in sync_leader.group_members if x != sync_leader.player_id] + if sync_children: + await sync_leader.set_members(player_ids_to_remove=sync_children) + # Reset the leaders queue since it is no longer part of this group + sync_leader.update_state() + + async def _handle_leader_transition(self, new_leader: Player | None) -> None: + """Handle transition from current leader to new leader.""" + prev_leader = self.sync_leader + was_playing = False + + if ( + prev_leader + and new_leader + and prev_leader != new_leader + and self.provider.domain in SUPPORT_DYNAMIC_LEADER + ): + # provider supports dynamic leader selection, so just remove/add members + await prev_leader.ungroup() + self.sync_leader = new_leader + # allow some time to propagate the changes before resyncing + await asyncio.sleep(2) + await self._form_syncgroup() + return + + if prev_leader: + # Save current media and playback state for potential restart + was_playing = self.playback_state == PlaybackState.PLAYING + # Stop current playback and dissolve existing group + await self.stop() + await self._dissolve_syncgroup() + # allow some time to propagate the changes before resyncing + await asyncio.sleep(2) + + # Set new leader + self.sync_leader = new_leader + + if new_leader: + # form a syncgroup with the new leader + await self._form_syncgroup() + + # Restart playback if requested and we have media to play + if was_playing and self.current_media is not None: + await new_leader.play_media(self.current_media) + + def _select_sync_leader(self) -> Player | None: + """Select the active sync leader player for a syncgroup.""" + if self.sync_leader and self.sync_leader.player_id in self.group_members: + # Don't change the sync leader if we already have one + return self.sync_leader + for prefer_sync_leader in (True, False): + for child_player in self.mass.players.iter_group_members(self): + if prefer_sync_leader and child_player.synced_to: + # prefer the first player that already has sync children + continue + if child_player.active_group not in ( + None, + self.player_id, + child_player.player_id, + ): + # this should not happen (because its already handled in the power on logic), + # but guard it just in case bad things happen + continue + return child_player + return None + + async def _handle_member_collisions(self, member: Player) -> None: + """Handle collisions when adding a member to the sync group.""" + active_groups = member.active_groups + for group in active_groups: + if group == self.player_id: + continue + # collision: child player is part another group that is already active ! + # solve this by trying to leave the group first + if other_group := self.mass.players.get(group): + if ( + other_group.supports_feature(PlayerFeature.SET_MEMBERS) + and member.player_id not in other_group.static_group_members + ): + await other_group.set_members(player_ids_to_remove=[member.player_id]) + else: + # if the other group does not support SET_MEMBERS or it is a static + # member, we need to power it off to leave the group + await other_group.power(False) + if ( + member.synced_to is not None + and member.synced_to != self.sync_leader + and (synced_to_player := self.mass.players.get(member.synced_to)) + and member.player_id in synced_to_player.group_members + ): + # collision: child player is synced to another player and still in that group + # ungroup it first + await synced_to_player.set_members(player_ids_to_remove=[member.player_id]) + + +class SyncGroupController: + """Controller managing SyncGroup players.""" + + def __init__(self, player_controller: PlayerController) -> None: + """Initialize SyncGroupController.""" + self.player_controller = player_controller + self.mass = player_controller.mass + + async def create_group_player( + self, provider: PlayerProvider, name: str, members: list[str], dynamic: bool = True + ) -> Player: + """ + Create new SyncGroup Player. + + :param provider: The provider to create the group player for + :param name: Name of the group player + :param members: List of player ids to add to the group + :param dynamic: Whether the group is dynamic (members can change) + """ + # default implementation for providers that support syncing players + if ProviderFeature.SYNC_PLAYERS not in provider.supported_features: + # the frontend should already prevent this, but just in case + raise UnsupportedFeaturedException( + f"Provider {provider.name} does not support player syncing!" + ) + # Create a new syncgroup player with the given members + members = [x for x in members if x in [y.player_id for y in provider.players]] + player_id = f"{SYNCGROUP_PREFIX}{shortuuid.random(8).lower()}" + self.mass.config.create_default_player_config( + player_id=player_id, + provider=provider.lookup_key, + name=name, + enabled=True, + values={ + CONF_GROUP_MEMBERS: members, + CONF_DYNAMIC_GROUP_MEMBERS: dynamic, + }, + ) + return await self._register_syncgroup_player(player_id, provider) + + async def remove_group_player(self, player_id: str) -> None: + """ + Remove a group player. + + :param player_id: ID of the group player to remove. + """ + # we simply permanently unregister the syncgroup player and wipe its config + await self.mass.players.unregister(player_id, True) + + async def _register_syncgroup_player(self, player_id: str, provider: PlayerProvider) -> Player: + """Register a syncgroup player.""" + syncgroup = SyncGroupPlayer(provider, player_id) + await self.mass.players.register_or_update(syncgroup) + return syncgroup + + async def on_provider_loaded(self, provider: PlayerProvider) -> None: + """Handle logic when a provider is loaded.""" + # register existing syncgroup players for this provider + for player_conf in await self.mass.config.get_player_configs(provider.lookup_key): + if player_conf.player_id.startswith(SYNCGROUP_PREFIX): + await self._register_syncgroup_player(player_conf.player_id, provider) + + async def on_provider_unload(self, provider: PlayerProvider) -> None: + """Handle logic when a provider is (about to get) unloaded.""" + # unregister existing syncgroup players for this provider + for player in self.mass.players.all( + provider_filter=provider.lookup_key, return_sync_groups=True + ): + if player.player_id.startswith(SYNCGROUP_PREFIX): + await self.mass.players.unregister(player.player_id, False) diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py index f55fb34f..55ec3da0 100644 --- a/music_assistant/controllers/streams.py +++ b/music_assistant/controllers/streams.py @@ -14,7 +14,7 @@ import os import urllib.parse from collections.abc import AsyncGenerator from dataclasses import dataclass -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING from aiofiles.os import wrap from aiohttp import web @@ -54,6 +54,7 @@ from music_assistant.constants import ( SILENCE_FILE, VERBOSE_LOG_LEVEL, ) +from music_assistant.controllers.players.player_controller import AnnounceData from music_assistant.helpers.audio import ( CACHE_FILES_IN_USE, get_chunksize, @@ -117,14 +118,6 @@ class CrossfadeData: session_id: str -class AnnounceData(TypedDict): - """Announcement data.""" - - announcement_url: str - pre_announce: bool - pre_announce_url: str - - class StreamsController(CoreController): """Webserver Controller to stream audio to players.""" @@ -1052,7 +1045,7 @@ class StreamsController(CoreController): if plugin_source.stream_type == StreamType.CUSTOM else plugin_source.path ) - player.active_source = plugin_source_id + player.state.active_source = plugin_source_id plugin_source.in_use_by = player_id try: async for chunk in get_ffmpeg_stream( @@ -1069,7 +1062,7 @@ class StreamsController(CoreController): "Finished streaming PluginSource %s to %s", plugin_source_id, player_id ) await asyncio.sleep(0.5) - player.active_source = player.player_id + player.state.active_source = player.player_id plugin_source.in_use_by = None async def get_queue_item_stream( diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index bd03bb39..5b41c5bb 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -44,10 +44,10 @@ from music_assistant.constants import ( MASS_LOGGER_NAME, VERBOSE_LOG_LEVEL, ) +from music_assistant.controllers.players.sync_groups import SyncGroupPlayer from music_assistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER from music_assistant.helpers.util import clean_stream_title, remove_file -from music_assistant.models.player import SyncGroupPlayer from .datetime import utc from .dsp import filter_to_ffmpeg_params diff --git a/music_assistant/mass.py b/music_assistant/mass.py index 5678ed03..74dcfb57 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -42,7 +42,7 @@ from music_assistant.controllers.config import ConfigController from music_assistant.controllers.metadata import MetaDataController from music_assistant.controllers.music import MusicController from music_assistant.controllers.player_queues import PlayerQueuesController -from music_assistant.controllers.players import PlayerController +from music_assistant.controllers.players.player_controller import PlayerController from music_assistant.controllers.streams import StreamsController from music_assistant.controllers.webserver import WebserverController from music_assistant.helpers.aiohttp_client import create_clientsession @@ -586,11 +586,10 @@ class MusicAssistant: if provider.manifest.mdns_discovery: for mdns_type in provider.manifest.mdns_discovery: self._aiobrowser.types.discard(mdns_type) - # make sure to stop any running sync tasks first - for sync_task in self.music.in_progress_syncs: - if sync_task.provider_instance == instance_id: - if sync_task.task: - sync_task.task.cancel() + if isinstance(provider, PlayerProvider): + await self.players.on_provider_unload(provider) + if isinstance(provider, MusicProvider): + await self.music.on_provider_unload(provider) # check if there are no other providers dependent of this provider for dep_prov in self.providers: if dep_prov.manifest.depends_on == provider.domain: @@ -717,7 +716,9 @@ class MusicAssistant: self.signal_event(EventType.PROVIDERS_UPDATED, data=self.get_providers()) await self._update_available_providers_cache() if isinstance(provider, MusicProvider): - await self.music.schedule_provider_sync(provider.instance_id) + await self.music.on_provider_loaded(provider) + if isinstance(provider, PlayerProvider): + await self.players.on_provider_loaded(provider) async def __load_provider_manifests(self) -> None: """Preload all available provider manifest files.""" diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index af671f4e..1c5b404a 100644 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -44,9 +44,6 @@ from music_assistant.constants import ( ATTR_FAKE_MUTE, ATTR_FAKE_POWER, ATTR_FAKE_VOLUME, - CONF_CROSSFADE_DURATION, - CONF_DYNAMIC_GROUP_MEMBERS, - CONF_ENABLE_ICY_METADATA, CONF_ENTRY_ANNOUNCE_VOLUME, CONF_ENTRY_ANNOUNCE_VOLUME_MAX, CONF_ENTRY_ANNOUNCE_VOLUME_MIN, @@ -72,15 +69,10 @@ from music_assistant.constants import ( CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, CONF_EXPOSE_PLAYER_TO_HA, CONF_FLOW_MODE, - CONF_GROUP_MEMBERS, CONF_HIDE_PLAYER_IN_UI, - CONF_HTTP_PROFILE, CONF_MUTE_CONTROL, - CONF_OUTPUT_CODEC, CONF_POWER_CONTROL, CONF_PRE_ANNOUNCE_CHIME_URL, - CONF_SAMPLE_RATES, - CONF_SMART_FADES_MODE, CONF_VOLUME_CONTROL, ) from music_assistant.helpers.util import ( @@ -200,18 +192,6 @@ class Player(ABC): """Return if the player is available.""" return self._attr_available - @available.setter - def available(self, value: bool) -> None: - """ - Set the availability of the player. - - :param value: bool if the player is available or not. - """ - if self._attr_available != value: - self._attr_available = value - # also update the state - self._state.available = value - @property def name(self) -> str | None: """Return the name of the player.""" @@ -279,16 +259,6 @@ class Player(ABC): """Return the elapsed time in (fractional) seconds of the current track (if any).""" return self._attr_elapsed_time - @elapsed_time.setter - def elapsed_time(self, value: float | None) -> None: - """Set the elapsed time on the player.""" - if self._attr_elapsed_time != value: - self._attr_elapsed_time = value - # also update the state - self._state.elapsed_time = value - # update the last updated time - self._attr_elapsed_time_last_updated = time.time() - @property def elapsed_time_last_updated(self) -> float | None: """ @@ -350,11 +320,6 @@ class Player(ABC): """ return self._attr_active_source - @active_source.setter - def active_source(self, value: str | None) -> None: - """Set the active source of the player.""" - self._attr_active_source = value - @property def source_list(self) -> list[PlayerSource]: """Return list of available (native) sources for this player.""" @@ -365,11 +330,6 @@ class Player(ABC): """Return the current media being played by the player.""" return self._attr_current_media - @current_media.setter - def current_media(self, value: PlayerMedia | None) -> None: - """Set the current media being played by the player.""" - self._attr_current_media = value - @property def needs_poll(self) -> bool: """Return if the player needs to be polled for state updates.""" @@ -830,8 +790,7 @@ class Player(ABC): """ # if the player is grouped/synced, use the active source of the group/parent player if parent_player_id := (self.synced_to or self.active_group): - if parent_player := self.mass.players.get(parent_player_id): - return parent_player.active_source_state + return parent_player_id # in case player's source is None, return the player_id (to indicate MA is active source) return self.active_source or self.player_id @@ -1027,7 +986,7 @@ class Player(ABC): """ return bool(self._config.get_value(CONF_EXPOSE_PLAYER_TO_HA)) - @cached_property + @property @final def mass_queue_active(self) -> bool: """ @@ -1235,37 +1194,38 @@ class Player(ABC): Returns a dict with the state attributes that have changed. """ prev_state = deepcopy(self._state) - self._state.name = self.display_name - self._state.available = self.available - self._state.device_info = self.device_info - self._state.supported_features = self.supported_features - self._state.playback_state = self.playback_state - self._state.elapsed_time = self.elapsed_time - self._state.elapsed_time_last_updated = self.elapsed_time_last_updated - self._state.powered = self.power_state - self._state.volume_level = self.volume_state - self._state.volume_muted = self.volume_muted_state - self._state.group_members = UniqueList(self.group_members) - self._state.static_group_members = UniqueList(self.static_group_members) - self._state.can_group_with = self.can_group_with - self._state.synced_to = self.synced_to - self._state.active_source = self.active_source_state - self._state.source_list = self.source_list_state - self._state.active_group = self.active_group - self._state.current_media = self.current_media - self._state.enabled = self.enabled - self._state.hide_player_in_ui = self.hide_player_in_ui - self._state.expose_to_ha = self.expose_to_ha - self._state.icon = self.icon - self._state.group_volume = self.group_volume - self._state.extra_attributes = self.extra_attributes - self._state.power_control = self.power_control - self._state.volume_control = self.volume_control - self._state.mute_control = self.mute_control - - # correct available state if needed - if not self._state.enabled: - self._state.available = False + self._state = PlayerState( + player_id=self.player_id, + provider=self.provider_id, + type=self.type, + available=self.enabled and self.available, + device_info=self.device_info, + supported_features=self.supported_features, + playback_state=self.playback_state, + elapsed_time=self.elapsed_time, + elapsed_time_last_updated=self.elapsed_time_last_updated, + powered=self.powered, + volume_level=self.volume_level, + volume_muted=self.volume_muted, + group_members=UniqueList(self.group_members), + static_group_members=UniqueList(self.static_group_members), + can_group_with=self.can_group_with, + synced_to=self.synced_to, + active_source=self.active_source_state, + source_list=self.source_list_state, + active_group=self.active_group, + current_media=self.current_media, + name=self.display_name, + enabled=self.enabled, + hide_player_in_ui=self.hide_player_in_ui, + expose_to_ha=self.expose_to_ha, + icon=self.icon, + group_volume=self.group_volume, + extra_attributes=self.extra_attributes, + power_control=self.power_control, + volume_control=self.volume_control, + mute_control=self.mute_control, + ) # correct group_members if needed if self._state.group_members == [self.player_id]: @@ -1314,6 +1274,18 @@ class Player(ABC): return not self.__eq__(other) +__all__ = [ + # explicitly re-export the models we imported from the models package, + # for convenience reasons + "EXTRA_ATTRIBUTES_TYPES", + "DeviceInfo", + "Player", + "PlayerMedia", + "PlayerSource", + "PlayerState", +] + + class GroupPlayer(Player): """Helper class for a (generic) group player.""" @@ -1382,425 +1354,3 @@ class GroupPlayer(Player): # This will set the (relative) volume level on all child players. # free to override if you want to handle this differently. await self.mass.players.set_group_volume(self, volume_level) - - -class SyncGroupPlayer(GroupPlayer): - """Helper class for a (provider specific) SyncGroup player.""" - - _attr_type: PlayerType = PlayerType.GROUP - sync_leader: Player | None = None - """The active sync leader player for this syncgroup.""" - - @cached_property - def is_dynamic(self) -> bool: - """Return if the player is a dynamic group player.""" - return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False)) - - def __init__( - self, - provider: PlayerProvider, - player_id: str, - ) -> None: - """Initialize GroupPlayer instance.""" - super().__init__(provider, player_id) - self._attr_name = self.config.name or f"SyncGroup {player_id}" - self._attr_available = True - self._attr_powered = False # group players are always powered off by default - self._attr_active_source = player_id - self._attr_device_info = DeviceInfo(model="Sync Group", manufacturer=provider.name) - self._attr_supported_features = { - PlayerFeature.POWER, - PlayerFeature.VOLUME_SET, - } - - async def on_config_updated(self) -> None: - """Handle logic when the player is loaded or updated.""" - # Config is only available after the player was registered - static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])) - self._attr_static_group_members = static_members.copy() - if not self.powered: - self._attr_group_members = static_members.copy() - if self.is_dynamic: - self._attr_supported_features.add(PlayerFeature.SET_MEMBERS) - else: - self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS) - - @property - def supported_features(self) -> set[PlayerFeature]: - """Return the supported features of the player.""" - return self._attr_supported_features - - @property - def playback_state(self) -> PlaybackState: - """Return the current playback state of the player.""" - if self.power_state: - return self.sync_leader.playback_state if self.sync_leader else PlaybackState.IDLE - else: - return PlaybackState.IDLE - - @cached_property - def flow_mode(self) -> bool: - """ - Return if the player needs flow mode. - - Will by default be set to True if the player does not support PlayerFeature.ENQUEUE - or has a flow mode config entry set to True. - """ - if leader := self.sync_leader: - return leader.flow_mode - return False - - @property - def elapsed_time(self) -> float | None: - """Return the elapsed time in (fractional) seconds of the current track (if any).""" - return self.sync_leader.elapsed_time if self.sync_leader else None - - @elapsed_time.setter - def elapsed_time(self, value: float | None) -> None: - """Set the elapsed time on the player.""" - raise NotImplementedError("elapsed_time is read-only on a SyncGroup player") - - @property - def elapsed_time_last_updated(self) -> float | None: - """Return when the elapsed time was last updated.""" - return self.sync_leader.elapsed_time_last_updated if self.sync_leader else None - - @property - def can_group_with(self) -> set[str]: - """ - Return the id's of players this player can group with. - - This should return set of player_id's this player can group/sync with - or just the provider's instance_id if all players can group with each other. - """ - if self.is_dynamic and (leader := self.sync_leader): - return leader.can_group_with - elif self.is_dynamic: - return {self.provider.lookup_key} - else: - return set() - - async def get_config_entries(self) -> list[ConfigEntry]: - """Return all (provider/player specific) Config Entries for the given player (if any).""" - entries: list[ConfigEntry] = [ - # default entries for player groups - *await super().get_config_entries(), - # add syncgroup specific entries - ConfigEntry( - key=CONF_GROUP_MEMBERS, - type=ConfigEntryType.STRING, - multi_value=True, - label="Group members", - default_value=[], - description="Select all players you want to be part of this group", - required=False, # needed for dynamic members (which allows empty members list) - options=[ - ConfigValueOption(x.display_name, x.player_id) - for x in self.provider.players - if x.type != PlayerType.GROUP - ], - ), - ConfigEntry( - key="dynamic_members", - type=ConfigEntryType.BOOLEAN, - label="Enable dynamic members", - description="Allow (un)joining members dynamically, so the group more or less " - "behaves the same like manually syncing players together, " - "with the main difference being that the group player will hold the queue.", - default_value=False, - required=False, - ), - ] - # combine base group entries with (base) player entries for this player type - child_player = next((x for x in self.provider.players if x.type != PlayerType.GROUP), None) - if child_player: - allowed_conf_entries = ( - CONF_HTTP_PROFILE, - CONF_ENABLE_ICY_METADATA, - CONF_CROSSFADE_DURATION, - CONF_OUTPUT_CODEC, - CONF_FLOW_MODE, - CONF_SAMPLE_RATES, - CONF_SMART_FADES_MODE, - ) - child_config_entries = await child_player.get_config_entries() - entries.extend( - [entry for entry in child_config_entries if entry.key in allowed_conf_entries] - ) - return entries - - async def stop(self) -> None: - """Send STOP command to given player.""" - if sync_leader := self.sync_leader: - await sync_leader.stop() - - async def play(self) -> None: - """Send PLAY command to given player.""" - if sync_leader := self.sync_leader: - await sync_leader.play() - - async def pause(self) -> None: - """Send PAUSE command to given player.""" - if sync_leader := self.sync_leader: - await sync_leader.pause() - - async def _handle_member_collisions(self, member: Player) -> None: - """Handle collisions when adding a member to the sync group.""" - active_groups = member.active_groups - for group in active_groups: - if group == self.player_id: - continue - # collision: child player is part another group that is already active ! - # solve this by trying to leave the group first - if other_group := self.mass.players.get(group): - if ( - other_group.supports_feature(PlayerFeature.SET_MEMBERS) - and member.player_id not in other_group.static_group_members - ): - await other_group.set_members(player_ids_to_remove=[member.player_id]) - else: - # if the other group does not support SET_MEMBERS or it is a static - # member, we need to power it off to leave the group - await other_group.power(False) - if ( - member.synced_to is not None - and member.synced_to != self.sync_leader - and (synced_to_player := self.mass.players.get(member.synced_to)) - and member.player_id in synced_to_player.group_members - ): - # collision: child player is synced to another player and still in that group - # ungroup it first - await synced_to_player.set_members(player_ids_to_remove=[member.player_id]) - - async def power(self, powered: bool) -> None: - """Handle POWER command to group player.""" - # always stop at power off - if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): - await self.stop() - - # optimistically set the group state - prev_power = self._attr_powered - self._attr_powered = powered - self.update_state() - - if powered: - # reset the group members to the available static members when powering on - self._attr_group_members = [] - for static_group_member in self._attr_static_group_members: - if ( - (member_player := self.mass.players.get(static_group_member)) - and member_player.available - and member_player.enabled - ): - self._attr_group_members.append(static_group_member) - # Select sync leader and handle turn on - new_leader = self._select_sync_leader() - # handle TURN_ON of the group player by turning on all members - for member in self.mass.players.iter_group_members( - self, only_powered=False, active_only=False - ): - await self._handle_member_collisions(member) - if not member.powered and member.power_control != PLAYER_CONTROL_NONE: - await member.power(True) - # Set up the sync group with the new leader - await self._handle_leader_transition(new_leader) - elif prev_power: - # handle TURN_OFF of the group player by dissolving group and turning off all members - await self._dissolve_syncgroup() - # turn off all group members - for member in self.mass.players.iter_group_members( - self, only_powered=True, active_only=True - ): - if member.powered and member.power_control != PLAYER_CONTROL_NONE: - await member.power(False) - - if not powered: - # Reset to unfiltered static members list when powered off - # (the frontend will hide unavailable members) - self._attr_group_members = self._attr_static_group_members.copy() - # and clear the sync leader - self.sync_leader = None - - async def _dissolve_syncgroup(self) -> None: - """Dissolve the current syncgroup by ungrouping all members and restoring leader queue.""" - if sync_leader := self.sync_leader: - # dissolve the temporary syncgroup from the sync leader - sync_children = [x for x in sync_leader.group_members if x != sync_leader.player_id] - if sync_children: - await sync_leader.set_members(player_ids_to_remove=sync_children) - # Reset the leaders queue since it is no longer part of this group - sync_leader.active_source = None - sync_leader.current_media = None - sync_leader.update_state() - - async def _handle_leader_transition(self, new_leader: Player | None) -> None: - """Handle transition from current leader to new leader.""" - prev_leader = self.sync_leader - was_playing = False - - if prev_leader: - # Save current media and playback state for potential restart - was_playing = self.playback_state == PlaybackState.PLAYING - # Stop current playback and dissolve existing group - await self.stop() - await self._dissolve_syncgroup() - - # Set new leader - self.sync_leader = new_leader - - if new_leader: - # form a syncgroup with the new leader - await self._form_syncgroup() - - # Restart playback if requested and we have media to play - if was_playing and self.current_media is not None: - await new_leader.play_media(self.current_media) - - async def volume_set(self, volume_level: int) -> None: - """Send VOLUME_SET command to given player.""" - # group volume is already handled in the player manager - - async def play_media(self, media: PlayerMedia) -> None: - """Handle PLAY MEDIA on given player.""" - # power on (which will also resync if needed) - await self.power(True) - # simply forward the command to the sync leader - if sync_leader := self.sync_leader: - await sync_leader.play_media(media) - self._attr_current_media = media - self._attr_active_source = media.source_id - self.update_state() - else: - raise RuntimeError("an empty group cannot play media, consider adding members first") - - async def enqueue_next_media(self, media: PlayerMedia) -> None: - """Handle enqueuing of a next media item on the player.""" - if sync_leader := self.sync_leader: - await sync_leader.enqueue_next_media(media) - - async def set_members( - self, - player_ids_to_add: list[str] | None = None, - player_ids_to_remove: list[str] | None = None, - ) -> None: - """Handle SET_MEMBERS command on the player.""" - if not self.is_dynamic: - raise UnsupportedFeaturedException( - f"Group {self.display_name} does not allow dynamically adding/removing members!" - ) - # handle additions - final_players_to_add: list[str] = [] - for player_id in player_ids_to_add or []: - if player_id in self._attr_group_members: - continue - if player_id == self.player_id: - raise UnsupportedFeaturedException( - f"Cannot add {self.display_name} to itself as a member!" - ) - self._attr_group_members.append(player_id) - final_players_to_add.append(player_id) - # handle removals - final_players_to_remove: list[str] = [] - for player_id in player_ids_to_remove or []: - if player_id not in self._attr_group_members: - continue - if player_id == self.player_id: - raise UnsupportedFeaturedException( - f"Cannot remove {self.display_name} from itself as a member!" - ) - self._attr_group_members.remove(player_id) - final_players_to_remove.append(player_id) - self.update_state() - if not self.powered: - # Don't need to do anything else if the group is powered off - # The syncing will be done once powered on - return - next_leader = self._select_sync_leader() - prev_leader = self.sync_leader - - if prev_leader and next_leader is None: - # Edge case: we no longer have any members in the group (and thus no leader) - await self._handle_leader_transition(None) - elif prev_leader != next_leader: - # Edge case: we had changed the leader (or just got one) - await self._handle_leader_transition(next_leader) - elif self.sync_leader and (player_ids_to_add or player_ids_to_remove): - # if the group still has the same leader, we need to (re)sync the members - # Handle collisions for newly added players - for player_id in final_players_to_add: - if player := self.mass.players.get(player_id): - await self._handle_member_collisions(player) - - await self.sync_leader.set_members( - player_ids_to_add=final_players_to_add, - player_ids_to_remove=final_players_to_remove, - ) - - async def _form_syncgroup(self) -> None: - """Form syncgroup by syncing all (possible) members.""" - if self.sync_leader is None: - # This is an empty group, leader will be selected once a member is added - self._attr_group_members = [] - self.update_state() - return - # ensure the sync leader is first in the list - self._attr_group_members = [ - self.sync_leader.player_id, - *[x for x in self._attr_group_members if x != self.sync_leader.player_id], - ] - self.update_state() - members_to_sync: list[str] = [] - for member in self.mass.players.iter_group_members(self, active_only=False): - # Handle collisions before attempting to sync - await self._handle_member_collisions(member) - - if member.synced_to and member.synced_to != self.sync_leader.player_id: - # ungroup first - await member.ungroup() - if member.player_id == self.sync_leader.player_id: - # skip sync leader - continue - if ( - member.synced_to == self.sync_leader.player_id - and member.player_id in self.sync_leader.group_members - ): - # already synced - continue - members_to_sync.append(member.player_id) - if members_to_sync: - await self.sync_leader.set_members(members_to_sync) - - def _select_sync_leader(self) -> Player | None: - """Select the active sync leader player for a syncgroup.""" - if self.sync_leader and self.sync_leader.player_id in self.group_members: - # Don't change the sync leader if we already have one - return self.sync_leader - for prefer_sync_leader in (True, False): - for child_player in self.mass.players.iter_group_members(self): - if prefer_sync_leader and child_player.synced_to: - # prefer the first player that already has sync children - continue - if child_player.active_group not in ( - None, - self.player_id, - child_player.player_id, - ): - # this should not happen (because its already handled in the power on logic), - # but guard it just in case bad things happen - continue - return child_player - return None - - -__all__ = [ - # explicitly re-export the models we imported from the models package, - # for convenience reasons - "EXTRA_ATTRIBUTES_TYPES", - "DeviceInfo", - "GroupPlayer", - "Player", - "PlayerMedia", - "PlayerSource", - "PlayerState", - "SyncGroupPlayer", -] diff --git a/music_assistant/models/player_provider.py b/music_assistant/models/player_provider.py index 0f516676..5f193278 100644 --- a/music_assistant/models/player_provider.py +++ b/music_assistant/models/player_provider.py @@ -4,18 +4,9 @@ from __future__ import annotations from typing import TYPE_CHECKING -import shortuuid -from music_assistant_models.enums import ProviderFeature from zeroconf import ServiceStateChange from zeroconf.asyncio import AsyncServiceInfo -from music_assistant.constants import ( - CONF_DYNAMIC_GROUP_MEMBERS, - CONF_GROUP_MEMBERS, - SYNCGROUP_PREFIX, -) -from music_assistant.models.player import SyncGroupPlayer - from .provider import Provider if TYPE_CHECKING: @@ -59,24 +50,6 @@ class PlayerProvider(Provider): :param members: List of player ids to add to the group :param dynamic: Whether the group is dynamic (members can change) """ - # default implementation for providers that support syncing players - if ProviderFeature.SYNC_PLAYERS in self.supported_features: - # we simply create a new syncgroup player with the given members - # feel free to override or extend this method in your provider - members = [x for x in members if x in [y.player_id for y in self.players]] - player_id = f"{SYNCGROUP_PREFIX}{shortuuid.random(8).lower()}" - self.mass.config.create_default_player_config( - player_id=player_id, - provider=self.lookup_key, - name=name, - enabled=True, - values={ - CONF_GROUP_MEMBERS: members, - CONF_DYNAMIC_GROUP_MEMBERS: dynamic, - }, - ) - return await self._register_syncgroup_player(player_id) - # all other providers should implement this method raise NotImplementedError async def remove_group_player(self, player_id: str) -> None: @@ -87,14 +60,6 @@ class PlayerProvider(Provider): :param player_id: ID of the group player to remove. """ - # default implementation for providers that support syncing players - if ProviderFeature.SYNC_PLAYERS in self.supported_features and player_id.startswith( - SYNCGROUP_PREFIX - ): - # we simply permanently unregister the syncgroup player and wipe its config - await self.mass.players.unregister(player_id, True) - return - # all other providers should implement this method raise NotImplementedError async def discover_players(self) -> None: @@ -114,22 +79,8 @@ class PlayerProvider(Provider): await self.on_mdns_service_state_change( mdns_name, ServiceStateChange.Added, info ) - # discover syncgroup players - if ( - ProviderFeature.SYNC_PLAYERS in self.supported_features - and ProviderFeature.CREATE_GROUP_PLAYER in self.supported_features - ): - for player_conf in await self.mass.config.get_player_configs(self.lookup_key): - if player_conf.player_id.startswith(SYNCGROUP_PREFIX): - await self._register_syncgroup_player(player_conf.player_id) - - async def _register_syncgroup_player(self, player_id: str) -> Player: - """Register a syncgroup player.""" - syncgroup = SyncGroupPlayer(self, player_id) - await self.mass.players.register_or_update(syncgroup) - return syncgroup @property def players(self) -> list[Player]: """Return all players belonging to this provider.""" - return self.mass.players.all(provider_filter=self.lookup_key) + return self.mass.players.all(provider_filter=self.lookup_key, return_sync_groups=False) diff --git a/music_assistant/providers/_demo_player_provider/__init__.py b/music_assistant/providers/_demo_player_provider/__init__.py index 286bf0f5..1607b798 100644 --- a/music_assistant/providers/_demo_player_provider/__init__.py +++ b/music_assistant/providers/_demo_player_provider/__init__.py @@ -53,8 +53,6 @@ SUPPORTED_FEATURES = { # that your provider supports or an empty set if none. # see the ProviderFeature enum for all available features ProviderFeature.SYNC_PLAYERS, - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/airplay/__init__.py b/music_assistant/providers/airplay/__init__.py index 6d42ffeb..2bbd0283 100644 --- a/music_assistant/providers/airplay/__init__.py +++ b/music_assistant/providers/airplay/__init__.py @@ -20,9 +20,6 @@ if TYPE_CHECKING: SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/bluesound/__init__.py b/music_assistant/providers/bluesound/__init__.py index 737abe8e..2dc7892d 100644 --- a/music_assistant/providers/bluesound/__init__.py +++ b/music_assistant/providers/bluesound/__init__.py @@ -17,8 +17,6 @@ if TYPE_CHECKING: SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/deezer/__init__.py b/music_assistant/providers/deezer/__init__.py index 0ca2b909..08bc478f 100644 --- a/music_assistant/providers/deezer/__init__.py +++ b/music_assistant/providers/deezer/__init__.py @@ -842,4 +842,5 @@ class DeezerProvider(MusicProvider): Blowfish.MODE_CBC, b"\x00\x01\x02\x03\x04\x05\x06\x07", ) - return cipher.decrypt(chunk) # type: ignore[no-any-return] + + return cipher.decrypt(chunk) # type: ignore[no-any-return,unused-ignore] diff --git a/music_assistant/providers/musiccast/__init__.py b/music_assistant/providers/musiccast/__init__.py index eab00b68..5d893e98 100644 --- a/music_assistant/providers/musiccast/__init__.py +++ b/music_assistant/providers/musiccast/__init__.py @@ -11,9 +11,6 @@ from .provider import MusicCastProvider SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/podcast-index/__init__.py b/music_assistant/providers/podcast-index/__init__.py deleted file mode 100644 index 599d4821..00000000 --- a/music_assistant/providers/podcast-index/__init__.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Podcast Index provider for Music Assistant.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from music_assistant_models.config_entries import ConfigEntry, ConfigValueType -from music_assistant_models.enums import ConfigEntryType, ProviderFeature - -from .constants import CONF_API_KEY, CONF_API_SECRET, CONF_STORED_PODCASTS -from .provider import PodcastIndexProvider - -if TYPE_CHECKING: - from music_assistant_models.config_entries import ProviderConfig - from music_assistant_models.provider import ProviderManifest - - from music_assistant.mass import MusicAssistant - from music_assistant.models import ProviderInstanceType - -SUPPORTED_FEATURES = { - ProviderFeature.SEARCH, - ProviderFeature.BROWSE, -} - - -async def setup( - mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig -) -> ProviderInstanceType: - """Initialize provider(instance) with given configuration.""" - return PodcastIndexProvider(mass, manifest, config, SUPPORTED_FEATURES) - - -async def get_config_entries( - mass: MusicAssistant, - instance_id: str | None = None, - action: str | None = None, - values: dict[str, ConfigValueType] | None = None, -) -> tuple[ConfigEntry, ...]: - """ - Return Config entries to setup this provider. - - instance_id: id of an existing provider instance (None if new instance setup). - action: [optional] action key called from config entries UI. - values: the (intermediate) raw values for config entries sent with the action. - """ - # ruff: noqa: ARG001 - return ( - ConfigEntry( - key=CONF_API_KEY, - type=ConfigEntryType.STRING, - label="API Key", - required=True, - description="Your Podcast Index API key. Get your free API credentials at https://api.podcastindex.org/", - ), - ConfigEntry( - key=CONF_API_SECRET, - type=ConfigEntryType.SECURE_STRING, - label="API Secret", - required=True, - description="Your Podcast Index API secret", - ), - ConfigEntry( - key=CONF_STORED_PODCASTS, - type=ConfigEntryType.STRING, - multi_value=True, - label="Subscribed Podcasts", - default_value=[], - required=False, - hidden=True, - ), - ) diff --git a/music_assistant/providers/podcast-index/constants.py b/music_assistant/providers/podcast-index/constants.py deleted file mode 100644 index 46eb24d0..00000000 --- a/music_assistant/providers/podcast-index/constants.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Constants for Podcast Index provider.""" - -# Configuration keys -CONF_API_KEY = "api_key" -CONF_API_SECRET = "api_secret" -CONF_STORED_PODCASTS = "stored_podcasts" - -# API settings -API_BASE_URL = "https://api.podcastindex.org/api/1.0" - -# Browse categories -BROWSE_TRENDING = "trending" -BROWSE_RECENT = "recent" -BROWSE_CATEGORIES = "categories" diff --git a/music_assistant/providers/podcast-index/helpers.py b/music_assistant/providers/podcast-index/helpers.py deleted file mode 100644 index 858fa8d1..00000000 --- a/music_assistant/providers/podcast-index/helpers.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Helper functions for Podcast Index provider.""" - -from __future__ import annotations - -import hashlib -import time -from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any - -import aiohttp -from music_assistant_models.enums import ContentType, ImageType, MediaType -from music_assistant_models.errors import ( - InvalidDataError, - LoginFailed, - ProviderUnavailableError, -) -from music_assistant_models.media_items import ( - AudioFormat, - ItemMapping, - MediaItemImage, - Podcast, - PodcastEpisode, - ProviderMapping, - UniqueList, -) - -from .constants import API_BASE_URL - -if TYPE_CHECKING: - from music_assistant.mass import MusicAssistant - - -async def make_api_request( - mass: MusicAssistant, - api_key: str, - api_secret: str, - endpoint: str, - params: dict[str, Any] | None = None, -) -> dict[str, Any]: - """ - Make authenticated request to Podcast Index API. - - Handles authentication using SHA1 hash of API key, secret, and timestamp. - Maps HTTP errors appropriately: 401 -> LoginFailed, others -> ProviderUnavailableError. - """ - # Prepare authentication headers - auth_date = str(int(time.time())) - auth_string = api_key + api_secret + auth_date - auth_hash = hashlib.sha1(auth_string.encode()).hexdigest() - - headers = { - "X-Auth-Key": api_key, - "X-Auth-Date": auth_date, - "Authorization": auth_hash, - } - - url = f"{API_BASE_URL}/{endpoint}" - - try: - async with mass.http_session.get(url, headers=headers, params=params or {}) as response: - response.raise_for_status() - - try: - data: dict[str, Any] = await response.json() - except aiohttp.ContentTypeError as err: - raise InvalidDataError("Invalid JSON response from API") from err - - if str(data.get("status")).lower() != "true": - raise InvalidDataError(data.get("description") or "API error") - - return data - - except aiohttp.ClientConnectorError as err: - raise ProviderUnavailableError(f"Failed to connect to Podcast Index API: {err}") from err - except aiohttp.ServerTimeoutError as err: - raise ProviderUnavailableError(f"Podcast Index API timeout: {err}") from err - except aiohttp.ClientResponseError as err: - if err.status == 401: - raise LoginFailed(f"Authentication failed: {err.status}") from err - raise ProviderUnavailableError(f"API request failed: {err.status}") from err - - -def parse_podcast_from_feed( - feed_data: dict[str, Any], lookup_key: str, domain: str, instance_id: str -) -> Podcast | None: - """Parse podcast from API feed data.""" - feed_url = feed_data.get("url") - podcast_id = feed_data.get("id") - - if not feed_url or not podcast_id: - return None - - podcast = Podcast( - item_id=str(podcast_id), - name=feed_data.get("title", "Unknown Podcast"), - publisher=feed_data.get("author") or feed_data.get("ownerName", "Unknown"), - provider=lookup_key, - provider_mappings={ - ProviderMapping( - item_id=str(podcast_id), - provider_domain=domain, - provider_instance=instance_id, - url=feed_url, - ) - }, - ) - - # Add metadata - podcast.metadata.description = feed_data.get("description", "") - podcast.metadata.explicit = bool(feed_data.get("explicit", False)) - - # Set episode count only if provided - episode_count = feed_data.get("episodeCount") - if episode_count is not None: - podcast.total_episodes = int(episode_count) or 0 - - # Add image - prefer 'image' field, fallback to 'artwork' - image_url = feed_data.get("image") or feed_data.get("artwork") - if image_url: - podcast.metadata.add_image( - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=lookup_key, - remotely_accessible=True, - ) - ) - - # Add categories as genres - categories is a dict {id: name} - categories = feed_data.get("categories", {}) - if categories and isinstance(categories, dict): - podcast.metadata.genres = set(categories.values()) - - # Add language - language = feed_data.get("language", "") - if language: - podcast.metadata.languages = UniqueList([language]) - - return podcast - - -def parse_episode_from_data( - episode_data: dict[str, Any], - podcast_id: str, - episode_idx: int, - lookup_key: str, - domain: str, - instance_id: str, - podcast_name: str | None = None, -) -> PodcastEpisode | None: - """Parse episode from API episode data.""" - episode_api_id = episode_data.get("id") - if not episode_api_id: - return None - - episode_id = f"{podcast_id}|{episode_api_id}" - - position = episode_data.get("episode") - if position is None: - position = episode_idx + 1 - - if podcast_name is None: - podcast_name = episode_data.get("feedTitle") or "Unknown Podcast" - - raw_duration = episode_data.get("duration") - try: - duration = int(raw_duration) if raw_duration is not None else 0 - except (ValueError, TypeError): - duration = 0 - - episode = PodcastEpisode( - item_id=episode_id, - provider=lookup_key, - name=episode_data.get("title", "Unknown Episode"), - duration=duration, - position=position, - podcast=ItemMapping( - item_id=podcast_id, - provider=lookup_key, - name=podcast_name, - media_type=MediaType.PODCAST, - ), - provider_mappings={ - ProviderMapping( - item_id=episode_id, - provider_domain=domain, - provider_instance=instance_id, - available=True, - audio_format=AudioFormat( - content_type=ContentType.try_parse( - episode_data.get("enclosureType") or "audio/mpeg" - ), - ), - url=episode_data.get("enclosureUrl"), - ) - }, - ) - - # Add metadata - episode.metadata.description = episode_data.get("description", "") - episode.metadata.explicit = bool(episode_data.get("explicit", 0)) - - date_published = episode_data.get("datePublished") - if date_published: - episode.metadata.release_date = datetime.fromtimestamp(date_published, tz=UTC) - - image_url = episode_data.get("image") or episode_data.get("feedImage") - if image_url: - episode.metadata.add_image( - MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=lookup_key, - remotely_accessible=True, - ) - ) - - return episode diff --git a/music_assistant/providers/podcast-index/icon.svg b/music_assistant/providers/podcast-index/icon.svg deleted file mode 100644 index 52c2ebaf..00000000 --- a/music_assistant/providers/podcast-index/icon.svg +++ /dev/null @@ -1,46 +0,0 @@ - - - - diff --git a/music_assistant/providers/podcast-index/icon_monochrome.svg b/music_assistant/providers/podcast-index/icon_monochrome.svg deleted file mode 100644 index 84b762fa..00000000 --- a/music_assistant/providers/podcast-index/icon_monochrome.svg +++ /dev/null @@ -1,46 +0,0 @@ - - - - diff --git a/music_assistant/providers/podcast-index/manifest.json b/music_assistant/providers/podcast-index/manifest.json deleted file mode 100644 index 3b27a0b5..00000000 --- a/music_assistant/providers/podcast-index/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "domain": "podcast-index", - "name": "Podcast Index", - "description": "Discover and play podcasts using the open Podcast Index.", - "documentation": "https://music-assistant.io/music-providers/podcast-index/", - "type": "music", - "requirements": [], - "codeowners": "@ozgav", - "multi_instance": false, - "stage": "beta" -} diff --git a/music_assistant/providers/podcast-index/provider.py b/music_assistant/providers/podcast-index/provider.py deleted file mode 100644 index aca7f106..00000000 --- a/music_assistant/providers/podcast-index/provider.py +++ /dev/null @@ -1,521 +0,0 @@ -"""Podcast Index provider implementation.""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator, Sequence -from typing import Any, cast - -import aiohttp -from music_assistant_models.enums import ContentType, MediaType, StreamType -from music_assistant_models.errors import ( - InvalidDataError, - LoginFailed, - MediaNotFoundError, - ProviderUnavailableError, -) -from music_assistant_models.media_items import ( - AudioFormat, - BrowseFolder, - MediaItemType, - Podcast, - PodcastEpisode, - SearchResults, -) -from music_assistant_models.streamdetails import StreamDetails - -from music_assistant.constants import VERBOSE_LOG_LEVEL -from music_assistant.controllers.cache import use_cache -from music_assistant.models.music_provider import MusicProvider - -from .constants import ( - BROWSE_CATEGORIES, - BROWSE_RECENT, - BROWSE_TRENDING, - CONF_API_KEY, - CONF_API_SECRET, - CONF_STORED_PODCASTS, -) -from .helpers import make_api_request, parse_episode_from_data, parse_podcast_from_feed - - -class PodcastIndexProvider(MusicProvider): - """Podcast Index provider for Music Assistant.""" - - api_key: str = "" - api_secret: str = "" - - async def handle_async_init(self) -> None: - """Handle async initialization of the provider.""" - self.api_key = str(self.config.get_value(CONF_API_KEY)) - self.api_secret = str(self.config.get_value(CONF_API_SECRET)) - - if not self.api_key or not self.api_secret: - raise LoginFailed("API key and secret are required") - - # Test API connection - try: - await self._api_request("stats/current") - except (LoginFailed, ProviderUnavailableError): - # Re-raise these specific errors as they have proper context - raise - except aiohttp.ClientConnectorError as err: - raise ProviderUnavailableError( - f"Failed to connect to Podcast Index API: {err}" - ) from err - except aiohttp.ServerTimeoutError as err: - raise ProviderUnavailableError(f"Podcast Index API timeout: {err}") from err - except Exception as err: - raise LoginFailed(f"Failed to connect to API: {err}") from err - - async def search( - self, search_query: str, media_types: list[MediaType], limit: int = 10 - ) -> SearchResults: - """ - Perform search on Podcast Index. - - Searches for podcasts by term. Future enhancement could include - category search if needed. - """ - result = SearchResults() - if MediaType.PODCAST not in media_types: - return result - - response = await self._api_request( - "search/byterm", params={"q": search_query, "max": limit} - ) - - podcasts = [] - for feed_data in response.get("feeds", []): - podcast = parse_podcast_from_feed( - feed_data, self.lookup_key, self.domain, self.instance_id - ) - if podcast: - podcasts.append(podcast) - - result.podcasts = podcasts - return result - - async def browse(self, path: str) -> Sequence[BrowseFolder | Podcast | PodcastEpisode]: - """Browse this provider's items.""" - base = f"{self.instance_id}://" - - if path == base: - # Return main browse categories - return [ - BrowseFolder( - item_id=BROWSE_TRENDING, - provider=self.domain, - path=f"{base}{BROWSE_TRENDING}", - name="Trending Podcasts", - ), - BrowseFolder( - item_id=BROWSE_RECENT, - provider=self.domain, - path=f"{base}{BROWSE_RECENT}", - name="Recent Episodes", - ), - BrowseFolder( - item_id=BROWSE_CATEGORIES, - provider=self.domain, - path=f"{base}{BROWSE_CATEGORIES}", - name="Categories", - ), - ] - - # Parse path after base - if path.startswith(base): - subpath_parts = path[len(base) :].split("/") - subpath = subpath_parts[0] if subpath_parts else "" - - if subpath == BROWSE_TRENDING: - return await self._browse_trending() - elif subpath == BROWSE_RECENT: - return await self._browse_recent_episodes() - elif subpath == BROWSE_CATEGORIES: - if len(subpath_parts) > 1: - # Browse specific category - category name is directly in path - category_name = subpath_parts[1] - return await self._browse_category_podcasts(category_name) - else: - # Browse categories - return await self._browse_categories() - - return [] - - async def library_add(self, item: MediaItemType) -> bool: - """ - Add podcast to library. - - Retrieves the RSS feed URL for the podcast and adds it to the stored - podcasts configuration. Returns True if successfully added, False if - the podcast was already in the library or if the feed URL couldn't be found. - """ - # Only handle podcasts - delegate others to base class - if not isinstance(item, Podcast): - return await super().library_add(item) - - stored_podcasts = cast("list[str]", self.config.get_value(CONF_STORED_PODCASTS)) - - # Get the RSS URL from the podcast via API - try: - feed_url = await self._get_feed_url_for_podcast(item.item_id) - except Exception as err: - self.logger.warning( - "Failed to retrieve feed URL for podcast %s: %s", item.name, err, exc_info=True - ) - return False - - if not feed_url: - self.logger.warning( - "No feed URL found for podcast %s (ID: %s)", item.name, item.item_id - ) - return False - - if feed_url in stored_podcasts: - return False - - self.logger.debug("Adding podcast %s to library", item.name) - stored_podcasts.append(feed_url) - self.update_config_value(CONF_STORED_PODCASTS, stored_podcasts) - return True - - async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: - """ - Remove podcast from library. - - Removes the podcast's RSS feed URL from the stored podcasts configuration. - Always returns True for idempotent operation. If feed URL retrieval fails, - logs a warning but still returns True to maintain the idempotent contract - as required by MA convention. - """ - stored_podcasts = cast("list[str]", self.config.get_value(CONF_STORED_PODCASTS)) - - # Get the RSS URL for this podcast - try: - feed_url = await self._get_feed_url_for_podcast(prov_item_id) - except Exception as err: - self.logger.warning( - "Failed to retrieve feed URL for podcast removal %s: %s", - prov_item_id, - err, - exc_info=True, - ) - # Still return True for idempotent operation - return True - - if not feed_url or feed_url not in stored_podcasts: - return True - - self.logger.debug("Removing podcast %s from library", prov_item_id) - stored_podcasts = [x for x in stored_podcasts if x != feed_url] - self.update_config_value(CONF_STORED_PODCASTS, stored_podcasts) - return True - - @use_cache(3600 * 24 * 14) # Cache for 14 days - async def get_podcast(self, prov_podcast_id: str) -> Podcast: - """Get podcast details.""" - try: - # Try by ID first - response = await self._api_request("podcasts/byfeedid", params={"id": prov_podcast_id}) - if response.get("feed"): - podcast = parse_podcast_from_feed( - response["feed"], self.lookup_key, self.domain, self.instance_id - ) - if podcast: - return podcast - except (ProviderUnavailableError, InvalidDataError): - # Re-raise these specific errors - raise - except Exception as err: - self.logger.debug("Unexpected error getting podcast %s: %s", prov_podcast_id, err) - - raise MediaNotFoundError(f"Podcast {prov_podcast_id} not found") - - async def get_podcast_episodes( - self, prov_podcast_id: str - ) -> AsyncGenerator[PodcastEpisode, None]: - """Get episodes for a podcast.""" - self.logger.debug("Getting episodes for podcast ID: %s", prov_podcast_id) - - # Try to get the podcast name from the current context first - podcast_name = None - try: - podcast = await self.mass.music.podcasts.get_provider_item( - prov_podcast_id, self.instance_id - ) - if podcast: - podcast_name = podcast.name - self.logger.debug("Got podcast name from MA context: %s", podcast_name) - except Exception as err: - self.logger.debug("Could not get podcast from MA context: %s", err) - - # If we don't have the name, get it from the API - if not podcast_name: - try: - podcast_response = await self._api_request( - "podcasts/byfeedid", params={"id": prov_podcast_id} - ) - if podcast_response.get("feed"): - podcast_name = podcast_response["feed"].get("title") - self.logger.debug("Got podcast name from API fallback: %s", podcast_name) - except Exception as err: - self.logger.warning("Could not get podcast name from API: %s", err) - - try: - response = await self._api_request( - "episodes/byfeedid", params={"id": prov_podcast_id, "max": 1000} - ) - - episodes = response.get("items", []) - for idx, episode_data in enumerate(episodes): - episode = parse_episode_from_data( - episode_data, - prov_podcast_id, - idx, - self.lookup_key, - self.domain, - self.instance_id, - podcast_name, - ) - if episode: - yield episode - - except (ProviderUnavailableError, InvalidDataError): - # Re-raise these specific errors - raise - except Exception as err: - self.logger.warning( - "Unexpected error getting episodes for %s: %s", prov_podcast_id, err - ) - - @use_cache(43200) # Cache for 12 hours - async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode: - """ - Get podcast episode details using direct API lookup. - - Uses the efficient episodes/byid endpoint for direct episode retrieval. - """ - try: - podcast_id, episode_id = prov_episode_id.split("|", 1) - - response = await self._api_request("episodes/byid", params={"id": episode_id}) - episode_data = response.get("episode") - - if episode_data: - episode = parse_episode_from_data( - episode_data, podcast_id, 0, self.lookup_key, self.domain, self.instance_id - ) - if episode: - return episode - - except (ProviderUnavailableError, InvalidDataError): - # Re-raise these specific errors - raise - except ValueError as err: - # Handle malformed episode ID - raise InvalidDataError(f"Invalid episode ID format: {prov_episode_id}") from err - except Exception as err: - self.logger.warning("Unexpected error getting episode %s: %s", prov_episode_id, err) - - raise MediaNotFoundError(f"Episode {prov_episode_id} not found") - - @use_cache(86400) - async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: - """ - Get stream details for a podcast episode. - - Uses the Podcast Index episodes/byid endpoint for efficient direct lookup - rather than fetching all episodes for a podcast. - """ - if media_type != MediaType.PODCAST_EPISODE: - raise MediaNotFoundError("Stream details only available for episodes") - - try: - podcast_id, episode_id = item_id.split("|", 1) - - # Use direct episode lookup for efficiency - response = await self._api_request("episodes/byid", params={"id": episode_id}) - episode_data = response.get("episode") - - if episode_data: - stream_url = episode_data.get("enclosureUrl") - if stream_url: - return StreamDetails( - provider=self.lookup_key, - item_id=item_id, - audio_format=AudioFormat( - content_type=ContentType.try_parse( - episode_data.get("enclosureType") or "audio/mpeg" - ), - ), - media_type=MediaType.PODCAST_EPISODE, - stream_type=StreamType.HTTP, - path=stream_url, - allow_seek=True, - ) - - except (ProviderUnavailableError, InvalidDataError): - # Re-raise these specific errors - raise - except ValueError as err: - # Handle malformed episode ID - raise InvalidDataError(f"Invalid episode ID format: {item_id}") from err - except Exception as err: - self.logger.warning("Unexpected error getting stream for %s: %s", item_id, err) - - raise MediaNotFoundError(f"Stream not found for {item_id}") - - async def get_item(self, media_type: MediaType, prov_item_id: str) -> Podcast | PodcastEpisode: - """Get single MediaItem from provider.""" - if media_type == MediaType.PODCAST: - return await self.get_podcast(prov_item_id) - elif media_type == MediaType.PODCAST_EPISODE: - return await self.get_podcast_episode(prov_item_id) - else: - raise MediaNotFoundError(f"Media type {media_type} not supported by this provider") - - async def _fetch_podcasts( - self, endpoint: str, params: dict[str, Any] | None = None - ) -> list[Podcast]: - """Fetch and parse podcasts from API endpoint.""" - response = await self._api_request(endpoint, params) - podcasts = [] - for feed_data in response.get("feeds", []): - podcast = parse_podcast_from_feed( - feed_data, self.lookup_key, self.domain, self.instance_id - ) - if podcast: - podcasts.append(podcast) - return podcasts - - async def _api_request( - self, endpoint: str, params: dict[str, Any] | None = None - ) -> dict[str, Any]: - """Make authenticated request to Podcast Index API.""" - self.logger.log( - VERBOSE_LOG_LEVEL, "Making API request to %s with params: %s", endpoint, params - ) - return await make_api_request(self.mass, self.api_key, self.api_secret, endpoint, params) - - async def _get_feed_url_for_podcast(self, podcast_id: str) -> str | None: - """Get RSS feed URL for a podcast ID.""" - try: - response = await self._api_request("podcasts/byfeedid", params={"id": podcast_id}) - feed_data: dict[str, Any] = response.get("feed", {}) - return feed_data.get("url") - except (ProviderUnavailableError, InvalidDataError): - # Re-raise these specific errors - raise - except Exception as err: - self.logger.warning( - "Unexpected error getting feed URL for podcast %s: %s", - podcast_id, - err, - exc_info=True, - ) - return None - - @use_cache(7200) # Cache for 2 hours - async def _browse_trending(self) -> list[Podcast]: - """Browse trending podcasts.""" - try: - return await self._fetch_podcasts("podcasts/trending", {"max": 50}) - except (ProviderUnavailableError, InvalidDataError): - raise - except Exception as err: - self.logger.warning( - "Unexpected error getting trending podcasts: %s", err, exc_info=True - ) - return [] - - @use_cache(14400) # Cache for 4 hours - async def _browse_recent_episodes(self) -> list[PodcastEpisode]: - """Browse recent episodes.""" - try: - response = await self._api_request("recent/episodes", params={"max": 50}) - - episodes = [] - for idx, episode_data in enumerate(response.get("items", [])): - # Extract podcast ID from episode data - podcast_id = str(episode_data.get("feedId", "")) - # Pass feedTitle to avoid unnecessary API calls - podcast_name = episode_data.get("feedTitle") - episode = parse_episode_from_data( - episode_data, - podcast_id, - idx, - self.lookup_key, - self.domain, - self.instance_id, - podcast_name, - ) - if episode: - episodes.append(episode) - - return episodes - - except (ProviderUnavailableError, InvalidDataError): - # Re-raise these specific errors - raise - except Exception as err: - self.logger.warning("Unexpected error getting recent episodes: %s", err, exc_info=True) - return [] - - @use_cache(86400) # Cache for 24 hours - async def _browse_categories(self) -> list[BrowseFolder]: - """Browse podcast categories.""" - try: - response = await self._api_request("categories/list") - - categories = [] - # Categories API returns feeds array with {id, name} objects - categories_data = response.get("feeds", []) - - for category in categories_data: - cat_name = category.get("name", "Unknown Category") - - categories.append( - BrowseFolder( - item_id=cat_name, # Use name as ID - provider=self.domain, - path=f"{self.instance_id}://{BROWSE_CATEGORIES}/{cat_name}", - name=cat_name, - ) - ) - - # Sort by name - return sorted(categories, key=lambda x: x.name) - - except (ProviderUnavailableError, InvalidDataError): - # Re-raise these specific errors - raise - except Exception as err: - self.logger.warning("Unexpected error getting categories: %s", err, exc_info=True) - return [] - - @use_cache(43200) # Cache for 12 hours - async def _browse_category_podcasts(self, category_name: str) -> list[Podcast]: - """Browse podcasts in a specific category using search.""" - try: - # Search for podcasts using the category name directly - search_response = await self._api_request( - "search/byterm", params={"q": category_name, "max": 50} - ) - - podcasts = [] - for feed_data in search_response.get("feeds", []): - podcast = parse_podcast_from_feed( - feed_data, self.lookup_key, self.domain, self.instance_id - ) - if podcast: - podcasts.append(podcast) - - return podcasts - - except (ProviderUnavailableError, InvalidDataError): - raise - except Exception as err: - self.logger.warning( - "Unexpected error getting category podcasts: %s", err, exc_info=True - ) - return [] diff --git a/music_assistant/providers/podcast_index/__init__.py b/music_assistant/providers/podcast_index/__init__.py new file mode 100644 index 00000000..599d4821 --- /dev/null +++ b/music_assistant/providers/podcast_index/__init__.py @@ -0,0 +1,71 @@ +"""Podcast Index provider for Music Assistant.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueType +from music_assistant_models.enums import ConfigEntryType, ProviderFeature + +from .constants import CONF_API_KEY, CONF_API_SECRET, CONF_STORED_PODCASTS +from .provider import PodcastIndexProvider + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ProviderConfig + from music_assistant_models.provider import ProviderManifest + + from music_assistant.mass import MusicAssistant + from music_assistant.models import ProviderInstanceType + +SUPPORTED_FEATURES = { + ProviderFeature.SEARCH, + ProviderFeature.BROWSE, +} + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + return PodcastIndexProvider(mass, manifest, config, SUPPORTED_FEATURES) + + +async def get_config_entries( + mass: MusicAssistant, + instance_id: str | None = None, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """ + Return Config entries to setup this provider. + + instance_id: id of an existing provider instance (None if new instance setup). + action: [optional] action key called from config entries UI. + values: the (intermediate) raw values for config entries sent with the action. + """ + # ruff: noqa: ARG001 + return ( + ConfigEntry( + key=CONF_API_KEY, + type=ConfigEntryType.STRING, + label="API Key", + required=True, + description="Your Podcast Index API key. Get your free API credentials at https://api.podcastindex.org/", + ), + ConfigEntry( + key=CONF_API_SECRET, + type=ConfigEntryType.SECURE_STRING, + label="API Secret", + required=True, + description="Your Podcast Index API secret", + ), + ConfigEntry( + key=CONF_STORED_PODCASTS, + type=ConfigEntryType.STRING, + multi_value=True, + label="Subscribed Podcasts", + default_value=[], + required=False, + hidden=True, + ), + ) diff --git a/music_assistant/providers/podcast_index/constants.py b/music_assistant/providers/podcast_index/constants.py new file mode 100644 index 00000000..46eb24d0 --- /dev/null +++ b/music_assistant/providers/podcast_index/constants.py @@ -0,0 +1,14 @@ +"""Constants for Podcast Index provider.""" + +# Configuration keys +CONF_API_KEY = "api_key" +CONF_API_SECRET = "api_secret" +CONF_STORED_PODCASTS = "stored_podcasts" + +# API settings +API_BASE_URL = "https://api.podcastindex.org/api/1.0" + +# Browse categories +BROWSE_TRENDING = "trending" +BROWSE_RECENT = "recent" +BROWSE_CATEGORIES = "categories" diff --git a/music_assistant/providers/podcast_index/helpers.py b/music_assistant/providers/podcast_index/helpers.py new file mode 100644 index 00000000..858fa8d1 --- /dev/null +++ b/music_assistant/providers/podcast_index/helpers.py @@ -0,0 +1,218 @@ +"""Helper functions for Podcast Index provider.""" + +from __future__ import annotations + +import hashlib +import time +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +import aiohttp +from music_assistant_models.enums import ContentType, ImageType, MediaType +from music_assistant_models.errors import ( + InvalidDataError, + LoginFailed, + ProviderUnavailableError, +) +from music_assistant_models.media_items import ( + AudioFormat, + ItemMapping, + MediaItemImage, + Podcast, + PodcastEpisode, + ProviderMapping, + UniqueList, +) + +from .constants import API_BASE_URL + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +async def make_api_request( + mass: MusicAssistant, + api_key: str, + api_secret: str, + endpoint: str, + params: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ + Make authenticated request to Podcast Index API. + + Handles authentication using SHA1 hash of API key, secret, and timestamp. + Maps HTTP errors appropriately: 401 -> LoginFailed, others -> ProviderUnavailableError. + """ + # Prepare authentication headers + auth_date = str(int(time.time())) + auth_string = api_key + api_secret + auth_date + auth_hash = hashlib.sha1(auth_string.encode()).hexdigest() + + headers = { + "X-Auth-Key": api_key, + "X-Auth-Date": auth_date, + "Authorization": auth_hash, + } + + url = f"{API_BASE_URL}/{endpoint}" + + try: + async with mass.http_session.get(url, headers=headers, params=params or {}) as response: + response.raise_for_status() + + try: + data: dict[str, Any] = await response.json() + except aiohttp.ContentTypeError as err: + raise InvalidDataError("Invalid JSON response from API") from err + + if str(data.get("status")).lower() != "true": + raise InvalidDataError(data.get("description") or "API error") + + return data + + except aiohttp.ClientConnectorError as err: + raise ProviderUnavailableError(f"Failed to connect to Podcast Index API: {err}") from err + except aiohttp.ServerTimeoutError as err: + raise ProviderUnavailableError(f"Podcast Index API timeout: {err}") from err + except aiohttp.ClientResponseError as err: + if err.status == 401: + raise LoginFailed(f"Authentication failed: {err.status}") from err + raise ProviderUnavailableError(f"API request failed: {err.status}") from err + + +def parse_podcast_from_feed( + feed_data: dict[str, Any], lookup_key: str, domain: str, instance_id: str +) -> Podcast | None: + """Parse podcast from API feed data.""" + feed_url = feed_data.get("url") + podcast_id = feed_data.get("id") + + if not feed_url or not podcast_id: + return None + + podcast = Podcast( + item_id=str(podcast_id), + name=feed_data.get("title", "Unknown Podcast"), + publisher=feed_data.get("author") or feed_data.get("ownerName", "Unknown"), + provider=lookup_key, + provider_mappings={ + ProviderMapping( + item_id=str(podcast_id), + provider_domain=domain, + provider_instance=instance_id, + url=feed_url, + ) + }, + ) + + # Add metadata + podcast.metadata.description = feed_data.get("description", "") + podcast.metadata.explicit = bool(feed_data.get("explicit", False)) + + # Set episode count only if provided + episode_count = feed_data.get("episodeCount") + if episode_count is not None: + podcast.total_episodes = int(episode_count) or 0 + + # Add image - prefer 'image' field, fallback to 'artwork' + image_url = feed_data.get("image") or feed_data.get("artwork") + if image_url: + podcast.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=lookup_key, + remotely_accessible=True, + ) + ) + + # Add categories as genres - categories is a dict {id: name} + categories = feed_data.get("categories", {}) + if categories and isinstance(categories, dict): + podcast.metadata.genres = set(categories.values()) + + # Add language + language = feed_data.get("language", "") + if language: + podcast.metadata.languages = UniqueList([language]) + + return podcast + + +def parse_episode_from_data( + episode_data: dict[str, Any], + podcast_id: str, + episode_idx: int, + lookup_key: str, + domain: str, + instance_id: str, + podcast_name: str | None = None, +) -> PodcastEpisode | None: + """Parse episode from API episode data.""" + episode_api_id = episode_data.get("id") + if not episode_api_id: + return None + + episode_id = f"{podcast_id}|{episode_api_id}" + + position = episode_data.get("episode") + if position is None: + position = episode_idx + 1 + + if podcast_name is None: + podcast_name = episode_data.get("feedTitle") or "Unknown Podcast" + + raw_duration = episode_data.get("duration") + try: + duration = int(raw_duration) if raw_duration is not None else 0 + except (ValueError, TypeError): + duration = 0 + + episode = PodcastEpisode( + item_id=episode_id, + provider=lookup_key, + name=episode_data.get("title", "Unknown Episode"), + duration=duration, + position=position, + podcast=ItemMapping( + item_id=podcast_id, + provider=lookup_key, + name=podcast_name, + media_type=MediaType.PODCAST, + ), + provider_mappings={ + ProviderMapping( + item_id=episode_id, + provider_domain=domain, + provider_instance=instance_id, + available=True, + audio_format=AudioFormat( + content_type=ContentType.try_parse( + episode_data.get("enclosureType") or "audio/mpeg" + ), + ), + url=episode_data.get("enclosureUrl"), + ) + }, + ) + + # Add metadata + episode.metadata.description = episode_data.get("description", "") + episode.metadata.explicit = bool(episode_data.get("explicit", 0)) + + date_published = episode_data.get("datePublished") + if date_published: + episode.metadata.release_date = datetime.fromtimestamp(date_published, tz=UTC) + + image_url = episode_data.get("image") or episode_data.get("feedImage") + if image_url: + episode.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=image_url, + provider=lookup_key, + remotely_accessible=True, + ) + ) + + return episode diff --git a/music_assistant/providers/podcast_index/icon.svg b/music_assistant/providers/podcast_index/icon.svg new file mode 100644 index 00000000..52c2ebaf --- /dev/null +++ b/music_assistant/providers/podcast_index/icon.svg @@ -0,0 +1,46 @@ + + + + diff --git a/music_assistant/providers/podcast_index/icon_monochrome.svg b/music_assistant/providers/podcast_index/icon_monochrome.svg new file mode 100644 index 00000000..84b762fa --- /dev/null +++ b/music_assistant/providers/podcast_index/icon_monochrome.svg @@ -0,0 +1,46 @@ + + + + diff --git a/music_assistant/providers/podcast_index/manifest.json b/music_assistant/providers/podcast_index/manifest.json new file mode 100644 index 00000000..e4a75dc9 --- /dev/null +++ b/music_assistant/providers/podcast_index/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "podcast_index", + "name": "Podcast Index", + "description": "Discover and play podcasts using the open Podcast Index.", + "documentation": "https://music-assistant.io/music-providers/podcast_index/", + "type": "music", + "requirements": [], + "codeowners": "@ozgav", + "multi_instance": false, + "stage": "beta" +} diff --git a/music_assistant/providers/podcast_index/provider.py b/music_assistant/providers/podcast_index/provider.py new file mode 100644 index 00000000..aca7f106 --- /dev/null +++ b/music_assistant/providers/podcast_index/provider.py @@ -0,0 +1,521 @@ +"""Podcast Index provider implementation.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, Sequence +from typing import Any, cast + +import aiohttp +from music_assistant_models.enums import ContentType, MediaType, StreamType +from music_assistant_models.errors import ( + InvalidDataError, + LoginFailed, + MediaNotFoundError, + ProviderUnavailableError, +) +from music_assistant_models.media_items import ( + AudioFormat, + BrowseFolder, + MediaItemType, + Podcast, + PodcastEpisode, + SearchResults, +) +from music_assistant_models.streamdetails import StreamDetails + +from music_assistant.constants import VERBOSE_LOG_LEVEL +from music_assistant.controllers.cache import use_cache +from music_assistant.models.music_provider import MusicProvider + +from .constants import ( + BROWSE_CATEGORIES, + BROWSE_RECENT, + BROWSE_TRENDING, + CONF_API_KEY, + CONF_API_SECRET, + CONF_STORED_PODCASTS, +) +from .helpers import make_api_request, parse_episode_from_data, parse_podcast_from_feed + + +class PodcastIndexProvider(MusicProvider): + """Podcast Index provider for Music Assistant.""" + + api_key: str = "" + api_secret: str = "" + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + self.api_key = str(self.config.get_value(CONF_API_KEY)) + self.api_secret = str(self.config.get_value(CONF_API_SECRET)) + + if not self.api_key or not self.api_secret: + raise LoginFailed("API key and secret are required") + + # Test API connection + try: + await self._api_request("stats/current") + except (LoginFailed, ProviderUnavailableError): + # Re-raise these specific errors as they have proper context + raise + except aiohttp.ClientConnectorError as err: + raise ProviderUnavailableError( + f"Failed to connect to Podcast Index API: {err}" + ) from err + except aiohttp.ServerTimeoutError as err: + raise ProviderUnavailableError(f"Podcast Index API timeout: {err}") from err + except Exception as err: + raise LoginFailed(f"Failed to connect to API: {err}") from err + + async def search( + self, search_query: str, media_types: list[MediaType], limit: int = 10 + ) -> SearchResults: + """ + Perform search on Podcast Index. + + Searches for podcasts by term. Future enhancement could include + category search if needed. + """ + result = SearchResults() + if MediaType.PODCAST not in media_types: + return result + + response = await self._api_request( + "search/byterm", params={"q": search_query, "max": limit} + ) + + podcasts = [] + for feed_data in response.get("feeds", []): + podcast = parse_podcast_from_feed( + feed_data, self.lookup_key, self.domain, self.instance_id + ) + if podcast: + podcasts.append(podcast) + + result.podcasts = podcasts + return result + + async def browse(self, path: str) -> Sequence[BrowseFolder | Podcast | PodcastEpisode]: + """Browse this provider's items.""" + base = f"{self.instance_id}://" + + if path == base: + # Return main browse categories + return [ + BrowseFolder( + item_id=BROWSE_TRENDING, + provider=self.domain, + path=f"{base}{BROWSE_TRENDING}", + name="Trending Podcasts", + ), + BrowseFolder( + item_id=BROWSE_RECENT, + provider=self.domain, + path=f"{base}{BROWSE_RECENT}", + name="Recent Episodes", + ), + BrowseFolder( + item_id=BROWSE_CATEGORIES, + provider=self.domain, + path=f"{base}{BROWSE_CATEGORIES}", + name="Categories", + ), + ] + + # Parse path after base + if path.startswith(base): + subpath_parts = path[len(base) :].split("/") + subpath = subpath_parts[0] if subpath_parts else "" + + if subpath == BROWSE_TRENDING: + return await self._browse_trending() + elif subpath == BROWSE_RECENT: + return await self._browse_recent_episodes() + elif subpath == BROWSE_CATEGORIES: + if len(subpath_parts) > 1: + # Browse specific category - category name is directly in path + category_name = subpath_parts[1] + return await self._browse_category_podcasts(category_name) + else: + # Browse categories + return await self._browse_categories() + + return [] + + async def library_add(self, item: MediaItemType) -> bool: + """ + Add podcast to library. + + Retrieves the RSS feed URL for the podcast and adds it to the stored + podcasts configuration. Returns True if successfully added, False if + the podcast was already in the library or if the feed URL couldn't be found. + """ + # Only handle podcasts - delegate others to base class + if not isinstance(item, Podcast): + return await super().library_add(item) + + stored_podcasts = cast("list[str]", self.config.get_value(CONF_STORED_PODCASTS)) + + # Get the RSS URL from the podcast via API + try: + feed_url = await self._get_feed_url_for_podcast(item.item_id) + except Exception as err: + self.logger.warning( + "Failed to retrieve feed URL for podcast %s: %s", item.name, err, exc_info=True + ) + return False + + if not feed_url: + self.logger.warning( + "No feed URL found for podcast %s (ID: %s)", item.name, item.item_id + ) + return False + + if feed_url in stored_podcasts: + return False + + self.logger.debug("Adding podcast %s to library", item.name) + stored_podcasts.append(feed_url) + self.update_config_value(CONF_STORED_PODCASTS, stored_podcasts) + return True + + async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: + """ + Remove podcast from library. + + Removes the podcast's RSS feed URL from the stored podcasts configuration. + Always returns True for idempotent operation. If feed URL retrieval fails, + logs a warning but still returns True to maintain the idempotent contract + as required by MA convention. + """ + stored_podcasts = cast("list[str]", self.config.get_value(CONF_STORED_PODCASTS)) + + # Get the RSS URL for this podcast + try: + feed_url = await self._get_feed_url_for_podcast(prov_item_id) + except Exception as err: + self.logger.warning( + "Failed to retrieve feed URL for podcast removal %s: %s", + prov_item_id, + err, + exc_info=True, + ) + # Still return True for idempotent operation + return True + + if not feed_url or feed_url not in stored_podcasts: + return True + + self.logger.debug("Removing podcast %s from library", prov_item_id) + stored_podcasts = [x for x in stored_podcasts if x != feed_url] + self.update_config_value(CONF_STORED_PODCASTS, stored_podcasts) + return True + + @use_cache(3600 * 24 * 14) # Cache for 14 days + async def get_podcast(self, prov_podcast_id: str) -> Podcast: + """Get podcast details.""" + try: + # Try by ID first + response = await self._api_request("podcasts/byfeedid", params={"id": prov_podcast_id}) + if response.get("feed"): + podcast = parse_podcast_from_feed( + response["feed"], self.lookup_key, self.domain, self.instance_id + ) + if podcast: + return podcast + except (ProviderUnavailableError, InvalidDataError): + # Re-raise these specific errors + raise + except Exception as err: + self.logger.debug("Unexpected error getting podcast %s: %s", prov_podcast_id, err) + + raise MediaNotFoundError(f"Podcast {prov_podcast_id} not found") + + async def get_podcast_episodes( + self, prov_podcast_id: str + ) -> AsyncGenerator[PodcastEpisode, None]: + """Get episodes for a podcast.""" + self.logger.debug("Getting episodes for podcast ID: %s", prov_podcast_id) + + # Try to get the podcast name from the current context first + podcast_name = None + try: + podcast = await self.mass.music.podcasts.get_provider_item( + prov_podcast_id, self.instance_id + ) + if podcast: + podcast_name = podcast.name + self.logger.debug("Got podcast name from MA context: %s", podcast_name) + except Exception as err: + self.logger.debug("Could not get podcast from MA context: %s", err) + + # If we don't have the name, get it from the API + if not podcast_name: + try: + podcast_response = await self._api_request( + "podcasts/byfeedid", params={"id": prov_podcast_id} + ) + if podcast_response.get("feed"): + podcast_name = podcast_response["feed"].get("title") + self.logger.debug("Got podcast name from API fallback: %s", podcast_name) + except Exception as err: + self.logger.warning("Could not get podcast name from API: %s", err) + + try: + response = await self._api_request( + "episodes/byfeedid", params={"id": prov_podcast_id, "max": 1000} + ) + + episodes = response.get("items", []) + for idx, episode_data in enumerate(episodes): + episode = parse_episode_from_data( + episode_data, + prov_podcast_id, + idx, + self.lookup_key, + self.domain, + self.instance_id, + podcast_name, + ) + if episode: + yield episode + + except (ProviderUnavailableError, InvalidDataError): + # Re-raise these specific errors + raise + except Exception as err: + self.logger.warning( + "Unexpected error getting episodes for %s: %s", prov_podcast_id, err + ) + + @use_cache(43200) # Cache for 12 hours + async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode: + """ + Get podcast episode details using direct API lookup. + + Uses the efficient episodes/byid endpoint for direct episode retrieval. + """ + try: + podcast_id, episode_id = prov_episode_id.split("|", 1) + + response = await self._api_request("episodes/byid", params={"id": episode_id}) + episode_data = response.get("episode") + + if episode_data: + episode = parse_episode_from_data( + episode_data, podcast_id, 0, self.lookup_key, self.domain, self.instance_id + ) + if episode: + return episode + + except (ProviderUnavailableError, InvalidDataError): + # Re-raise these specific errors + raise + except ValueError as err: + # Handle malformed episode ID + raise InvalidDataError(f"Invalid episode ID format: {prov_episode_id}") from err + except Exception as err: + self.logger.warning("Unexpected error getting episode %s: %s", prov_episode_id, err) + + raise MediaNotFoundError(f"Episode {prov_episode_id} not found") + + @use_cache(86400) + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + """ + Get stream details for a podcast episode. + + Uses the Podcast Index episodes/byid endpoint for efficient direct lookup + rather than fetching all episodes for a podcast. + """ + if media_type != MediaType.PODCAST_EPISODE: + raise MediaNotFoundError("Stream details only available for episodes") + + try: + podcast_id, episode_id = item_id.split("|", 1) + + # Use direct episode lookup for efficiency + response = await self._api_request("episodes/byid", params={"id": episode_id}) + episode_data = response.get("episode") + + if episode_data: + stream_url = episode_data.get("enclosureUrl") + if stream_url: + return StreamDetails( + provider=self.lookup_key, + item_id=item_id, + audio_format=AudioFormat( + content_type=ContentType.try_parse( + episode_data.get("enclosureType") or "audio/mpeg" + ), + ), + media_type=MediaType.PODCAST_EPISODE, + stream_type=StreamType.HTTP, + path=stream_url, + allow_seek=True, + ) + + except (ProviderUnavailableError, InvalidDataError): + # Re-raise these specific errors + raise + except ValueError as err: + # Handle malformed episode ID + raise InvalidDataError(f"Invalid episode ID format: {item_id}") from err + except Exception as err: + self.logger.warning("Unexpected error getting stream for %s: %s", item_id, err) + + raise MediaNotFoundError(f"Stream not found for {item_id}") + + async def get_item(self, media_type: MediaType, prov_item_id: str) -> Podcast | PodcastEpisode: + """Get single MediaItem from provider.""" + if media_type == MediaType.PODCAST: + return await self.get_podcast(prov_item_id) + elif media_type == MediaType.PODCAST_EPISODE: + return await self.get_podcast_episode(prov_item_id) + else: + raise MediaNotFoundError(f"Media type {media_type} not supported by this provider") + + async def _fetch_podcasts( + self, endpoint: str, params: dict[str, Any] | None = None + ) -> list[Podcast]: + """Fetch and parse podcasts from API endpoint.""" + response = await self._api_request(endpoint, params) + podcasts = [] + for feed_data in response.get("feeds", []): + podcast = parse_podcast_from_feed( + feed_data, self.lookup_key, self.domain, self.instance_id + ) + if podcast: + podcasts.append(podcast) + return podcasts + + async def _api_request( + self, endpoint: str, params: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Make authenticated request to Podcast Index API.""" + self.logger.log( + VERBOSE_LOG_LEVEL, "Making API request to %s with params: %s", endpoint, params + ) + return await make_api_request(self.mass, self.api_key, self.api_secret, endpoint, params) + + async def _get_feed_url_for_podcast(self, podcast_id: str) -> str | None: + """Get RSS feed URL for a podcast ID.""" + try: + response = await self._api_request("podcasts/byfeedid", params={"id": podcast_id}) + feed_data: dict[str, Any] = response.get("feed", {}) + return feed_data.get("url") + except (ProviderUnavailableError, InvalidDataError): + # Re-raise these specific errors + raise + except Exception as err: + self.logger.warning( + "Unexpected error getting feed URL for podcast %s: %s", + podcast_id, + err, + exc_info=True, + ) + return None + + @use_cache(7200) # Cache for 2 hours + async def _browse_trending(self) -> list[Podcast]: + """Browse trending podcasts.""" + try: + return await self._fetch_podcasts("podcasts/trending", {"max": 50}) + except (ProviderUnavailableError, InvalidDataError): + raise + except Exception as err: + self.logger.warning( + "Unexpected error getting trending podcasts: %s", err, exc_info=True + ) + return [] + + @use_cache(14400) # Cache for 4 hours + async def _browse_recent_episodes(self) -> list[PodcastEpisode]: + """Browse recent episodes.""" + try: + response = await self._api_request("recent/episodes", params={"max": 50}) + + episodes = [] + for idx, episode_data in enumerate(response.get("items", [])): + # Extract podcast ID from episode data + podcast_id = str(episode_data.get("feedId", "")) + # Pass feedTitle to avoid unnecessary API calls + podcast_name = episode_data.get("feedTitle") + episode = parse_episode_from_data( + episode_data, + podcast_id, + idx, + self.lookup_key, + self.domain, + self.instance_id, + podcast_name, + ) + if episode: + episodes.append(episode) + + return episodes + + except (ProviderUnavailableError, InvalidDataError): + # Re-raise these specific errors + raise + except Exception as err: + self.logger.warning("Unexpected error getting recent episodes: %s", err, exc_info=True) + return [] + + @use_cache(86400) # Cache for 24 hours + async def _browse_categories(self) -> list[BrowseFolder]: + """Browse podcast categories.""" + try: + response = await self._api_request("categories/list") + + categories = [] + # Categories API returns feeds array with {id, name} objects + categories_data = response.get("feeds", []) + + for category in categories_data: + cat_name = category.get("name", "Unknown Category") + + categories.append( + BrowseFolder( + item_id=cat_name, # Use name as ID + provider=self.domain, + path=f"{self.instance_id}://{BROWSE_CATEGORIES}/{cat_name}", + name=cat_name, + ) + ) + + # Sort by name + return sorted(categories, key=lambda x: x.name) + + except (ProviderUnavailableError, InvalidDataError): + # Re-raise these specific errors + raise + except Exception as err: + self.logger.warning("Unexpected error getting categories: %s", err, exc_info=True) + return [] + + @use_cache(43200) # Cache for 12 hours + async def _browse_category_podcasts(self, category_name: str) -> list[Podcast]: + """Browse podcasts in a specific category using search.""" + try: + # Search for podcasts using the category name directly + search_response = await self._api_request( + "search/byterm", params={"q": category_name, "max": 50} + ) + + podcasts = [] + for feed_data in search_response.get("feeds", []): + podcast = parse_podcast_from_feed( + feed_data, self.lookup_key, self.domain, self.instance_id + ) + if podcast: + podcasts.append(podcast) + + return podcasts + + except (ProviderUnavailableError, InvalidDataError): + raise + except Exception as err: + self.logger.warning( + "Unexpected error getting category podcasts: %s", err, exc_info=True + ) + return [] diff --git a/music_assistant/providers/resonate/provider.py b/music_assistant/providers/resonate/provider.py index dea31689..d1367acb 100644 --- a/music_assistant/providers/resonate/provider.py +++ b/music_assistant/providers/resonate/provider.py @@ -58,8 +58,6 @@ class ResonateProvider(PlayerProvider): """Return the features supported by this Provider.""" return { ProviderFeature.SYNC_PLAYERS, - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } async def loaded_in_mass(self) -> None: diff --git a/music_assistant/providers/snapcast/__init__.py b/music_assistant/providers/snapcast/__init__.py index 9deef506..51095efb 100644 --- a/music_assistant/providers/snapcast/__init__.py +++ b/music_assistant/providers/snapcast/__init__.py @@ -36,9 +36,6 @@ from music_assistant.providers.snapcast.provider import SnapCastProvider SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, ProviderFeature.REMOVE_PLAYER, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/sonos/__init__.py b/music_assistant/providers/sonos/__init__.py index 796ac880..cf5d720e 100644 --- a/music_assistant/providers/sonos/__init__.py +++ b/music_assistant/providers/sonos/__init__.py @@ -25,9 +25,6 @@ if TYPE_CHECKING: SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index 9dcc7fe0..a847062d 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio import time +from copy import deepcopy from typing import TYPE_CHECKING from aiohttp import ClientConnectorError @@ -300,7 +301,6 @@ class SonosPlayer(Player): await airplay_player.stop() else: await self.client.player.group.stop() - self._attr_playback_state = PlaybackState.IDLE self.update_state() async def pause(self) -> None: @@ -309,11 +309,6 @@ class SonosPlayer(Player): Will only be called if the player reports PlayerFeature.PAUSE is supported. """ - - def _update_state() -> None: - self._attr_playback_state = PlaybackState.PAUSED - self.update_state() - if self.client.player.is_passive: self.logger.debug("Ignore STOP command: Player is synced to another player.") return @@ -321,7 +316,6 @@ class SonosPlayer(Player): # linked airplay player is active, redirect the command self.logger.debug("Redirecting PAUSE command to linked airplay player.") await airplay_player.pause() - _update_state() return active_source = self._attr_active_source if self.mass.player_queues.get(active_source): @@ -332,14 +326,11 @@ class SonosPlayer(Player): # TODO: revisit this later once we implemented support for range requests # as I have the feeling the pause issue is related to seek support (=range requests) await self.stop() - _update_state() return if not self.client.player.group.playback_actions.can_pause: await self.stop() - _update_state() return await self.client.player.group.pause() - _update_state() async def next_track(self) -> None: """ @@ -384,11 +375,7 @@ class SonosPlayer(Player): :param media: Details of the item that needs to be played on the player. """ - - def _update_state() -> None: - self._attr_current_media = media - self._attr_playback_state = PlaybackState.PLAYING - self.update_state() + self._attr_current_media = deepcopy(media) if self.client.player.is_passive: # this should be already handled by the player manager, but just in case... @@ -403,17 +390,15 @@ class SonosPlayer(Player): # airplay mode is enabled, redirect the command self.logger.debug("Redirecting PLAY_MEDIA command to linked airplay player.") await self._play_media_airplay(airplay_player, media) - _update_state() return if media.media_type in ( MediaType.PLUGIN_SOURCE, MediaType.FLOW_STREAM, - ) or media.source_id.startswith(UGP_PREFIX): + ) or (media.source_id and media.source_id.startswith(UGP_PREFIX)): # flow stream or plugin source playback # always use the legacy (UPNP) playback method for this await self._play_media_legacy(media) - _update_state() return if media.source_id and media.queue_item_id: @@ -428,7 +413,6 @@ class SonosPlayer(Player): queue_version=str(int(mass_queue.items_last_updated)), ) self.mass.call_later(5, self.sync_play_modes, media.source_id) - _update_state() return # All other playback types @@ -439,7 +423,6 @@ class SonosPlayer(Player): await self.client.player.group.play_stream_url( media.uri, {"name": media.title, "type": "track"} ) - _update_state() async def select_source(self, source: str) -> None: """ @@ -492,22 +475,26 @@ class SonosPlayer(Player): :param player_ids_to_add: List of player_id's to add to the group. :param player_ids_to_remove: List of player_id's to remove from the group. """ + player_ids_to_add = player_ids_to_add or [] + player_ids_to_remove = player_ids_to_remove or [] if airplay_player := self.get_linked_airplay_player(False): # if airplay mode is enabled, we could possibly receive child player id's that are # not Sonos players, but AirPlay players. We redirect those. - airplay_child_ids = [x for x in player_ids_to_add or [] if x.startswith("ap")] - player_ids_to_add = [x for x in player_ids_to_add or [] if x not in airplay_child_ids] - if airplay_child_ids: - if ( - airplay_player.active_source != self._attr_active_source - and airplay_player.playback_state == PlaybackState.PLAYING - ): - # edge case player is not playing a MA queue - fail this request - raise PlayerCommandFailed("Player is not playing a Music Assistant queue.") - await self.mass.players.cmd_group_many(airplay_player.player_id, airplay_child_ids) - if player_ids_to_add: + airplay_player_ids_to_add = [x for x in player_ids_to_add if x.startswith("ap")] + player_ids_to_add = [x for x in player_ids_to_add if x not in airplay_player_ids_to_add] + airplay_player_ids_to_remove = [x for x in player_ids_to_remove if x.startswith("ap")] + player_ids_to_remove = [ + x for x in player_ids_to_remove if x not in airplay_player_ids_to_remove + ] + if airplay_player_ids_to_add or airplay_player_ids_to_remove: + await self.mass.players.cmd_set_members( + airplay_player.player_id, + player_ids_to_add=airplay_player_ids_to_add, + player_ids_to_remove=airplay_player_ids_to_remove, + ) + if player_ids_to_add or player_ids_to_remove: await self.client.player.group.modify_group_members( - player_ids_to_add=player_ids_to_add, player_ids_to_remove=[] + player_ids_to_add=player_ids_to_add, player_ids_to_remove=player_ids_to_remove ) async def ungroup(self) -> None: @@ -551,8 +538,15 @@ class SonosPlayer(Player): def on_player_event(self, event: SonosEvent | None) -> None: """Handle incoming event from player.""" - self.update_attributes() - self.update_state() + try: + self.update_attributes() + except Exception as err: + self.logger.exception("Failed to update player attributes: %s", err) + return + try: + self.update_state() + except Exception as err: + self.logger.exception("Failed to update player state: %s", err) def update_attributes(self) -> None: # noqa: PLR0915 """Update the player attributes.""" @@ -600,7 +594,7 @@ class SonosPlayer(Player): self._attr_group_members.clear() # map playback state - self._playback_state = PLAYBACK_STATE_MAP[active_group.playback_state] + self._attr_playback_state = PLAYBACK_STATE_MAP[active_group.playback_state] self._attr_elapsed_time = active_group.position # figure out the active source based on the container @@ -637,9 +631,7 @@ class SonosPlayer(Player): if SOURCE_SPOTIFY not in [x.id for x in self._attr_source_list]: self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_SPOTIFY]) elif active_service == MusicService.MUSIC_ASSISTANT: - if self.client.player.is_coordinator: - self._attr_active_source = self._player_id - elif object_id := container.get("id", {}).get("objectId"): + if object_id := container.get("id", {}).get("objectId"): self._attr_active_source = object_id.split(":")[-1] else: self._attr_active_source = None diff --git a/music_assistant/providers/sonos_s1/__init__.py b/music_assistant/providers/sonos_s1/__init__.py index 42ef9404..a84d4b47 100644 --- a/music_assistant/providers/sonos_s1/__init__.py +++ b/music_assistant/providers/sonos_s1/__init__.py @@ -27,9 +27,6 @@ if TYPE_CHECKING: SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/squeezelite/__init__.py b/music_assistant/providers/squeezelite/__init__.py index 0da03798..cb17862f 100644 --- a/music_assistant/providers/squeezelite/__init__.py +++ b/music_assistant/providers/squeezelite/__init__.py @@ -26,9 +26,6 @@ if TYPE_CHECKING: SUPPORTED_FEATURES = { ProviderFeature.SYNC_PLAYERS, - # support sync groups by reporting create/remove player group support - ProviderFeature.CREATE_GROUP_PLAYER, - ProviderFeature.REMOVE_GROUP_PLAYER, } diff --git a/music_assistant/providers/universal_group/player.py b/music_assistant/providers/universal_group/player.py index 42c748d1..396cdf74 100644 --- a/music_assistant/providers/universal_group/player.py +++ b/music_assistant/providers/universal_group/player.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from copy import deepcopy from time import time from typing import TYPE_CHECKING, cast @@ -262,7 +263,7 @@ class UniversalGroupPlayer(GroupPlayer): base_url = f"{self.mass.streams.base_url}/ugp/{self.player_id}.flac" # set the state optimistically - self._attr_current_media = media + self._attr_current_media = deepcopy(media) self._attr_elapsed_time = 0 self._attr_elapsed_time_last_updated = time() - 1 self._attr_playback_state = PlaybackState.PLAYING diff --git a/pyproject.toml b/pyproject.toml index 31e518bd..5ee94d77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,6 @@ test = [ "ruff==0.12.12", ] - [project.scripts] mass = "music_assistant.__main__:main" @@ -130,7 +129,16 @@ enable_error_code = [ "truthy-iterable", ] exclude = [ - '^music_assistant/controllers/.*$', + '^music_assistant/controllers/__init__.py$', + '^music_assistant/controllers/cache.py$', + '^music_assistant/controllers/config.py$', + '^music_assistant/controllers/media/.*$', + '^music_assistant/controllers/metadata.py$', + '^music_assistant/controllers/music.py$', + '^music_assistant/controllers/player_queues.py$', + '^music_assistant/controllers/players/player_controller.py', + '^music_assistant/controllers/streams.py$', + '^music_assistant/controllers/webserver.py', '^music_assistant/helpers/app_vars.py', '^music_assistant/models/player_provider.py', '^music_assistant/providers/apple_music/.*$',