Allow (optional) dynamic member add/remove on syncgroups
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 18 Oct 2024 23:29:43 +0000 (01:29 +0200)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 18 Oct 2024 23:29:43 +0000 (01:29 +0200)
music_assistant/server/controllers/players.py
music_assistant/server/providers/player_group/__init__.py

index 0e01c0c6b93af358d98ab72e1799760f1bdd229f..88df2bd18e4074003c308b59d899331d3b900f86 100644 (file)
@@ -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;
index b18411403133fe5c4e379cf45abc19b600bab078..58ce7f7275e2a492ff2179b0899f902baa33854d 100644 (file)
@@ -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,