Add Universal group Player provider (#632)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 19 Apr 2023 05:25:38 +0000 (07:25 +0200)
committerGitHub <noreply@github.com>
Wed, 19 Apr 2023 05:25:38 +0000 (07:25 +0200)
Adds a "virtual" player provider to create a universal group from all player types.

* Supports sync of all supported players (within the same ecosystem)
* Sync between different ecocystems or players that do not support sync is not implemented

14 files changed:
.vscode/settings.json
music_assistant/common/models/config_entries.py
music_assistant/common/models/player.py
music_assistant/common/models/queue_item.py
music_assistant/constants.py
music_assistant/server/controllers/config.py
music_assistant/server/controllers/player_queues.py
music_assistant/server/controllers/players.py
music_assistant/server/helpers/audio.py
music_assistant/server/models/player_provider.py
music_assistant/server/providers/chromecast/__init__.py
music_assistant/server/providers/lms_cli/__init__.py
music_assistant/server/providers/universal_group/__init__.py [new file with mode: 0644]
music_assistant/server/providers/universal_group/manifest.json [new file with mode: 0644]

index 44b46c5fea7706833a3966bb89e6c3febf7be216..ae9f98ef0f29746c5f4d095e0de2d156fb17c0b6 100644 (file)
@@ -6,5 +6,5 @@
             "source.organizeImports": true
         }
     },
-    "python.formatting.provider": "black"
+    "python.formatting.provider": "black",
 }
index 756f38a238d285e4d8ea024bf27e84f56754f976..f16223d10ef6ae6f6bcb26c66b8aa4a3c7b684b6 100644 (file)
@@ -15,6 +15,8 @@ from music_assistant.constants import (
     CONF_EQ_MID,
     CONF_EQ_TREBLE,
     CONF_FLOW_MODE,
+    CONF_GROUPED_POWER_ON,
+    CONF_HIDE_GROUP_CHILDS,
     CONF_LOG_LEVEL,
     CONF_OUTPUT_CHANNELS,
     CONF_OUTPUT_CODEC,
@@ -30,7 +32,7 @@ LOGGER = logging.getLogger(__name__)
 ENCRYPT_CALLBACK: callable[[str], str] | None = None
 DECRYPT_CALLBACK: callable[[str], str] | None = None
 
-ConfigValueType = str | int | float | bool | None
+ConfigValueType = str | int | float | bool | list[str] | list[int] | None
 
 ConfigEntryTypeMap = {
     ConfigEntryType.BOOLEAN: bool,
@@ -99,7 +101,7 @@ class ConfigEntry(DataClassDictMixin):
         allow_none: bool = True,
     ) -> ConfigValueType:
         """Parse value from the config entry details and plain value."""
-        expected_type = ConfigEntryTypeMap.get(self.type, NoneType)
+        expected_type = list if self.multi_value else ConfigEntryTypeMap.get(self.type, NoneType)
         if value is None:
             value = self.default_value
         if value is None and (not self.required or allow_none):
@@ -260,98 +262,25 @@ class PlayerConfig(Config):
     default_name: str | None = None
 
 
-DEFAULT_PROVIDER_CONFIG_ENTRIES = (
-    ConfigEntry(
-        key=CONF_LOG_LEVEL,
-        type=ConfigEntryType.STRING,
-        label="Log level",
-        options=[
-            ConfigValueOption("global", "GLOBAL"),
-            ConfigValueOption("info", "INFO"),
-            ConfigValueOption("warning", "WARNING"),
-            ConfigValueOption("error", "ERROR"),
-            ConfigValueOption("debug", "DEBUG"),
-        ],
-        default_value="GLOBAL",
-        description="Set the log verbosity for this provider",
-        advanced=True,
-    ),
+CONF_ENTRY_LOG_LEVEL = ConfigEntry(
+    key=CONF_LOG_LEVEL,
+    type=ConfigEntryType.STRING,
+    label="Log level",
+    options=[
+        ConfigValueOption("global", "GLOBAL"),
+        ConfigValueOption("info", "INFO"),
+        ConfigValueOption("warning", "WARNING"),
+        ConfigValueOption("error", "ERROR"),
+        ConfigValueOption("debug", "DEBUG"),
+    ],
+    default_value="GLOBAL",
+    description="Set the log verbosity for this provider",
+    advanced=True,
 )
 
+DEFAULT_PROVIDER_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,)
 
-DEFAULT_PLAYER_CONFIG_ENTRIES = (
-    ConfigEntry(
-        key=CONF_VOLUME_NORMALISATION,
-        type=ConfigEntryType.BOOLEAN,
-        label="Enable volume normalization (EBU-R128 based)",
-        default_value=True,
-        description="Enable volume normalization based on the EBU-R128 "
-        "standard without affecting dynamic range",
-    ),
-    ConfigEntry(
-        key=CONF_FLOW_MODE,
-        type=ConfigEntryType.BOOLEAN,
-        label="Enable queue flow mode",
-        default_value=False,
-        description='Enable "flow" mode where all queue tracks are sent as a continuous '
-        "audio stream. Use for players that do not natively support gapless and/or "
-        "crossfading or if the player has trouble transitioning between tracks.",
-        advanced=True,
-    ),
-    ConfigEntry(
-        key=CONF_VOLUME_NORMALISATION_TARGET,
-        type=ConfigEntryType.INTEGER,
-        range=(-30, 0),
-        default_value=-14,
-        label="Target level for volume normalisation",
-        description="Adjust average (perceived) loudness to this target level, "
-        "default is -14 LUFS",
-        depends_on=CONF_VOLUME_NORMALISATION,
-        advanced=True,
-    ),
-    ConfigEntry(
-        key=CONF_EQ_BASS,
-        type=ConfigEntryType.INTEGER,
-        range=(-10, 10),
-        default_value=0,
-        label="Equalizer: bass",
-        description="Use the builtin basic equalizer to adjust the bass of audio.",
-        advanced=True,
-    ),
-    ConfigEntry(
-        key=CONF_EQ_MID,
-        type=ConfigEntryType.INTEGER,
-        range=(-10, 10),
-        default_value=0,
-        label="Equalizer: midrange",
-        description="Use the builtin basic equalizer to adjust the midrange of audio.",
-        advanced=True,
-    ),
-    ConfigEntry(
-        key=CONF_EQ_TREBLE,
-        type=ConfigEntryType.INTEGER,
-        range=(-10, 10),
-        default_value=0,
-        label="Equalizer: treble",
-        description="Use the builtin basic equalizer to adjust the treble of audio.",
-        advanced=True,
-    ),
-    ConfigEntry(
-        key=CONF_OUTPUT_CHANNELS,
-        type=ConfigEntryType.STRING,
-        options=[
-            ConfigValueOption("Stereo (both channels)", "stereo"),
-            ConfigValueOption("Left channel", "left"),
-            ConfigValueOption("Right channel", "right"),
-            ConfigValueOption("Mono (both channels)", "mono"),
-        ],
-        default_value="stereo",
-        label="Output Channel Mode",
-        description="You can configure this player to play only the left or right channel, "
-        "for example to a create a stereo pair with 2 players.",
-        advanced=True,
-    ),
-)
+# some reusable player config entries
 
 CONF_ENTRY_OUTPUT_CODEC = ConfigEntry(
     key=CONF_OUTPUT_CODEC,
@@ -370,3 +299,119 @@ CONF_ENTRY_OUTPUT_CODEC = ConfigEntry(
     "Change this setting only if needed for your device/environment.",
     advanced=True,
 )
+
+CONF_ENTRY_FLOW_MODE = ConfigEntry(
+    key=CONF_FLOW_MODE,
+    type=ConfigEntryType.BOOLEAN,
+    label="Enable queue flow mode",
+    default_value=False,
+    description='Enable "flow" mode where all queue tracks are sent as a continuous '
+    "audio stream. Use for players that do not natively support gapless and/or "
+    "crossfading or if the player has trouble transitioning between tracks.",
+    advanced=False,
+)
+
+CONF_ENTRY_OUTPUT_CHANNELS = ConfigEntry(
+    key=CONF_OUTPUT_CHANNELS,
+    type=ConfigEntryType.STRING,
+    options=[
+        ConfigValueOption("Stereo (both channels)", "stereo"),
+        ConfigValueOption("Left channel", "left"),
+        ConfigValueOption("Right channel", "right"),
+        ConfigValueOption("Mono (both channels)", "mono"),
+    ],
+    default_value="stereo",
+    label="Output Channel Mode",
+    description="You can configure this player to play only the left or right channel, "
+    "for example to a create a stereo pair with 2 players.",
+    advanced=True,
+)
+
+CONF_ENTRY_VOLUME_NORMALISATION = ConfigEntry(
+    key=CONF_VOLUME_NORMALISATION,
+    type=ConfigEntryType.BOOLEAN,
+    label="Enable volume normalization (EBU-R128 based)",
+    default_value=True,
+    description="Enable volume normalization based on the EBU-R128 "
+    "standard without affecting dynamic range",
+)
+
+CONF_ENTRY_VOLUME_NORMALISATION_TARGET = ConfigEntry(
+    key=CONF_VOLUME_NORMALISATION_TARGET,
+    type=ConfigEntryType.INTEGER,
+    range=(-30, 0),
+    default_value=-14,
+    label="Target level for volume normalisation",
+    description="Adjust average (perceived) loudness to this target level, " "default is -14 LUFS",
+    depends_on=CONF_VOLUME_NORMALISATION,
+    advanced=True,
+)
+
+CONF_ENTRY_EQ_BASS = ConfigEntry(
+    key=CONF_EQ_BASS,
+    type=ConfigEntryType.INTEGER,
+    range=(-10, 10),
+    default_value=0,
+    label="Equalizer: bass",
+    description="Use the builtin basic equalizer to adjust the bass of audio.",
+    advanced=True,
+)
+
+CONF_ENTRY_EQ_MID = ConfigEntry(
+    key=CONF_EQ_MID,
+    type=ConfigEntryType.INTEGER,
+    range=(-10, 10),
+    default_value=0,
+    label="Equalizer: midrange",
+    description="Use the builtin basic equalizer to adjust the midrange of audio.",
+    advanced=True,
+)
+
+CONF_ENTRY_EQ_TREBLE = ConfigEntry(
+    key=CONF_EQ_TREBLE,
+    type=ConfigEntryType.INTEGER,
+    range=(-10, 10),
+    default_value=0,
+    label="Equalizer: treble",
+    description="Use the builtin basic equalizer to adjust the treble of audio.",
+    advanced=True,
+)
+
+CONF_ENTRY_HIDE_GROUP_MEMBERS = ConfigEntry(
+    key=CONF_HIDE_GROUP_CHILDS,
+    type=ConfigEntryType.STRING,
+    options=[
+        ConfigValueOption("Always", "always"),
+        ConfigValueOption("Only if the group is active/powered", "active"),
+        ConfigValueOption("Never", "never"),
+    ],
+    default_value="active",
+    label="Hide playergroup members in UI",
+    description="Hide the individual player entry for the members of this group "
+    "in the user interface.",
+    advanced=False,
+)
+
+CONF_ENTRY_GROUPED_POWER_ON = ConfigEntry(
+    key=CONF_GROUPED_POWER_ON,
+    type=ConfigEntryType.BOOLEAN,
+    default_value=True,
+    label="Forced Power ON of all group members",
+    description="Power ON all child players when the group player is powered on "
+    "(or playback started). \n"
+    "If this setting is disabled, playback will only start on players that "
+    "are already powered ON at the time of playback start.\n"
+    "When turning OFF the group player, all group members are turned off, "
+    "regardless of this setting.",
+    advanced=False,
+)
+
+DEFAULT_PLAYER_CONFIG_ENTRIES = (
+    CONF_ENTRY_VOLUME_NORMALISATION,
+    CONF_ENTRY_FLOW_MODE,
+    CONF_ENTRY_VOLUME_NORMALISATION_TARGET,
+    CONF_ENTRY_EQ_BASS,
+    CONF_ENTRY_EQ_MID,
+    CONF_ENTRY_EQ_TREBLE,
+    CONF_ENTRY_OUTPUT_CHANNELS,
+)
index c23033a4a44487ff7747659b70cd60187da0c773..89aa57a480f9282e8a67b1bbf8a279e3ee9fcec5 100644 (file)
@@ -13,9 +13,9 @@ from .enums import PlayerFeature, PlayerState, PlayerType
 class DeviceInfo(DataClassDictMixin):
     """Model for a player's deviceinfo."""
 
-    model: str = "unknown"
-    address: str = "unknown"
-    manufacturer: str = "unknown"
+    model: str = "Unknown model"
+    address: str = ""
+    manufacturer: str = "Unknown Manufacturer"
 
 
 @dataclass
index a3338c7c15c4bc7f2c2d29a39696374de4963de6..fe38717434e51ce6996bba122fedcbe79b9665c4 100644 (file)
@@ -8,10 +8,10 @@ from uuid import uuid4
 from mashumaro import DataClassDictMixin
 
 from .enums import MediaType
-from .media_items import ItemMapping, MediaItemImage, Radio, StreamDetails, Track
+from .media_items import ItemMapping, MediaItemImage, StreamDetails
 
 if TYPE_CHECKING:
-    pass
+    from .media_items import Album, Radio, Track
 
 
 @dataclass
@@ -85,6 +85,6 @@ def get_image(media_item: Track | Radio | None) -> MediaItemImage | None:
         return None
     if media_item.image:
         return media_item.image
-    if isinstance(media_item, Track) and media_item.album and getattr(media_item.album, "image"):
+    if isinstance(media_item, Track) and isinstance(media_item.album, Album):
         return media_item.album.image
     return None
index d841463ef99628a9c53ad68e806781ef033e6cb6..698a7750ba6ecaba6541bd2d6bbd18e12f950415 100755 (executable)
@@ -46,6 +46,7 @@ CONF_FLOW_MODE: Final[str] = "flow_mode"
 CONF_LOG_LEVEL: Final[str] = "log_level"
 CONF_HIDE_GROUP_CHILDS: Final[str] = "hide_group_childs"
 CONF_OUTPUT_CODEC: Final[str] = "output_codec"
+CONF_GROUPED_POWER_ON: Final[str] = "grouped_power_on"
 
 # config default values
 DEFAULT_HOST: Final[str] = "0.0.0.0"
index ba517d645870dddeaf48897c89af531291d0fc43..5e9a8b3f0ce9b5aacd292674509cd6245282815b 100644 (file)
@@ -282,24 +282,25 @@ class ConfigController:
                 raw_conf["available"] = False
                 raw_conf["name"] = raw_conf.get("name")
                 raw_conf["default_name"] = raw_conf.get("default_name") or raw_conf["player_id"]
-            entries = DEFAULT_PLAYER_CONFIG_ENTRIES + prov_entries
+            prov_entries = prov.get_player_config_entries(player_id)
+            prov_entries_keys = {x.key for x in prov_entries}
+            # combine provider defined entries with default player config entries
+            entries = prov_entries + tuple(
+                x for x in DEFAULT_PLAYER_CONFIG_ENTRIES if x.key not in prov_entries_keys
+            )
             return PlayerConfig.parse(entries, raw_conf)
         raise KeyError(f"No config found for player id {player_id}")
 
     @api_command("config/players/get_value")
     def get_player_config_value(self, player_id: str, key: str) -> ConfigValueType:
         """Return single configentry value for a player."""
-        conf = self.get(f"{CONF_PLAYERS}/{player_id}")
-        if not conf:
-            player = self.mass.players.get(player_id, True)
-            conf = {"provider": player.provider, "player_id": player_id, "values": {}}
-        prov = self.mass.get_provider(conf["provider"])
-        entries = DEFAULT_PLAYER_CONFIG_ENTRIES + prov.get_player_config_entries(player_id)
-        for entry in entries:
-            if entry.key == key:
-                # always create a copy to prevent we're altering the base object
-                return ConfigEntry.from_dict(entry.to_dict()).parse_value(conf["values"].get(key))
-        raise KeyError(f"ConfigEntry {key} is invalid")
+        conf = self.get_player_config(player_id)
+        # always create a copy to prevent we're altering the base object
+        return (
+            conf.values[key].value
+            if conf.values[key].value is not None
+            else conf.values[key].default_value
+        )
 
     @api_command("config/players/save")
     def save_player_config(
index 830529fe8e1f1ca9aa531b12a6c3a8a772be4158..5c291137d4842d8eaad0a18f6c8845b8caa59ee3 100755 (executable)
@@ -443,6 +443,9 @@ class PlayerQueuesController:
             # track is already played for > 90% - skip to next
             resume_item = next_item
             resume_pos = 0
+        elif queue.current_index is not None and len(queue_items) > 0:
+            resume_item = self.get_item(queue_id, queue.current_index)
+            resume_pos = 0
         elif queue.current_index is None and len(queue_items) > 0:
             # items available in queue but no previous track, start at 0
             resume_item = self.get_item(queue_id, 0)
@@ -628,7 +631,7 @@ class PlayerQueuesController:
         cur_item = self.get_item(queue.queue_id, cur_index)
         next_index = self.get_next_index(queue.queue_id, cur_index)
         next_item = self.get_item(queue.queue_id, next_index)
-        if not next_item:
+        if not cur_item or not next_item:
             raise QueueEmpty("No more tracks left in the queue.")
         queue.index_in_buffer = next_index
         # work out crossfade
index 21d42dff7e379f5e9ddf7ec16946b6ecc9137eb3..dbcc21327d3026b4575efa04435e3a29ca2f70d6 100755 (executable)
@@ -136,6 +136,8 @@ class PlayerController:
             player.name,
         )
         self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player)
+        # always call update to fix special attributes like display name, group volume etc.
+        self.update(player.player_id)
 
     @api_command("players/register_or_update")
     def register_or_update(self, player: Player) -> None:
@@ -228,11 +230,12 @@ class PlayerController:
             for child_player_id in player.group_childs:
                 if child_player_id == player_id:
                     continue
-                self.update(child_player_id, skip_forward=True, force_update=force_update)
+                self.update(child_player_id, skip_forward=True)
 
         # update group player(s) when child updates
         for group_player in self._get_player_groups(player_id):
-            self.update(group_player.player_id, skip_forward=True, force_update=force_update)
+            player_prov = self.get_player_provider(group_player.player_id)
+            player_prov.on_child_state(group_player.player_id, player, changed_keys)
 
     def get_player_provider(self, player_id: str) -> PlayerProvider:
         """Return PlayerProvider for given player."""
@@ -317,9 +320,12 @@ class PlayerController:
         player = self.get(player_id, True)
         if player.powered == powered:
             return
-        # stop player at power off
-        if not powered and player.state in (PlayerState.PLAYING, PlayerState.PAUSED):
+        # send stop at power off
+        if not powered:
             await self.cmd_stop(player_id)
+        # unsync player at power off
+        if not powered and player.synced_to is not None:
+            await self.cmd_unsync(player_id)
         if PlayerFeature.POWER not in player.supported_features:
             player.powered = powered
             self.update(player_id)
@@ -358,7 +364,7 @@ class PlayerController:
         group_player = self.get(player_id, True)
         assert group_player
         # handle group volume by only applying the volume to powered members
-        cur_volume = group_player.volume_level
+        cur_volume = group_player.group_volume
         new_volume = volume_level
         volume_dif = new_volume - cur_volume
         volume_dif_percent = 1 + new_volume / 100 if cur_volume == 0 else volume_dif / cur_volume
@@ -477,29 +483,27 @@ class PlayerController:
         """Return the active_source id for given player."""
         # if player is synced, return master/group leader
         if player.synced_to and player.synced_to in self._players:
-            return self._get_active_source(self.get(player.synced_to))
+            return player.synced_to
         # iterate player groups to find out if one is playing
         if group_players := self._get_player_groups(player.player_id):
             # prefer the first playing (or paused) group parent
             for group_player in group_players:
                 if group_player.state in (PlayerState.PLAYING, PlayerState.PAUSED):
-                    return group_player.active_source
+                    return group_player.player_id
             # fallback to the first powered group player
             for group_player in group_players:
                 if group_player.powered:
-                    return group_player.active_source
-        # defaults to the player's own player id
+                    return group_player.player_id
+        # guess source from player's current url
         if player.current_url:
             if self.mass.webserver.base_url in player.current_url:
                 return player.player_id
-            elif ":" in player.current_url:
+            if ":" in player.current_url:
                 # extract source from uri/url
                 return player.current_url.split(":")[0]
             return player.current_item_id or player.current_url
-        elif not player.powered:
-            # reset active source when player powers off
-            return player.player_id
-        return player.active_source
+        # defaults to the player's own player id
+        return player.player_id
 
     def _get_group_volume_level(self, player: Player) -> int:
         """Calculate a group volume from the grouped members."""
index 89384b3c6d122b9dee4b4e80e37e196bbbccc19b..111c1b0a8c4b74a1e9e31ee305783adf19501bd9 100644 (file)
@@ -508,12 +508,16 @@ async def get_radio_stream(
         meta_int = int(headers.get("icy-metaint", "0"))
         # stream with ICY Metadata
         if meta_int:
+            LOGGER.debug("Start streaming radio with ICY metadata from url %s", url)
             while True:
-                audio_chunk = await resp.content.readexactly(meta_int)
-                yield audio_chunk
-                meta_byte = await resp.content.readexactly(1)
-                meta_length = ord(meta_byte) * 16
-                meta_data = await resp.content.readexactly(meta_length)
+                try:
+                    audio_chunk = await resp.content.readexactly(meta_int)
+                    yield audio_chunk
+                    meta_byte = await resp.content.readexactly(1)
+                    meta_length = ord(meta_byte) * 16
+                    meta_data = await resp.content.readexactly(meta_length)
+                except asyncio.exceptions.IncompleteReadError:
+                    break
                 if not meta_data:
                     continue
                 meta_data = meta_data.rstrip(b"\0")
@@ -525,8 +529,10 @@ async def get_radio_stream(
                     streamdetails.stream_title = stream_title
         # Regular HTTP stream
         else:
+            LOGGER.debug("Start streaming radio without ICY metadata from url %s", url)
             async for chunk in resp.content.iter_any():
                 yield chunk
+        LOGGER.debug("Finished streaming radio from url %s", url)
 
 
 async def get_http_stream(
index becb661e159dcbd79249863f0e67974d76c619ad..3222ad0e474b7a6e08b80fe0d01f7f4e85844c97 100644 (file)
@@ -141,6 +141,11 @@ class PlayerProvider(Provider):
         If the player does not need any polling, simply do not override this method.
         """
 
+    def on_child_state(self, player_id: str, child_player: Player, changed_keys: set[str]) -> None:
+        """Call when the state of a child player updates."""
+        # default implementation: simply update the state of the group player
+        self.mass.players.update(player_id, skip_forward=True)
+
     # DO NOT OVERRIDE BELOW
 
     @property
index f36d2a747b40e4f6c6e961430421edb8b4b3049e..25c2b2d76745608d5b0c59d86a772615c529327b 100644 (file)
@@ -20,9 +20,9 @@ from pychromecast.models import CastInfo
 from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED
 
 from music_assistant.common.models.config_entries import (
+    CONF_ENTRY_HIDE_GROUP_MEMBERS,
     CONF_ENTRY_OUTPUT_CODEC,
     ConfigEntry,
-    ConfigValueOption,
     ConfigValueType,
 )
 from music_assistant.common.models.enums import (
@@ -36,12 +36,7 @@ from music_assistant.common.models.enums import (
 from music_assistant.common.models.errors import PlayerUnavailableError, QueueEmpty
 from music_assistant.common.models.player import DeviceInfo, Player
 from music_assistant.common.models.queue_item import QueueItem
-from music_assistant.constants import (
-    CONF_HIDE_GROUP_CHILDS,
-    CONF_OUTPUT_CODEC,
-    CONF_PLAYERS,
-    MASS_LOGO_ONLINE,
-)
+from music_assistant.constants import CONF_OUTPUT_CODEC, CONF_PLAYERS, MASS_LOGO_ONLINE
 from music_assistant.server.models.player_provider import PlayerProvider
 
 from .helpers import CastStatusListener, ChromecastInfo
@@ -173,22 +168,7 @@ class ChromecastProvider(PlayerProvider):
             and cast_player.cast_info.is_audio_group
             and not cast_player.cast_info.is_multichannel_group
         ):
-            entries = entries + (
-                ConfigEntry(
-                    key=CONF_HIDE_GROUP_CHILDS,
-                    type=ConfigEntryType.STRING,
-                    options=[
-                        ConfigValueOption("Always", "always"),
-                        ConfigValueOption("Only if the group is active/powered", "active"),
-                        ConfigValueOption("Never", "never"),
-                    ],
-                    default_value="active",
-                    label="Hide playergroup members in UI",
-                    description="Hide the individual player entry for the members of this group "
-                    "in the user interface.",
-                    advanced=True,
-                ),
-            )
+            entries = entries + (CONF_ENTRY_HIDE_GROUP_MEMBERS,)
         return entries
 
     def on_player_config_changed(
index 2edc7e4a2fcfd28ecf769b4bde388aa9d4c81464..ef0e77f3ce9e490fc155d39dd5504e7a54ab267d 100644 (file)
@@ -157,6 +157,9 @@ class LmsCli(PluginProvider):
                         str(kwargs),
                     )
                     cmd_result: list[str] = handler(player_id, *args, **kwargs)
+                    if asyncio.iscoroutine(cmd_result):
+                        cmd_result = await cmd_result
+
                     if isinstance(cmd_result, dict):
                         result_parts = dict_to_strings(cmd_result)
                         result_str = " ".join(urllib.parse.quote(x) for x in result_parts)
@@ -177,6 +180,8 @@ class LmsCli(PluginProvider):
                 response += "\n"
                 writer.write(response.encode("utf-8"))
                 await writer.drain()
+        except ConnectionResetError:
+            pass
         except Exception as err:
             self.logger.debug("Error handling CLI command", exc_info=err)
         finally:
@@ -204,6 +209,9 @@ class LmsCli(PluginProvider):
                     str(kwargs),
                 )
                 cmd_result = handler(player_id, *args, **kwargs)
+                if asyncio.iscoroutine(cmd_result):
+                    cmd_result = await cmd_result
+
                 if cmd_result is None:
                     cmd_result = {}
                 elif not isinstance(cmd_result, dict):
@@ -244,7 +252,7 @@ class LmsCli(PluginProvider):
             players.append(player_item_from_mass(start_index + index, mass_player))
         return PlayersResponse(count=len(players), players_loop=players)
 
-    def _handle_status(
+    async def _handle_status(
         self,
         player_id: str,
         *args,
@@ -260,9 +268,14 @@ class LmsCli(PluginProvider):
         assert queue is not None
         if start_index == "-":
             start_index = queue.current_index or 0
-        queue_items = self.mass.players.queues.items(queue.queue_id)[
-            start_index : start_index + limit
-        ]
+        queue_items = []
+        index = 0
+        async for item in self.mass.players.queues.items(queue.queue_id):
+            if index >= start_index:
+                queue_items.append(item)
+            if len(queue_items) == limit:
+                break
+            index += 1
         # we ignore the tags, just always send all info
         return player_status_from_mass(
             self.mass, player=player, queue=queue, queue_items=queue_items
diff --git a/music_assistant/server/providers/universal_group/__init__.py b/music_assistant/server/providers/universal_group/__init__.py
new file mode 100644 (file)
index 0000000..e42a003
--- /dev/null
@@ -0,0 +1,320 @@
+"""
+Universal Group Player provider.
+
+This is more like a "virtual" player provider,
+allowing the user to create player groups from all players known in the system.
+"""
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING
+
+from music_assistant.common.models.config_entries import (
+    CONF_ENTRY_FLOW_MODE,
+    CONF_ENTRY_GROUPED_POWER_ON,
+    CONF_ENTRY_HIDE_GROUP_MEMBERS,
+    CONF_ENTRY_OUTPUT_CHANNELS,
+    ConfigEntry,
+    ConfigValueOption,
+    ConfigValueType,
+)
+from music_assistant.common.models.enums import (
+    ConfigEntryType,
+    PlayerFeature,
+    PlayerState,
+    PlayerType,
+)
+from music_assistant.common.models.player import DeviceInfo, Player
+from music_assistant.common.models.queue_item import QueueItem
+from music_assistant.constants import CONF_GROUPED_POWER_ON
+from music_assistant.server.models.player_provider import PlayerProvider
+
+if TYPE_CHECKING:
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
+CONF_GROUP_MEMBERS = "group_members"
+
+CONF_ENTRY_OUTPUT_CHANNELS_FORCED_STEREO = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_OUTPUT_CHANNELS.to_dict(),
+        "hidden": True,
+        "default_value": "stereo",
+        "value": "stereo",
+    }
+)
+CONF_ENTRY_FORCED_FLOW_MODE = ConfigEntry.from_dict(
+    {**CONF_ENTRY_FLOW_MODE.to_dict(), "hidden": True, "default_value": True, "value": True}
+)
+# ruff: noqa: ARG002
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = UniversalGroupProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+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
+    all_players = tuple(
+        ConfigValueOption(x.display_name, x.player_id)
+        for x in mass.players.all(True, True, False)
+        if x.player_id != instance_id
+    )
+    return (
+        ConfigEntry(
+            key=CONF_GROUP_MEMBERS,
+            type=ConfigEntryType.STRING,
+            label="Group members",
+            default_value=[],
+            options=all_players,
+            description="Select all players you want to be part of this group",
+            multi_value=True,
+        ),
+    )
+
+
+class UniversalGroupProvider(PlayerProvider):
+    """Base/builtin provider for universally grouping players."""
+
+    prev_sync_leaders: tuple[str] | None = None
+
+    async def handle_setup(self) -> None:
+        """Handle async initialization of the provider."""
+        self.player = Player(
+            player_id=self.instance_id,
+            provider=self.domain,
+            type=PlayerType.GROUP,
+            name=self.name,
+            available=True,
+            powered=False,
+            device_info=DeviceInfo(model=self.manifest.name, manufacturer="Music Assistant"),
+            # TODO: derive playerfeatures from (all) underlying child players
+            supported_features=(
+                PlayerFeature.POWER,
+                PlayerFeature.PAUSE,
+                PlayerFeature.VOLUME_SET,
+                PlayerFeature.VOLUME_MUTE,
+                PlayerFeature.SET_MEMBERS,
+            ),
+            active_source=self.instance_id,
+            group_childs=self.config.get_value(CONF_GROUP_MEMBERS),
+        )
+        self.mass.players.register_or_update(self.player)
+
+    async def unload(self) -> None:
+        """Handle close/cleanup of the provider."""
+        self.mass.players.remove(self.instance_id)
+
+    def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry]:  # noqa: ARG002
+        """Return all (provider/player specific) Config Entries for the given player (if any)."""
+        return (
+            CONF_ENTRY_HIDE_GROUP_MEMBERS,
+            CONF_ENTRY_GROUPED_POWER_ON,
+            CONF_ENTRY_OUTPUT_CHANNELS_FORCED_STEREO,
+            CONF_ENTRY_FORCED_FLOW_MODE,
+        )
+
+    async def cmd_stop(self, player_id: str) -> None:
+        """Send STOP command to given player."""
+        # forward command to player and any connected sync child's
+        async with asyncio.TaskGroup() as tg:
+            for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
+                if member.state == PlayerState.IDLE:
+                    continue
+                tg.create_task(self.mass.players.cmd_stop(member.player_id))
+
+    async def cmd_play(self, player_id: str) -> None:
+        """Send PLAY command to given player."""
+        async with asyncio.TaskGroup() as tg:
+            for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
+                tg.create_task(self.mass.players.cmd_play(member.player_id))
+
+    async def cmd_play_media(
+        self,
+        player_id: str,
+        queue_item: QueueItem,
+        seek_position: int = 0,
+        fade_in: bool = False,
+        flow_mode: bool = False,
+    ) -> None:
+        """Send PLAY MEDIA command to given player.
+
+        This is called when the Queue wants the player to start playing a specific QueueItem.
+        The player implementation can decide how to process the request, such as playing
+        queue items one-by-one or enqueue all/some items.
+
+            - player_id: player_id of the player to handle the command.
+            - queue_item: the QueueItem to start playing on the player.
+            - seek_position: start playing from this specific position.
+            - fade_in: fade in the music at start (e.g. at resume).
+        """
+        # send stop first
+        await self.cmd_stop(player_id)
+        # power ON
+        await self.cmd_power(player_id, True)
+        # issue sync command (just in case)
+        await self._sync_players()
+        # forward command to all (powered) group child's
+        async with asyncio.TaskGroup() as tg:
+            for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
+                player_prov = self.mass.players.get_player_provider(member.player_id)
+                tg.create_task(
+                    player_prov.cmd_play_media(
+                        member.player_id,
+                        queue_item=queue_item,
+                        seek_position=seek_position,
+                        fade_in=fade_in,
+                        flow_mode=flow_mode,
+                    )
+                )
+
+    async def cmd_pause(self, player_id: str) -> None:
+        """Send PAUSE command to given player."""
+        async with asyncio.TaskGroup() as tg:
+            for member in self._get_active_members(only_powered=True, skip_sync_childs=True):
+                tg.create_task(self.mass.players.cmd_pause(member.player_id))
+
+    async def cmd_power(self, player_id: str, powered: bool) -> None:
+        """Send POWER command to given player."""
+        if self.player.powered == powered:
+            return  # nothing to do
+        group_power_on = self.mass.config.get_player_config_value(player_id, CONF_GROUPED_POWER_ON)
+        if powered and not group_power_on:
+            return  # nothing to do
+
+        async def set_child_power(child_player: Player) -> None:
+            await self.mass.players.cmd_power(child_player.player_id, powered)
+            # set optimistic state on child player to prevent race conditions in other actions
+            child_player.powered = powered
+
+        async with asyncio.TaskGroup() as tg:
+            for member in self._get_active_members(
+                only_powered=not powered, skip_sync_childs=False
+            ):
+                tg.create_task(set_child_power(member))
+
+        self.player.powered = powered
+        self.mass.players.update(self.instance_id)
+        if powered:
+            # sync all players on power on
+            await self._sync_players()
+
+    async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+        """Send VOLUME_SET command to given player."""
+
+    async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
+        """Send VOLUME MUTE command to given player."""
+
+    async def poll_player(self, player_id: str) -> None:
+        """Poll player for state updates."""
+        self.update_attributes()
+        self.mass.players.update(player_id, skip_forward=True)
+
+    def update_attributes(self) -> None:
+        """Update player attributes."""
+        all_members = self._get_active_members(only_powered=False, skip_sync_childs=False)
+        self.player.group_childs = list(x.player_id for x in all_members)
+        # read the state from the first powered child player
+        for member in all_members:
+            if member.synced_to:
+                continue
+            if not member.powered:
+                continue
+            if member.state not in (PlayerState.PLAYING, PlayerState.PAUSED):
+                continue
+            self.player.current_item_id = member.current_item_id
+            self.player.current_url = member.current_url
+            self.player.elapsed_time = member.elapsed_time
+            self.player.elapsed_time_last_updated = member.elapsed_time_last_updated
+            self.player.state = member.state
+            break
+        else:
+            self.player.state = PlayerState.IDLE
+            self.player.current_item_id = None
+            self.player.current_url = None
+
+    def on_child_state(self, player_id: str, child_player: Player, changed_keys: set[str]) -> None:
+        """Call when the state of a child player updates."""
+        # TODO: handle a sync leader powerin off
+        powered_players = self._get_active_members(True, False)
+        if "powered" in changed_keys:
+            if child_player.powered and self.player.state == PlayerState.PLAYING:
+                # a child player turned ON while the group player is already playing
+                # we need to resync/resume
+                self.mass.create_task(self.mass.players.queues.resume, player_id)
+            elif not child_player.powered and len(powered_players) == 0:
+                # the last player of a group turned off
+                # turn off the group
+                self.mass.create_task(self.cmd_power, player_id, False)
+        self.update_attributes()
+        self.mass.players.update(player_id, skip_forward=True)
+
+    def _get_active_members(
+        self, only_powered: bool = False, skip_sync_childs: bool = True
+    ) -> list[Player]:
+        """Get (child) players attached to a grouped player."""
+        child_players: list[Player] = []
+        conf_members: list[str] = self.config.get_value(CONF_GROUP_MEMBERS)
+        ignore_ids = set()
+        for child_id in conf_members:
+            if child_player := self.mass.players.get(child_id, False):
+                if not (not only_powered or child_player.powered):
+                    continue
+                if child_player.synced_to and skip_sync_childs:
+                    continue
+                allowed_sources = [child_player.player_id, self.instance_id] + conf_members
+                if child_player.active_source not in allowed_sources:
+                    # edge case: the child player has another group already active!
+                    continue
+                if child_player.synced_to and child_player.synced_to not in conf_members:
+                    # edge case: the child player is already synced to another player
+                    continue
+                child_players.append(child_player)
+                # handle edge case where a group is in the group and both the group
+                # and (one of its) child's are added to this universal group.
+                if child_player.type == PlayerType.GROUP:
+                    ignore_ids.update(
+                        x for x in child_player.group_childs if x != child_player.player_id
+                    )
+        return [x for x in child_players if x.player_id not in ignore_ids]
+
+    async def _sync_players(self) -> None:
+        """Sync all (possible) players."""
+        sync_leaders = set()
+        # TODO: sort members on sync master priority attribute ?
+        for member in self._get_active_members(only_powered=True):
+            if member.synced_to is not None:
+                continue
+            if not member.can_sync_with:
+                continue
+            # check if we can join this player to an already chosen sync leader
+            if existing_leader := next(
+                (x for x in member.can_sync_with if x in sync_leaders), None
+            ):
+                await self.mass.players.cmd_sync(member.player_id, existing_leader)
+                # set optimistic state to prevent race condition in play media
+                member.synced_to = existing_leader
+                continue
+            # pick this member as new sync leader
+            sync_leaders.add(member.player_id)
+        self.prev_sync_leaders = tuple(sync_leaders)
diff --git a/music_assistant/server/providers/universal_group/manifest.json b/music_assistant/server/providers/universal_group/manifest.json
new file mode 100644 (file)
index 0000000..3d61312
--- /dev/null
@@ -0,0 +1,13 @@
+{
+  "type": "player",
+  "domain": "universal_group",
+  "name": "Universal Group Player",
+  "description": "Create a Player Group with your favorite players, regardless of type and model.",
+  "codeowners": ["@music-assistant"],
+  "requirements": [],
+  "documentation": "",
+  "multi_instance": true,
+  "builtin": false,
+  "load_by_default": false,
+  "icon": "mdi:mdi-speaker-multiple"
+}