Feat: Bump models to 1.1.2
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 20 Nov 2024 20:56:00 +0000 (21:56 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 20 Nov 2024 21:27:29 +0000 (22:27 +0100)
Feat: Bump models to 1.1.2
Feat: Adjust code to changes in the models
Feat: Refactor sync to group to make it more universal

42 files changed:
music_assistant/controllers/players.py
music_assistant/helpers/tags.py
music_assistant/models/metadata_provider.py
music_assistant/models/player_provider.py
music_assistant/models/provider.py
music_assistant/providers/_template_music_provider/__init__.py
music_assistant/providers/_template_player_provider/__init__.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/apple_music/__init__.py
music_assistant/providers/bluesound/__init__.py
music_assistant/providers/builtin/__init__.py
music_assistant/providers/chromecast/__init__.py
music_assistant/providers/deezer/__init__.py
music_assistant/providers/dlna/__init__.py
music_assistant/providers/fanarttv/__init__.py
music_assistant/providers/filesystem_local/__init__.py
music_assistant/providers/fully_kiosk/__init__.py
music_assistant/providers/hass_players/__init__.py
music_assistant/providers/jellyfin/__init__.py
music_assistant/providers/musicbrainz/__init__.py
music_assistant/providers/opensubsonic/sonic_provider.py
music_assistant/providers/player_group/__init__.py
music_assistant/providers/plex/__init__.py
music_assistant/providers/qobuz/__init__.py
music_assistant/providers/radiobrowser/__init__.py
music_assistant/providers/siriusxm/__init__.py
music_assistant/providers/slimproto/__init__.py
music_assistant/providers/snapcast/__init__.py
music_assistant/providers/sonos/const.py
music_assistant/providers/sonos/player.py
music_assistant/providers/sonos/provider.py
music_assistant/providers/sonos_s1/__init__.py
music_assistant/providers/sonos_s1/player.py
music_assistant/providers/soundcloud/__init__.py
music_assistant/providers/spotify/__init__.py
music_assistant/providers/test/__init__.py
music_assistant/providers/theaudiodb/__init__.py
music_assistant/providers/tidal/__init__.py
music_assistant/providers/tunein/__init__.py
music_assistant/providers/ytmusic/__init__.py
pyproject.toml
requirements_all.txt

index 24c8d282d180ecaaae29b73ca354ff670510d0d1..c0ee63978f172e67c7df19789ac03c7cfd0b3688 100644 (file)
@@ -110,6 +110,12 @@ class PlayerController(CoreController):
         self.manifest.icon = "speaker-multiple"
         self._poll_task: asyncio.Task | None = None
         self._player_throttlers: dict[str, Throttler] = {}
+        # TEMP 2024-11-20: register some aliases for renamed commands
+        # remove after a few releases
+        self.mass.register_api_command("players/cmd/sync", self.cmd_group)
+        self.mass.register_api_command("players/cmd/unsync", self.cmd_ungroup)
+        self.mass.register_api_command("players/cmd/sync_many", self.cmd_group_many)
+        self.mass.register_api_command("players/cmd/unsync_many", self.cmd_ungroup_many)
 
     async def setup(self, config: CoreConfig) -> None:
         """Async initialize of module."""
@@ -318,10 +324,10 @@ class PlayerController(CoreController):
         if player.powered == powered:
             return  # nothing to do
 
-        # unsync player at power off
+        # ungroup player at power off
         player_was_synced = player.synced_to is not None
         if not powered and (player.synced_to):
-            await self.cmd_unsync(player_id)
+            await self.cmd_ungroup(player_id)
 
         # always stop player at power off
         if (
@@ -615,84 +621,26 @@ class PlayerController(CoreController):
         async with self._player_throttlers[player_id]:
             await player_prov.enqueue_next_media(player_id=player_id, media=media)
 
-    @api_command("players/cmd/sync")
+    @api_command("players/cmd/group")
     @handle_player_command
-    async def cmd_sync(self, player_id: str, target_player: str) -> None:
-        """Handle SYNC command for given player.
+    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 player is already synced to another player, it will be unsynced there first.
         If the target player itself is already synced to another player, this may fail.
         If the player can not be synced with the given target player, this may fail.
 
             - player_id: player_id of the player to handle the command.
             - target_player: player_id of the syncgroup leader or group player.
         """
-        await self.cmd_sync_many(target_player, [player_id])
+        await self.cmd_group_many(target_player, [player_id])
 
-    @api_command("players/cmd/unsync")
-    @handle_player_command
-    async def cmd_unsync(self, player_id: str) -> None:
-        """Handle UNSYNC command for given player.
-
-        Remove the given player from any syncgroups it currently is synced to.
-        If the player is not currently synced to any other player,
-        this will silently be ignored.
-
-            - player_id: player_id of the player to handle the command.
-        """
-        if not (player := self.get(player_id)):
-            self.logger.warning("Player %s is not available", player_id)
-            return
-        if PlayerFeature.SYNC not in player.supported_features:
-            self.logger.warning("Player %s does not support (un)sync commands", player.name)
-            return
-        if not (player.synced_to or player.group_childs):
-            return  # nothing to do
-
-        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
-            # 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;
-        # we dissolve the entire syncgroup in this case.
-        # while maybe not strictly needed to do this for all player providers,
-        # we do this to keep the functionality consistent across all providers
-        if player.group_childs:
-            self.logger.warning(
-                "Detected unsync command to player %s which is a sync(group) leader, "
-                "all sync members will be unsynced!",
-                player.name,
-            )
-            async with TaskManager(self.mass) as tg:
-                for group_child_id in player.group_childs:
-                    if group_child_id == player_id:
-                        continue
-                    tg.create_task(self.cmd_unsync(group_child_id))
-            return
-
-        # (optimistically) reset active source player if it is unsynced
-        player.active_source = None
-
-        # forward command to the player provider
-        if player_provider := self.get_player_provider(player_id):
-            await player_provider.cmd_unsync(player_id)
-        # if the command succeeded we optimistically reset the sync state
-        # this is to prevent race conditions and to update the UI as fast as possible
-        player.synced_to = None
-
-    @api_command("players/cmd/sync_many")
-    async def cmd_sync_many(self, target_player: str, child_player_ids: list[str]) -> None:
-        """Create temporary sync group by joining given players to target player."""
+    @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."""
         parent_player: Player = self.get(target_player, True)
         prev_group_childs = parent_player.group_childs.copy()
-        if PlayerFeature.SYNC not in parent_player.supported_features:
+        if PlayerFeature.SET_MEMBERS not in parent_player.supported_features:
             msg = f"Player {parent_player.name} does not support sync commands"
             raise UnsupportedFeaturedException(msg)
 
@@ -700,7 +648,7 @@ class PlayerController(CoreController):
             # 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 unsync it first before you can join other players to it.",
+                "you need to ungroup it first before you can join other players to it.",
             )
 
         # filter all player ids on compatibility and availability
@@ -711,10 +659,16 @@ class PlayerController(CoreController):
             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
-            if PlayerFeature.SYNC not in child_player.supported_features:
-                # this should not happen, but just in case bad things happen, guard it
-                self.logger.warning("Player %s does not support sync commands", child_player.name)
-                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 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:
                 continue  # already synced to this target
 
@@ -722,15 +676,15 @@ class PlayerController(CoreController):
                 # guard edge case: childplayer is already a sync leader on its own
                 raise PlayerCommandFailed(
                     f"Player {child_player.name} is already synced with other players, "
-                    "you need to unsync it first before you can join it to another player.",
+                    "you need to ungroup it first before you can join it to another player.",
                 )
             if child_player.synced_to:
-                # player already synced to another player, unsync first
+                # player already synced to another player, ungroup first
                 self.logger.warning(
-                    "Player %s is already synced to another player, unsyncing first",
+                    "Player %s is already synced to another player, ungrouping first",
                     child_player.name,
                 )
-                await self.cmd_unsync(child_player.player_id)
+                await self.cmd_ungroup(child_player.player_id)
             # power on the player if needed
             if not child_player.powered:
                 await self.cmd_power(child_player.player_id, True, skip_update=True)
@@ -743,17 +697,74 @@ class PlayerController(CoreController):
         player_provider = self.get_player_provider(target_player)
         async with self._player_throttlers[target_player]:
             try:
-                await player_provider.cmd_sync_many(target_player, final_player_ids)
+                await player_provider.cmd_group_many(target_player, final_player_ids)
             except Exception:
                 # restore sync state if the command failed
-                parent_player.group_childs = prev_group_childs
+                parent_player.group_childs.set(prev_group_childs)
                 raise
 
-    @api_command("players/cmd/unsync_many")
-    async def cmd_unsync_many(self, player_ids: list[str]) -> None:
-        """Handle UNSYNC command for all the given players."""
+    @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.
+
+            - player_id: player_id of the player to handle the command.
+        """
+        if not (player := self.get(player_id)):
+            self.logger.warning("Player %s is not available", player_id)
+            return
+        if PlayerFeature.SET_MEMBERS not in player.supported_features:
+            self.logger.warning("Player %s does not support (un)group commands", player.name)
+            return
+        if not (player.synced_to or player.group_childs):
+            return  # nothing to do
+
+        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 ungroup
+            # redirect the command to the group provider
+            group_provider = cast(PlayerGroupProvider, group_provider)
+            await group_provider.cmd_ungroup_member(player_id, player.active_group)
+            return
+
+        # handle (edge)case where un ungroup command is sent to a sync leader;
+        # we dissolve the entire syncgroup in this case.
+        # while maybe not strictly needed to do this for all player providers,
+        # we do this to keep the functionality consistent across all providers
+        if player.group_childs:
+            self.logger.warning(
+                "Detected ungroup command to player %s which is a sync(group) leader, "
+                "all sync members will be ungrouped!",
+                player.name,
+            )
+            async with TaskManager(self.mass) as tg:
+                for group_child_id in player.group_childs:
+                    if group_child_id == player_id:
+                        continue
+                    tg.create_task(self.cmd_ungroup(group_child_id))
+            return
+
+        # (optimistically) reset active source player if it is ungrouped
+        player.active_source = None
+
+        # forward command to the player provider
+        if player_provider := self.get_player_provider(player_id):
+            await player_provider.cmd_ungroup(player_id)
+        # if the command succeeded we optimistically reset the sync state
+        # this is to prevent race conditions and to update the UI as fast as possible
+        player.synced_to = None
+
+    @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_unsync(player_id)
+            await self.cmd_ungroup(player_id)
 
     def set(self, player: Player) -> None:
         """Set/Update player details on the controller."""
@@ -851,8 +862,10 @@ class PlayerController(CoreController):
         player.active_source = self._get_active_source(player)
         player.volume_level = player.volume_level or 0  # guard for None volume
         # correct group_members if needed
-        if player.group_childs == {player.player_id}:
-            player.group_childs = set()
+        if player.group_childs == [player.player_id]:
+            player.group_childs.clear()
+        elif player.group_childs and player.player_id not in player.group_childs:
+            player.group_childs.set([player.player_id, *player.group_childs])
         # Auto correct player state if player is synced (or group child)
         # This is because some players/providers do not accurately update this info
         # for the sync child's.
@@ -1135,7 +1148,7 @@ class PlayerController(CoreController):
     def _handle_player_unavailable(self, player: Player) -> None:
         """Handle a player becoming unavailable."""
         if player.synced_to:
-            self.mass.create_task(self.cmd_unsync(player.player_id))
+            self.mass.create_task(self.cmd_ungroup(player.player_id))
             # also set this optimistically because the above command will most likely fail
             player.synced_to = None
             return
@@ -1146,7 +1159,7 @@ class PlayerController(CoreController):
                 self.mass.create_task(self.cmd_power(group_child_id, False, True))
                 # also set this optimistically because the above command will most likely fail
                 child_player.synced_to = None
-            player.group_childs = set()
+            player.group_childs.clear()
         if player.active_group and (group_player := self.get(player.active_group)):
             # remove player from group if its part of a group
             group_player = self.get(player.active_group)
@@ -1179,13 +1192,13 @@ class PlayerController(CoreController):
         queue = self.mass.player_queues.get(player.active_source)
         prev_queue_active = queue and queue.active
         prev_item_id = player.current_item_id
-        # unsync player if its currently synced
+        # ungroup player if its currently synced
         if prev_synced_to:
             self.logger.debug(
-                "Announcement to player %s - unsyncing player...",
+                "Announcement to player %s - ungrouping player...",
                 player.display_name,
             )
-            await self.cmd_unsync(player.player_id)
+            await self.cmd_ungroup(player.player_id)
         # stop player if its currently playing
         elif prev_state in (PlayerState.PLAYING, PlayerState.PAUSED):
             self.logger.debug(
@@ -1267,7 +1280,7 @@ class PlayerController(CoreController):
             await self.cmd_power(player.player_id, False)
             return
         elif prev_synced_to:
-            await self.cmd_sync(player.player_id, prev_synced_to)
+            await self.cmd_group(player.player_id, prev_synced_to)
         elif prev_queue_active and prev_state == PlayerState.PLAYING:
             await self.mass.player_queues.resume(queue.queue_id, True)
         elif prev_state == PlayerState.PLAYING:
index 818c2d31059df904976912df507681ed577fca99..55def74b6f4623927fc45d1b5f6d367c9f8b14ec 100644 (file)
@@ -14,7 +14,6 @@ from typing import Any
 import eyed3
 from music_assistant_models.enums import AlbumType
 from music_assistant_models.errors import InvalidDataError
-from music_assistant_models.media_items import MediaItemChapter
 
 from music_assistant.constants import MASS_LOGGER_NAME, UNKNOWN_ARTIST
 from music_assistant.helpers.process import AsyncProcess
@@ -260,14 +259,14 @@ class AudioTags:
         """Return artist sort name tag(s) if present."""
         return split_items(self.tags.get("albumartistsort"), False)
 
+    @property
+    def is_audiobook(self) -> bool:
+        """Return True if this is an audiobook."""
+        return self.filename.endswith("m4b") and len(self.chapters) > 1
+
     @property
     def album_type(self) -> AlbumType:
         """Return albumtype tag if present."""
-        # handle audiobook/podcast
-        if self.filename.endswith("m4b") and len(self.chapters) > 1:
-            return AlbumType.AUDIOBOOK
-        if "podcast" in self.tags.get("genre", "").lower() and len(self.chapters) > 1:
-            return AlbumType.PODCAST
         if self.tags.get("compilation", "") == "1":
             return AlbumType.COMPILATION
         tag = (
@@ -280,8 +279,6 @@ class AudioTags:
         # the album type tag is messy within id3 and may even contain multiple types
         # try to parse one in order of preference
         for album_type in (
-            AlbumType.PODCAST,
-            AlbumType.AUDIOBOOK,
             AlbumType.COMPILATION,
             AlbumType.EP,
             AlbumType.SINGLE,
@@ -316,20 +313,9 @@ class AudioTags:
         return None
 
     @property
-    def chapters(self) -> list[MediaItemChapter]:
+    def chapters(self) -> list[dict[str, Any]]:
         """Return chapters in MediaItem (if any)."""
-        chapters: list[MediaItemChapter] = []
-        if raw_chapters := self.raw.get("chapters"):
-            for chapter_data in raw_chapters:
-                chapters.append(
-                    MediaItemChapter(
-                        chapter_id=chapter_data["id"],
-                        position_start=chapter_data["start"],
-                        position_end=chapter_data["end"],
-                        title=chapter_data.get("tags", {}).get("title"),
-                    )
-                )
-        return chapters
+        return self.raw.get("chapters") or []
 
     @property
     def lyrics(self) -> str | None:
index 25f28aa701af56d305e3bdc4d9de8094f8f44a08..bf674961bf18755dd6f30c47bbc4b0e5e4877bd6 100644 (file)
@@ -13,11 +13,11 @@ if TYPE_CHECKING:
 
 # ruff: noqa: ARG001, ARG002
 
-DEFAULT_SUPPORTED_FEATURES = (
+DEFAULT_SUPPORTED_FEATURES = {
     ProviderFeature.ARTIST_METADATA,
     ProviderFeature.ALBUM_METADATA,
     ProviderFeature.TRACK_METADATA,
-)
+}
 
 
 class MetadataProvider(Provider):
@@ -27,7 +27,7 @@ class MetadataProvider(Provider):
     """
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return DEFAULT_SUPPORTED_FEATURES
 
index a9491a3f428a337730e4d4eb78fb44043114b2a2..784e040698426e7932dc97fedb3be68e4ba320f6 100644 (file)
@@ -169,8 +169,8 @@ class PlayerProvider(Provider):
         # will only be called for players with 'next_previous' feature set.
         raise NotImplementedError
 
-    async def cmd_sync(self, player_id: str, target_player: str) -> None:
-        """Handle SYNC command for given player.
+    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 (master) player/sync group.
 
@@ -180,21 +180,21 @@ class PlayerProvider(Provider):
         # will only be called for players with SYNC feature set.
         raise NotImplementedError
 
-    async def cmd_unsync(self, player_id: str) -> None:
-        """Handle UNSYNC command for given player.
+    async def cmd_ungroup(self, player_id: str) -> None:
+        """Handle UNGROUP command for given player.
 
-        Remove the given player from any syncgroups it currently is synced to.
+        Remove the given player from any (sync)groups it currently is grouped to.
 
             - player_id: player_id of the player to handle the command.
         """
         # will only be called for players with SYNC feature set.
         raise NotImplementedError
 
-    async def cmd_sync_many(self, target_player: str, child_player_ids: list[str]) -> None:
+    async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None:
         """Create temporary sync group by joining given players to target player."""
         for child_id in child_player_ids:
-            # default implementation, simply call the cmd_sync for all child players
-            await self.cmd_sync(child_id, target_player)
+            # default implementation, simply call the cmd_group for all child players
+            await self.cmd_group(child_id, target_player)
 
     async def poll_player(self, player_id: str) -> None:
         """Poll player for state updates.
index 89b133799fe6cd62872fbe07589d4d2ac6958722..7b406df670e0b5213242b67c0088696c170bb19b 100644 (file)
@@ -42,7 +42,7 @@ class Provider:
         self.available = False
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return ()
 
index 89cbba27de9ecdd2cb1f9ab2db918343990e2622..89b787b3f9dd6a608b53458ce71939bf4c1a7ec6 100644 (file)
@@ -127,7 +127,7 @@ class MyDemoMusicprovider(MusicProvider):
     """
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         # MANDATORY
         # you should return a tuple of provider-level features
index ae3cd3090976d849f01f84a74b1d0942588658c2..6cbb659bb1d464addf5f63b22587f7bdb906dca7 100644 (file)
@@ -105,7 +105,7 @@ class MyDemoPlayerprovider(PlayerProvider):
     """
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         # MANDATORY
         # you should return a tuple of provider-level features
@@ -210,7 +210,7 @@ class MyDemoPlayerprovider(PlayerProvider):
             device_info=DeviceInfo(
                 model="Model XYX",
                 manufacturer="Super Brand",
-                address=cur_address,
+                ip_address=cur_address,
             ),
             # set the supported features for this player only with
             # the ones the player actually supports
@@ -339,8 +339,8 @@ class MyDemoPlayerprovider(PlayerProvider):
         """
         # this method should handle the enqueuing of the next queue item on the player.
 
-    async def cmd_sync(self, player_id: str, target_player: str) -> None:
-        """Handle SYNC command for given player.
+    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 (master) player/sync group.
 
@@ -351,10 +351,10 @@ class MyDemoPlayerprovider(PlayerProvider):
         # this method should handle the sync command for the given player.
         # you should join the given player to the target_player/syncgroup.
 
-    async def cmd_unsync(self, player_id: str) -> None:
-        """Handle UNSYNC command for given player.
+    async def cmd_ungroup(self, player_id: str) -> None:
+        """Handle UNGROUP command for given player.
 
-        Remove the given player from any syncgroups it currently is synced to.
+        Remove the given player from any (sync)groups it currently is grouped to.
 
             - player_id: player_id of the player to handle the command.
         """
index b625ed8949bcf88ca02f1d413ab3eec73fb091cb..143d4de168923b48dddeca88a878fd2bbea55f2e 100644 (file)
@@ -145,7 +145,7 @@ class AirplayProvider(PlayerProvider):
     _play_media_lock: asyncio.Lock = asyncio.Lock()
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return (ProviderFeature.SYNC_PLAYERS,)
 
@@ -210,7 +210,7 @@ class AirplayProvider(PlayerProvider):
                     mass_player.device_info = DeviceInfo(
                         model=mass_player.device_info.model,
                         manufacturer=mass_player.device_info.manufacturer,
-                        address=str(cur_address),
+                        ip_address=str(cur_address),
                     )
                 if not mass_player.available:
                     self.logger.debug("Player back online: %s", display_name)
@@ -347,8 +347,8 @@ class AirplayProvider(PlayerProvider):
         await self.mass.cache.set(player_id, volume_level, base_key=CACHE_KEY_PREV_VOLUME)
 
     @lock
-    async def cmd_sync(self, player_id: str, target_player: str) -> None:
-        """Handle SYNC command for given player.
+    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 (master) player/sync group.
 
@@ -398,10 +398,10 @@ class AirplayProvider(PlayerProvider):
             self.mass.players.update(parent_player.player_id, skip_forward=True)
 
     @lock
-    async def cmd_unsync(self, player_id: str) -> None:
-        """Handle UNSYNC command for given player.
+    async def cmd_ungroup(self, player_id: str) -> None:
+        """Handle UNGROUP command for given player.
 
-        Remove the given player from any syncgroups it currently is synced to.
+        Remove the given player from any (sync)groups it currently is grouped to.
 
             - player_id: player_id of the player to handle the command.
         """
@@ -514,14 +514,15 @@ class AirplayProvider(PlayerProvider):
             device_info=DeviceInfo(
                 model=model,
                 manufacturer=manufacturer,
-                address=address,
+                ip_address=address,
             ),
             supported_features=(
                 PlayerFeature.PAUSE,
-                PlayerFeature.SYNC,
+                PlayerFeature.SET_MEMBERS,
                 PlayerFeature.VOLUME_SET,
             ),
             volume_level=volume,
+            can_group_with={self.instance_id},
         )
         await self.mass.players.register_or_update(mass_player)
 
index 364e53d810ad64c2ed0a176850d04bf28122c463..9474c50d7684be8836907262138c18914215b79b 100644 (file)
@@ -53,7 +53,7 @@ if TYPE_CHECKING:
     from music_assistant.models import ProviderInstanceType
 
 
-SUPPORTED_FEATURES = (
+SUPPORTED_FEATURES = {
     ProviderFeature.LIBRARY_ARTISTS,
     ProviderFeature.LIBRARY_ALBUMS,
     ProviderFeature.LIBRARY_TRACKS,
@@ -63,7 +63,7 @@ SUPPORTED_FEATURES = (
     ProviderFeature.ARTIST_ALBUMS,
     ProviderFeature.ARTIST_TOPTRACKS,
     ProviderFeature.SIMILAR_TRACKS,
-)
+}
 
 DEVELOPER_TOKEN = app_var(8)
 WIDEVINE_BASE_PATH = "/usr/local/bin/widevine_cdm"
@@ -127,7 +127,7 @@ class AppleMusicProvider(MusicProvider):
             self._decrypt_private_key = await _file.read()
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return SUPPORTED_FEATURES
 
index ab2258cf7c8fec8cc64173558c1fdb1cdaccb4c9..22f8e7473c0b5abf27ffdf145840967ee6f25c78 100644 (file)
@@ -37,7 +37,7 @@ if TYPE_CHECKING:
 
 
 PLAYER_FEATURES_BASE = {
-    PlayerFeature.SYNC,
+    PlayerFeature.SET_MEMBERS,
     PlayerFeature.VOLUME_MUTE,
     PlayerFeature.PAUSE,
 }
@@ -188,9 +188,10 @@ class BluesoundPlayer:
 
         if self.sync_status.leader is None:
             if self.sync_status.followers:
-                self.mass_player.group_childs = (
-                    self.sync_status.followers if len(self.sync_status.followers) > 1 else set()
-                )
+                if len(self.sync_status.followers) > 1:
+                    self.mass_player.group_childs.set(self.sync_status.followers)
+                else:
+                    self.mass_player.group_childs.clear()
                 self.mass_player.synced_to = None
 
             if self.status.state == "stream":
@@ -205,7 +206,7 @@ class BluesoundPlayer:
                 self.mass_player.current_media = None
 
         else:
-            self.mass_player.group_childs = set()
+            self.mass_player.group_childs.clear()
             self.mass_player.synced_to = self.sync_status.leader
             self.mass_player.active_source = self.sync_status.leader
 
@@ -219,9 +220,9 @@ class BluesoundPlayerProvider(PlayerProvider):
     bluos_players: dict[str, BluesoundPlayer]
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return (ProviderFeature.SYNC_PLAYERS,)
+        return {ProviderFeature.SYNC_PLAYERS}
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
@@ -257,7 +258,7 @@ class BluesoundPlayerProvider(PlayerProvider):
                     mass_player.device_info = DeviceInfo(
                         model=mass_player.device_info.model,
                         manufacturer=mass_player.device_info.manufacturer,
-                        address=str(cur_address),
+                        ip_address=str(cur_address),
                     )
                 if not mass_player.available:
                     self.logger.debug("Player back online: %s", mass_player.display_name)
@@ -284,7 +285,7 @@ class BluesoundPlayerProvider(PlayerProvider):
             device_info=DeviceInfo(
                 model="BluOS speaker",
                 manufacturer="Bluesound",
-                address=cur_address,
+                ip_address=cur_address,
             ),
             # Set the supported features for this player
             supported_features=(
@@ -294,6 +295,7 @@ class BluesoundPlayerProvider(PlayerProvider):
             ),
             needs_poll=True,
             poll_interval=30,
+            can_group_with={self.instance_id},
         )
         await self.mass.players.register(mass_player)
 
@@ -392,12 +394,12 @@ class BluesoundPlayerProvider(PlayerProvider):
         if bluos_player := self.bluos_players[player_id]:
             await bluos_player.update_attributes()
 
-    # TODO fix sync & unsync
+    # TODO fix sync & ungroup
 
-    async def cmd_sync(self, player_id: str, target_player: str) -> None:
+    async def cmd_group(self, player_id: str, target_player: str) -> None:
         """Handle SYNC command for BluOS player."""
 
-    async def cmd_unsync(self, player_id: str) -> None:
-        """Handle UNSYNC command for BluOS player."""
+    async def cmd_ungroup(self, player_id: str) -> None:
+        """Handle UNGROUP command for BluOS player."""
         if bluos_player := self.bluos_players[player_id]:
             await bluos_player.client.player.leave_group()
index 6872d751db1b00642902009c0ec94813039b41b5..74d955e0ee0da17921fad8d22e9b45ec93d20518 100644 (file)
@@ -165,9 +165,9 @@ class BuiltinProvider(MusicProvider):
         return False
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return (
+        return {
             ProviderFeature.BROWSE,
             ProviderFeature.LIBRARY_TRACKS,
             ProviderFeature.LIBRARY_RADIOS,
@@ -176,7 +176,7 @@ class BuiltinProvider(MusicProvider):
             ProviderFeature.LIBRARY_RADIOS_EDIT,
             ProviderFeature.PLAYLIST_CREATE,
             ProviderFeature.PLAYLIST_TRACKS_EDIT,
-        )
+        }
 
     async def get_track(self, prov_track_id: str) -> Track:
         """Get full track details by id."""
index 549a7c269e226f3325439d0f86041478ffc02924..b257eff40f0b7a739d2b3000553bbe90f483c3b8 100644 (file)
@@ -386,7 +386,7 @@ class ChromecastProvider(PlayerProvider):
                     powered=False,
                     device_info=DeviceInfo(
                         model=cast_info.model_name,
-                        address=f"{cast_info.host}:{cast_info.port}",
+                        ip_address=f"{cast_info.host}:{cast_info.port}",
                         manufacturer=cast_info.manufacturer,
                     ),
                     supported_features=(
@@ -437,19 +437,19 @@ class ChromecastProvider(PlayerProvider):
         # handle stereo pairs
         if castplayer.cast_info.is_multichannel_group:
             castplayer.player.type = PlayerType.STEREO_PAIR
-            castplayer.player.group_childs = set()
+            castplayer.player.group_childs.clear()
         # handle cast groups
         if castplayer.cast_info.is_audio_group and not castplayer.cast_info.is_multichannel_group:
             castplayer.player.type = PlayerType.GROUP
-            castplayer.player.group_childs = {
+            castplayer.player.group_childs.set(
                 str(UUID(x)) for x in castplayer.mz_controller.members
-            }
-            castplayer.player.supported_features = (
+            )
+            castplayer.player.supported_features = {
                 PlayerFeature.POWER,
                 PlayerFeature.VOLUME_SET,
                 PlayerFeature.PAUSE,
                 PlayerFeature.ENQUEUE,
-            )
+            }
 
         # update player status
         castplayer.player.name = castplayer.cast_info.friendly_name
@@ -578,7 +578,7 @@ class ChromecastProvider(PlayerProvider):
             castplayer.player.available = new_available
             castplayer.player.device_info = DeviceInfo(
                 model=castplayer.cast_info.model_name,
-                address=f"{castplayer.cast_info.host}:{castplayer.cast_info.port}",
+                ip_address=f"{castplayer.cast_info.host}:{castplayer.cast_info.port}",
                 manufacturer=castplayer.cast_info.manufacturer,
             )
             self.mass.loop.call_soon_threadsafe(self.mass.players.update, castplayer.player_id)
index eb79c49009906ff972fa2405449ac15dd55fce7a..3b6b89d5b5f88654b1439fe893bae5d38416201f 100644 (file)
@@ -48,7 +48,7 @@ from music_assistant.models.music_provider import MusicProvider
 
 from .gw_client import GWClient
 
-SUPPORTED_FEATURES = (
+SUPPORTED_FEATURES = {
     ProviderFeature.LIBRARY_ARTISTS,
     ProviderFeature.LIBRARY_ALBUMS,
     ProviderFeature.LIBRARY_TRACKS,
@@ -68,7 +68,7 @@ SUPPORTED_FEATURES = (
     ProviderFeature.PLAYLIST_CREATE,
     ProviderFeature.RECOMMENDATIONS,
     ProviderFeature.SIMILAR_TRACKS,
-)
+}
 
 
 @dataclass
@@ -190,7 +190,7 @@ class DeezerProvider(MusicProvider):
         await self.gw_client.setup()
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return SUPPORTED_FEATURES
 
index 10f651ee27e28242bf595d6826f5a6531a6e7def..4631592051e70b5154c54eef3f2c394cc1962e05 100644 (file)
@@ -493,7 +493,7 @@ class DLNAPlayerProvider(PlayerProvider):
                         # device info will be discovered later after connect
                         device_info=DeviceInfo(
                             model="unknown",
-                            address=description_url,
+                            ip_address=description_url,
                             manufacturer="unknown",
                         ),
                         needs_poll=True,
@@ -542,7 +542,7 @@ class DLNAPlayerProvider(PlayerProvider):
                 # connect was successful, update device info
                 dlna_player.player.device_info = DeviceInfo(
                     model=dlna_player.device.model_name,
-                    address=dlna_player.device.device.presentation_url
+                    ip_address=dlna_player.device.device.presentation_url
                     or dlna_player.description_url,
                     manufacturer=dlna_player.device.manufacturer,
                 )
@@ -611,4 +611,4 @@ class DLNAPlayerProvider(PlayerProvider):
             supported_features.add(PlayerFeature.VOLUME_MUTE)
         if dlna_player.device.has_pause:
             supported_features.add(PlayerFeature.PAUSE)
-        dlna_player.player.supported_features = tuple(supported_features)
+        dlna_player.player.supported_features = supported_features
index 9b8a65c14adcabb4e169f1128a914cb6ca4050a7..a8e68a74cc58e49983cef38aa2c8577b9dd32558 100644 (file)
@@ -23,10 +23,10 @@ if TYPE_CHECKING:
     from music_assistant import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
-SUPPORTED_FEATURES = (
+SUPPORTED_FEATURES = {
     ProviderFeature.ARTIST_METADATA,
     ProviderFeature.ALBUM_METADATA,
-)
+}
 
 CONF_ENABLE_ARTIST_IMAGES = "enable_artist_images"
 CONF_ENABLE_ALBUM_IMAGES = "enable_album_images"
@@ -101,7 +101,7 @@ class FanartTvMetadataProvider(MetadataProvider):
             self.throttler = Throttler(rate_limit=1, period=30)
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return SUPPORTED_FEATURES
 
index 317d8aa6203de8fa545fdc0a32630a539544662b..dc530474c5dfb0275d41baaad56b2360d205dec1 100644 (file)
@@ -111,14 +111,14 @@ IMAGE_EXTENSIONS = ("jpg", "jpeg", "JPG", "JPEG", "png", "PNG", "gif", "GIF")
 SEEKABLE_FILES = (ContentType.MP3, ContentType.WAV, ContentType.FLAC)
 
 
-SUPPORTED_FEATURES = (
+SUPPORTED_FEATURES = {
     ProviderFeature.LIBRARY_ARTISTS,
     ProviderFeature.LIBRARY_ALBUMS,
     ProviderFeature.LIBRARY_TRACKS,
     ProviderFeature.LIBRARY_PLAYLISTS,
     ProviderFeature.BROWSE,
     ProviderFeature.SEARCH,
-)
+}
 
 listdir = wrap(os.listdir)
 isdir = wrap(os.path.isdir)
@@ -175,14 +175,14 @@ class LocalFileSystemProvider(MusicProvider):
     scan_limiter = asyncio.Semaphore(25)
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         if self.write_access:
-            return (
+            return {
                 *SUPPORTED_FEATURES,
                 ProviderFeature.PLAYLIST_CREATE,
                 ProviderFeature.PLAYLIST_TRACKS_EDIT,
-            )
+            }
         return SUPPORTED_FEATURES
 
     @property
index e773dcd0f4acfc7efa59b7ec4a4077cd6cbeef47..ad4076e1fb6ad086819e2832804cb6a9098d7b23 100644 (file)
@@ -125,7 +125,7 @@ class FullyKioskProvider(PlayerProvider):
                 device_info=DeviceInfo(
                     model=self._fully.deviceInfo["deviceModel"],
                     manufacturer=self._fully.deviceInfo["deviceManufacturer"],
-                    address=address,
+                    ip_address=address,
                 ),
                 supported_features=(PlayerFeature.VOLUME_SET,),
                 needs_poll=True,
index 5095749c35026c78f2b4974c7cc22c52bb113404..40790479abc8af8171ddca64b0f3a1d1c24f895f 100644 (file)
@@ -334,8 +334,8 @@ class HomeAssistantPlayers(PlayerProvider):
             target={"entity_id": player_id},
         )
 
-    async def cmd_sync(self, player_id: str, target_player: str) -> None:
-        """Handle SYNC command for given player.
+    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 (master) player/sync group.
 
@@ -350,10 +350,10 @@ class HomeAssistantPlayers(PlayerProvider):
             target={"entity_id": target_player},
         )
 
-    async def cmd_unsync(self, player_id: str) -> None:
-        """Handle UNSYNC command for given player.
+    async def cmd_ungroup(self, player_id: str) -> None:
+        """Handle UNGROUP command for given player.
 
-        Remove the given player from any syncgroups it currently is synced to.
+        Remove the given player from any (sync)groups it currently is grouped to.
 
             - player_id: player_id of the player to handle the command.
         """
@@ -460,13 +460,13 @@ class HomeAssistantPlayers(PlayerProvider):
                 player.current_item_id = value
             if key == "group_members":
                 if value and value[0] == player.player_id:
-                    player.group_childs = value
+                    player.group_childs.set(value)
                     player.synced_to = None
                 elif value and value[0] != player.player_id:
-                    player.group_childs = set()
+                    player.group_childs.clear()
                     player.synced_to = value[0]
                 else:
-                    player.group_childs = set()
+                    player.group_childs.clear()
                     player.synced_to = None
 
     async def _late_add_player(self, entity_id: str) -> None:
index f6a03ed95eca45622133a19a37f07f2959da812f..a76b235a13cd6888cad6d9bf42e3d6d40557425d 100644 (file)
@@ -153,9 +153,9 @@ class JellyfinProvider(MusicProvider):
             raise LoginFailed(f"Authentication failed: {err}") from err
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return a list of supported features."""
-        return (
+        return {
             ProviderFeature.LIBRARY_ARTISTS,
             ProviderFeature.LIBRARY_ALBUMS,
             ProviderFeature.LIBRARY_TRACKS,
@@ -164,7 +164,7 @@ class JellyfinProvider(MusicProvider):
             ProviderFeature.SEARCH,
             ProviderFeature.ARTIST_ALBUMS,
             ProviderFeature.SIMILAR_TRACKS,
-        )
+        }
 
     @property
     def is_streaming_provider(self) -> bool:
index 95918ad5f134ceb03a36b4ee713b95232bb453bf..88ee5a5ba6d46bbb5493ed80a5086269c6e75d35 100644 (file)
@@ -33,7 +33,7 @@ if TYPE_CHECKING:
 
 LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
 
-SUPPORTED_FEATURES = ()
+SUPPORTED_FEATURES = set()
 
 
 async def setup(
@@ -200,7 +200,7 @@ class MusicbrainzProvider(MetadataProvider):
         self.cache = self.mass.cache
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return SUPPORTED_FEATURES
 
index 85594609457ae247a907976424ee877010a24abc..9aeae69cb2a8083a5c0a82a37903d4e2127fd73d 100644 (file)
@@ -112,9 +112,9 @@ class OpenSonicProvider(MusicProvider):
             self.logger.info("Server does not support transcodeOffset, seeking in player provider")
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return a list of supported features."""
-        return (
+        return {
             ProviderFeature.LIBRARY_ARTISTS,
             ProviderFeature.LIBRARY_ALBUMS,
             ProviderFeature.LIBRARY_TRACKS,
@@ -127,7 +127,7 @@ class OpenSonicProvider(MusicProvider):
             ProviderFeature.SIMILAR_TRACKS,
             ProviderFeature.PLAYLIST_TRACKS_EDIT,
             ProviderFeature.PLAYLIST_CREATE,
-        )
+        }
 
     @property
     def is_streaming_provider(self) -> bool:
index 32afe391ac0269f737f04652296470f7fa1da3cd..c78a807906094185c3ea83f4c2fafc8a943e79cb 100644 (file)
@@ -158,9 +158,9 @@ class PlayerGroupProvider(PlayerProvider):
         ]
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return (ProviderFeature.REMOVE_PLAYER,)
+        return {ProviderFeature.REMOVE_PLAYER}
 
     async def loaded_in_mass(self) -> None:
         """Call after the provider has been loaded."""
@@ -279,22 +279,18 @@ class PlayerGroupProvider(PlayerProvider):
             # ensure we filter invalid members
             members = self._filter_members(config.get_value(CONF_GROUP_TYPE), members)
             if group_player := self.mass.players.get(config.player_id):
-                group_player.group_childs = members
+                group_player.group_childs.set(members)
                 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
-                    )
+                if PlayerFeature.SET_MEMBERS in group_player.supported_features:
+                    group_player.supported_features.remove(PlayerFeature.SET_MEMBERS)
+                    group_player.can_group_with.clear()
                 else:
-                    group_player.supported_features = (
-                        *group_player.supported_features,
-                        PlayerFeature.SYNC,
-                    )
+                    group_player.supported_features.add(PlayerFeature.SET_MEMBERS)
         await super().on_player_config_change(config, changed_keys)
 
     async def cmd_stop(self, player_id: str) -> None:
@@ -352,13 +348,13 @@ class PlayerGroupProvider(PlayerProvider):
             group_member_ids = self.mass.config.get_raw_player_config_value(
                 player_id, CONF_GROUP_MEMBERS, []
             )
-            group_player.group_childs = {
+            group_player.group_childs.set(
                 x
                 for x in group_member_ids
                 if (child_player := self.mass.players.get(x))
                 and child_player.available
                 and child_player.enabled
-            }
+            )
 
         if powered:
             # handle TURN_ON of the group player by turning on all members
@@ -382,7 +378,7 @@ class PlayerGroupProvider(PlayerProvider):
         else:
             # handle TURN_OFF of the group player by turning off all members
             # optimistically set the group state to prevent race conditions
-            # with the unsync command
+            # with the ungroup command
             group_player.powered = False
             for member in self.mass.players.iter_group_members(
                 group_player, only_powered=True, active_only=True
@@ -401,7 +397,7 @@ class PlayerGroupProvider(PlayerProvider):
         self.mass.players.update(group_player.player_id)
         if not powered:
             # reset the group members when powered off
-            group_player.group_childs = set(
+            group_player.group_childs.set(
                 self.mass.config.get_raw_player_config_value(player_id, CONF_GROUP_MEMBERS, [])
             )
 
@@ -549,11 +545,11 @@ class PlayerGroupProvider(PlayerProvider):
             return
         if group_player.powered:
             # edge case: the group player is powered and being removed
-            # make sure to turn it off first (which will also unsync a syncgroup)
+            # make sure to turn it off first (which will also ungroup 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.
+    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 (master) player/sync group.
 
@@ -576,17 +572,17 @@ class PlayerGroupProvider(PlayerProvider):
                 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
+        group_player.group_childs.set(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.
+    async def cmd_ungroup_member(self, player_id: str, target_player: str) -> None:
+        """Handle UNGROUP 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.
+            - player_id: player_id of the (child) player to ungroup from the group.
             - target_player: player_id of the group player.
         """
         group_player = self.mass.players.get(target_player, raise_unavailable=True)
@@ -607,12 +603,12 @@ class PlayerGroupProvider(PlayerProvider):
         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)
+            await player_provider.cmd_ungroup(child_player.player_id)
             child_player.active_group = None
             child_player.active_source = None
-        group_player.group_childs = {x for x in group_player.group_childs if x != player_id}
+        group_player.group_childs.set({x for x in group_player.group_childs if x != player_id})
         if is_sync_leader and was_playing:
-            # unsyncing the sync leader will stop the group so we need to resume
+            # ungrouping 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
@@ -670,7 +666,7 @@ class PlayerGroupProvider(PlayerProvider):
             CONFIG_ENTRY_DYNAMIC_MEMBERS.key,
             CONFIG_ENTRY_DYNAMIC_MEMBERS.default_value,
         ):
-            player_features.add(PlayerFeature.SYNC)
+            player_features.add(PlayerFeature.SET_MEMBERS)
 
         player = Player(
             player_id=group_player_id,
@@ -681,7 +677,6 @@ class PlayerGroupProvider(PlayerProvider):
             powered=False,
             device_info=DeviceInfo(model=model_name, manufacturer=manufacturer),
             supported_features=tuple(player_features),
-            group_childs=set(members),
             active_source=group_player_id,
             needs_poll=True,
             poll_interval=30,
@@ -741,8 +736,8 @@ class PlayerGroupProvider(PlayerProvider):
         members_to_sync: list[str] = []
         for member in self.mass.players.iter_group_members(group_player, active_only=False):
             if member.synced_to and member.synced_to != sync_leader.player_id:
-                # unsync first
-                await self.mass.players.cmd_unsync(member.player_id)
+                # ungroup first
+                await self.mass.players.cmd_ungroup(member.player_id)
             if sync_leader.player_id == member.player_id:
                 # skip sync leader
                 continue
@@ -754,7 +749,7 @@ class PlayerGroupProvider(PlayerProvider):
                 continue
             members_to_sync.append(member.player_id)
         if members_to_sync:
-            await self.mass.players.cmd_sync_many(sync_leader.player_id, members_to_sync)
+            await self.mass.players.cmd_group_many(sync_leader.player_id, members_to_sync)
 
     async def _on_mass_player_added_event(self, event: MassEvent) -> None:
         """Handle player added event from player controller."""
index 5bf783d98c4407e2d12d7a5f16d7a627cee304bc..519a8feaeff32deba638592eec3d638a5ef39464 100644 (file)
@@ -37,7 +37,6 @@ from music_assistant_models.media_items import (
     AudioFormat,
     ItemMapping,
     MediaItem,
-    MediaItemChapter,
     MediaItemImage,
     Playlist,
     ProviderMapping,
@@ -380,9 +379,9 @@ class PlexProvider(MusicProvider):
             raise SetupFailedError from err
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return a list of supported features."""
-        return (
+        return {
             ProviderFeature.LIBRARY_ARTISTS,
             ProviderFeature.LIBRARY_ALBUMS,
             ProviderFeature.LIBRARY_TRACKS,
@@ -390,7 +389,7 @@ class PlexProvider(MusicProvider):
             ProviderFeature.BROWSE,
             ProviderFeature.SEARCH,
             ProviderFeature.ARTIST_ALBUMS,
-        )
+        }
 
     @property
     def is_streaming_provider(self) -> bool:
@@ -720,17 +719,7 @@ class PlexProvider(MusicProvider):
         if plex_track.duration:
             track.duration = int(plex_track.duration / 1000)
         if plex_track.chapters:
-            track.metadata.chapters = UniqueList(
-                [
-                    MediaItemChapter(
-                        chapter_id=plex_chapter.id,
-                        position_start=plex_chapter.start,
-                        position_end=plex_chapter.end,
-                        title=plex_chapter.title,
-                    )
-                    for plex_chapter in plex_track.chapters
-                ]
-            )
+            pass  # TODO!
 
         return track
 
index ee502f41bcda4d6978f4a25c1069e3ec4855a238..34402a8ffaeedf34bfe091046c5a11d6618c98dc 100644 (file)
@@ -61,7 +61,7 @@ if TYPE_CHECKING:
     from music_assistant.models import ProviderInstanceType
 
 
-SUPPORTED_FEATURES = (
+SUPPORTED_FEATURES = {
     ProviderFeature.LIBRARY_ARTISTS,
     ProviderFeature.LIBRARY_ALBUMS,
     ProviderFeature.LIBRARY_TRACKS,
@@ -75,7 +75,7 @@ SUPPORTED_FEATURES = (
     ProviderFeature.SEARCH,
     ProviderFeature.ARTIST_ALBUMS,
     ProviderFeature.ARTIST_TOPTRACKS,
-)
+}
 
 VARIOUS_ARTISTS_ID = "145383"
 
@@ -137,7 +137,7 @@ class QobuzProvider(MusicProvider):
             raise LoginFailed(msg)
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return SUPPORTED_FEATURES
 
index a38b9f44a39bb77021f7c3856908db9376122b4f..b365bdb6ae91821ac03dc54bdd02fc041e4c2dd2 100644 (file)
@@ -33,7 +33,7 @@ from radios import FilterBy, Order, RadioBrowser, RadioBrowserError, Station
 from music_assistant.controllers.cache import use_cache
 from music_assistant.models.music_provider import MusicProvider
 
-SUPPORTED_FEATURES = (
+SUPPORTED_FEATURES = {
     ProviderFeature.SEARCH,
     ProviderFeature.BROWSE,
     # RadioBrowser doesn't support a library feature at all
@@ -41,7 +41,7 @@ SUPPORTED_FEATURES = (
     # have that included in backups so we store it in the config.
     ProviderFeature.LIBRARY_RADIOS,
     ProviderFeature.LIBRARY_RADIOS_EDIT,
-)
+}
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
@@ -93,7 +93,7 @@ class RadioBrowserProvider(MusicProvider):
     """Provider implementation for RadioBrowser."""
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return SUPPORTED_FEATURES
 
index 339e8622907893a696a2e502a1bd6ada2de8d49a..d90687301961f46d7c5f88e432b44d219195d196 100644 (file)
@@ -112,12 +112,12 @@ class SiriusXMProvider(MusicProvider):
     _current_stream_details: StreamDetails | None = None
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return (
+        return {
             ProviderFeature.BROWSE,
             ProviderFeature.LIBRARY_RADIOS,
-        )
+        }
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
index 7394d17f07d47fa53cf996af97f27d1ee5b62783..c355660c03a639d987d6986ed4abbd28a2fc185b 100644 (file)
@@ -225,9 +225,9 @@ class SlimprotoProvider(PlayerProvider):
     _multi_streams: dict[str, MultiClientStream]
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return (ProviderFeature.SYNC_PLAYERS,)
+        return {ProviderFeature.SYNC_PLAYERS}
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
@@ -535,8 +535,8 @@ class SlimprotoProvider(PlayerProvider):
         if slimplayer := self.slimproto.get_player(player_id):
             await slimplayer.mute(muted)
 
-    async def cmd_sync(self, player_id: str, target_player: str) -> None:
-        """Handle SYNC command for given player."""
+    async def cmd_group(self, player_id: str, target_player: str) -> None:
+        """Handle GROUP command for given player."""
         child_player = self.mass.players.get(player_id)
         assert child_player  # guard
         parent_player = self.mass.players.get(target_player)
@@ -569,10 +569,10 @@ class SlimprotoProvider(PlayerProvider):
             self.mass.players.update(child_player.player_id, skip_forward=True)
             self.mass.players.update(parent_player.player_id, skip_forward=True)
 
-    async def cmd_unsync(self, player_id: str) -> None:
-        """Handle UNSYNC command for given player.
+    async def cmd_ungroup(self, player_id: str) -> None:
+        """Handle UNGROUP command for given player.
 
-        Remove the given player from any syncgroups it currently is synced to.
+        Remove the given player from any (sync)groups it currently is grouped to.
 
             - player_id: player_id of the player to handle the command.
         """
@@ -638,17 +638,18 @@ class SlimprotoProvider(PlayerProvider):
                 powered=slimplayer.powered,
                 device_info=DeviceInfo(
                     model=slimplayer.device_model,
-                    address=slimplayer.device_address,
+                    ip_address=slimplayer.device_address,
                     manufacturer=slimplayer.device_type,
                 ),
                 supported_features=(
                     PlayerFeature.POWER,
-                    PlayerFeature.SYNC,
+                    PlayerFeature.SET_MEMBERS,
                     PlayerFeature.VOLUME_SET,
                     PlayerFeature.PAUSE,
                     PlayerFeature.VOLUME_MUTE,
                     PlayerFeature.ENQUEUE,
                 ),
+                can_group_with={self.instance_id},
             )
             await self.mass.players.register_or_update(player)
 
index b987c483a67d0a9463628d60c63b5f1142d6a991..373d7332db83a74d312de44a2b5fbd2923ebf996 100644 (file)
@@ -272,9 +272,9 @@ class SnapCastProvider(PlayerProvider):
             return self._get_ma_id(snap_client_id)
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return (ProviderFeature.SYNC_PLAYERS,)
+        return {ProviderFeature.SYNC_PLAYERS}
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
@@ -373,16 +373,16 @@ class SnapCastProvider(PlayerProvider):
                 powered=snap_client.connected,
                 device_info=DeviceInfo(
                     model=snap_client._client.get("host").get("os"),
-                    address=snap_client._client.get("host").get("ip"),
+                    ip_address=snap_client._client.get("host").get("ip"),
                     manufacturer=snap_client._client.get("host").get("arch"),
                 ),
                 supported_features=(
-                    PlayerFeature.SYNC,
+                    PlayerFeature.SET_MEMBERS,
                     PlayerFeature.VOLUME_SET,
                     PlayerFeature.VOLUME_MUTE,
                 ),
-                group_childs=set(),
                 synced_to=self._synced_to(player_id),
+                can_group_with={self.instance_id},
             )
         asyncio.run_coroutine_threadsafe(
             self.mass.players.register_or_update(player), loop=self.mass.loop
@@ -449,7 +449,7 @@ class SnapCastProvider(PlayerProvider):
         ma_player.volume_muted = snapclient.muted
         self.mass.players.update(player_id)
 
-    async def cmd_sync(self, player_id: str, target_player: str) -> None:
+    async def cmd_group(self, player_id: str, target_player: str) -> None:
         """Sync Snapcast player."""
         group = self._get_snapgroup(target_player)
         mass_target_player = self.mass.players.get(target_player)
@@ -461,13 +461,13 @@ class SnapCastProvider(PlayerProvider):
             self.mass.players.update(player_id)
             self.mass.players.update(target_player)
 
-    async def cmd_unsync(self, player_id: str) -> None:
-        """Unsync Snapcast player."""
+    async def cmd_ungroup(self, player_id: str) -> None:
+        """Ungroup Snapcast player."""
         mass_player = self.mass.players.get(player_id)
         if mass_player.synced_to is None:
             for mass_child_id in list(mass_player.group_childs):
                 if mass_child_id != player_id:
-                    await self.cmd_unsync(mass_child_id)
+                    await self.cmd_ungroup(mass_child_id)
             return
         mass_sync_master_player = self.mass.players.get(mass_player.synced_to)
         mass_sync_master_player.group_childs.remove(player_id)
index 1daa076adf9c64a990e395e1f8a815aa37cf316b..5e02456635444a63e0e4d57f12b7e62e133e2510 100644 (file)
@@ -13,7 +13,7 @@ PLAYBACK_STATE_MAP = {
 }
 
 PLAYER_FEATURES_BASE = {
-    PlayerFeature.SYNC,
+    PlayerFeature.SET_MEMBERS,
     PlayerFeature.VOLUME_MUTE,
     PlayerFeature.PAUSE,
     PlayerFeature.ENQUEUE,
index a19d3c4adaaf2ac083b80480be11fb6b5c2b745b..67a307df113d9497d309520687fe0827bb33496e 100644 (file)
@@ -128,9 +128,12 @@ class SonosPlayer:
             device_info=DeviceInfo(
                 model=self.discovery_info["device"]["modelDisplayName"],
                 manufacturer=self.prov.manifest.name,
-                address=self.ip_address,
+                ip_address=self.ip_address,
             ),
-            supported_features=tuple(supported_features),
+            supported_features=supported_features,
+            # NOTE: strictly taken we can have multiple sonos households
+            # but for now we assume we only have one
+            can_group_with={self.prov.instance_id},
         )
         self.update_attributes()
         await self.mass.players.register_or_update(mass_player)
@@ -247,11 +250,10 @@ class SonosPlayer:
         if self.client.player.is_coordinator:
             # player is group coordinator
             active_group = self.client.player.group
-            self.mass_player.group_childs = (
-                set(self.client.player.group_members)
-                if len(self.client.player.group_members) > 1
-                else set()
-            )
+            if len(self.client.player.group_members) > 1:
+                self.mass_player.group_childs.set(self.client.player.group_members)
+            else:
+                self.mass_player.group_childs.clear()
             self.mass_player.synced_to = None
         else:
             # player is group child (synced to another player)
@@ -260,7 +262,7 @@ class SonosPlayer:
                 # handle race condition where the group parent is not yet discovered
                 return
             active_group = group_parent.client.player.group
-            self.mass_player.group_childs = set()
+            self.mass_player.group_childs.clear()
             self.mass_player.synced_to = active_group.coordinator_id
             self.mass_player.active_source = active_group.coordinator_id
 
index fed714df91f9184c7320ec672bb24647a1349674..99d8d17696aa25f4178631bb2167c8a9711ac212 100644 (file)
@@ -47,9 +47,9 @@ class SonosPlayerProvider(PlayerProvider):
     sonos_players: dict[str, SonosPlayer]
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return (ProviderFeature.SYNC_PLAYERS,)
+        return {ProviderFeature.SYNC_PLAYERS}
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
@@ -102,7 +102,7 @@ class SonosPlayerProvider(PlayerProvider):
                     mass_player.device_info = DeviceInfo(
                         model=mass_player.device_info.model,
                         manufacturer=mass_player.device_info.manufacturer,
-                        address=str(cur_address),
+                        ip_address=str(cur_address),
                     )
                 if not sonos_player.connected:
                     self.logger.debug("Player back online: %s", mass_player.display_name)
@@ -188,27 +188,27 @@ class SonosPlayerProvider(PlayerProvider):
         if sonos_player := self.sonos_players[player_id]:
             await sonos_player.cmd_volume_mute(muted)
 
-    async def cmd_sync(self, player_id: str, target_player: str) -> None:
-        """Handle SYNC command for given player.
+    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 (master) player/sync group.
 
             - player_id: player_id of the player to handle the command.
             - target_player: player_id of the syncgroup master or group player.
         """
-        await self.cmd_sync_many(target_player, [player_id])
+        await self.cmd_group_many(target_player, [player_id])
 
-    async def cmd_sync_many(self, target_player: str, child_player_ids: list[str]) -> None:
+    async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None:
         """Create temporary sync group by joining given players to target player."""
         sonos_player = self.sonos_players[target_player]
         await sonos_player.client.player.group.modify_group_members(
             player_ids_to_add=child_player_ids, player_ids_to_remove=[]
         )
 
-    async def cmd_unsync(self, player_id: str) -> None:
-        """Handle UNSYNC command for given player.
+    async def cmd_ungroup(self, player_id: str) -> None:
+        """Handle UNGROUP command for given player.
 
-        Remove the given player from any syncgroups it currently is synced to.
+        Remove the given player from any (sync)groups it currently is grouped to.
 
             - player_id: player_id of the player to handle the command.
         """
@@ -244,7 +244,7 @@ class SonosPlayerProvider(PlayerProvider):
                 x for x in sonos_player.client.player.group.player_ids if x != player_id
             ]
             if group_childs:
-                await self.mass.players.cmd_unsync_many(group_childs)
+                await self.mass.players.cmd_ungroup_many(group_childs)
             await self.mass.players.play_media(airplay.player_id, media)
             if group_childs:
                 # ensure master player is first in the list
index 7a3fdab49f0488e40a5b946e4ead86134cbf4925..9a9007ad40c295e3e557d10da281669b44c98416 100644 (file)
@@ -55,7 +55,7 @@ if TYPE_CHECKING:
 
 
 PLAYER_FEATURES = (
-    PlayerFeature.SYNC,
+    PlayerFeature.SET_MEMBERS,
     PlayerFeature.VOLUME_MUTE,
     PlayerFeature.PAUSE,
     PlayerFeature.ENQUEUE,
@@ -138,9 +138,9 @@ class SonosPlayerProvider(PlayerProvider):
     _discovery_reschedule_timer: asyncio.TimerHandle | None = None
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return (ProviderFeature.SYNC_PLAYERS,)
+        return {ProviderFeature.SYNC_PLAYERS}
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
@@ -255,8 +255,8 @@ class SonosPlayerProvider(PlayerProvider):
 
         await asyncio.to_thread(set_volume_mute, player_id, muted)
 
-    async def cmd_sync(self, player_id: str, target_player: str) -> None:
-        """Handle SYNC command for given player.
+    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 (master) player/sync group.
 
@@ -268,10 +268,10 @@ class SonosPlayerProvider(PlayerProvider):
         await sonos_master_player.join([sonos_player])
         self.mass.call_later(2, sonos_player.poll_speaker)
 
-    async def cmd_unsync(self, player_id: str) -> None:
-        """Handle UNSYNC command for given player.
+    async def cmd_ungroup(self, player_id: str) -> None:
+        """Handle UNGROUP command for given player.
 
-        Remove the given player from any syncgroups it currently is synced to.
+        Remove the given player from any (sync)groups it currently is grouped to.
 
             - player_id: player_id of the player to handle the command.
         """
@@ -438,11 +438,12 @@ class SonosPlayerProvider(PlayerProvider):
                 supported_features=PLAYER_FEATURES,
                 device_info=DeviceInfo(
                     model=speaker_info["model_name"],
-                    address=soco.ip_address,
+                    ip_address=soco.ip_address,
                     manufacturer="SONOS",
                 ),
                 needs_poll=True,
                 poll_interval=30,
+                can_group_with={self.instance_id},
             )
         self.sonosplayers[player_id] = sonos_player = SonosPlayer(
             self,
@@ -450,10 +451,10 @@ class SonosPlayerProvider(PlayerProvider):
             mass_player=mass_player,
         )
         if not soco.fixed_volume:
-            mass_player.supported_features = (
+            mass_player.supported_features = {
                 *mass_player.supported_features,
                 PlayerFeature.VOLUME_SET,
-            )
+            }
         asyncio.run_coroutine_threadsafe(
             self.mass.players.register_or_update(sonos_player.mass_player), loop=self.mass.loop
         )
index a0d0d525e77b03aba93ff0ba3999643bf1626bba..a66406ce6b9228b244a9c1ef1ca880df7802090f 100644 (file)
@@ -44,7 +44,7 @@ CALLBACK_TYPE = Callable[[], None]
 LOGGER = logging.getLogger(__name__)
 
 PLAYER_FEATURES = (
-    PlayerFeature.SYNC,
+    PlayerFeature.SET_MEMBERS,
     PlayerFeature.VOLUME_MUTE,
     PlayerFeature.VOLUME_SET,
 )
@@ -285,7 +285,7 @@ class SonosPlayer:
         self.setup()
         self.mass_player.device_info = DeviceInfo(
             model=self.mass_player.device_info.model,
-            address=ip_address,
+            ip_address=ip_address,
             manufacturer=self.mass_player.device_info.manufacturer,
         )
         self.update_player()
@@ -621,7 +621,7 @@ class SonosPlayer:
             self.mass_player.powered = False
             self.mass_player.state = PlayerState.IDLE
             self.mass_player.synced_to = None
-            self.mass_player.group_childs = set()
+            self.mass_player.group_childs.clear()
             return
 
         # transport info (playback state)
@@ -651,16 +651,16 @@ class SonosPlayer:
         if self.sync_coordinator:
             # player is synced to another player
             self.mass_player.synced_to = self.sync_coordinator.player_id
-            self.mass_player.group_childs = set()
+            self.mass_player.group_childs.clear()
             self.mass_player.active_source = self.sync_coordinator.mass_player.active_source
         elif len(self.group_members_ids) > 1:
             # this player is the sync leader in a group
             self.mass_player.synced_to = None
-            self.mass_player.group_childs = set(self.group_members_ids)
+            self.mass_player.group_childs.extend(self.group_members_ids)
         else:
             # standalone player, not synced
             self.mass_player.synced_to = None
-            self.mass_player.group_childs = set()
+            self.mass_player.group_childs.clear()
 
     def _set_basic_track_info(self, update_position: bool = False) -> None:
         """Query the speaker to update media metadata and position info."""
index 505f4984582da72eea542d49579a9c4943c0424c..82aaebf7baedca557f9c474e12407a1e96b810f8 100644 (file)
@@ -34,7 +34,7 @@ from music_assistant.models.music_provider import MusicProvider
 CONF_CLIENT_ID = "client_id"
 CONF_AUTHORIZATION = "authorization"
 
-SUPPORTED_FEATURES = (
+SUPPORTED_FEATURES = {
     ProviderFeature.LIBRARY_ARTISTS,
     ProviderFeature.LIBRARY_TRACKS,
     ProviderFeature.LIBRARY_PLAYLISTS,
@@ -42,7 +42,7 @@ SUPPORTED_FEATURES = (
     ProviderFeature.SEARCH,
     ProviderFeature.ARTIST_TOPTRACKS,
     ProviderFeature.SIMILAR_TRACKS,
-)
+}
 
 
 if TYPE_CHECKING:
@@ -117,7 +117,7 @@ class SoundcloudMusicProvider(MusicProvider):
         self._user_id = self._me["id"]
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return SUPPORTED_FEATURES
 
index abc1770ada22536b91137ce0e641b4adab3ead3c..a8df349834c8d8dbf1fa49c24aa6f7f414ab4366 100644 (file)
@@ -91,7 +91,7 @@ SCOPE = [
 CALLBACK_REDIRECT_URL = "https://music-assistant.io/callback"
 
 LIKED_SONGS_FAKE_PLAYLIST_ID_PREFIX = "liked_songs"
-SUPPORTED_FEATURES = (
+SUPPORTED_FEATURES = {
     ProviderFeature.LIBRARY_ARTISTS,
     ProviderFeature.LIBRARY_ALBUMS,
     ProviderFeature.LIBRARY_TRACKS,
@@ -106,7 +106,7 @@ SUPPORTED_FEATURES = (
     ProviderFeature.ARTIST_ALBUMS,
     ProviderFeature.ARTIST_TOPTRACKS,
     ProviderFeature.SIMILAR_TRACKS,
-)
+}
 
 
 async def setup(
@@ -257,9 +257,9 @@ class SpotifyProvider(MusicProvider):
         await self.login()
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return (
+        return {
             ProviderFeature.LIBRARY_ARTISTS,
             ProviderFeature.LIBRARY_ALBUMS,
             ProviderFeature.LIBRARY_TRACKS,
@@ -275,7 +275,7 @@ class SpotifyProvider(MusicProvider):
             ProviderFeature.ARTIST_ALBUMS,
             ProviderFeature.ARTIST_TOPTRACKS,
             ProviderFeature.SIMILAR_TRACKS,
-        )
+        }
 
     @property
     def name(self) -> str:
index 630f64bc6ac47b9ec1c3f4206951d5ec3eb6511b..40f5562f511fdb59a6800afb89fa80b78f88b226 100644 (file)
@@ -82,9 +82,9 @@ class TestProvider(MusicProvider):
         return False
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return (ProviderFeature.LIBRARY_TRACKS,)
+        return {ProviderFeature.LIBRARY_TRACKS}
 
     async def get_track(self, prov_track_id: str) -> Track:
         """Get full track details by id."""
index 73777653be11a1f699c577dc832578b76dc1fca9..cfaca28be7df1390527da1a9f7983087736d33e2 100644 (file)
@@ -38,11 +38,11 @@ if TYPE_CHECKING:
     from music_assistant import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
-SUPPORTED_FEATURES = (
+SUPPORTED_FEATURES = {
     ProviderFeature.ARTIST_METADATA,
     ProviderFeature.ALBUM_METADATA,
     ProviderFeature.TRACK_METADATA,
-)
+}
 
 IMG_MAPPING = {
     "strArtistThumb": ImageType.THUMB,
@@ -143,7 +143,7 @@ class AudioDbMetadataProvider(MetadataProvider):
         self.throttler = Throttler(rate_limit=1, period=1)
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return SUPPORTED_FEATURES
 
index dedfa653b02a24d547805d25b32f957e9e349038..09711526573c428cefdaf2d2581da9484a507286 100644 (file)
@@ -372,9 +372,9 @@ class TidalProvider(MusicProvider):
             raise
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return (
+        return {
             ProviderFeature.LIBRARY_ARTISTS,
             ProviderFeature.LIBRARY_ALBUMS,
             ProviderFeature.LIBRARY_TRACKS,
@@ -390,7 +390,7 @@ class TidalProvider(MusicProvider):
             ProviderFeature.SIMILAR_TRACKS,
             ProviderFeature.BROWSE,
             ProviderFeature.PLAYLIST_TRACKS_EDIT,
-        )
+        }
 
     async def search(
         self,
index 83544115013428103374feb9610cdde9460e7240..8be3df297b5f4b308260eae04a254daa56846b20 100644 (file)
@@ -26,10 +26,10 @@ from music_assistant.constants import CONF_USERNAME
 from music_assistant.helpers.throttle_retry import Throttler
 from music_assistant.models.music_provider import MusicProvider
 
-SUPPORTED_FEATURES = (
+SUPPORTED_FEATURES = {
     ProviderFeature.LIBRARY_RADIOS,
     ProviderFeature.BROWSE,
-)
+}
 
 if TYPE_CHECKING:
     from collections.abc import AsyncGenerator
@@ -82,7 +82,7 @@ class TuneInProvider(MusicProvider):
     _throttler: Throttler
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return SUPPORTED_FEATURES
 
index 0443569a9a23f728079f5261421edddac691aefa..5672e6ffd93eb98bffce4c2d885946afe753c5e6 100644 (file)
@@ -100,7 +100,7 @@ YT_PERSONAL_PLAYLISTS = (
 )
 YTM_PREMIUM_CHECK_TRACK_ID = "dQw4w9WgXcQ"
 
-SUPPORTED_FEATURES = (
+SUPPORTED_FEATURES = {
     ProviderFeature.LIBRARY_ARTISTS,
     ProviderFeature.LIBRARY_ALBUMS,
     ProviderFeature.LIBRARY_TRACKS,
@@ -110,7 +110,7 @@ SUPPORTED_FEATURES = (
     ProviderFeature.ARTIST_ALBUMS,
     ProviderFeature.ARTIST_TOPTRACKS,
     ProviderFeature.SIMILAR_TRACKS,
-)
+}
 
 
 # TODO: fix disabled tests
@@ -185,7 +185,7 @@ class YoutubeMusicProvider(MusicProvider):
             raise LoginFailed("User does not have Youtube Music Premium")
 
     @property
-    def supported_features(self) -> tuple[ProviderFeature, ...]:
+    def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
         return SUPPORTED_FEATURES
 
index 715ababb484d9656f6bde7d942fb2883262591ec..cd9e330c77054230fa63c870a607656e0159e024 100644 (file)
@@ -25,7 +25,7 @@ dependencies = [
   "mashumaro==3.14",
   "memory-tempfile==2.2.3",
   "music-assistant-frontend==v2.9.15",
-  "music-assistant-models==1.1.0",
+  "music-assistant-models==1.1.2",
   "orjson==3.10.7",
   "pillow==11.0.0",
   "python-slugify==8.0.4",
index 02eb48d9afc34ee1aaf9af748b4fe38c06241455..a0900ae09e7238a897458f88b49bf7cf5bfe5b1c 100644 (file)
@@ -23,7 +23,7 @@ ifaddr==0.2.0
 mashumaro==3.14
 memory-tempfile==2.2.3
 music-assistant-frontend==v2.9.15
-music-assistant-models==1.1.0
+music-assistant-models==1.1.2
 orjson==3.10.7
 pillow==11.0.0
 pkce==1.0.3