From fa6ef958db84907a2880e0b59f994d0e8395045a Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 19 Oct 2024 01:29:43 +0200 Subject: [PATCH] Allow (optional) dynamic member add/remove on syncgroups --- music_assistant/server/controllers/players.py | 31 ++---- .../server/providers/player_group/__init__.py | 97 +++++++++++++++++++ 2 files changed, 105 insertions(+), 23 deletions(-) diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index 0e01c0c6..88df2bd1 100644 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -52,6 +52,7 @@ from music_assistant.server.helpers.throttle_retry import Throttler from music_assistant.server.helpers.util import TaskManager from music_assistant.server.models.core_controller import CoreController from music_assistant.server.models.player_provider import PlayerProvider +from music_assistant.server.providers.player_group import PlayerGroupProvider if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Coroutine, Iterator @@ -320,19 +321,6 @@ class PlayerController(CoreController): if player.powered == powered: return # nothing to do - if player.active_group and not powered: - # this is simply not possible (well, not without major headaches) - # the player is part of a permanent (sync)group and the user tries to power off - # one child player... we can't allow this, as it would break the group so we - # power off the whole group instead. - self.logger.info( - "Detected a power OFF command to player %s which is part of a (active) group. " - "This command will be redirected to the entire group.", - player.name, - ) - await self.cmd_power(player.active_group, False) - return - # always stop player at power off if ( not powered @@ -620,17 +608,14 @@ class PlayerController(CoreController): if not (player.synced_to or player.group_childs): return # nothing to do - if player.active_group: - # this is simply not possible (well, not without major headaches) + if player.active_group and ( + (group_provider := self.get_player_provider(player.active_group)) + and group_provider.domain == "player_group" + ): # the player is part of a permanent (sync)group and the user tries to unsync - # one child player... we can't allow this, as it would break the group so we - # power unsync the whole group instead. - self.logger.info( - "Detected a (un)sync command to player %s which is part of a (active) group. " - "This command will be redirected by turning off the entire group!", - player.name, - ) - await self.cmd_power(player.active_group, False) + # redirect the command to the group provider + group_provider = cast(PlayerGroupProvider, group_provider) + await group_provider.cmd_unsync_member(player_id, player.active_group) return # handle (edge)case where un unsync command is sent to a sync leader; diff --git a/music_assistant/server/providers/player_group/__init__.py b/music_assistant/server/providers/player_group/__init__.py index b1841140..58ce7f72 100644 --- a/music_assistant/server/providers/player_group/__init__.py +++ b/music_assistant/server/providers/player_group/__init__.py @@ -105,6 +105,18 @@ CONFIG_ENTRY_UGP_NOTE = ConfigEntry( "the Universal Group only to group players of different ecosystems.", required=False, ) +CONFIG_ENTRY_DYNAMIC_MEMBERS = ConfigEntry( + key="dynamic_members", + type=ConfigEntryType.BOOLEAN, + label="Enable dynamic members (experimental)", + description="Allow members to (temporary) join/leave the group dynamically, " + "so the group more or less behaves the same like manually syncing players together, " + "with the main difference being that the groupplayer will hold the queue. \n\n" + "NOTE: This is an experimental feature which we are testing out. " + "You may run into some unexpected behavior!", + default_value=False, + required=False, +) async def setup( @@ -255,6 +267,7 @@ class PlayerGroupProvider(PlayerProvider): return ( *base_entries, group_members, + CONFIG_ENTRY_DYNAMIC_MEMBERS, *(entry for entry in child_config_entries if entry.key in allowed_conf_entries), ) @@ -269,6 +282,18 @@ class PlayerGroupProvider(PlayerProvider): if group_player.powered: # power on group player (which will also resync) if needed await self.cmd_power(group_player.player_id, True) + if f"values/{CONFIG_ENTRY_DYNAMIC_MEMBERS.key}" in changed_keys: + # dynamic members feature changed + if group_player := self.mass.players.get(config.player_id): + if PlayerFeature.SYNC in group_player.supported_features: + group_player.supported_features = tuple( + x for x in group_player.supported_features if x != PlayerFeature.SYNC + ) + else: + group_player.supported_features = ( + *group_player.supported_features, + PlayerFeature.SYNC, + ) await super().on_player_config_change(config, changed_keys) async def cmd_stop(self, player_id: str) -> None: @@ -518,6 +543,71 @@ class PlayerGroupProvider(PlayerProvider): # make sure to turn it off first (which will also unsync a syncgroup) await self.cmd_power(player_id, False) + async def cmd_sync(self, player_id: str, target_player: str) -> None: + """Handle SYNC command for given player. + + Join/add the given player(id) to the given (master) player/sync group. + + - player_id: player_id of the player to handle the command. + - target_player: player_id of the sync leader. + """ + group_player = self.mass.players.get(target_player, raise_unavailable=True) + if TYPE_CHECKING: + group_player = cast(Player, group_player) + dynamic_members_enabled = self.mass.config.get_raw_player_config_value( + group_player.player_id, + CONFIG_ENTRY_DYNAMIC_MEMBERS.key, + CONFIG_ENTRY_DYNAMIC_MEMBERS.default_value, + ) + group_type = self.mass.config.get_raw_player_config_value( + group_player.player_id, CONF_ENTRY_GROUP_TYPE.key, CONF_ENTRY_GROUP_TYPE.default_value + ) + if not dynamic_members_enabled: + raise UnsupportedFeaturedException( + f"Adjusting group members is not allowed for group {group_player.display_name}" + ) + new_members = self._filter_members(group_type, [*group_player.group_childs, player_id]) + group_player.group_childs = new_members + if group_player.powered: + # power on group player (which will also resync) if needed + await self.cmd_power(target_player, True) + + async def cmd_unsync_member(self, player_id: str, target_player: str) -> None: + """Handle UNSYNC command for given player. + + Remove the given player(id) from the given (master) player/sync group. + + - player_id: player_id of the (child) player to unsync from the group. + - target_player: player_id of the group player. + """ + group_player = self.mass.players.get(target_player, raise_unavailable=True) + child_player = self.mass.players.get(player_id, raise_unavailable=True) + if TYPE_CHECKING: + group_player = cast(Player, group_player) + child_player = cast(Player, child_player) + dynamic_members_enabled = self.mass.config.get_raw_player_config_value( + group_player.player_id, + CONFIG_ENTRY_DYNAMIC_MEMBERS.key, + CONFIG_ENTRY_DYNAMIC_MEMBERS.default_value, + ) + if not dynamic_members_enabled: + raise UnsupportedFeaturedException( + f"Adjusting group members is not allowed for group {group_player.display_name}" + ) + is_sync_leader = len(child_player.group_childs) > 0 + was_playing = child_player.state == PlayerState.PLAYING + # forward command to the player provider + if player_provider := self.mass.players.get_player_provider(child_player.player_id): + await player_provider.cmd_unsync(child_player.player_id) + child_player.active_group = None + child_player.active_source = None + if is_sync_leader and was_playing: + # unsyncing the sync leader will stop the group so we need to resume + self.mass.call_later(2, self.mass.players.cmd_play, group_player.player_id) + elif group_player.powered: + # power on group player (which will also resync) if needed + await self.cmd_power(group_player.player_id, True) + async def _register_all_players(self) -> None: """Register all (virtual/fake) group players in the Player controller.""" player_configs = await self.mass.config.get_player_configs( @@ -568,6 +658,13 @@ class PlayerGroupProvider(PlayerProvider): else: raise PlayerUnavailableError(f"Provider for syncgroup {group_type} is not available!") + if self.mass.config.get_raw_player_config_value( + group_player_id, + CONFIG_ENTRY_DYNAMIC_MEMBERS.key, + CONFIG_ENTRY_DYNAMIC_MEMBERS.default_value, + ): + player_features.add(PlayerFeature.SYNC) + player = Player( player_id=group_player_id, provider=self.instance_id, -- 2.34.1