Merge players with multiple protocols together (#3150)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 15 Feb 2026 14:50:45 +0000 (15:50 +0100)
committerGitHub <noreply@github.com>
Sun, 15 Feb 2026 14:50:45 +0000 (15:50 +0100)
* Base implementation of merging players through protocol linking

* Restore after merge conflict

* follow-up

* Fix config handling for players

* some tweaks

* Handle more (edge) cases

* bunch of fixes

* More fixes

* Some more tweaks

* Fix group_members calc

* A bunch of tweaks and refactoring

* more tweaks

* more follow-up

* Update controller.py

* refactoring groups

* more fixes for syncgroup

* fix readme nitpick

* revert frozen in uv run

* Add default providers conf

* fix tests

* more tweaks

* fixes for syncgroups

* More small tweaks

* more sync player tweaks

79 files changed:
music_assistant/constants.py
music_assistant/controllers/config.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/players/README.md [new file with mode: 0644]
music_assistant/controllers/players/__init__.py
music_assistant/controllers/players/controller.py [new file with mode: 0644]
music_assistant/controllers/players/helpers.py [new file with mode: 0644]
music_assistant/controllers/players/player_controller.py [deleted file]
music_assistant/controllers/players/protocol_linking.py [new file with mode: 0644]
music_assistant/controllers/players/sync_groups.py [deleted file]
music_assistant/controllers/streams/streams_controller.py
music_assistant/controllers/webserver/README.md
music_assistant/helpers/audio.py
music_assistant/helpers/util.py
music_assistant/mass.py
music_assistant/models/player.py
music_assistant/models/player_provider.py
music_assistant/models/provider.py
music_assistant/providers/_demo_player_provider/player.py
music_assistant/providers/_demo_player_provider/provider.py
music_assistant/providers/airplay/helpers.py
music_assistant/providers/airplay/player.py
music_assistant/providers/airplay/protocols/raop.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/airplay/stream_session.py
music_assistant/providers/airplay_receiver/__init__.py
music_assistant/providers/alexa/__init__.py
music_assistant/providers/bluesound/const.py
music_assistant/providers/bluesound/player.py
music_assistant/providers/bluesound/provider.py
music_assistant/providers/chromecast/helpers.py
music_assistant/providers/chromecast/player.py
music_assistant/providers/chromecast/provider.py
music_assistant/providers/dlna/manifest.json
music_assistant/providers/dlna/player.py
music_assistant/providers/dlna/provider.py
music_assistant/providers/fully_kiosk/player.py
music_assistant/providers/hass_players/player.py
music_assistant/providers/hass_players/provider.py
music_assistant/providers/heos/player.py
music_assistant/providers/heos/provider.py
music_assistant/providers/musiccast/player.py
music_assistant/providers/musiccast/provider.py
music_assistant/providers/plex_connect/__init__.py
music_assistant/providers/plex_connect/player_remote.py
music_assistant/providers/roku_media_assistant/player.py
music_assistant/providers/roku_media_assistant/provider.py
music_assistant/providers/sendspin/player.py
music_assistant/providers/snapcast/player.py
music_assistant/providers/snapcast/provider.py
music_assistant/providers/sonos/const.py
music_assistant/providers/sonos/player.py
music_assistant/providers/sonos/provider.py
music_assistant/providers/sonos_s1/constants.py
music_assistant/providers/sonos_s1/player.py
music_assistant/providers/sonos_s1/provider.py
music_assistant/providers/spotify_connect/ARCHITECTURE.md
music_assistant/providers/spotify_connect/__init__.py
music_assistant/providers/squeezelite/player.py
music_assistant/providers/squeezelite/provider.py
music_assistant/providers/sync_group/__init__.py [new file with mode: 0644]
music_assistant/providers/sync_group/constants.py [new file with mode: 0644]
music_assistant/providers/sync_group/icon.svg [new file with mode: 0644]
music_assistant/providers/sync_group/icon_monochrome.svg [new file with mode: 0644]
music_assistant/providers/sync_group/manifest.json [new file with mode: 0644]
music_assistant/providers/sync_group/player.py [new file with mode: 0644]
music_assistant/providers/sync_group/provider.py [new file with mode: 0644]
music_assistant/providers/universal_group/player.py
music_assistant/providers/universal_player/README.md [new file with mode: 0644]
music_assistant/providers/universal_player/__init__.py [new file with mode: 0644]
music_assistant/providers/universal_player/constants.py [new file with mode: 0644]
music_assistant/providers/universal_player/manifest.json [new file with mode: 0644]
music_assistant/providers/universal_player/player.py [new file with mode: 0644]
music_assistant/providers/universal_player/provider.py [new file with mode: 0644]
pyproject.toml
tests/common.py
tests/core/test_player_controller.py [new file with mode: 0644]
tests/core/test_player_grouping.py [new file with mode: 0644]
tests/core/test_protocol_linking.py [new file with mode: 0644]

index dd5d7786840bf6c1feafce4c172b248ab89b75e6..f82c797d50062326d2799643482425998270693f 100644 (file)
@@ -9,14 +9,8 @@ from music_assistant_models.config_entries import (
     ConfigEntry,
     ConfigValueOption,
 )
-from music_assistant_models.enums import ConfigEntryType, ContentType, MediaType
-from music_assistant_models.media_items import (
-    Audiobook,
-    AudioFormat,
-    PodcastEpisode,
-    Radio,
-    Track,
-)
+from music_assistant_models.enums import ConfigEntryType, ContentType, MediaType, PlayerFeature
+from music_assistant_models.media_items import Audiobook, AudioFormat, PodcastEpisode, Radio, Track
 
 APPLICATION_NAME: Final = "Music Assistant"
 
@@ -108,6 +102,13 @@ CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS: Final[str] = "volume_normalization_
 CONF_POWER_CONTROL: Final[str] = "power_control"
 CONF_VOLUME_CONTROL: Final[str] = "volume_control"
 CONF_MUTE_CONTROL: Final[str] = "mute_control"
+CONF_PREFERRED_OUTPUT_PROTOCOL: Final[str] = "preferred_output_protocol"
+CONF_LINKED_PROTOCOL_PLAYER_IDS: Final[str] = (
+    "linked_protocol_player_ids"  # cached for fast restart
+)
+CONF_PROTOCOL_PARENT_ID: Final[str] = (
+    "protocol_parent_id"  # cached native player ID for protocol player
+)
 CONF_OUTPUT_CODEC: Final[str] = "output_codec"
 CONF_ALLOW_AUDIO_CACHE: Final[str] = "allow_audio_cache"
 CONF_SMART_FADES_MODE: Final[str] = "smart_fades_mode"
@@ -117,6 +118,10 @@ CONF_SSL_FINGERPRINT: Final[str] = "ssl_fingerprint"
 CONF_AUTH_ALLOW_SELF_REGISTRATION: Final[str] = "auth_allow_self_registration"
 CONF_ZEROCONF_INTERFACES: Final[str] = "zeroconf_interfaces"
 CONF_ENABLED: Final[str] = "enabled"
+CONF_PROTOCOL_KEY_SPLITTER: Final[str] = "||protocol||"
+CONF_PROTOCOL_CATEGORY_PREFIX: Final[str] = "protocol"
+CONF_DEFAULT_PROVIDERS_SETUP: Final[str] = "default_providers_setup"
+
 
 # config default values
 DEFAULT_HOST: Final[str] = "0.0.0.0"
@@ -159,7 +164,7 @@ CONFIGURABLE_CORE_CONTROLLERS = (
 )
 VERBOSE_LOG_LEVEL: Final[int] = 5
 PROVIDERS_WITH_SHAREABLE_URLS = ("spotify", "qobuz")
-SYNCGROUP_PREFIX: Final[str] = "syncgroup_"
+
 
 ####### REUSABLE CONFIG ENTRIES #######
 
@@ -237,7 +242,7 @@ CONF_ENTRY_VOLUME_NORMALIZATION = ConfigEntry(
 CONF_ENTRY_VOLUME_NORMALIZATION_TARGET = ConfigEntry(
     key=CONF_VOLUME_NORMALIZATION_TARGET,
     type=ConfigEntryType.INTEGER,
-    range=(-70, -5),
+    range=(-30, -5),
     default_value=-17,
     label="Target level for volume normalization",
     description="Adjust average (perceived) loudness to this target level",
@@ -939,3 +944,42 @@ SOUNDTRACK_INDICATORS = [
 # for provider domains in this list, we won't show the default
 # http-streaming specific config options in player settings
 NON_HTTP_PROVIDERS = ("airplay", "sendspin", "snapcast")
+
+# Protocol priority values (lower = more preferred)
+PROTOCOL_PRIORITY: Final[dict[str, int]] = {
+    "sendspin": 10,
+    "squeezelite": 20,
+    "chromecast": 30,
+    "airplay": 40,
+    "dlna": 50,
+}
+
+PROTOCOL_FEATURES: Final[set[PlayerFeature]] = {
+    # Player features that may be copied from (inactive) protocol implementations
+    PlayerFeature.VOLUME_SET,
+    PlayerFeature.VOLUME_MUTE,
+    PlayerFeature.PLAY_ANNOUNCEMENT,
+    PlayerFeature.SET_MEMBERS,
+}
+
+ACTIVE_PROTOCOL_FEATURES: Final[set[PlayerFeature]] = {
+    # Player features that may be copied from the active output protocol
+    *PROTOCOL_FEATURES,
+    PlayerFeature.ENQUEUE,
+    PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE,
+    PlayerFeature.GAPLESS_PLAYBACK,
+    PlayerFeature.MULTI_DEVICE_DSP,
+    PlayerFeature.PAUSE,
+}
+
+DEFAULT_PROVIDERS: Final[set[tuple[str, bool]]] = {
+    # list of providers that are setup by default once
+    # (and they can be removed/disabled by the user if they want to)
+    # the boolean value indicates whether it needs to be discovered on mdns
+    ("airplay", False),
+    ("chromecast", False),
+    ("dlna", False),
+    ("sonos", True),
+    ("bluesound", True),
+    ("heos", True),
+}
index 6e76abf1f4d2cd97d4195d58804a5e5bffb9bc5c..235de4f617a87f2e68fb19d7b26a98b1bcf8e1cd 100644 (file)
@@ -47,6 +47,7 @@ from music_assistant_models.errors import (
 
 from music_assistant.constants import (
     CONF_CORE,
+    CONF_ENABLED,
     CONF_ENTRY_ANNOUNCE_VOLUME,
     CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
     CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
@@ -92,22 +93,25 @@ from music_assistant.constants import (
     CONF_PLAYERS,
     CONF_POWER_CONTROL,
     CONF_PRE_ANNOUNCE_CHIME_URL,
+    CONF_PREFERRED_OUTPUT_PROTOCOL,
+    CONF_PROTOCOL_CATEGORY_PREFIX,
+    CONF_PROTOCOL_KEY_SPLITTER,
     CONF_PROVIDERS,
     CONF_SERVER_ID,
-    CONF_SMART_FADES_MODE,
     CONF_VOLUME_CONTROL,
     CONFIGURABLE_CORE_CONTROLLERS,
     DEFAULT_CORE_CONFIG_ENTRIES,
     DEFAULT_PROVIDER_CONFIG_ENTRIES,
     ENCRYPT_SUFFIX,
     NON_HTTP_PROVIDERS,
-    SYNCGROUP_PREFIX,
 )
 from music_assistant.helpers.api import api_command
 from music_assistant.helpers.json import JSON_DECODE_EXCEPTIONS, async_json_dumps, async_json_loads
 from music_assistant.helpers.util import load_provider_module, validate_announcement_chime_url
 from music_assistant.models import ProviderModuleType
 from music_assistant.models.music_provider import MusicProvider
+from music_assistant.providers.sync_group.constants import SGP_PREFIX
+from music_assistant.providers.universal_group.constants import UGP_PREFIX
 
 if TYPE_CHECKING:
     from music_assistant import MusicAssistant
@@ -547,12 +551,18 @@ class ConfigController:
             # filter out unavailable players
             # (unless disabled, otherwise there is no way to re-enable them)
             # note that we only check for missing players in the player controller,
-            # and we do allow players that are temporary unavailable (player.available = false)
-            # because this can also mean that the player needs additional configuration
-            # such as airplay devices that need pairing.
-            player = self.mass.players.get(raw_conf["player_id"], False)
+            # and we do allow players that are temporary unavailable
+            # (player.state.available = false) because this can also mean that the
+            # player needs additional configuration such as airplay devices that need pairing.
+            player = self.mass.players.get_player(raw_conf["player_id"], False)
             if not include_unavailable and player is None and raw_conf.get("enabled", True):
                 continue
+            # filter out protocol players
+            # their configuration is handled differently as part of their parent player
+            if raw_conf.get("player_type") == PlayerType.PROTOCOL or (
+                player and player.state.type == PlayerType.PROTOCOL
+            ):
+                continue
             # filter out disabled players
             if not include_disabled and not raw_conf.get("enabled", True):
                 continue
@@ -560,9 +570,9 @@ class ConfigController:
                 result.append(await self.get_player_config(raw_conf["player_id"]))
             else:
                 raw_conf["default_name"] = (
-                    player.display_name if player else raw_conf.get("default_name")
+                    player.state.name if player else raw_conf.get("default_name")
                 )
-                raw_conf["available"] = player.available if player else False
+                raw_conf["available"] = player.state.available if player else False
                 result.append(cast("PlayerConfig", PlayerConfig.parse([], raw_conf)))
         return result
 
@@ -570,27 +580,29 @@ class ConfigController:
     async def get_player_config(
         self,
         player_id: str,
-        action: str | None = None,
-        values: dict[str, ConfigValueType] | None = None,
     ) -> PlayerConfig:
         """Return (full) configuration for a single player."""
         raw_conf: dict[str, Any]
         if raw_conf := self.get(f"{CONF_PLAYERS}/{player_id}"):
-            if player := self.mass.players.get(player_id, False):
-                raw_conf["default_name"] = player.display_name
+            raw_conf = deepcopy(raw_conf)
+            if player := self.mass.players.get_player(player_id, False):
+                raw_conf["default_name"] = player.state.name
                 raw_conf["provider"] = player.provider.instance_id
-                # pass action and values to get_config_entries
-                if values is None:
-                    values = raw_conf.get("values", {})
-                conf_entries = await self.get_player_config_entries(
-                    player_id, action=action, values=values
+                config_entries = await self.get_player_config_entries(
+                    player_id,
                 )
+                # also grab (raw) values for protocol outputs
+                if protocol_values := await self._get_output_protocol_config_values(config_entries):
+                    if "values" not in raw_conf:
+                        raw_conf["values"] = {}
+                    raw_conf["values"].update(protocol_values)
             else:
                 # handle unavailable player and/or provider
-                conf_entries = []
+                config_entries = []
                 raw_conf["available"] = False
                 raw_conf["default_name"] = raw_conf.get("default_name") or raw_conf["player_id"]
-            return cast("PlayerConfig", PlayerConfig.parse(conf_entries, raw_conf))
+
+            return cast("PlayerConfig", PlayerConfig.parse(config_entries, raw_conf))
         msg = f"No config found for player id {player_id}"
         raise KeyError(msg)
 
@@ -608,13 +620,36 @@ class ConfigController:
         action: [optional] action key called from config entries UI.
         values: the (intermediate) raw values for config entries sent with the action.
         """
-        if not (player := self.mass.players.get(player_id, False)):
+        if not (player := self.mass.players.get_player(player_id, False)):
             msg = f"Player {player_id} not found"
             raise KeyError(msg)
-        # get player(protocol) specific entries
-        player_entries = await self._get_player_config_entries(player, action=action, values=values)
-        # get default entries which are common for all players
-        default_entries = self._get_default_player_config_entries(player)
+
+        default_entries: list[ConfigEntry]
+        player_entries: list[ConfigEntry]
+        if player.state.type == PlayerType.PROTOCOL:
+            default_entries = []
+            player_entries = await self._get_player_config_entries(
+                player, action=action, values=values
+            )
+        else:
+            # get default entries which are common for all (non protocol)players
+            default_entries = self._get_default_player_config_entries(player)
+
+            # get player(protocol) specific entries
+            # this basically injects virtual config entries for each protocol output
+            # this feels maybe a bit of a hack to do it this way but it keeps the UI logic simple
+            # and maximizes api client compatibility because you can configure the whole player
+            # including its protocols from a single config endpoint without needing special handling
+            # for protocol players in the UI/api clients
+            if protocol_entries := await self._create_output_protocol_config_entries(
+                player, action=action, values=values
+            ):
+                player_entries = protocol_entries
+            else:
+                player_entries = await self._get_player_config_entries(
+                    player, action=action, values=values
+                )
+
         player_entries_keys = {entry.key for entry in player_entries}
         all_entries = [
             # ignore default entries that were overridden by the player specific ones
@@ -763,6 +798,7 @@ class ConfigController:
         self, player_id: str, values: dict[str, ConfigValueType]
     ) -> PlayerConfig:
         """Save/update PlayerConfig."""
+        values = await self._update_output_protocol_config(values)
         config = await self.get_player_config(player_id)
         old_config = deepcopy(config)
         changed_keys = config.update(values)
@@ -797,7 +833,7 @@ class ConfigController:
         if not player_config:
             msg = f"Player configuration for {player_id} does not exist"
             raise KeyError(msg)
-        if self.mass.players.get(player_id):
+        if self.mass.players.get_player(player_id):
             try:
                 await self.mass.players.remove(player_id)
             except UnsupportedFeaturedException:
@@ -948,7 +984,7 @@ class ConfigController:
         """
         Create builtin ProviderConfig.
 
-        This is meant as helper to create default configs for builtin providers.
+        This is meant as helper to create default configs for builtin/default providers.
         Called by the server initialization code which load all providers at startup.
         """
         for _ in await self.get_provider_configs(provider_domain=provider_domain):
@@ -1303,11 +1339,13 @@ class ConfigController:
 
         # some type hints to help with the code below
         instance_id: str
+        player_id: str
         provider_config: dict[str, Any]
         player_config: dict[str, Any]
 
         # Older versions of MA can create corrupt entries with no domain if retrying
         # logic runs after a provider has been removed. Remove those corrupt entries.
+        # TODO: remove after 2.8 release
         for instance_id, provider_config in {**self._data.get(CONF_PROVIDERS, {})}.items():
             if "domain" not in provider_config:
                 self._data[CONF_PROVIDERS].pop(instance_id, None)
@@ -1315,6 +1353,7 @@ class ConfigController:
                 changed = True
 
         # migrate manual_ips to new format
+        # TODO: remove after 2.8 release
         for instance_id, provider_config in self._data.get(CONF_PROVIDERS, {}).items():
             if not (values := provider_config.get("values")):
                 continue
@@ -1325,6 +1364,7 @@ class ConfigController:
             changed = True
 
         # migrate sample_rates config entry
+        # TODO: remove after 2.8 release
         for player_config in self._data.get(CONF_PLAYERS, {}).values():
             if not (values := player_config.get("values")):
                 continue
@@ -1340,59 +1380,8 @@ class ConfigController:
             ]
             changed = True
 
-        # migrate player_group entries
-        ugp_found = False
-        for player_config in self._data.get(CONF_PLAYERS, {}).values():
-            provider = player_config.get("provider")
-            if (
-                not provider
-                or not isinstance(provider, str)
-                or not provider.startswith("player_group")
-            ):
-                continue
-            if not (values := player_config.get("values")):
-                continue
-            if (group_type := values.pop("group_type", None)) is None:
-                continue
-            # this is a legacy player group, migrate the values
-            changed = True
-            if group_type == "universal":
-                player_config["provider"] = "universal_group"
-                ugp_found = True
-            else:
-                player_config["provider"] = group_type
-        for provider_config in list(self._data.get(CONF_PROVIDERS, {}).values()):
-            instance_id = provider_config["instance_id"]
-            if not instance_id.startswith("player_group"):
-                continue
-            # this is the legacy player_group provider, migrate into 'universal_group'
-            changed = True
-            self._data[CONF_PROVIDERS].pop(instance_id, None)
-            if not ugp_found:
-                continue
-            provider_config["domain"] = "universal_group"
-            provider_config["instance_id"] = "universal_group"
-            self._data[CONF_PROVIDERS]["universal_group"] = provider_config
-
-        # Migrate resonate provider to sendspin (renamed in 2.7 beta 19)
-        for instance_id, provider_config in list(self._data.get(CONF_PROVIDERS, {}).items()):
-            if provider_config.get("domain") == "resonate":
-                self._data[CONF_PROVIDERS].pop(instance_id, None)
-                provider_config["domain"] = "sendspin"
-                provider_config["instance_id"] = "sendspin"
-                self._data[CONF_PROVIDERS]["sendspin"] = provider_config
-                changed = True
-
-        # Migrate smart_fades mode value to smart_crossfade
-        for player_config in self._data.get(CONF_PLAYERS, {}).values():
-            if not (values := player_config.get("values")):
-                continue
-            if values.get(CONF_SMART_FADES_MODE) == "smart_fades":
-                # Update old 'smart_fades' value to new 'smart_crossfade' value
-                values[CONF_SMART_FADES_MODE] = "smart_crossfade"
-                changed = True
-
         # Remove obsolete builtin_player configurations (provider was deleted in 2.7)
+        # TODO: remove after 2.8 release
         for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
             if player_config.get("provider") != "builtin_player":
                 continue
@@ -1404,34 +1393,37 @@ class ConfigController:
             changed = True
 
         # Remove corrupt player configurations that are missing the required 'provider' key
+        # or have an invalid/removed provider
+        all_provider_ids: set[str] = set(self._data.get(CONF_PROVIDERS, {}).keys())
+        # TODO: remove after 2.8 release
         for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
-            if "provider" in player_config:
+            player_provider = player_config.get("provider")
+            if not player_provider:
+                LOGGER.warning("Removing corrupt player configuration: %s", player_id)
+            elif player_provider not in all_provider_ids:
+                LOGGER.warning("Removed orphaned player configuration: %s", player_id)
+            else:
                 continue
             self._data[CONF_PLAYERS].pop(player_id, None)
             # Also remove any DSP config for this player
             if CONF_PLAYER_DSP in self._data:
                 self._data[CONF_PLAYER_DSP].pop(player_id, None)
-            LOGGER.warning("Removed corrupt player configuration (missing provider): %s", player_id)
             changed = True
 
-        # migrate player configs: always use instance_id for provider
-        for player_config in self._data.get(CONF_PLAYERS, {}).values():
-            if "provider" not in player_config:
+        # migrate sync_group players to use the new sync_group provider
+        # TODO: remove after 2.8 release
+        for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
+            if not player_id.startswith(SGP_PREFIX):
                 continue
             player_provider = player_config["provider"]
-            try:
-                if not (prov := self.mass.get_provider(player_provider)):
-                    continue
-            except KeyError:
-                # removed provider
-                continue
-            if player_config["provider"] == prov.instance_id:
+            if player_provider == "sync_group":
                 continue
-            player_config["provider"] = prov.instance_id
+            player_config["provider"] = "sync_group"
             changed = True
 
         # Migrate AirPlay legacy credentials (ap_credentials) to protocol-specific keys
         # The old key was used for both RAOP and AirPlay, now we have separate keys
+        # TODO: remove after 2.8 release
         for player_id, player_config in self._data.get(CONF_PLAYERS, {}).items():
             if player_config.get("provider") != "airplay":
                 continue
@@ -1610,12 +1602,12 @@ class ConfigController:
         values: the (intermediate) raw values for config entries sent with the action.
         """
         default_entries: list[ConfigEntry]
-        is_dedicated_group_player = player.type in (
+        is_dedicated_group_player = player.state.type in (
             PlayerType.GROUP,
             PlayerType.STEREO_PAIR,
-        ) and not player.player_id.startswith(("universal_", SYNCGROUP_PREFIX))
+        ) and not player.player_id.startswith((UGP_PREFIX, SGP_PREFIX))
         is_http_based_player_protocol = player.provider.domain not in NON_HTTP_PROVIDERS
-        if player.type == PlayerType.GROUP and not is_dedicated_group_player:
+        if player.state.type == PlayerType.GROUP and not is_dedicated_group_player:
             # no audio related entries for universal group players or sync group players
             default_entries = []
         else:
@@ -1650,7 +1642,7 @@ class ConfigController:
         """
         entries: list[ConfigEntry] = []
         # default protocol-player config entries
-        if player.type == PlayerType.PROTOCOL:
+        if player.state.type == PlayerType.PROTOCOL:
             # protocol players have no generic config entries
             # only audio/protocol specific ones
             return []
@@ -1704,7 +1696,7 @@ class ConfigController:
             ),
         ]
         # group-player config entries
-        if player.type == PlayerType.GROUP:
+        if player.state.type == PlayerType.GROUP:
             entries += [
                 CONF_ENTRY_PLAYER_ICON_GROUP,
             ]
@@ -1727,33 +1719,51 @@ class ConfigController:
         volume_controls = [x for x in all_controls if x.supports_volume]
         mute_controls = [x for x in all_controls if x.supports_mute]
         # work out player supported features
-        supports_power = PlayerFeature.POWER in player.supported_features
-        supports_volume = PlayerFeature.VOLUME_SET in player.supported_features
-        supports_mute = PlayerFeature.VOLUME_MUTE in player.supported_features
-        # create base options per control type (and add defaults like native and fake)
-        base_power_options: list[ConfigValueOption] = [
-            ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
-            ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE),
-        ]
-        if supports_power:
+        base_power_options: list[ConfigValueOption] = []
+        if player.supports_feature(PlayerFeature.POWER):
             base_power_options.append(
                 ConfigValueOption(title="Native power control", value=PLAYER_CONTROL_NATIVE),
             )
-        base_volume_options: list[ConfigValueOption] = [
-            ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
-        ]
-        if supports_volume:
+        base_volume_options: list[ConfigValueOption] = []
+        if player.supports_feature(PlayerFeature.VOLUME_SET):
             base_volume_options.append(
                 ConfigValueOption(title="Native volume control", value=PLAYER_CONTROL_NATIVE),
             )
-        base_mute_options: list[ConfigValueOption] = [
+        base_mute_options: list[ConfigValueOption] = []
+        if player.supports_feature(PlayerFeature.VOLUME_MUTE):
+            base_mute_options.append(
+                ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE),
+            )
+        # append protocol-specific volume and mute controls to the base options
+        for linked_protocol in player.linked_output_protocols:
+            if protocol_player := self.mass.players.get_player(linked_protocol.output_protocol_id):
+                if protocol_player.supports_feature(PlayerFeature.VOLUME_SET):
+                    base_volume_options.append(
+                        ConfigValueOption(
+                            title=linked_protocol.name, value=linked_protocol.output_protocol_id
+                        )
+                    )
+                if protocol_player.supports_feature(PlayerFeature.VOLUME_MUTE):
+                    base_mute_options.append(
+                        ConfigValueOption(
+                            title=linked_protocol.name,
+                            value=linked_protocol.output_protocol_id,
+                        )
+                    )
+        # append none+fake options
+        base_power_options += [
+            ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
+            ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE),
+        ]
+        base_volume_options += [
             ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
-            ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE),
         ]
-        if supports_mute:
+        base_mute_options.append(ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE))
+        if player.supports_feature(PlayerFeature.VOLUME_SET):
             base_mute_options.append(
-                ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE),
+                ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE)
             )
+
         # return final config entries for all options
         return [
             # Power control config entry
@@ -1761,43 +1771,226 @@ class ConfigController:
                 key=CONF_POWER_CONTROL,
                 type=ConfigEntryType.STRING,
                 label="Power Control",
-                default_value=PLAYER_CONTROL_NATIVE if supports_power else PLAYER_CONTROL_NONE,
-                required=True,
+                default_value=base_power_options[0].value
+                if base_power_options
+                else PLAYER_CONTROL_NONE,
+                required=False,
                 options=[
                     *base_power_options,
                     *(ConfigValueOption(x.name, x.id) for x in power_controls),
                 ],
                 category="player_controls",
-                hidden=player.type == PlayerType.GROUP,
             ),
             # Volume control config entry
             ConfigEntry(
                 key=CONF_VOLUME_CONTROL,
                 type=ConfigEntryType.STRING,
                 label="Volume Control",
-                default_value=PLAYER_CONTROL_NATIVE if supports_volume else PLAYER_CONTROL_NONE,
+                default_value=base_volume_options[0].value
+                if base_volume_options
+                else PLAYER_CONTROL_NONE,
                 required=True,
                 options=[
                     *base_volume_options,
                     *(ConfigValueOption(x.name, x.id) for x in volume_controls),
                 ],
                 category="player_controls",
-                hidden=player.type == PlayerType.GROUP,
             ),
             # Mute control config entry
             ConfigEntry(
                 key=CONF_MUTE_CONTROL,
                 type=ConfigEntryType.STRING,
                 label="Mute Control",
-                default_value=PLAYER_CONTROL_NATIVE if supports_mute else PLAYER_CONTROL_NONE,
+                default_value=base_mute_options[0].value
+                if base_mute_options
+                else PLAYER_CONTROL_NONE,
                 required=True,
                 options=[
                     *base_mute_options,
                     *[ConfigValueOption(x.name, x.id) for x in mute_controls],
                 ],
                 category="player_controls",
-                hidden=player.type == PlayerType.GROUP,
             ),
             # auto-play on power on control config entry
             CONF_ENTRY_AUTO_PLAY,
         ]
+
+    async def _create_output_protocol_config_entries(  # noqa: PLR0915
+        self,
+        player: Player,
+        action: str | None = None,
+        values: dict[str, ConfigValueType] | None = None,
+    ) -> list[ConfigEntry]:
+        """
+        Create config entry for preferred output protocol.
+
+        Returns empty list if there are no output protocol options (native only or no protocols).
+        The player.output_protocols property includes native, active, and disabled protocols,
+        with the available flag indicating their status.
+        """
+        all_entries: list[ConfigEntry] = []
+        output_protocols = player.output_protocols
+
+        # Only show config if there are multiple output options
+        if len(output_protocols) <= 1:
+            return all_entries
+
+        # Build options from available output protocols, sorted by priority
+        options: list[ConfigValueOption] = []
+        default_value: str | None = None
+
+        # Add each available output protocol as an option, sorted by priority
+        for protocol in sorted(output_protocols, key=lambda p: p.priority):
+            if provider_manifest := self.mass.get_provider_manifest(protocol.protocol_domain):
+                protocol_name = provider_manifest.name
+            else:
+                protocol_name = protocol.protocol_domain.upper()
+            if protocol.available:
+                # Use "native" for native playback,
+                # otherwise use the protocol output id (=player id)
+                title = f"{protocol_name} (native)" if protocol.is_native else protocol_name
+                value = "native" if protocol.is_native else protocol.output_protocol_id
+                options.append(ConfigValueOption(title=title, value=value))
+                # First available protocol becomes the default (highest priority)
+                if default_value is None:
+                    default_value = str(value)
+
+        all_entries.append(
+            ConfigEntry(
+                key=CONF_PREFERRED_OUTPUT_PROTOCOL,
+                type=ConfigEntryType.STRING,
+                label="Preferred Output Protocol",
+                description="Select the preferred protocol for audio playback to this device.",
+                default_value=default_value or "native",
+                required=True,
+                options=options,
+                category="protocol_general",
+                requires_reload=False,
+            )
+        )
+
+        # Add config entries for all protocol players/outputs
+        for protocol in output_protocols:
+            domain = protocol.protocol_domain
+            if provider_manifest := self.mass.get_provider_manifest(protocol.protocol_domain):
+                protocol_name = provider_manifest.name
+            else:
+                protocol_name = protocol.protocol_domain.upper()
+            protocol_player_enabled = self.get_raw_player_config_value(
+                protocol.output_protocol_id, CONF_ENABLED, True
+            )
+            provider_available = self.mass.get_provider(protocol.protocol_domain) is not None
+            if not provider_available:
+                # protocol provider is not available, skip adding entries
+                continue
+            protocol_prefix = f"{protocol.output_protocol_id}{CONF_PROTOCOL_KEY_SPLITTER}"
+            protocol_enabled_key = f"{protocol_prefix}enabled"
+            protocol_category = f"{CONF_PROTOCOL_CATEGORY_PREFIX}_{domain}"
+            category_translation_key = "settings.category.protocol_output_settings"
+            if not protocol.is_native:
+                all_entries.append(
+                    ConfigEntry(
+                        key=protocol_enabled_key,
+                        type=ConfigEntryType.BOOLEAN,
+                        label="Enable",
+                        description="Enable or disable this output protocol for the player.",
+                        value=protocol_player_enabled,
+                        default_value=protocol_player_enabled,
+                        category=protocol_category,
+                        category_translation_key=category_translation_key,
+                        category_translation_params=[protocol_name],
+                        requires_reload=False,
+                    )
+                )
+            if protocol.is_native:
+                # add protocol-specific entries from native player
+                protocol_entries = await self._get_player_config_entries(
+                    player, action=action, values=values
+                )
+                for proto_entry in protocol_entries:
+                    # deep copy to avoid mutating shared/constant ConfigEntry objects
+                    entry = deepcopy(proto_entry)
+                    entry.category = protocol_category
+                    entry.category_translation_key = category_translation_key
+                    entry.category_translation_params = [protocol_name]
+                    all_entries.append(entry)
+
+            elif protocol_player := self.mass.players.get_player(protocol.output_protocol_id):
+                # we grab the config entries from the protocol player
+                # and then prefix them to avoid key collisions
+
+                if action and protocol_prefix in action:
+                    protocol_action = action.replace(protocol_prefix, "")
+                else:
+                    protocol_action = None
+                if values:
+                    # extract only relevant values for this protocol player
+                    protocol_values = {
+                        key.replace(protocol_prefix, ""): val
+                        for key, val in values.items()
+                        if key.startswith(protocol_prefix)
+                    }
+                else:
+                    protocol_values = None
+                protocol_entries = await self._get_player_config_entries(
+                    protocol_player, action=protocol_action, values=protocol_values
+                )
+                for proto_entry in protocol_entries:
+                    # deep copy to avoid mutating shared/constant ConfigEntry objects
+                    entry = deepcopy(proto_entry)
+                    entry.category = protocol_category
+                    entry.category_translation_key = category_translation_key
+                    entry.category_translation_params = [protocol_name]
+                    entry.key = f"{protocol_prefix}{entry.key}"
+                    entry.depends_on = None if protocol.is_native else protocol_enabled_key
+                    entry.action = f"{protocol_prefix}{entry.action}" if entry.action else None
+                    all_entries.append(entry)
+
+        return all_entries
+
+    async def _update_output_protocol_config(
+        self, values: dict[str, ConfigValueType]
+    ) -> dict[str, ConfigValueType]:
+        """
+        Update output protocol related config for a player based on config values.
+
+        Returns updated values dict with output protocol related entries removed.
+        """
+        protocol_values: dict[str, dict[str, ConfigValueType]] = {}
+        for key, value in list(values.items()):
+            if CONF_PROTOCOL_KEY_SPLITTER not in key:
+                continue
+            # extract protocol player id and actual key
+            protocol_player_id, actual_key = key.split(CONF_PROTOCOL_KEY_SPLITTER)
+            if protocol_player_id not in protocol_values:
+                protocol_values[protocol_player_id] = {}
+            protocol_values[protocol_player_id][actual_key] = value
+            # remove from main values dict
+            del values[key]
+        for protocol_player_id, proto_values in protocol_values.items():
+            await self.save_player_config(protocol_player_id, proto_values)
+            if proto_values.get(CONF_ENABLED):
+                # wait max 10 seconds for protocol to become available
+                for _ in range(10):
+                    protocol_player = self.mass.players.get_player(protocol_player_id)
+                    if protocol_player is not None:
+                        break
+                    await asyncio.sleep(1)
+            # wait max 10 seconds for protocol
+        return values
+
+    async def _get_output_protocol_config_values(
+        self,
+        entries: list[ConfigEntry],
+    ) -> dict[str, ConfigValueType]:
+        """Extract output protocol related config values for given (parent) player entries."""
+        values: dict[str, ConfigValueType] = {}
+        for entry in entries:
+            if CONF_PROTOCOL_KEY_SPLITTER not in entry.key:
+                continue
+            protocol_player_id, actual_key = entry.key.split(CONF_PROTOCOL_KEY_SPLITTER)
+            stored_value = self.get_raw_player_config_value(protocol_player_id, actual_key)
+            if stored_value is None:
+                continue
+            values[entry.key] = stored_value
+        return values
index d30f26c8b9a5d2116d240842eed24c77cb9d111a..658bcd85d520e9d74a37c8360558dea4053cb9c3 100644 (file)
@@ -28,7 +28,6 @@ from music_assistant_models.enums import (
     EventType,
     MediaType,
     PlaybackState,
-    PlayerFeature,
     ProviderFeature,
     QueueOption,
     RepeatMode,
@@ -69,6 +68,7 @@ from music_assistant.constants import (
     VERBOSE_LOG_LEVEL,
     PlaylistPlayableItem,
 )
+from music_assistant.controllers.players.controller import IN_QUEUE_COMMAND
 from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
 from music_assistant.helpers.api import api_command
 from music_assistant.helpers.audio import get_stream_details, get_stream_dsp_details
@@ -299,7 +299,7 @@ class PlayerQueuesController(CoreController):
     @api_command("player_queues/get_active_queue")
     def get_active_queue(self, player_id: str) -> PlayerQueue | None:
         """Return the current active/synced queue for a player."""
-        if player := self.mass.players.get(player_id):
+        if player := self.mass.players.get_player(player_id):
             return self.mass.players.get_active_queue(player)
         return None
 
@@ -406,7 +406,7 @@ class PlayerQueuesController(CoreController):
         if not (queue := self.get(queue_id)):
             raise PlayerUnavailableError(f"Queue {queue_id} is not available")
         # always fetch the underlying player so we can raise early if its not available
-        queue_player = self.mass.players.get(queue_id, True)
+        queue_player = self.mass.players.get_player(queue_id, True)
         assert queue_player is not None  # for type checking
         if queue_player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
             self.logger.warning("Ignore queue command: An announcement is in progress")
@@ -711,15 +711,18 @@ class PlayerQueuesController(CoreController):
 
         - queue_id: queue_id of the playerqueue to handle the command.
         """
-        queue_player = self.mass.players.get(queue_id, True)
+        queue_player = self.mass.players.get_player(queue_id, True)
         if queue_player is None:
             raise PlayerUnavailableError(f"Player {queue_id} is not available")
         if (queue := self.get(queue_id)) and queue.active:
             if queue.state == PlaybackState.PLAYING:
                 queue.resume_pos = int(queue.corrected_elapsed_time)
-            # forward the actual command to the player
-            if temp_player := self.mass.players.get(queue_id):
-                await temp_player.stop()
+        # Set context to prevent circular call, then forward the actual command to the player
+        token = IN_QUEUE_COMMAND.set(True)
+        try:
+            await self.mass.players.cmd_stop(queue_id)
+        finally:
+            IN_QUEUE_COMMAND.reset(token)
 
     @api_command("player_queues/play")
     async def play(self, queue_id: str) -> None:
@@ -728,7 +731,7 @@ class PlayerQueuesController(CoreController):
 
         - queue_id: queue_id of the playerqueue to handle the command.
         """
-        queue_player = self.mass.players.get(queue_id, True)
+        queue_player = self.mass.players.get_player(queue_id, True)
         if queue_player is None:
             raise PlayerUnavailableError(f"Player {queue_id} is not available")
         if (
@@ -748,41 +751,43 @@ class PlayerQueuesController(CoreController):
 
         - queue_id: queue_id of the playerqueue to handle the command.
         """
-        if queue := self._queues.get(queue_id):
-            if queue.state == PlaybackState.PLAYING:
-                queue.resume_pos = int(queue.corrected_elapsed_time)
-        # forward the actual command to the player controller
-        queue_player = self.mass.players.get(queue_id)
-        assert queue_player is not None  # for type checking
-        if not (self.mass.players.get_player_provider(queue_id)):
-            return  # guard
-
-        if PlayerFeature.PAUSE not in queue_player.supported_features:
-            # if player does not support pause, we need to send stop
-            await queue_player.stop()
+        if not (queue := self._queues.get(queue_id)):
             return
-        await queue_player.pause()
-
-        async def _watch_pause() -> None:
+        queue_active = queue.active
+        if queue.active and queue.state == PlaybackState.PLAYING:
+            queue.resume_pos = int(queue.corrected_elapsed_time)
+        # forward the actual command to the player controller
+        # Set context to prevent circular call, then forward the actual command to the player
+        token = IN_QUEUE_COMMAND.set(True)
+        try:
+            await self.mass.players.cmd_pause(queue_id)
+        finally:
+            IN_QUEUE_COMMAND.reset(token)
+
+        async def _watch_pause(player: Player) -> None:
             count = 0
             # wait for pause
-            while count < 5 and queue_player.playback_state == PlaybackState.PLAYING:
+            while count < 5 and player.state.playback_state == PlaybackState.PLAYING:
                 count += 1
                 await asyncio.sleep(1)
             # wait for unpause
-            if queue_player.playback_state != PlaybackState.PAUSED:
+            if player.state.playback_state != PlaybackState.PAUSED:
                 return
             count = 0
-            while count < 30 and queue_player.playback_state == PlaybackState.PAUSED:
+            while count < 30 and player.state.playback_state == PlaybackState.PAUSED:
                 count += 1
                 await asyncio.sleep(1)
             # if player is still paused when the limit is reached, send stop
-            if queue_player.playback_state == PlaybackState.PAUSED:
-                await queue_player.stop()
+            if player.state.playback_state == PlaybackState.PAUSED:
+                await self.stop(queue_id)
 
         # we auto stop a player from paused when its paused for 30 seconds
-        if not queue_player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
-            self.mass.create_task(_watch_pause())
+        if (
+            queue_active
+            and (queue_player := self.mass.players.get_player(queue_id))
+            and not queue_player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS)
+        ):
+            self.mass.create_task(_watch_pause(queue_player))
 
     @api_command("player_queues/play_pause")
     async def play_pause(self, queue_id: str) -> None:
@@ -802,8 +807,7 @@ class PlayerQueuesController(CoreController):
         - queue_id: queue_id of the queue to handle the command.
         """
         if (queue := self.get(queue_id)) is None or not queue.active:
-            # TODO: forward to underlying player if not active
-            return
+            raise InvalidCommand(f"Queue {queue_id} is not active")
         idx = self._queues[queue_id].current_index
         if idx is None:
             self.logger.warning("Queue %s has no current index", queue.display_name)
@@ -829,8 +833,7 @@ class PlayerQueuesController(CoreController):
         - queue_id: queue_id of the queue to handle the command.
         """
         if (queue := self.get(queue_id)) is None or not queue.active:
-            # TODO: forward to underlying player if not active
-            return
+            raise InvalidCommand(f"Queue {queue_id} is not active")
         current_index = self._queues[queue_id].current_index
         if current_index is None:
             return
@@ -849,8 +852,7 @@ class PlayerQueuesController(CoreController):
         - seconds: number of seconds to skip in track. Use negative value to skip back.
         """
         if (queue := self.get(queue_id)) is None or not queue.active:
-            # TODO: forward to underlying player if not active
-            return
+            raise InvalidCommand(f"Queue {queue_id} is not active")
         await self.seek(queue_id, int(self._queues[queue_id].elapsed_time + seconds))
 
     @api_command("player_queues/seek")
@@ -860,20 +862,20 @@ class PlayerQueuesController(CoreController):
         - queue_id: queue_id of the queue to handle the command.
         - position: position in seconds to seek to in the current playing item.
         """
-        if not (queue := self.get(queue_id)):
-            return
-        queue_player = self.mass.players.get(queue_id, True)
+        if (queue := self.get(queue_id)) is None or not queue.active:
+            raise InvalidCommand(f"Queue {queue_id} is not active")
+        queue_player = self.mass.players.get_player(queue_id, True)
         if queue_player is None:
             raise PlayerUnavailableError(f"Player {queue_id} is not available")
         if not queue.current_item:
-            raise InvalidCommand(f"Queue {queue_player.display_name} has no item(s) loaded.")
+            raise InvalidCommand(f"Queue {queue_player.state.name} has no item(s) loaded.")
         if not queue.current_item.duration:
             raise InvalidCommand("Can not seek items without duration.")
         position = max(0, int(position))
         if position > queue.current_item.duration:
             raise InvalidCommand("Can not seek outside of duration range.")
         if queue.current_index is None:
-            raise InvalidCommand(f"Queue {queue_player.display_name} has no current index.")
+            raise InvalidCommand(f"Queue {queue_player.state.name} has no current index.")
         await self.play_index(queue_id, queue.current_index, seek_position=position)
 
     @api_command("player_queues/resume")
@@ -902,12 +904,12 @@ class PlayerQueuesController(CoreController):
             resume_pos = 0
 
         if resume_item is not None:
-            queue_player = self.mass.players.get(queue_id)
+            queue_player = self.mass.players.get_player(queue_id)
             if queue_player is None:
                 raise PlayerUnavailableError(f"Player {queue_id} is not available")
             if (
                 fade_in is None
-                and queue_player.playback_state == PlaybackState.IDLE
+                and queue_player.state.playback_state == PlaybackState.IDLE
                 and (time.time() - queue.elapsed_time_last_updated) > 60
             ):
                 # enable fade in effect if the player is idle for a while
@@ -952,7 +954,7 @@ class PlayerQueuesController(CoreController):
         self.signal_update(queue_id)
         queue.index_in_buffer = index
         queue.flow_mode_stream_log = []
-        target_player = self.mass.players.get(queue_id)
+        target_player = self.mass.players.get_player(queue_id)
         if target_player is None:
             raise PlayerUnavailableError(f"Player {queue_id} is not available")
         queue.next_item_id_enqueued = None
@@ -1054,14 +1056,14 @@ class PlayerQueuesController(CoreController):
         if auto_play is None:
             auto_play = source_queue.state == PlaybackState.PLAYING
 
-        target_player = self.mass.players.get(target_queue_id)
+        target_player = self.mass.players.get_player(target_queue_id)
         if target_player is None:
             raise PlayerUnavailableError(f"Player {target_queue_id} is not available")
-        if target_player.active_group or target_player.synced_to:
+        if target_player.state.active_group or target_player.state.synced_to:
             # edge case: the user wants to move playback from the group as a whole, to a single
             # player in the group or it is grouped and the command targeted at the single player.
             # We need to dissolve the group first.
-            group_id = target_player.active_group or target_player.synced_to
+            group_id = target_player.state.active_group or target_player.state.synced_to
             assert group_id is not None  # checked in if condition above
             await self.mass.players.cmd_ungroup(group_id)
             await asyncio.sleep(3)
@@ -1138,7 +1140,7 @@ class PlayerQueuesController(CoreController):
             except Exception as err:
                 self.logger.warning(
                     "Failed to restore the queue(items) for %s - %s",
-                    player.display_name,
+                    player.state.name,
                     str(err),
                 )
                 # Reset to clean state on failure
@@ -1148,8 +1150,8 @@ class PlayerQueuesController(CoreController):
             queue = PlayerQueue(
                 queue_id=queue_id,
                 active=False,
-                display_name=player.display_name,
-                available=player.available,
+                display_name=player.state.name,
+                available=player.state.available,
                 dont_stop_the_music_enabled=False,
                 items=0,
             )
@@ -1178,7 +1180,7 @@ class PlayerQueuesController(CoreController):
             # do nothing while the announcement is in progress
             return
         # determine if this queue is currently active for this player
-        queue.active = player.active_source in (queue.queue_id, None)
+        queue.active = player.state.active_source in (queue.queue_id, None)
         if not queue.active and queue_id not in self._prev_states:
             queue.state = PlaybackState.IDLE
             # return early if the queue is not active and we have no previous state
@@ -1510,15 +1512,18 @@ class PlayerQueuesController(CoreController):
             # handle error or return early
             raise InvalidDataError("Queue session_id is None")
         media = PlayerMedia(
-            uri=await self.mass.streams.resolve_stream_url(
-                queue.session_id, queue_item, flow_mode=flow_mode
-            ),
+            uri=queue_item.uri,
             media_type=MediaType.FLOW_STREAM if flow_mode else queue_item.media_type,
             title="Music Assistant" if flow_mode else queue_item.name,
             image_url=MASS_LOGO_ONLINE,
             duration=duration,
             source_id=queue_item.queue_id,
             queue_item_id=queue_item.queue_item_id,
+            custom_data={
+                "session_id": queue.session_id,
+                "original_uri": queue_item.uri,
+                "flow_mode": flow_mode,
+            },
         )
         if not flow_mode and queue_item.media_item:
             media.title = queue_item.media_item.name
@@ -2113,12 +2118,14 @@ class PlayerQueuesController(CoreController):
         queue = self._queues[queue_id]
 
         # basic properties
-        queue.display_name = player.display_name
-        queue.available = player.available
+        queue.display_name = player.state.name
+        queue.available = player.state.available
         queue.items = len(self._queue_items[queue_id])
 
         queue.state = (
-            player.playback_state or PlaybackState.IDLE if queue.active else PlaybackState.IDLE
+            player.state.playback_state or PlaybackState.IDLE
+            if queue.active
+            else PlaybackState.IDLE
         )
         # update current item/index from player report
         if queue.active and queue.state in (
@@ -2133,7 +2140,7 @@ class PlayerQueuesController(CoreController):
                 current_index, elapsed_time = self._get_flow_queue_stream_index(queue, player)
             elif item_id := self._parse_player_current_item_id(queue_id, player):
                 # normal mode, the player itself will report the current item
-                elapsed_time = int(player.corrected_elapsed_time or 0)
+                elapsed_time = int(player.state.corrected_elapsed_time or 0)
                 current_index = self.index_by_id(queue_id, item_id)
             else:
                 # this may happen if the player is still transitioning between tracks
@@ -2174,8 +2181,8 @@ class PlayerQueuesController(CoreController):
         output_formats = []
         if output_format := player.extra_data.get("output_format"):
             output_formats.append(str(output_format))
-        for child_id in player.group_members:
-            if (child := self.mass.players.get(child_id)) and (
+        for child_id in player.state.group_members:
+            if (child := self.mass.players.get_player(child_id)) and (
                 output_format := child.extra_data.get("output_format")
             ):
                 output_formats.append(str(output_format))
@@ -2343,7 +2350,7 @@ class PlayerQueuesController(CoreController):
         self, queue: PlayerQueue, player: Player
     ) -> tuple[int | None, int]:
         """Calculate current queue index and current track elapsed time when flow mode is active."""
-        elapsed_time_queue_total = player.corrected_elapsed_time or 0
+        elapsed_time_queue_total = player.state.corrected_elapsed_time or 0
         if queue.current_index is None and not queue.flow_mode_stream_log:
             return queue.current_index, int(queue.elapsed_time)
 
@@ -2378,7 +2385,7 @@ class PlayerQueuesController(CoreController):
                     track_sec_skipped = 0
                 track_time = elapsed_time_queue_total + track_sec_skipped - played_time
                 break
-        if player.playback_state != PlaybackState.PLAYING:
+        if player.state.playback_state != PlaybackState.PLAYING:
             # if the player is not playing, we can't be sure that the elapsed time is correct
             # so we just return the queue index and the elapsed time
             return queue.current_index, int(queue.elapsed_time)
@@ -2386,33 +2393,41 @@ class PlayerQueuesController(CoreController):
 
     def _parse_player_current_item_id(self, queue_id: str, player: Player) -> str | None:
         """Parse QueueItem ID from Player's current url."""
-        if not player._current_media:
-            # YES, we use player._current_media on purpose here because we need the raw metadata
+        protocol_player = player
+        if player.active_output_protocol and player.active_output_protocol != "native":
+            protocol_player = self.mass.players.get_player(player.active_output_protocol) or player
+        if not protocol_player.current_media:
+            # YES, we use player.current_media on purpose here because we need the raw metadata
             return None
         # prefer queue_id and queue_item_id within the current media
-        if player._current_media.source_id == queue_id and player._current_media.queue_item_id:
-            return player._current_media.queue_item_id
+        if (
+            protocol_player.current_media.source_id == queue_id
+            and protocol_player.current_media.queue_item_id
+        ):
+            return protocol_player.current_media.queue_item_id
         # special case for sonos players
-        if player._current_media.uri and player._current_media.uri.startswith(f"mass:{queue_id}"):
-            if player._current_media.queue_item_id:
-                return player._current_media.queue_item_id
-            return player._current_media.uri.split(":")[-1]
+        if protocol_player.current_media.uri and protocol_player.current_media.uri.startswith(
+            f"mass:{queue_id}"
+        ):
+            if protocol_player.current_media.queue_item_id:
+                return protocol_player.current_media.queue_item_id
+            return protocol_player.current_media.uri.split(":")[-1]
         # try to extract the item id from a mass stream url
         if (
-            player._current_media.uri
-            and queue_id in player._current_media.uri
-            and self.mass.streams.base_url in player._current_media.uri
+            protocol_player.current_media.uri
+            and queue_id in protocol_player.current_media.uri
+            and self.mass.streams.base_url in protocol_player.current_media.uri
         ):
-            current_item_id = player._current_media.uri.rsplit("/")[-1].split(".")[0]
+            current_item_id = protocol_player.current_media.uri.rsplit("/")[-1].split(".")[0]
             if self.get_item(queue_id, current_item_id):
                 return current_item_id
         # try to extract the item id from a queue_id/item_id combi
         if (
-            player._current_media.uri
-            and queue_id in player._current_media.uri
-            and "/" in player._current_media.uri
+            protocol_player.current_media.uri
+            and queue_id in protocol_player.current_media.uri
+            and "/" in protocol_player.current_media.uri
         ):
-            current_item_id = player._current_media.uri.split("/")[1]
+            current_item_id = protocol_player.current_media.uri.split("/")[1]
             if self.get_item(queue_id, current_item_id):
                 return current_item_id
 
diff --git a/music_assistant/controllers/players/README.md b/music_assistant/controllers/players/README.md
new file mode 100644 (file)
index 0000000..d114034
--- /dev/null
@@ -0,0 +1,302 @@
+# Player Controller Architecture
+
+This document provides an overview of the Music Assistant Player Controller architecture, including the Player/PlayerState model, multi-protocol player system, and universal player concept.
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Player vs PlayerState](#player-vs-playerstate)
+- [Core Components](#core-components)
+- [Player Types](#player-types)
+- [Multi-Protocol Player System](#multi-protocol-player-system)
+- [Universal Player](#universal-player)
+- [Protocol Linking](#protocol-linking)
+- [Development Guide](#development-guide)
+
+## Overview
+
+The Player Controller is a core controller that manages all connected audio players from various providers. It provides:
+- Unified control interface for all players (play, pause, volume, etc.)
+- Multi-protocol player linking (combining AirPlay, Chromecast, DLNA for the same device)
+- Universal Player wrapping for devices without native vendor support
+- Sync group management for synchronized playback
+- Player state management and event broadcasting
+- User access control and permissions
+
+## Player vs PlayerState
+
+The Player Controller distinguishes between two key concepts:
+
+### Player (Internal Model)
+
+The `Player` class is the actual object provided by a Player Provider. It:
+- Incorporates the actual state of the player (volume, playback state, etc.)
+- Contains methods for controlling the player (play, pause, volume, etc.)
+- Is used internally by providers and the controller
+- May contain provider-specific implementation details
+
+### PlayerState (API Model)
+
+The `PlayerState` is a dataclass representing the final state of the player. It:
+- Includes any user customizations (custom name, hidden status, etc.)
+- Applies transformations (e.g., fake power/volume controls)
+- Is the object exposed to the outside world via the API
+- Is a snapshot created when `player.update_state()` is called
+- Contains only serializable data suitable for API consumers
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                     Player (Internal)                            │
+│  - Provider-specific implementation                             │
+│  - Control methods (play, pause, volume_set, etc.)              │
+│  - Raw state (_attr_volume_level, _attr_playback_state, etc.)   │
+│  - Device info and identifiers                                  │
+└─────────────────────────────────┬───────────────────────────────┘
+                                  │
+                                  │ update_state()
+                                  ▼
+┌─────────────────────────────────────────────────────────────────┐
+│                   PlayerState (API)                             │
+│  - Final display name (with user customizations)                │
+│  - Transformed state (fake controls applied)                    │
+│  - Player controls configuration                                │
+│  - Serializable for API/WebSocket                               │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+## Core Components
+
+### 1. PlayerController ([controller.py](controller.py))
+
+The main orchestrator that manages:
+- Player registration and lifecycle
+- Player commands (play, pause, stop, volume, etc.)
+- Protocol linking and evaluation
+- Universal player creation
+- Sync group coordination
+
+**Key responsibilities:**
+- Routes commands to appropriate players or protocol players
+- Manages player availability and state
+- Handles announcements and TTS playback
+- Coordinates sync groups and grouped playback
+
+### 2. ProtocolLinkingMixin ([protocol_linking.py](protocol_linking.py))
+
+Mixin class containing all protocol linking logic:
+- Matching protocol players to native players via device identifiers
+- Creating and managing Universal Players
+- Protocol link lifecycle (add, remove, cleanup)
+- Output protocol selection for playback
+
+### 3. Helper Utilities ([helpers.py](helpers.py))
+
+Contains standalone helper functions and decorators:
+- `handle_player_command` decorator for command validation
+- `AnnounceData` type definition
+
+## Player Types
+
+Players in Music Assistant have different types based on their capabilities:
+
+### PlayerType.PLAYER
+
+A regular player with native (vendor-specific) support. Examples:
+- Sonos speakers via the Sonos provider
+- Apple devices via the AirPlay provider (HomePod, Apple TV)
+- Google devices via the Chromecast provider (Nest Audio, Google Home)
+
+### PlayerType.PROTOCOL
+
+A generic protocol player without native vendor support. These are streaming endpoints discovered via generic protocols but manufactured by third parties. Examples:
+- Samsung TV discovered via AirPlay (not an Apple device)
+- Sony speaker discovered via Chromecast (not a Google device)
+- Any DLNA/UPnP device (always PROTOCOL type)
+
+**Important:** Protocol players with `PlayerType.PROTOCOL` are hidden from the UI and wrapped in a Universal Player or attached to an existing native player.
+
+### PlayerType.GROUP
+
+A group player that represents (synchronized) playback across multiple physical speakers.
+
+### PlayerType.STEREO_PAIR
+
+A dedicated stereo pair of two speakers acting as one player.
+
+## Multi-Protocol Player System
+
+Modern audio devices often support multiple streaming protocols (AirPlay, Chromecast, DLNA). The Player Controller automatically detects and links these protocols to provide a unified experience.
+
+### How It Works
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│                     Physical Device                                 │
+│                  (e.g., Samsung Soundbar)                           │
+├─────────────────────────────────────────────────────────────────────┤
+│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐               │
+│  │   AirPlay    │  │  Chromecast  │  │    DLNA      │               │
+│  │   Protocol   │  │   Protocol   │  │   Protocol   │               │
+│  │   Player     │  │   Player     │  │   Player     │               │
+│  │  (hidden)    │  │  (hidden)    │  │  (hidden)    │               │
+│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘               │
+│         │                 │                 │                       │
+│         └─────────────────┼─────────────────┘                       │
+│                           │                                         │
+│                           ▼                                         │
+│              ┌─────────────────────────┐                            │
+│              │    Universal Player     │                            │
+│              │  (visible in UI)        │                            │
+│              │  - Aggregates protocols │                            │
+│              │  - Selects best output  │                            │
+│              │  - Unified control      │                            │
+│              └─────────────────────────┘                            │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+### Device Identifier Matching
+
+Protocol players are matched to the same physical device using identifiers in order of reliability:
+
+1. **MAC_ADDRESS** - Most reliable, unique to the network interface
+2. **SERIAL_NUMBER** - Unique device serial number
+3. **UUID** - Universally unique identifier
+4. **player_id** - Fallback for players without identifiers (e.g., Sendspin)
+
+**Note:** IP_ADDRESS is intentionally NOT used for matching as it can change with DHCP and cause incorrect matches between different devices.
+
+**Fallback behavior:** Protocol players that don't expose any identifiers (like Sendspin clients) will still get wrapped in a Universal Player using their player_id as the device key. This ensures all protocol players get a consistent user-facing interface.
+
+### Output Protocol Selection
+
+When playing media, the controller selects the best output protocol:
+
+1. **Grouped protocol** - If a protocol is actively grouped/synced, use it
+2. **User preference** - Honor user's configured preferred protocol
+3. **Native playback** - Use native PLAY_MEDIA if available
+4. **Best available** - Select by protocol priority (AirPlay > Chromecast > DLNA)
+
+## Universal Player
+
+The Universal Player is a virtual player that wraps one or more protocol players when no native vendor support exists.
+
+### When Created
+
+A Universal Player is created when:
+1. A device is discovered via a protocol but has no native provider
+2. The device's protocol player has `PlayerType.PROTOCOL`
+3. There is no existing native player that matches the device identifiers
+
+### Features
+
+- **Aggregates Features** - Combines capabilities from all linked protocols
+- **No PLAY_MEDIA** - Delegates playback to protocol players
+- **Unified Control** - Single point of control for volume, power, etc.
+- **Protocol Selection** - Automatically selects best protocol for playback
+
+### Lifecycle
+
+```
+1. Protocol player registered with PlayerType.PROTOCOL
+2. Controller checks for cached parent_id from previous session:
+   - If found, restores link immediately (skips evaluation)
+   - If parent not yet registered, waits without creating universal player
+3. If no cached parent, checks for matching native player (links immediately if found)
+4. If no native player, schedules delayed evaluation:
+   - 10 seconds standard delay (allows other protocols to register)
+   - 30 seconds if previously linked to a native player (allows native provider to start)
+5. After delay, finds all matching protocol players by identifiers
+6. Creates UniversalPlayer and links all protocols
+7. Protocol players become hidden, Universal Player visible
+```
+
+## Protocol Linking
+
+### Native Player Linking
+
+When a native player (e.g., Sonos) is registered, the controller:
+1. Searches for protocol players with matching identifiers
+2. Links matching protocols to the native player
+3. Protocol players become hidden, native player gains `output_protocols`
+
+### Protocol to Universal
+
+When protocol players are registered without a native match:
+1. Each protocol player schedules a delayed evaluation
+2. After the delay, matching protocols are grouped
+3. A Universal Player is created to wrap them all
+4. All protocol players link to the Universal Player
+
+### Universal to Native Promotion
+
+When a native player appears for a device that has a Universal Player:
+1. Native player is registered
+2. Controller finds matching Universal Player
+3. All protocol links transfer to the native player
+4. Universal Player is removed
+5. Native player becomes the visible entity
+
+## Development Guide
+
+### Adding Protocol Support
+
+When implementing a new protocol provider:
+
+1. Set `_attr_type = PlayerType.PROTOCOL` for generic devices (non-vendor devices)
+2. Set `_attr_type = PlayerType.PLAYER` for devices with native support (vendor's own devices)
+3. Populate `device_info.identifiers` with MAC, UUID, etc. (see below)
+4. Filter out devices that should only be handled by native providers (e.g., passive satellites)
+5. The Player Controller handles linking automatically
+
+### Adding Native Provider Support
+
+When implementing a native provider (e.g., Sonos, Bluesound) that should link to protocol players:
+
+1. Set `_attr_type = PlayerType.PLAYER` (or the property 'type') for all devices
+2. **Populate device identifiers** - This is critical for protocol linking:
+   ```python
+   self._attr_device_info = DeviceInfo(
+       model="Device Model",
+       manufacturer="Manufacturer Name",
+   )
+   # Add identifiers in order of preference (MAC is most reliable)
+   self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
+   self._attr_device_info.add_identifier(IdentifierType.UUID, "device-uuid-here")
+   ```
+3. The controller will automatically:
+   - Find protocol players (AirPlay, Chromecast, DLNA) with matching identifiers
+   - Link them to your native player as `output_protocols`
+   - Replace any existing Universal Player for that device
+
+**Identifier Priority:**
+- `MAC_ADDRESS` - Most reliable, unique to network interface
+- `SERIAL_NUMBER` - Unique device serial number
+- `UUID` - Universally unique identifier
+- `player_id` - Fallback when no identifiers available
+
+**Note:** `IP_ADDRESS` is NOT used for matching as it can change with DHCP.
+
+### Testing Protocol Linking
+
+Key scenarios to test:
+
+1. **Single protocol device** - Should create Universal Player
+2. **Multi-protocol device** - All protocols linked to one Universal Player
+3. **Late protocol discovery** - New protocol added to existing Universal Player
+4. **Native player appears** - Universal Player replaced by native
+5. **Protocol disappears** - Handle graceful degradation
+
+### Configuration Storage
+
+Protocol links are persisted in player configuration:
+- `linked_protocol_player_ids` - List of protocol player IDs
+- Restored on restart for fast reconnection
+
+### Key Methods (in protocol_linking.py)
+
+- `_evaluate_protocol_links()` - Entry point for link evaluation
+- `_try_link_protocol_to_native()` - Link protocol to existing native
+- `_schedule_protocol_evaluation()` - Delay evaluation for batching
+- `_create_or_update_universal_player()` - Create/update Universal Player
+- `_check_replace_universal_player()` - Replace Universal with native
+- `_select_best_output_protocol()` - Choose protocol for playback
index 455198a96a75471841be719a3e2442a4b86ba547..bdee144625a508722e105fa3dccb6ce6d7ae73ff 100644 (file)
@@ -16,6 +16,6 @@ The playerstate is the object that is exposed to the outside world (via the API)
 
 from __future__ import annotations
 
-from .player_controller import PlayerController
+from .controller import PlayerController
 
 __all__ = ["PlayerController"]
diff --git a/music_assistant/controllers/players/controller.py b/music_assistant/controllers/players/controller.py
new file mode 100644 (file)
index 0000000..dc121fc
--- /dev/null
@@ -0,0 +1,2856 @@
+"""
+MusicAssistant PlayerController.
+
+Handles all logic to control supported players,
+which are provided by Player Providers.
+
+Note that the PlayerController has a concept of a 'player' and a 'playerstate'.
+The Player is the actual object that is provided by the provider,
+which incorporates the (unaltered) state of the player (e.g. volume, state, etc)
+and functions for controlling the player (e.g. play, pause, etc).
+
+The playerstate is the (final) state of the player, including any user customizations
+and transformations that are applied to the player.
+The playerstate is the object that is exposed to the outside world (via the API).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from contextlib import suppress
+from contextvars import ContextVar
+from typing import TYPE_CHECKING, Any, cast
+
+from music_assistant_models.auth import UserRole
+from music_assistant_models.constants import (
+    PLAYER_CONTROL_FAKE,
+    PLAYER_CONTROL_NATIVE,
+    PLAYER_CONTROL_NONE,
+)
+from music_assistant_models.enums import (
+    EventType,
+    MediaType,
+    PlaybackState,
+    PlayerFeature,
+    PlayerType,
+    ProviderFeature,
+    ProviderType,
+)
+from music_assistant_models.errors import (
+    AlreadyRegisteredError,
+    InsufficientPermissions,
+    MusicAssistantError,
+    PlayerCommandFailed,
+    PlayerUnavailableError,
+    ProviderUnavailableError,
+    UnsupportedFeaturedException,
+)
+from music_assistant_models.player import PlayerOptionValueType  # noqa: TC002
+from music_assistant_models.player_control import PlayerControl  # noqa: TC002
+
+from music_assistant.constants import (
+    ANNOUNCE_ALERT_FILE,
+    ATTR_ANNOUNCEMENT_IN_PROGRESS,
+    ATTR_AVAILABLE,
+    ATTR_ELAPSED_TIME,
+    ATTR_ENABLED,
+    ATTR_FAKE_MUTE,
+    ATTR_FAKE_POWER,
+    ATTR_FAKE_VOLUME,
+    ATTR_GROUP_MEMBERS,
+    ATTR_LAST_POLL,
+    ATTR_MUTE_LOCK,
+    ATTR_PREVIOUS_VOLUME,
+    CONF_AUTO_PLAY,
+    CONF_ENTRY_ANNOUNCE_VOLUME,
+    CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
+    CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
+    CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
+    CONF_ENTRY_TTS_PRE_ANNOUNCE,
+    CONF_ENTRY_ZEROCONF_INTERFACES,
+    CONF_PLAYER_DSP,
+    CONF_PLAYERS,
+    CONF_PRE_ANNOUNCE_CHIME_URL,
+)
+from music_assistant.controllers.webserver.helpers.auth_middleware import (
+    get_current_user,
+    get_sendspin_player_id,
+)
+from music_assistant.helpers.api import api_command
+from music_assistant.helpers.tags import async_parse_tags
+from music_assistant.helpers.throttle_retry import Throttler
+from music_assistant.helpers.util import TaskManager, validate_announcement_chime_url
+from music_assistant.models.core_controller import CoreController
+from music_assistant.models.player import Player, PlayerMedia, PlayerState
+from music_assistant.models.player_provider import PlayerProvider
+from music_assistant.models.plugin import PluginProvider, PluginSource
+
+from .helpers import AnnounceData, handle_player_command
+from .protocol_linking import ProtocolLinkingMixin
+
+if TYPE_CHECKING:
+    from collections.abc import Iterator
+
+    from music_assistant_models.config_entries import (
+        ConfigEntry,
+        ConfigValueType,
+        CoreConfig,
+        PlayerConfig,
+    )
+    from music_assistant_models.player_queue import PlayerQueue
+
+    from music_assistant import MusicAssistant
+
+CACHE_CATEGORY_PLAYER_POWER = 1
+
+# Context variable to prevent circular calls between players and player_queues controllers
+IN_QUEUE_COMMAND: ContextVar[bool] = ContextVar("IN_QUEUE_COMMAND", default=False)
+
+
+class PlayerController(ProtocolLinkingMixin, CoreController):
+    """Controller holding all logic to control registered players."""
+
+    domain: str = "players"
+
+    def __init__(self, mass: MusicAssistant) -> None:
+        """Initialize core controller."""
+        super().__init__(mass)
+        self._players: dict[str, Player] = {}
+        self._controls: dict[str, PlayerControl] = {}
+        self.manifest.name = "Player Controller"
+        self.manifest.description = (
+            "Music Assistant's core controller which manages all players from all providers."
+        )
+        self.manifest.icon = "speaker-multiple"
+        self._poll_task: asyncio.Task[None] | None = None
+        self._player_throttlers: dict[str, Throttler] = {}
+        self._player_command_locks: dict[str, asyncio.Lock] = {}
+        # Lock to prevent race conditions during player registration
+        self._register_lock = asyncio.Lock()
+        # Track pending protocol player evaluations (delayed to allow all protocols to register)
+        self._pending_protocol_evaluations: dict[str, asyncio.TimerHandle] = {}
+
+    async def get_config_entries(
+        self,
+        action: str | None = None,
+        values: dict[str, ConfigValueType] | None = None,
+    ) -> tuple[ConfigEntry, ...]:
+        """Return Config Entries for the Player Controller."""
+        return (CONF_ENTRY_ZEROCONF_INTERFACES,)
+
+    async def setup(self, config: CoreConfig) -> None:
+        """Async initialize of module."""
+        self._poll_task = self.mass.create_task(self._poll_players())
+
+    async def close(self) -> None:
+        """Cleanup on exit."""
+        if self._poll_task and not self._poll_task.done():
+            self._poll_task.cancel()
+        # Cancel all pending protocol evaluations
+        for handle in self._pending_protocol_evaluations.values():
+            handle.cancel()
+        self._pending_protocol_evaluations.clear()
+
+    async def on_provider_loaded(self, provider: PlayerProvider) -> None:
+        """Handle logic when a provider is loaded."""
+
+    async def on_provider_unload(self, provider: PlayerProvider) -> None:
+        """Handle logic when a provider is (about to get) unloaded."""
+
+    @property
+    def providers(self) -> list[PlayerProvider]:
+        """Return all loaded/running MusicProviders."""
+        return cast("list[PlayerProvider]", self.mass.get_providers(ProviderType.PLAYER))
+
+    def all_players(
+        self,
+        return_unavailable: bool = True,
+        return_disabled: bool = False,
+        provider_filter: str | None = None,
+        return_protocol_players: bool = False,
+    ) -> list[Player]:
+        """
+        Return all registered players.
+
+        Note that this applies user filters for players (for non admin users).
+
+        :param return_unavailable [bool]: Include unavailable players.
+        :param return_disabled [bool]: Include disabled players.
+        :param provider_filter [str]: Optional filter by provider lookup key.
+        :param return_protocol_players [bool]: Include protocol players (hidden by default).
+
+        :return: List of Player objects.
+        """
+        current_user = get_current_user()
+        user_filter = (
+            current_user.player_filter
+            if current_user and current_user.role != UserRole.ADMIN
+            else None
+        )
+        current_sendspin_player = get_sendspin_player_id()
+        return [
+            player
+            for player in self._players.values()
+            if (player.state.available or return_unavailable)
+            and (player.state.enabled or return_disabled)
+            and (provider_filter is None or player.provider.instance_id == provider_filter)
+            and (
+                not user_filter
+                or player.player_id in user_filter
+                or player.player_id == current_sendspin_player
+            )
+            and (return_protocol_players or player.state.type != PlayerType.PROTOCOL)
+        ]
+
+    @api_command("players/all")
+    def all_player_states(
+        self,
+        return_unavailable: bool = True,
+        return_disabled: bool = False,
+        provider_filter: str | None = None,
+        return_protocol_players: bool = False,
+    ) -> list[PlayerState]:
+        """
+        Return PlayerState for all registered players.
+
+        :param return_unavailable [bool]: Include unavailable players.
+        :param return_disabled [bool]: Include disabled players.
+        :param provider_filter [str]: Optional filter by provider lookup key.
+        :param return_protocol_players [bool]: Include protocol players (hidden by default).
+
+        :return: List of PlayerState objects.
+        """
+        return [
+            player.state
+            for player in self.all_players(
+                return_unavailable=return_unavailable,
+                return_disabled=return_disabled,
+                provider_filter=provider_filter,
+                return_protocol_players=return_protocol_players,
+            )
+        ]
+
+    def get_player(
+        self,
+        player_id: str,
+        raise_unavailable: bool = False,
+    ) -> Player | None:
+        """
+        Return Player by player_id.
+
+        :param player_id [str]: ID of the player.
+        :param raise_unavailable [bool]: Raise if player is unavailable.
+
+        :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
+        :return: Player object or None.
+        """
+        if player := self._players.get(player_id):
+            if (not player.state.available or not player.state.enabled) and raise_unavailable:
+                msg = f"Player {player_id} is not available"
+                raise PlayerUnavailableError(msg)
+            return player
+        if raise_unavailable:
+            msg = f"Player {player_id} is not available"
+            raise PlayerUnavailableError(msg)
+        return None
+
+    @api_command("players/get")
+    def get_player_state(
+        self,
+        player_id: str,
+        raise_unavailable: bool = False,
+    ) -> PlayerState | None:
+        """
+        Return PlayerState by player_id.
+
+        :param player_id [str]: ID of the player.
+        :param raise_unavailable [bool]: Raise if player is unavailable.
+
+        :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
+        :return: Player object or None.
+        """
+        current_user = get_current_user()
+        user_filter = (
+            current_user.player_filter
+            if current_user and current_user.role != UserRole.ADMIN
+            else None
+        )
+        current_sendspin_player = get_sendspin_player_id()
+        if (
+            current_user
+            and user_filter
+            and player_id not in user_filter
+            and player_id != current_sendspin_player
+        ):
+            msg = f"{current_user.username} does not have access to player {player_id}"
+            raise InsufficientPermissions(msg)
+        if player := self.get_player(player_id, raise_unavailable):
+            return player.state
+        return None
+
+    def get_player_by_name(self, name: str) -> Player | None:
+        """
+        Return Player by name.
+
+        Performs case-insensitive matching against the player's state name
+        (the final name visible in clients and API).
+        If multiple players match, logs a warning and returns the first match.
+
+        :param name: Name of the player.
+        :return: Player object or None.
+        """
+        name_normalized = name.strip().lower()
+        matches: list[Player] = []
+
+        for player in self._players.values():
+            if player.state.name.strip().lower() == name_normalized:
+                matches.append(player)
+
+        if not matches:
+            return None
+
+        if len(matches) > 1:
+            player_ids = [p.player_id for p in matches]
+            self.logger.warning(
+                "players/get_by_name: Multiple players found with name '%s': %s - "
+                "returning first match (%s). "
+                "Consider using the players/get API with player_id instead "
+                "for unambiguous lookups.",
+                name,
+                player_ids,
+                matches[0].player_id,
+            )
+
+        return matches[0]
+
+    @api_command("players/get_by_name")
+    def get_player_state_by_name(self, name: str) -> PlayerState | None:
+        """
+        Return PlayerState by name.
+
+        :param name: Name of the player.
+        :return: PlayerState object or None.
+        """
+        current_user = get_current_user()
+        user_filter = (
+            current_user.player_filter
+            if current_user and current_user.role != UserRole.ADMIN
+            else None
+        )
+        current_sendspin_player = get_sendspin_player_id()
+        if player := self.get_player_by_name(name):
+            if (
+                current_user
+                and user_filter
+                and player.player_id not in user_filter
+                and player.player_id != current_sendspin_player
+            ):
+                msg = f"{current_user.username} does not have access to player {player.player_id}"
+                raise InsufficientPermissions(msg)
+            return player.state
+        return None
+
+    @api_command("players/player_controls")
+    def player_controls(
+        self,
+    ) -> list[PlayerControl]:
+        """Return all registered playercontrols."""
+        return list(self._controls.values())
+
+    @api_command("players/player_control")
+    def get_player_control(
+        self,
+        control_id: str,
+    ) -> PlayerControl | None:
+        """
+        Return PlayerControl by control_id.
+
+        :param control_id: ID of the player control.
+        :return: PlayerControl object or None.
+        """
+        if control := self._controls.get(control_id):
+            return control
+        return None
+
+    @api_command("players/plugin_sources")
+    def get_plugin_sources(self) -> list[PluginSource]:
+        """Return all available plugin sources."""
+        return [
+            plugin_prov.get_source()
+            for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN)
+            if isinstance(plugin_prov, PluginProvider)
+            and ProviderFeature.AUDIO_SOURCE in plugin_prov.supported_features
+        ]
+
+    @api_command("players/plugin_source")
+    def get_plugin_source(
+        self,
+        source_id: str,
+    ) -> PluginSource | None:
+        """
+        Return PluginSource by source_id.
+
+        :param source_id: ID of the plugin source.
+        :return: PluginSource object or None.
+        """
+        for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN):
+            assert isinstance(plugin_prov, PluginProvider)  # for type checking
+            if ProviderFeature.AUDIO_SOURCE not in plugin_prov.supported_features:
+                continue
+            if (source := plugin_prov.get_source()) and source.id == source_id:
+                return source
+        return None
+
+    # Player commands
+
+    @api_command("players/cmd/stop")
+    @handle_player_command
+    async def cmd_stop(self, player_id: str) -> None:
+        """Send STOP command to given player.
+
+        - player_id: player_id of the player to handle the command.
+        """
+        player = self._get_player_with_redirect(player_id)
+        # Redirect to queue controller if it is active (skip if already in queue command context)
+        if not IN_QUEUE_COMMAND.get() and (active_queue := self.get_active_queue(player)):
+            await self.mass.player_queues.stop(active_queue.queue_id)
+            return
+        # Delegate to internal handler for actual implementation
+        await self._handle_cmd_stop(player.player_id)
+
+    @api_command("players/cmd/play")
+    @handle_player_command
+    async def cmd_play(self, player_id: str) -> None:
+        """Send PLAY (unpause) command to given player.
+
+        - player_id: player_id of the player to handle the command.
+        """
+        player = self._get_player_with_redirect(player_id)
+        if player.state.playback_state == PlaybackState.PLAYING:
+            self.logger.info(
+                "Ignore PLAY request to player %s: player is already playing", player.state.name
+            )
+            return
+        # player is not paused: check for queue redirect, then delegate to internal handler
+        if player.state.playback_state != PlaybackState.PAUSED:
+            source = player.state.active_source
+            if active_queue := self.mass.player_queues.get(source or player_id):
+                await self.mass.player_queues.resume(active_queue.queue_id)
+                return
+
+        # Delegate to internal handler for actual implementation
+        await self._handle_cmd_play(player.player_id)
+
+    @api_command("players/cmd/pause")
+    @handle_player_command
+    async def cmd_pause(self, player_id: str) -> None:
+        """Send PAUSE command to given player.
+
+        - player_id: player_id of the player to handle the command.
+        """
+        player = self._get_player_with_redirect(player_id)
+        # Redirect to queue controller if it is active (skip if already in queue command context)
+        if not IN_QUEUE_COMMAND.get() and (active_queue := self.get_active_queue(player)):
+            await self.mass.player_queues.pause(active_queue.queue_id)
+            return
+        # Delegate to internal handler for actual implementation
+        await self._handle_cmd_pause(player.player_id)
+
+    @api_command("players/cmd/play_pause")
+    async def cmd_play_pause(self, player_id: str) -> None:
+        """Toggle play/pause on given player.
+
+        - player_id: player_id of the player to handle the command.
+        """
+        player = self._get_player_with_redirect(player_id)
+        if player.state.playback_state == PlaybackState.PLAYING:
+            await self.cmd_pause(player.player_id)
+        else:
+            await self.cmd_play(player.player_id)
+
+    @api_command("players/cmd/resume")
+    @handle_player_command
+    async def cmd_resume(
+        self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
+    ) -> None:
+        """Send RESUME command to given player.
+
+        Resume (or restart) playback on the player.
+
+        :param player_id: player_id of the player to handle the command.
+        :param source: Optional source to resume.
+        :param media: Optional media to resume.
+        """
+        await self._handle_cmd_resume(player_id, source, media)
+
+    @api_command("players/cmd/seek")
+    async def cmd_seek(self, player_id: str, position: int) -> None:
+        """Handle SEEK command for given player.
+
+        - player_id: player_id of the player to handle the command.
+        - position: position in seconds to seek to in the current playing item.
+        """
+        player = self._get_player_with_redirect(player_id)
+        # Check if a plugin source is active with a seek callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.can_seek and plugin_source.on_seek:
+                await plugin_source.on_seek(position)
+                return
+        # Redirect to queue controller if it is active
+        if not IN_QUEUE_COMMAND.get() and (active_queue := self.get_active_queue(player)):
+            await self.mass.player_queues.seek(active_queue.queue_id, position)
+            return
+        # handle command on player/source directly
+        active_source = next((x for x in player.source_list if x.id == player.active_source), None)
+        if active_source and not active_source.can_seek:
+            msg = (
+                f"The active source ({active_source.name}) on player "
+                f"{player.display_name} does not support seeking"
+            )
+            raise PlayerCommandFailed(msg)
+        if PlayerFeature.SEEK not in player.supported_features:
+            msg = f"Player {player.display_name} does not support seeking"
+            raise UnsupportedFeaturedException(msg)
+        # handle command on player directly
+        await player.seek(position)
+
+    @api_command("players/cmd/next")
+    async def cmd_next_track(self, player_id: str) -> None:
+        """Handle NEXT TRACK command for given player."""
+        player = self._get_player_with_redirect(player_id)
+        active_source_id = player.state.active_source or player.player_id
+        # Check if a plugin source is active with a next callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.can_next_previous and plugin_source.on_next:
+                await plugin_source.on_next()
+                return
+        # Redirect to queue controller if it is active
+        if active_queue := self.get_active_queue(player):
+            await self.mass.player_queues.next(active_queue.queue_id)
+            return
+        if PlayerFeature.NEXT_PREVIOUS in player.state.supported_features:
+            # player has some other source active and native next/previous support
+            active_source = next(
+                (x for x in player.state.source_list if x.id == active_source_id), None
+            )
+            if active_source and active_source.can_next_previous:
+                await player.next_track()
+                return
+            msg = "This action is (currently) unavailable for this source."
+            raise PlayerCommandFailed(msg)
+        # Player does not support next/previous feature
+        msg = f"Player {player.state.name} does not support skipping to the next track."
+        raise UnsupportedFeaturedException(msg)
+
+    @api_command("players/cmd/previous")
+    async def cmd_previous_track(self, player_id: str) -> None:
+        """Handle PREVIOUS TRACK command for given player."""
+        player = self._get_player_with_redirect(player_id)
+        active_source_id = player.state.active_source or player.player_id
+        # Check if a plugin source is active with a previous callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.can_next_previous and plugin_source.on_previous:
+                await plugin_source.on_previous()
+                return
+        # Redirect to queue controller if it is active
+        if active_queue := self.get_active_queue(player):
+            await self.mass.player_queues.previous(active_queue.queue_id)
+            return
+        if PlayerFeature.NEXT_PREVIOUS in player.state.supported_features:
+            # player has some other source active and native next/previous support
+            active_source = next(
+                (x for x in player.state.source_list if x.id == active_source_id), None
+            )
+            if active_source and active_source.can_next_previous:
+                await player.previous_track()
+                return
+            msg = "This action is (currently) unavailable for this source."
+            raise PlayerCommandFailed(msg)
+        # Player does not support next/previous feature
+        msg = f"Player {player.state.name} does not support skipping to the previous track."
+        raise UnsupportedFeaturedException(msg)
+
+    @api_command("players/cmd/power")
+    @handle_player_command
+    async def cmd_power(self, player_id: str, powered: bool) -> None:
+        """Send POWER command to given player.
+
+        :param player_id: player_id of the player to handle the command.
+        :param powered: bool if player should be powered on or off.
+        """
+        await self._handle_cmd_power(player_id, powered)
+
+    @api_command("players/cmd/volume_set")
+    @handle_player_command
+    async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+        """Send VOLUME_SET command to given player.
+
+        :param player_id: player_id of the player to handle the command.
+        :param volume_level: volume level (0..100) to set on the player.
+        """
+        await self._handle_cmd_volume_set(player_id, volume_level)
+
+    @api_command("players/cmd/volume_up")
+    @handle_player_command
+    async def cmd_volume_up(self, player_id: str) -> None:
+        """Send VOLUME_UP command to given player.
+
+        - player_id: player_id of the player to handle the command.
+        """
+        if not (player := self.get_player(player_id)):
+            return
+        current_volume = player.state.volume_level or 0
+        if current_volume < 5 or current_volume > 95:
+            step_size = 1
+        elif current_volume < 20 or current_volume > 80:
+            step_size = 2
+        else:
+            step_size = 5
+        new_volume = min(100, current_volume + step_size)
+        await self.cmd_volume_set(player_id, new_volume)
+
+    @api_command("players/cmd/volume_down")
+    @handle_player_command
+    async def cmd_volume_down(self, player_id: str) -> None:
+        """Send VOLUME_DOWN command to given player.
+
+        - player_id: player_id of the player to handle the command.
+        """
+        if not (player := self.get_player(player_id)):
+            return
+        current_volume = player.state.volume_level or 0
+        if current_volume < 5 or current_volume > 95:
+            step_size = 1
+        elif current_volume < 20 or current_volume > 80:
+            step_size = 2
+        else:
+            step_size = 5
+        new_volume = max(0, current_volume - step_size)
+        await self.cmd_volume_set(player_id, new_volume)
+
+    @api_command("players/cmd/group_volume")
+    @handle_player_command
+    async def cmd_group_volume(
+        self,
+        player_id: str,
+        volume_level: int,
+    ) -> None:
+        """
+        Handle adjusting the overall/group volume to a playergroup (or synced players).
+
+        Will set a new (overall) volume level to a group player or syncgroup.
+
+        :param player_id: Player ID of group player or syncleader to handle the command.
+        :param volume_level: Volume level (0..100) to set to the group.
+        """
+        player = self.get_player(player_id, True)
+        assert player is not None  # for type checker
+        if player.state.type == PlayerType.GROUP or player.state.group_members:
+            # dedicated group player or sync leader
+            await self.set_group_volume(player, volume_level)
+            return
+        if player.state.synced_to and (sync_leader := self.get_player(player.state.synced_to)):
+            # redirect to sync leader
+            await self.set_group_volume(sync_leader, volume_level)
+            return
+        # treat as normal player volume change
+        await self.cmd_volume_set(player_id, volume_level)
+
+    @api_command("players/cmd/group_volume_up")
+    @handle_player_command
+    async def cmd_group_volume_up(self, player_id: str) -> None:
+        """Send VOLUME_UP command to given playergroup.
+
+        - player_id: player_id of the player to handle the command.
+        """
+        group_player_state = self.get_player_state(player_id, True)
+        assert group_player_state
+        cur_volume = group_player_state.group_volume
+        if cur_volume < 5 or cur_volume > 95:
+            step_size = 1
+        elif cur_volume < 20 or cur_volume > 80:
+            step_size = 2
+        else:
+            step_size = 5
+        new_volume = min(100, cur_volume + step_size)
+        await self.cmd_group_volume(player_id, new_volume)
+
+    @api_command("players/cmd/group_volume_down")
+    @handle_player_command
+    async def cmd_group_volume_down(self, player_id: str) -> None:
+        """Send VOLUME_DOWN command to given playergroup.
+
+        - player_id: player_id of the player to handle the command.
+        """
+        group_player_state = self.get_player_state(player_id, True)
+        assert group_player_state
+        cur_volume = group_player_state.group_volume
+        if cur_volume < 5 or cur_volume > 95:
+            step_size = 1
+        elif cur_volume < 20 or cur_volume > 80:
+            step_size = 2
+        else:
+            step_size = 5
+        new_volume = max(0, cur_volume - step_size)
+        await self.cmd_group_volume(player_id, new_volume)
+
+    @api_command("players/cmd/group_volume_mute")
+    @handle_player_command
+    async def cmd_group_volume_mute(self, player_id: str, muted: bool) -> None:
+        """Send VOLUME_MUTE command to all players in a group.
+
+        - player_id: player_id of the group player or sync leader.
+        - muted: bool if group should be muted.
+        """
+        player = self.get_player(player_id, True)
+        assert player is not None  # for type checker
+        if player.type == PlayerType.GROUP or player.group_members:
+            # dedicated group player or sync leader
+            coros = []
+            for child_player in self.iter_group_members(
+                player, only_powered=True, exclude_self=False
+            ):
+                coros.append(self.cmd_volume_mute(child_player.player_id, muted))
+            await asyncio.gather(*coros)
+
+    @api_command("players/cmd/volume_mute")
+    @handle_player_command
+    async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
+        """Send VOLUME_MUTE command to given player.
+
+        - player_id: player_id of the player to handle the command.
+        - muted: bool if player should be muted.
+        """
+        player = self.get_player(player_id, True)
+        assert player
+
+        # Set/clear mute lock for players in a group
+        # This prevents auto-unmute when group volume changes
+        is_in_group = bool(player.state.synced_to or player.state.group_members)
+        if muted and is_in_group:
+            player.extra_data[ATTR_MUTE_LOCK] = True
+        elif not muted:
+            player.extra_data.pop(ATTR_MUTE_LOCK, None)
+
+        if player.volume_control == PLAYER_CONTROL_NONE:
+            raise UnsupportedFeaturedException(
+                f"Player {player.state.name} does not support muting"
+            )
+        if player.mute_control == PLAYER_CONTROL_NATIVE:
+            # player supports mute command natively: forward to player
+            await player.volume_mute(muted)
+            return
+        if player.mute_control == PLAYER_CONTROL_FAKE:
+            # user wants to use fake mute control - so we use volume instead
+            self.logger.debug(
+                "Using volume for muting for player %s",
+                player.state.name,
+            )
+            if muted:
+                player.extra_data[ATTR_PREVIOUS_VOLUME] = player.state.volume_level
+                player.extra_data[ATTR_FAKE_MUTE] = True
+                await self._handle_cmd_volume_set(player_id, 0)
+                player.update_state()
+            else:
+                prev_volume = player.extra_data.get(ATTR_PREVIOUS_VOLUME, 1)
+                player.extra_data[ATTR_FAKE_MUTE] = False
+                player.update_state()
+                await self._handle_cmd_volume_set(player_id, prev_volume)
+            return
+
+        # handle external player control
+        if player_control := self._controls.get(player.mute_control):
+            control_name = player_control.name if player_control else player.mute_control
+            self.logger.debug("Redirecting mute command to PlayerControl %s", control_name)
+            if not player_control or not player_control.supports_mute:
+                raise UnsupportedFeaturedException(
+                    f"Player control {control_name} is not available"
+                )
+            assert player_control.mute_set is not None
+            await player_control.mute_set(muted)
+            return
+
+        # handle to protocol player as volume_mute control
+        if protocol_player := self.get_player(player.state.volume_control):
+            self.logger.debug(
+                "Redirecting mute command to protocol player %s",
+                protocol_player.provider.manifest.name,
+            )
+            await self.cmd_volume_mute(protocol_player.player_id, muted)
+            return
+
+    @api_command("players/cmd/play_announcement")
+    @handle_player_command(lock=True)
+    async def play_announcement(
+        self,
+        player_id: str,
+        url: str,
+        pre_announce: bool | None = None,
+        volume_level: int | None = None,
+        pre_announce_url: str | None = None,
+    ) -> None:
+        """
+        Handle playback of an announcement (url) on given player.
+
+        :param player_id: Player ID of the player to handle the command.
+        :param url: URL of the announcement to play.
+        :param pre_announce: Optional bool if pre-announce should be used.
+        :param volume_level: Optional volume level to set for the announcement.
+        :param pre_announce_url: Optional custom URL to use for the pre-announce chime.
+        """
+        player = self.get_player(player_id, True)
+        assert player is not None  # for type checking
+        if not url.startswith("http"):
+            raise PlayerCommandFailed("Only URLs are supported for announcements")
+        if (
+            pre_announce
+            and pre_announce_url
+            and not validate_announcement_chime_url(pre_announce_url)
+        ):
+            raise PlayerCommandFailed("Invalid pre-announce chime URL specified.")
+        try:
+            # mark announcement_in_progress on player
+            player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = True
+            # determine pre-announce from (group)player config
+            if pre_announce is None and "tts" in url:
+                conf_pre_announce = self.mass.config.get_raw_player_config_value(
+                    player_id,
+                    CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
+                    CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value,
+                )
+                pre_announce = cast("bool", conf_pre_announce)
+            if pre_announce_url is None:
+                if conf_pre_announce_url := self.mass.config.get_raw_player_config_value(
+                    player_id,
+                    CONF_PRE_ANNOUNCE_CHIME_URL,
+                ):
+                    # player default custom chime url
+                    pre_announce_url = cast("str", conf_pre_announce_url)
+                else:
+                    # use global default chime url
+                    pre_announce_url = ANNOUNCE_ALERT_FILE
+            # if player type is group with all members supporting announcements,
+            # we forward the request to each individual player
+            if player.state.type == PlayerType.GROUP and (
+                all(
+                    PlayerFeature.PLAY_ANNOUNCEMENT in x.state.supported_features
+                    for x in self.iter_group_members(player)
+                )
+            ):
+                # forward the request to each individual player
+                async with TaskManager(self.mass) as tg:
+                    for group_member in player.state.group_members:
+                        tg.create_task(
+                            self.play_announcement(
+                                group_member,
+                                url=url,
+                                pre_announce=pre_announce,
+                                volume_level=volume_level,
+                                pre_announce_url=pre_announce_url,
+                            )
+                        )
+                return
+            self.logger.info(
+                "Playback announcement to player %s (with pre-announce: %s): %s",
+                player.state.name,
+                pre_announce,
+                url,
+            )
+            # determine if the player has native announcements support
+            # or if any linked protocol has announcement support
+            if announce_player := self._get_control_target(
+                player,
+                required_feature=PlayerFeature.PLAY_ANNOUNCEMENT,
+                require_active=False,
+                allow_native=True,
+            ):
+                native_announce_support = True
+            else:
+                announce_player = player
+            # create a PlayerMedia object for the announcement so
+            # we can send a regular play-media call downstream
+            announce_data = AnnounceData(
+                announcement_url=url,
+                pre_announce=bool(pre_announce),
+                pre_announce_url=pre_announce_url,
+            )
+            announcement = PlayerMedia(
+                uri=self.mass.streams.get_announcement_url(player_id, announce_data=announce_data),
+                media_type=MediaType.ANNOUNCEMENT,
+                title="Announcement",
+                custom_data=dict(announce_data),
+            )
+            # handle native announce support (player or linked protocol)
+            if native_announce_support:
+                announcement_volume = self.get_announcement_volume(player_id, volume_level)
+                await announce_player.play_announcement(announcement, announcement_volume)
+                return
+            # use fallback/default implementation
+            await self._play_announcement(player, announcement, volume_level)
+        finally:
+            player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = False
+
+    @handle_player_command(lock=True)
+    async def play_media(self, player_id: str, media: PlayerMedia) -> None:
+        """Handle PLAY MEDIA on given player.
+
+        - player_id: player_id of the player to handle the command.
+        - media: The Media that needs to be played on the player.
+        """
+        player = self._get_player_with_redirect(player_id)
+        # Delegate to internal handler for actual implementation
+        await self._handle_play_media(player.player_id, media)
+
+    @api_command("players/cmd/select_sound_mode")
+    @handle_player_command
+    async def select_sound_mode(self, player_id: str, sound_mode: str) -> None:
+        """
+        Handle SELECT SOUND MODE command on given player.
+
+        - player_id: player_id of the player to handle the command
+        - sound_mode: The ID of the sound mode that needs to be activated/selected.
+        """
+        player = self.get_player(player_id, True)
+        assert player is not None  # for type checking
+
+        if PlayerFeature.SELECT_SOUND_MODE not in player.supported_features:
+            raise UnsupportedFeaturedException(
+                f"Player {player.display_name} does not support sound mode selection"
+            )
+
+        prev_sound_mode = player.active_sound_mode
+        if sound_mode == prev_sound_mode:
+            return
+
+        # basic check if sound mode is valid for player
+        if not any(x for x in player.sound_mode_list if x.id == sound_mode):
+            raise PlayerCommandFailed(
+                f"{sound_mode} is an invalid sound_mode for player {player.display_name}"
+            )
+
+        # forward to player
+        await player.select_sound_mode(sound_mode)
+
+    @api_command("players/cmd/set_option")
+    @handle_player_command
+    async def set_option(
+        self, player_id: str, option_key: str, option_value: PlayerOptionValueType
+    ) -> None:
+        """
+        Handle SET_OPTION command on given player.
+
+        - player_id: player_id of the player to handle the command
+        - option_key: The key of the player option that needs to be activated/selected.
+        - option_value: The new value of the player option.
+        """
+        player = self.get_player(player_id, True)
+        assert player is not None  # for type checking
+
+        if PlayerFeature.OPTIONS not in player.supported_features:
+            raise UnsupportedFeaturedException(
+                f"Player {player.display_name} does not support set_option"
+            )
+
+        prev_player_option = next((x for x in player.options if x.key == option_key), None)
+        if not prev_player_option:
+            return
+        if prev_player_option.value == option_value:
+            return
+
+        if prev_player_option.read_only:
+            raise UnsupportedFeaturedException(
+                f"Player {player.display_name} option {option_key} is read-only"
+            )
+
+        # forward to player
+        await player.set_option(option_key=option_key, option_value=option_value)
+
+    @api_command("players/cmd/select_source")
+    @handle_player_command
+    async def select_source(self, player_id: str, source: str | None) -> None:
+        """
+        Handle SELECT SOURCE command on given player.
+
+        - player_id: player_id of the player to handle the command.
+        - source: The ID of the source that needs to be activated/selected.
+        """
+        if source is None:
+            source = player_id  # default to MA queue source
+        player = self.get_player(player_id, True)
+        assert player is not None  # for type checking
+        # Check if player is currently grouped (reject for public API)
+        if player.state.synced_to or player.state.active_group:
+            raise PlayerCommandFailed(f"Player {player.state.name} is currently grouped")
+        # Delegate to internal handler for actual implementation
+        await self._handle_select_source(player_id, source)
+
+    @handle_player_command(lock=True)
+    async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
+        """
+        Handle enqueuing of a next media item on the player.
+
+        :param player_id: player_id of the player to handle the command.
+        :param media: The Media that needs to be enqueued on the player.
+        :raises UnsupportedFeaturedException: if the player does not support enqueueing.
+        :raises PlayerUnavailableError: if the player is not available.
+        """
+        # Note: No group redirect needed here as enqueue doesn't use _get_player_with_redirect
+        # Delegate to internal handler for actual implementation
+        await self._handle_enqueue_next_media(player_id, media)
+
+    @api_command("players/cmd/set_members")
+    async def cmd_set_members(
+        self,
+        target_player: str,
+        player_ids_to_add: list[str] | None = None,
+        player_ids_to_remove: list[str] | None = None,
+    ) -> None:
+        """
+        Join/unjoin given player(s) to/from target player.
+
+        Will add the given player(s) to the target player (sync leader or group player).
+
+        :param target_player: player_id of the syncgroup leader or group player.
+        :param player_ids_to_add: List of player_id's to add to the target player.
+        :param player_ids_to_remove: List of player_id's to remove from the target player.
+
+        :raises UnsupportedFeaturedException: if the target player does not support grouping.
+        :raises PlayerUnavailableError: if the target player is not available.
+        """
+        parent_player: Player | None = self.get_player(target_player, True)
+        assert parent_player is not None  # for type checking
+        if PlayerFeature.SET_MEMBERS not in parent_player.state.supported_features:
+            msg = f"Player {parent_player.name} does not support group commands"
+            raise UnsupportedFeaturedException(msg)
+
+        # guard edge case: player already synced to another player
+        if parent_player.state.synced_to:
+            raise PlayerCommandFailed(
+                f"Player {parent_player.name} is already synced to another player on its own, "
+                "you need to ungroup it first before you can join other players to it.",
+            )
+        # handle dissolve sync group if the target player is currently
+        # a sync leader and is being removed from itself
+        should_stop = False
+        if player_ids_to_remove and target_player in player_ids_to_remove:
+            self.logger.info(
+                "Dissolving sync group of player %s as it is being removed from itself",
+                parent_player.name,
+            )
+            player_ids_to_add = None
+            player_ids_to_remove = [
+                x for x in parent_player.state.group_members if x != target_player
+            ]
+            should_stop = True
+        # filter all player ids on compatibility and availability
+        final_player_ids_to_add: list[str] = []
+        for child_player_id in player_ids_to_add or []:
+            if child_player_id == target_player:
+                continue
+            if child_player_id in final_player_ids_to_add:
+                continue
+            if (
+                not (child_player := self.get_player(child_player_id))
+                or not child_player.state.available
+            ):
+                self.logger.warning("Player %s is not available", child_player_id)
+                continue
+
+            # check if player can be synced/grouped with the target player
+            # state.can_group_with already handles all expansion and translation
+            if child_player_id not in parent_player.state.can_group_with:
+                self.logger.warning(
+                    "Player %s can not be grouped with %s",
+                    child_player.name,
+                    parent_player.name,
+                )
+                continue
+
+            if (
+                child_player.state.synced_to
+                and child_player.state.synced_to == target_player
+                and child_player_id in parent_player.state.group_members
+            ):
+                continue  # already synced to this target
+
+            # power on the player if needed
+            if (
+                not child_player.state.powered
+                and child_player.state.power_control != PLAYER_CONTROL_NONE
+            ):
+                await self._handle_cmd_power(child_player.player_id, True)
+            # if we reach here, all checks passed
+            final_player_ids_to_add.append(child_player_id)
+
+        # process player ids to remove and filter out invalid/unavailable players and edge cases
+        final_player_ids_to_remove: list[str] = []
+        if player_ids_to_remove:
+            for child_player_id in player_ids_to_remove:
+                if child_player_id not in parent_player.state.group_members:
+                    continue
+                final_player_ids_to_remove.append(child_player_id)
+
+        # Forward command to the appropriate player after all (base) sanity checks
+        # GROUP players (sync_group, universal_group) manage their own members internally
+        # and don't need protocol translation - call their set_members directly
+        if parent_player.type == PlayerType.GROUP:
+            await parent_player.set_members(
+                player_ids_to_add=final_player_ids_to_add,
+                player_ids_to_remove=final_player_ids_to_remove,
+            )
+            return
+        # For regular players, handle protocol selection and translation
+        # Store playback state before changing members to detect protocol changes
+        was_playing = parent_player.playback_state in (
+            PlaybackState.PLAYING,
+            PlaybackState.PAUSED,
+        )
+        previous_protocol = parent_player.active_output_protocol if was_playing else None
+
+        await self._handle_set_members_with_protocols(
+            parent_player, final_player_ids_to_add, final_player_ids_to_remove
+        )
+
+        if should_stop:
+            # Stop playback on the player if it is being removed from itself
+            await self._handle_cmd_stop(parent_player.player_id)
+            return
+
+        # Check if protocol changed due to member change and restart playback if needed
+        if not should_stop and was_playing:
+            # Determine which protocol would be used now with new members
+            _new_target_player, new_protocol = self._select_best_output_protocol(parent_player)
+            new_protocol_id = new_protocol.output_protocol_id if new_protocol else "native"
+            previous_protocol_id = previous_protocol or "native"
+
+            # If protocol changed, restart playback
+            if new_protocol_id != previous_protocol_id:
+                self.logger.info(
+                    "Protocol changed from %s to %s due to member change, restarting playback",
+                    previous_protocol_id,
+                    new_protocol_id,
+                )
+                # Restart playback on the new protocol using resume
+                await self.cmd_resume(
+                    parent_player.player_id,
+                    parent_player.state.active_source,
+                    parent_player.state.current_media,
+                )
+
+    @api_command("players/cmd/group")
+    @handle_player_command
+    async def cmd_group(self, player_id: str, target_player: str) -> None:
+        """Handle GROUP command for given player.
+
+        Join/add the given player(id) to the given (leader) player/sync group.
+        If the target player itself is already synced to another player, this may fail.
+        If the player can not be synced with the given target player, this may fail.
+
+        :param player_id: player_id of the player to handle the command.
+        :param target_player: player_id of the syncgroup leader or group player.
+
+        :raises UnsupportedFeaturedException: if the target player does not support grouping.
+        :raises PlayerCommandFailed: if the target player is already synced to another player.
+        :raises PlayerUnavailableError: if the target player is not available.
+        :raises PlayerCommandFailed: if the player is already grouped to another player.
+        """
+        await self.cmd_set_members(target_player, player_ids_to_add=[player_id])
+
+    @api_command("players/cmd/group_many")
+    async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None:
+        """
+        Join given player(s) to target player.
+
+        Will add the given player(s) to the target player (sync leader or group player).
+        This is a (deprecated) alias for cmd_set_members.
+        """
+        await self.cmd_set_members(target_player, player_ids_to_add=child_player_ids)
+
+    @api_command("players/cmd/ungroup")
+    @handle_player_command
+    async def cmd_ungroup(self, player_id: str) -> None:
+        """Handle UNGROUP command for given player.
+
+        Remove the given player from any (sync)groups it currently is synced to.
+        If the player is not currently grouped to any other player,
+        this will silently be ignored.
+
+        NOTE: This is a (deprecated) alias for cmd_set_members.
+        """
+        if not (player := self.get_player(player_id)):
+            self.logger.warning("Player %s is not available", player_id)
+            return
+
+        if (
+            player.state.active_group
+            and (group_player := self.get_player(player.state.active_group))
+            and (PlayerFeature.SET_MEMBERS in group_player.state.supported_features)
+        ):
+            # the player is part of a (permanent) groupplayer and the user tries to ungroup
+            if player_id in group_player.static_group_members:
+                raise UnsupportedFeaturedException(
+                    f"Player {player.name}  is a static member of group {group_player.name} "
+                    "and cannot be removed from that group!"
+                )
+            await group_player.set_members(player_ids_to_remove=[player_id])
+            return
+
+        if player.state.synced_to and (synced_player := self.get_player(player.state.synced_to)):
+            # player is a sync member
+            await synced_player.set_members(player_ids_to_remove=[player_id])
+            return
+
+        if not (player.state.synced_to or player.state.group_members):
+            return  # nothing to do
+
+        if PlayerFeature.SET_MEMBERS not in player.state.supported_features:
+            self.logger.warning("Player %s does not support (un)group commands", player.name)
+            return
+
+        # forward command to the player once all checks passed
+        await player.ungroup()
+
+    @api_command("players/cmd/ungroup_many")
+    async def cmd_ungroup_many(self, player_ids: list[str]) -> None:
+        """Handle UNGROUP command for all the given players."""
+        for player_id in list(player_ids):
+            await self.cmd_ungroup(player_id)
+
+    @api_command("players/create_group_player", required_role="admin")
+    async def create_group_player(
+        self, provider: str, name: str, members: list[str], dynamic: bool = True
+    ) -> Player:
+        """
+        Create a new (permanent) Group Player.
+
+        :param provider: The provider (id) to create the group player for.
+        :param name: Name of the new group player.
+        :param members: List of player ids to add to the group.
+        :param dynamic: Whether the group is dynamic (members can change).
+        """
+        if not (provider_instance := self.mass.get_provider(provider)):
+            raise ProviderUnavailableError(f"Provider {provider} not found")
+        provider_instance = cast("PlayerProvider", provider_instance)
+        if ProviderFeature.CREATE_GROUP_PLAYER not in provider_instance.supported_features:
+            raise UnsupportedFeaturedException(
+                f"Provider {provider} does not support creating group players"
+            )
+        return await provider_instance.create_group_player(name, members, dynamic)
+
+    @api_command("players/remove_group_player", required_role="admin")
+    async def remove_group_player(self, player_id: str) -> None:
+        """Remove a group player."""
+        if not (player := self.get_player(player_id)):
+            # we simply permanently delete the player by wiping its config
+            self.mass.config.remove(f"players/{player_id}")
+            return
+        if player.state.type != PlayerType.GROUP:
+            raise UnsupportedFeaturedException(f"Player {player.state.name} is not a group player")
+        player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
+        await player.provider.remove_group_player(player_id)
+
+    @api_command("players/add_currently_playing_to_favorites")
+    async def add_currently_playing_to_favorites(self, player_id: str) -> None:
+        """
+        Add the currently playing item/track on given player to the favorites.
+
+        This tries to resolve the currently playing media to an actual media item
+        and add that to the favorites in the library. Will raise an error if the
+        player is not currently playing anything or if the currently playing media
+        can not be resolved to a media item.
+        """
+        player = self._get_player_with_redirect(player_id)
+        # handle mass player queue active
+        if mass_queue := self.get_active_queue(player):
+            if not (current_item := mass_queue.current_item) or not current_item.media_item:
+                raise PlayerCommandFailed("No current item to add to favorites")
+            # if we're playing a radio station, try to resolve the currently playing track
+            if current_item.media_item.media_type == MediaType.RADIO:
+                if not (
+                    (streamdetails := mass_queue.current_item.streamdetails)
+                    and (stream_title := streamdetails.stream_title)
+                    and " - " in stream_title
+                ):
+                    # no stream title available, so we can't resolve the track
+                    # this can happen if the radio station does not provide metadata
+                    # or there's a commercial break
+                    # Possible future improvement could be to actually detect the song with a
+                    # shazam-like approach.
+                    raise PlayerCommandFailed("No current item to add to favorites")
+                # send the streamtitle into a global search query
+                search_artist, search_title_title = stream_title.split(" - ", 1)
+                # strip off any additional comments in the title (such as from Radio Paradise)
+                search_title_title = search_title_title.split(" | ")[0].strip()
+                if track := await self.mass.music.get_track_by_name(
+                    search_title_title, search_artist
+                ):
+                    # we found a track, so add it to the favorites
+                    await self.mass.music.add_item_to_favorites(track)
+                    return
+                # we could not resolve the track, so raise an error
+                raise PlayerCommandFailed("No current item to add to favorites")
+
+            # else: any other media item, just add it to the favorites directly
+            await self.mass.music.add_item_to_favorites(current_item.media_item)
+            return
+
+        # guard for player with no active source
+        if not player.state.active_source:
+            raise PlayerCommandFailed("Player has no active source")
+        # handle other source active using the current_media with uri
+        if current_media := player.state.current_media:
+            # prefer the uri of the current media item
+            if current_media.uri:
+                with suppress(MusicAssistantError):
+                    await self.mass.music.add_item_to_favorites(current_media.uri)
+                    return
+            # fallback to search based on artist and title (and album if available)
+            if current_media.artist and current_media.title:
+                if track := await self.mass.music.get_track_by_name(
+                    current_media.title,
+                    current_media.artist,
+                    current_media.album,
+                ):
+                    # we found a track, so add it to the favorites
+                    await self.mass.music.add_item_to_favorites(track)
+                    return
+        # if we reach here, we could not resolve the currently playing item
+        raise PlayerCommandFailed("No current item to add to favorites")
+
+    async def register(self, player: Player) -> None:
+        """Register a player on the Player Controller."""
+        if self.mass.closing:
+            return
+
+        # Use lock to prevent race conditions during concurrent player registrations
+        async with self._register_lock:
+            player_id = player.player_id
+
+            if player_id in self._players:
+                msg = f"Player {player_id} is already registered!"
+                raise AlreadyRegisteredError(msg)
+
+            # ignore disabled players
+            if not player.state.enabled:
+                return
+
+            # register throttler for this player
+            self._player_throttlers[player_id] = Throttler(1, 0.05)
+
+            # restore 'fake' power state from cache if available
+            cached_value = await self.mass.cache.get(
+                key=player.player_id,
+                provider=self.domain,
+                category=CACHE_CATEGORY_PLAYER_POWER,
+                default=False,
+            )
+            if cached_value is not None:
+                player.extra_data[ATTR_FAKE_POWER] = cached_value
+
+            # finally actually register it
+            self._players[player_id] = player
+            # update state without signaling event first (ensure all attributes are set)
+            player.update_state(signal_event=False)
+
+            # ensure we fetch and set the latest/full config for the player
+            player_config = await self.mass.config.get_player_config(player_id)
+            player.set_config(player_config)
+            # call hook after the player is registered and config is set
+            await player.on_config_updated()
+
+            # Handle protocol linking
+            # First enrich identifiers with real MAC (resolves virtual MACs via ARP)
+            await self._enrich_player_identifiers(player)
+            self._evaluate_protocol_links(player)
+
+            self.logger.info(
+                "Player (type %s) registered: %s/%s",
+                player.state.type.value,
+                player_id,
+                player.state.name,
+            )
+            # signal event that a player was added
+
+            if player.state.type != PlayerType.PROTOCOL:
+                self.mass.signal_event(
+                    EventType.PLAYER_ADDED, object_id=player.player_id, data=player
+                )
+
+            # register playerqueue for this player
+            # Skip if this is a protocol player pending evaluation (queue created when promoted)
+            if (
+                player.state.type != PlayerType.PROTOCOL
+                and player.player_id not in self._pending_protocol_evaluations
+            ):
+                await self.mass.player_queues.on_player_register(player)
+
+        # always call update to fix special attributes like display name, group volume etc.
+        player.update_state()
+
+        # Schedule debounced update of all players since can_group_with values may change
+        # when a new player is added (provider IDs expand to include the new player)
+        self._schedule_update_all_players()
+
+    async def register_or_update(self, player: Player) -> None:
+        """Register a new player on the controller or update existing one."""
+        if self.mass.closing:
+            return
+
+        if player.player_id in self._players:
+            self._players[player.player_id] = player
+            player.update_state()
+            # Also schedule update when replacing existing player
+            self._schedule_update_all_players()
+            return
+
+        await self.register(player)
+
+    def trigger_player_update(
+        self, player_id: str, force_update: bool = False, debounce_delay: float = 0.25
+    ) -> None:
+        """Trigger a (debounced) update for the given player."""
+        if self.mass.closing:
+            return
+        if not (player := self.get_player(player_id)):
+            return
+        task_id = f"player_update_state_{player_id}"
+        self.mass.call_later(
+            debounce_delay,
+            player.update_state,
+            force_update=force_update,
+            task_id=task_id,
+        )
+
+    async def unregister(self, player_id: str, permanent: bool = False) -> None:
+        """
+        Unregister a player from the player controller.
+
+        Called (by a PlayerProvider) when a player is removed or no longer available
+        (for a longer period of time). This will remove the player from the player
+        controller and optionally remove the player's config from the mass config.
+        If the player is not registered, this will silently be ignored.
+
+        :param player_id: Player ID of the player to unregister.
+        :param permanent: If True, remove the player permanently by deleting its config.
+                          If False, the player config will not be removed.
+        """
+        player = self._players.get(player_id)
+        if player is None:
+            return
+        await self._cleanup_player_memberships(player_id)
+        del self._players[player_id]
+        self.mass.player_queues.on_player_remove(player_id, permanent=permanent)
+        await player.on_unload()
+        if permanent:
+            # player permanent removal: cleanup protocol links, delete config
+            # and signal PLAYER_REMOVED event
+            self._cleanup_protocol_links(player)
+            self.delete_player_config(player_id)
+            self.logger.info("Player removed: %s", player.name)
+            if player.state.type != PlayerType.PROTOCOL:
+                self.mass.signal_event(EventType.PLAYER_REMOVED, player_id)
+        else:
+            # temporary unavailable: mark player as unavailable
+            # note: the player will be re-registered later if it comes back online
+            player.state.available = False
+            self.logger.info("Player unavailable: %s", player.name)
+            if player.state.type != PlayerType.PROTOCOL:
+                self.mass.signal_event(
+                    EventType.PLAYER_UPDATED, object_id=player.player_id, data=player.state
+                )
+        # Schedule debounced update of all players since can_group_with values may change
+        self._schedule_update_all_players()
+
+    @api_command("players/remove", required_role="admin")
+    async def remove(self, player_id: str) -> None:
+        """
+        Remove a player from a provider.
+
+        Can only be called when a PlayerProvider supports ProviderFeature.REMOVE_PLAYER.
+        """
+        player = self.get_player(player_id)
+        if player is None:
+            # we simply permanently delete the player config since it is not registered
+            self.delete_player_config(player_id)
+            return
+        if player.state.type == PlayerType.GROUP:
+            # Handle group player removal
+            player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
+            await player.provider.remove_group_player(player_id)
+            return
+        player.provider.check_feature(ProviderFeature.REMOVE_PLAYER)
+        await player.provider.remove_player(player_id)
+        # check for group memberships that need to be updated
+        if player.state.active_group and (
+            group_player := self.mass.players.get_player(player.state.active_group)
+        ):
+            # try to remove from the group
+            with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
+                await group_player.set_members(
+                    player_ids_to_remove=[player_id],
+                )
+        # We removed the player and can now clean up its config
+        self.delete_player_config(player_id)
+
+    def delete_player_config(self, player_id: str) -> None:
+        """
+        Permanently delete a player's configuration.
+
+        Should only be called for players that are not registered by the player controller.
+        """
+        # we simply permanently delete the player by wiping its config
+        conf_key = f"{CONF_PLAYERS}/{player_id}"
+        dsp_conf_key = f"{CONF_PLAYER_DSP}/{player_id}"
+        for key in (conf_key, dsp_conf_key):
+            self.mass.config.remove(key)
+
+    def signal_player_state_update(
+        self,
+        player: Player,
+        changed_values: dict[str, tuple[Any, Any]],
+        force_update: bool = False,
+        skip_forward: bool = False,
+    ) -> None:
+        """
+        Signal a player state update.
+
+        Called by a Player when its state has changed.
+        This will update the player state in the controller and signal the event bus.
+        """
+        player_id = player.player_id
+        if self.mass.closing:
+            return
+
+        # ignore updates for disabled players
+        if not player.state.enabled and ATTR_ENABLED not in changed_values:
+            return
+
+        if len(changed_values) == 0 and not force_update:
+            # nothing changed
+            return
+
+        # always signal update to the playerqueue
+        if player.state.type != PlayerType.PROTOCOL:
+            self.mass.player_queues.on_player_update(player, changed_values)
+
+        # to prevent spamming the eventbus on small changes (e.g. elapsed time),
+        # we check if there are only changes in the elapsed time
+        clean_changed_keys = set(changed_values.keys()) - {"current_media.elapsed_time"}
+        if clean_changed_keys == {ATTR_ELAPSED_TIME} and not force_update:
+            # ignore small changes in elapsed time
+            prev_value = changed_values[ATTR_ELAPSED_TIME][0] or 0
+            new_value = changed_values[ATTR_ELAPSED_TIME][1] or 0
+            if abs(prev_value - new_value) < 5:
+                return
+
+        # handle DSP reload of the leader when grouping/ungrouping
+        if ATTR_GROUP_MEMBERS in changed_values:
+            prev_group_members, new_group_members = changed_values[ATTR_GROUP_MEMBERS]
+            self._handle_group_dsp_change(player, prev_group_members or [], new_group_members)
+
+        if ATTR_GROUP_MEMBERS in changed_values:
+            # Removed group members also need to be updated since they are no longer part
+            # of this group and are available for playback again
+            prev_group_members = changed_values[ATTR_GROUP_MEMBERS][0] or []
+            new_group_members = changed_values[ATTR_GROUP_MEMBERS][1] or []
+            removed_members = set(prev_group_members) - set(new_group_members)
+            for _removed_player_id in removed_members:
+                if removed_player := self.get_player(_removed_player_id):
+                    removed_player.update_state()
+
+        became_inactive = False
+        if ATTR_AVAILABLE in changed_values:
+            became_inactive = changed_values[ATTR_AVAILABLE][1] is False
+        if not became_inactive and ATTR_ENABLED in changed_values:
+            became_inactive = changed_values[ATTR_ENABLED][1] is False
+        if became_inactive and (player.state.active_group or player.state.synced_to):
+            self.mass.create_task(self._cleanup_player_memberships(player.player_id))
+
+        # signal player update on the eventbus
+        if player.state.type != PlayerType.PROTOCOL:
+            self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player)
+
+        # signal a separate PlayerOptionsUpdated event
+        if options := changed_values.get("options"):
+            self.mass.signal_event(
+                EventType.PLAYER_OPTIONS_UPDATED, object_id=player_id, data=options
+            )
+
+        if skip_forward and not force_update:
+            return
+
+        # update/signal group player(s) child's when group updates
+        for child_player in self.iter_group_members(player, exclude_self=True):
+            self.trigger_player_update(child_player.player_id)
+        # update/signal group player(s) when child updates
+        for group_player in self._get_player_groups(player, powered_only=False):
+            self.trigger_player_update(group_player.player_id)
+        # update/signal manually synced to player when child updates
+        if (synced_to := player.state.synced_to) and (
+            synced_to_player := self.get_player(synced_to)
+        ):
+            self.trigger_player_update(synced_to_player.player_id)
+        # update/signal active groups when a group member updates
+        if (active_group := player.state.active_group) and (
+            active_group_player := self.get_player(active_group)
+        ):
+            self.trigger_player_update(active_group_player.player_id)
+        # If this is a protocol player, forward the state update to the parent player
+        if player.protocol_parent_id and (
+            parent_player := self.mass.players.get_player(player.protocol_parent_id)
+        ):
+            self.trigger_player_update(parent_player.player_id)
+        # If this is a parent player with linked protocols, forward state updates
+        # to linked protocol players so their state reflects parent dependencies
+        if player.state.type != PlayerType.PROTOCOL and player.linked_output_protocols:
+            for linked in player.linked_output_protocols:
+                if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
+                    self.mass.players.trigger_player_update(protocol_player.player_id)
+        # trigger update of all players in a provider if group related fields changed
+        if any(key in changed_values for key in ("group_members", "synced_to", "available")):
+            for prov_player in player.provider.players:
+                self.trigger_player_update(prov_player.player_id)
+
+    async def register_player_control(self, player_control: PlayerControl) -> None:
+        """Register a new PlayerControl on the controller."""
+        if self.mass.closing:
+            return
+        control_id = player_control.id
+
+        if control_id in self._controls:
+            msg = f"PlayerControl {control_id} is already registered"
+            raise AlreadyRegisteredError(msg)
+
+        # make sure that the playercontrol's provider is set to the instance_id
+        prov = self.mass.get_provider(player_control.provider)
+        if not prov or prov.instance_id != player_control.provider:
+            raise RuntimeError(f"Invalid provider ID given: {player_control.provider}")
+
+        self._controls[control_id] = player_control
+
+        self.logger.info(
+            "PlayerControl registered: %s/%s",
+            control_id,
+            player_control.name,
+        )
+
+        # always call update to update any attached players etc.
+        self.update_player_control(player_control.id)
+
+    async def register_or_update_player_control(self, player_control: PlayerControl) -> None:
+        """Register a new playercontrol on the controller or update existing one."""
+        if self.mass.closing:
+            return
+        if player_control.id in self._controls:
+            self._controls[player_control.id] = player_control
+            self.update_player_control(player_control.id)
+            return
+        await self.register_player_control(player_control)
+
+    def update_player_control(self, control_id: str) -> None:
+        """Update playercontrol state."""
+        if self.mass.closing:
+            return
+        # update all players that are using this control
+        for player in self._players.values():
+            if control_id in (
+                player.state.power_control,
+                player.state.volume_control,
+                player.state.mute_control,
+            ):
+                self.mass.loop.call_soon(player.update_state)
+
+    def remove_player_control(self, control_id: str) -> None:
+        """Remove a player_control from the player manager."""
+        control = self._controls.pop(control_id, None)
+        if control is None:
+            return
+        self._controls.pop(control_id, None)
+        self.logger.info("PlayerControl removed: %s", control.name)
+
+    def get_player_provider(self, player_id: str) -> PlayerProvider:
+        """Return PlayerProvider for given player."""
+        player = self._players[player_id]
+        assert player  # for type checker
+        return player.provider
+
+    def get_active_queue(self, player: Player) -> PlayerQueue | None:
+        """Return the current active queue for a player (if any)."""
+        # account for player that is synced (sync child)
+        if player.synced_to and player.synced_to != player.player_id:
+            if sync_leader := self.get_player(player.synced_to):
+                return self.get_active_queue(sync_leader)
+        # handle active group player
+        if player.state.active_group and player.state.active_group != player.player_id:
+            if group_player := self.get_player(player.state.active_group):
+                return self.get_active_queue(group_player)
+        # active_source may be filled queue id (or None)
+        active_source = player.state.active_source or player.player_id
+        if active_queue := self.mass.player_queues.get(active_source):
+            return active_queue
+        # handle active protocol player with parent player queue
+        if player.type == PlayerType.PROTOCOL and player.protocol_parent_id:
+            if parent_player := self.mass.players.get_player(player.protocol_parent_id):
+                return self.get_active_queue(parent_player)
+        return None
+
+    async def set_group_volume(self, group_player: Player, volume_level: int) -> None:
+        """Handle adjusting the overall/group volume to a playergroup (or synced players)."""
+        cur_volume = group_player.state.group_volume
+        volume_dif = volume_level - cur_volume
+        coros = []
+        # handle group volume by only applying the volume to powered members
+        for child_player in self.iter_group_members(
+            group_player, only_powered=True, exclude_self=False
+        ):
+            if child_player.state.volume_control == PLAYER_CONTROL_NONE:
+                continue
+            cur_child_volume = child_player.state.volume_level or 0
+            new_child_volume = int(cur_child_volume + volume_dif)
+            new_child_volume = max(0, new_child_volume)
+            new_child_volume = min(100, new_child_volume)
+            # Use private method to skip permission check - already validated on group
+            # ATTR_MUTE_LOCK on muted players prevents auto-unmute during group volume changes
+            coros.append(self._handle_cmd_volume_set(child_player.player_id, new_child_volume))
+        await asyncio.gather(*coros)
+
+    def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None:
+        """Get the (player specific) volume for a announcement."""
+        volume_strategy = self.mass.config.get_raw_player_config_value(
+            player_id,
+            CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key,
+            CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value,
+        )
+        volume_strategy_volume = self.mass.config.get_raw_player_config_value(
+            player_id,
+            CONF_ENTRY_ANNOUNCE_VOLUME.key,
+            CONF_ENTRY_ANNOUNCE_VOLUME.default_value,
+        )
+        if volume_strategy == "none":
+            return None
+        volume_level = volume_override
+        if volume_level is None and volume_strategy == "absolute":
+            volume_level = int(cast("float", volume_strategy_volume))
+        elif volume_level is None and volume_strategy == "relative":
+            if (player := self.get_player(player_id)) and player.state.volume_level is not None:
+                volume_level = int(
+                    player.state.volume_level + cast("float", volume_strategy_volume)
+                )
+        elif volume_level is None and volume_strategy == "percentual":
+            if (player := self.get_player(player_id)) and player.state.volume_level is not None:
+                percentual = (player.state.volume_level / 100) * cast(
+                    "float", volume_strategy_volume
+                )
+                volume_level = int(player.state.volume_level + percentual)
+        if volume_level is not None:
+            announce_volume_min = cast(
+                "float",
+                self.mass.config.get_raw_player_config_value(
+                    player_id,
+                    CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key,
+                    CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value,
+                ),
+            )
+            volume_level = max(int(announce_volume_min), volume_level)
+            announce_volume_max = cast(
+                "float",
+                self.mass.config.get_raw_player_config_value(
+                    player_id,
+                    CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key,
+                    CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value,
+                ),
+            )
+            volume_level = min(int(announce_volume_max), volume_level)
+        return None if volume_level is None else int(volume_level)
+
+    def iter_group_members(
+        self,
+        group_player: Player,
+        only_powered: bool = False,
+        only_playing: bool = False,
+        active_only: bool = False,
+        exclude_self: bool = True,
+    ) -> Iterator[Player]:
+        """Get (child) players attached to a group player or syncgroup."""
+        for child_id in list(group_player.state.group_members):
+            if child_player := self.get_player(child_id, False):
+                if not child_player.state.available or not child_player.state.enabled:
+                    continue
+                if only_powered and child_player.state.powered is False:
+                    continue
+                if active_only and child_player.state.active_group != group_player.player_id:
+                    continue
+                if exclude_self and child_player.player_id == group_player.player_id:
+                    continue
+                if only_playing and child_player.state.playback_state not in (
+                    PlaybackState.PLAYING,
+                    PlaybackState.PAUSED,
+                ):
+                    continue
+                yield child_player
+
+    async def wait_for_state(
+        self,
+        player: Player,
+        wanted_state: PlaybackState,
+        timeout: float = 60.0,
+        minimal_time: float = 0,
+    ) -> None:
+        """Wait for the given player to reach the given state."""
+        start_timestamp = time.time()
+        self.logger.debug(
+            "Waiting for player %s to reach state %s", player.state.name, wanted_state
+        )
+        try:
+            async with asyncio.timeout(timeout):
+                while player.state.playback_state != wanted_state:
+                    await asyncio.sleep(0.1)
+
+        except TimeoutError:
+            self.logger.debug(
+                "Player %s did not reach state %s within the timeout of %s seconds",
+                player.state.name,
+                wanted_state,
+                timeout,
+            )
+        elapsed_time = round(time.time() - start_timestamp, 2)
+        if elapsed_time < minimal_time:
+            self.logger.debug(
+                "Player %s reached state %s too soon (%s vs %s seconds) - add fallback sleep...",
+                player.state.name,
+                wanted_state,
+                elapsed_time,
+                minimal_time,
+            )
+            await asyncio.sleep(minimal_time - elapsed_time)
+        else:
+            self.logger.debug(
+                "Player %s reached state %s within %s seconds",
+                player.state.name,
+                wanted_state,
+                elapsed_time,
+            )
+
+    async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
+        """Call (by config manager) when the configuration of a player changes."""
+        player = self.get_player(config.player_id)
+        player_provider = self.mass.get_provider(config.provider)
+        player_disabled = ATTR_ENABLED in changed_keys and not config.enabled
+        player_enabled = ATTR_ENABLED in changed_keys and config.enabled
+
+        if player_disabled and player and player.state.available:
+            # edge case: ensure that the player is powered off if the player gets disabled
+            if player.state.power_control != PLAYER_CONTROL_NONE:
+                await self._handle_cmd_power(config.player_id, False)
+            elif player.state.playback_state != PlaybackState.IDLE:
+                await self.cmd_stop(config.player_id)
+
+        # signal player provider that the player got enabled/disabled
+        if (player_enabled or player_disabled) and player_provider:
+            assert isinstance(player_provider, PlayerProvider)  # for type checking
+            if player_disabled:
+                player_provider.on_player_disabled(config.player_id)
+            elif player_enabled:
+                player_provider.on_player_enabled(config.player_id)
+            return  # enabling/disabling a player will be handled by the provider
+
+        if not player:
+            return  # guard against player not being registered (yet)
+
+        resume_queue: PlayerQueue | None = (
+            self.mass.player_queues.get(player.state.active_source)
+            if player.state.active_source
+            else None
+        )
+
+        # ensure player state gets updated with any updated config
+        player.set_config(config)
+        await player.on_config_updated()
+        player.update_state()
+        # if the PlayerQueue was playing, restart playback
+        if resume_queue and resume_queue.state == PlaybackState.PLAYING:
+            requires_restart = any(
+                v for v in config.values.values() if v.key in changed_keys and v.requires_reload
+            )
+            if requires_restart:
+                # always stop first to ensure the player uses the new config
+                await self.mass.player_queues.stop(resume_queue.queue_id)
+                self.mass.call_later(
+                    1, self.mass.player_queues.resume, resume_queue.queue_id, False
+                )
+
+    async def on_player_dsp_change(self, player_id: str) -> None:
+        """Call (by config manager) when the DSP settings of a player change."""
+        # signal player provider that the config changed
+        if not (player := self.get_player(player_id)):
+            return
+        if player.state.playback_state == PlaybackState.PLAYING:
+            self.logger.info("Restarting playback of Player %s after DSP change", player_id)
+            # this will restart the queue stream/playback
+            if player.mass_queue_active:
+                self.mass.call_later(
+                    0, self.mass.player_queues.resume, player.state.active_source, False
+                )
+                return
+            # if the player is not using a queue, we need to stop and start playback
+            await self.cmd_stop(player_id)
+            await self.cmd_play(player_id)
+
+    async def _cleanup_player_memberships(self, player_id: str) -> None:
+        """Ensure a player is detached from any groups or syncgroups."""
+        if not (player := self.get_player(player_id)):
+            return
+
+        if (
+            player.state.active_group
+            and (group := self.get_player(player.state.active_group))
+            and group.supports_feature(PlayerFeature.SET_MEMBERS)
+        ):
+            # Ungroup the player if its part of an active group, this will ignore
+            # static_group_members since that is only checked when using cmd_set_members
+            with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
+                await group.set_members(player_ids_to_remove=[player_id])
+        elif player.state.synced_to and player.supports_feature(PlayerFeature.SET_MEMBERS):
+            # Remove the player if it was synced, otherwise it will still show as
+            # synced to the other player after it gets registered again
+            with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
+                await player.ungroup()
+
+    def _get_player_with_redirect(self, player_id: str) -> Player:
+        """Get player with check if playback related command should be redirected."""
+        player = self.get_player(player_id, True)
+        assert player is not None  # for type checking
+        if player.state.synced_to and (sync_leader := self.get_player(player.state.synced_to)):
+            self.logger.info(
+                "Player %s is synced to %s and can not accept "
+                "playback related commands itself, "
+                "redirected the command to the sync leader.",
+                player.name,
+                sync_leader.name,
+            )
+            return sync_leader
+        if player.state.active_group and (
+            active_group := self.get_player(player.state.active_group)
+        ):
+            self.logger.info(
+                "Player %s is part of a playergroup and can not accept "
+                "playback related commands itself, "
+                "redirected the command to the group leader.",
+                player.name,
+            )
+            return active_group
+        return player
+
+    def _get_active_plugin_source(self, player: Player) -> PluginSource | None:
+        """Get the active PluginSource for a player if any."""
+        # Check if any plugin source is in use by this player
+        for plugin_source in self.get_plugin_sources():
+            if plugin_source.in_use_by == player.player_id:
+                return plugin_source
+            if player.state.active_source == plugin_source.id:
+                return plugin_source
+        return None
+
+    def _get_player_groups(
+        self, player: Player, available_only: bool = True, powered_only: bool = False
+    ) -> Iterator[Player]:
+        """Return all groupplayers the given player belongs to."""
+        for _player in self.all_players(return_unavailable=not available_only):
+            if _player.player_id == player.player_id:
+                continue
+            if _player.state.type != PlayerType.GROUP:
+                continue
+            if powered_only and _player.state.powered is False:
+                continue
+            if player.player_id in _player.state.group_members:
+                yield _player
+
+    # Protocol linking methods are provided by ProtocolLinkingMixin (protocol_linking.py)
+
+    async def _play_announcement(  # noqa: PLR0915
+        self,
+        player: Player,
+        announcement: PlayerMedia,
+        volume_level: int | None = None,
+    ) -> None:
+        """Handle (default/fallback) implementation of the play announcement feature.
+
+        This default implementation will;
+        - stop playback of the current media (if needed)
+        - power on the player (if needed)
+        - raise the volume a bit
+        - play the announcement (from given url)
+        - wait for the player to finish playing
+        - restore the previous power and volume
+        - restore playback (if needed and if possible)
+
+        This default implementation will only be used if the player
+        (provider) has no native support for the PLAY_ANNOUNCEMENT feature.
+        """
+        prev_state = player.state.playback_state
+        prev_power = player.state.powered or prev_state != PlaybackState.IDLE
+        prev_synced_to = player.state.synced_to
+        prev_group = (
+            self.get_player(player.state.active_group) if player.state.active_group else None
+        )
+        prev_source = player.state.active_source
+        prev_media = player.state.current_media
+        prev_media_name = prev_media.title or prev_media.uri if prev_media else None
+        if prev_synced_to:
+            # ungroup player if its currently synced
+            self.logger.debug(
+                "Announcement to player %s - ungrouping player from %s...",
+                player.state.name,
+                prev_synced_to,
+            )
+            await self.cmd_ungroup(player.player_id)
+        elif prev_group:
+            # if the player is part of a group player, we need to ungroup it
+            if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
+                self.logger.debug(
+                    "Announcement to player %s - ungrouping from group player %s...",
+                    player.state.name,
+                    prev_group.display_name,
+                )
+                await prev_group.set_members(player_ids_to_remove=[player.player_id])
+            else:
+                # if the player is part of a group player that does not support ungrouping,
+                # we need to power off the groupplayer instead
+                self.logger.debug(
+                    "Announcement to player %s - turning off group player %s...",
+                    player.state.name,
+                    prev_group.display_name,
+                )
+                await self._handle_cmd_power(player.player_id, False)
+        elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
+            # normal/standalone player: stop player if its currently playing
+            self.logger.debug(
+                "Announcement to player %s - stop existing content (%s)...",
+                player.state.name,
+                prev_media_name,
+            )
+            await self.cmd_stop(player.player_id)
+            # wait for the player to stop
+            await self.wait_for_state(player, PlaybackState.IDLE, 10, 0.4)
+        # adjust volume if needed
+        # in case of a (sync) group, we need to do this for all child players
+        prev_volumes: dict[str, int] = {}
+        async with TaskManager(self.mass) as tg:
+            for volume_player_id in player.state.group_members or (player.player_id,):
+                if not (volume_player := self.get_player(volume_player_id)):
+                    continue
+                # catch any players that have a different source active
+                if (
+                    volume_player.state.active_source
+                    not in (
+                        player.state.active_source,
+                        volume_player.player_id,
+                        None,
+                    )
+                    and volume_player.state.playback_state == PlaybackState.PLAYING
+                ):
+                    self.logger.warning(
+                        "Detected announcement to playergroup %s while group member %s is playing "
+                        "other content, this may lead to unexpected behavior.",
+                        player.state.name,
+                        volume_player.state.name,
+                    )
+                    tg.create_task(self.cmd_stop(volume_player.player_id))
+                if volume_player.state.volume_control == PLAYER_CONTROL_NONE:
+                    continue
+                if (prev_volume := volume_player.state.volume_level) is None:
+                    continue
+                announcement_volume = self.get_announcement_volume(volume_player_id, volume_level)
+                if announcement_volume is None:
+                    continue
+                temp_volume = announcement_volume or player.state.volume_level
+                if temp_volume != prev_volume:
+                    prev_volumes[volume_player_id] = prev_volume
+                    self.logger.debug(
+                        "Announcement to player %s - setting temporary volume (%s)...",
+                        volume_player.state.name,
+                        announcement_volume,
+                    )
+                    tg.create_task(
+                        self._handle_cmd_volume_set(volume_player.player_id, announcement_volume)
+                    )
+        # play the announcement
+        self.logger.debug(
+            "Announcement to player %s - playing the announcement on the player...",
+            player.state.name,
+        )
+        await self.play_media(player_id=player.player_id, media=announcement)
+        # wait for the player(s) to play
+        await self.wait_for_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1)
+        # wait for the player to stop playing
+        if not announcement.duration:
+            if not announcement.custom_data:
+                raise ValueError("Announcement missing duration and custom_data")
+            media_info = await async_parse_tags(
+                announcement.custom_data["announcement_url"], require_duration=True
+            )
+            announcement.duration = int(media_info.duration) if media_info.duration else None
+
+        if announcement.duration is None:
+            raise ValueError("Announcement duration could not be determined")
+
+        await self.wait_for_state(
+            player,
+            PlaybackState.IDLE,
+            timeout=announcement.duration + 10,
+            minimal_time=float(announcement.duration) + 2,
+        )
+        self.logger.debug(
+            "Announcement to player %s - restore previous state...", player.state.name
+        )
+        # restore volume
+        async with TaskManager(self.mass) as tg:
+            for volume_player_id, prev_volume in prev_volumes.items():
+                tg.create_task(self._handle_cmd_volume_set(volume_player_id, prev_volume))
+        await asyncio.sleep(0.2)
+        # either power off the player or resume playing
+        if not prev_power:
+            if player.state.power_control != PLAYER_CONTROL_NONE:
+                self.logger.debug(
+                    "Announcement to player %s - turning player off again...", player.state.name
+                )
+                await self._handle_cmd_power(player.player_id, False)
+            # nothing to do anymore, player was not previously powered
+            # and does not support power control
+            return
+        if prev_synced_to:
+            self.logger.debug(
+                "Announcement to player %s - syncing back to %s...",
+                player.state.name,
+                prev_synced_to,
+            )
+            await self.cmd_set_members(prev_synced_to, player_ids_to_add=[player.player_id])
+        elif prev_group:
+            if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
+                self.logger.debug(
+                    "Announcement to player %s - grouping back to group player %s...",
+                    player.state.name,
+                    prev_group.display_name,
+                )
+                await prev_group.set_members(player_ids_to_add=[player.player_id])
+            elif prev_state == PlaybackState.PLAYING:
+                # if the player is part of a group player that does not support set_members,
+                # we need to restart the groupplayer
+                self.logger.debug(
+                    "Announcement to player %s - restarting playback on group player %s...",
+                    player.state.name,
+                    prev_group.display_name,
+                )
+                await self.cmd_play(prev_group.player_id)
+        elif prev_state == PlaybackState.PLAYING:
+            # player was playing something before the announcement - try to resume that here
+            await self._handle_cmd_resume(player.player_id, prev_source, prev_media)
+
+    async def _poll_players(self) -> None:
+        """Background task that polls players for updates."""
+        while True:
+            for player in list(self._players.values()):
+                # if the player is playing, update elapsed time every tick
+                # to ensure the queue has accurate details
+                player_playing = player.state.playback_state == PlaybackState.PLAYING
+                if player_playing:
+                    self.mass.loop.call_soon(
+                        self.mass.player_queues.on_player_update,
+                        player,
+                        {"corrected_elapsed_time": (None, player.corrected_elapsed_time)},
+                    )
+                # Poll player;
+                if not player.needs_poll:
+                    continue
+                try:
+                    last_poll: float = player.extra_data[ATTR_LAST_POLL]
+                except KeyError:
+                    last_poll = 0.0
+                if (self.mass.loop.time() - last_poll) < player.poll_interval:
+                    continue
+                player.extra_data[ATTR_LAST_POLL] = self.mass.loop.time()
+                try:
+                    await player.poll()
+                except Exception as err:
+                    self.logger.warning(
+                        "Error while requesting latest state from player %s: %s",
+                        player.state.name,
+                        str(err),
+                        exc_info=err if self.logger.isEnabledFor(10) else None,
+                    )
+                # Yield to event loop to prevent blocking
+                await asyncio.sleep(0)
+            await asyncio.sleep(1)
+
+    async def _handle_select_plugin_source(
+        self, player: Player, plugin_prov: PluginProvider
+    ) -> None:
+        """Handle playback/select of given plugin source on player."""
+        plugin_source = plugin_prov.get_source()
+        if plugin_source.in_use_by and plugin_source.in_use_by != player.player_id:
+            self.logger.debug(
+                "Plugin source %s is already in use by player %s, stopping playback there first.",
+                plugin_source.name,
+                plugin_source.in_use_by,
+            )
+            with suppress(PlayerCommandFailed):
+                await self.cmd_stop(plugin_source.in_use_by)
+        stream_url = await self.mass.streams.get_plugin_source_url(plugin_source, player.player_id)
+        plugin_source.in_use_by = player.player_id
+        # Call on_select callback if available
+        if plugin_source.on_select:
+            await plugin_source.on_select()
+        await self.play_media(
+            player_id=player.player_id,
+            media=PlayerMedia(
+                uri=stream_url,
+                media_type=MediaType.PLUGIN_SOURCE,
+                title=plugin_source.name,
+                custom_data={
+                    "provider": plugin_prov.instance_id,
+                    "source_id": plugin_source.id,
+                    "player_id": player.player_id,
+                    "audio_format": plugin_source.audio_format,
+                },
+            ),
+        )
+        # trigger player update to ensure the source is set
+        self.trigger_player_update(player.player_id)
+
+    def _handle_group_dsp_change(
+        self, player: Player, prev_group_members: list[str], new_group_members: list[str]
+    ) -> None:
+        """Handle DSP reload when group membership changes."""
+        prev_child_count = len(prev_group_members)
+        new_child_count = len(new_group_members)
+        is_player_group = player.state.type == PlayerType.GROUP
+
+        # handle special case for PlayerGroups: since there are no leaders,
+        # DSP still always work with a single player in the group.
+        multi_device_dsp_threshold = 1 if is_player_group else 0
+
+        prev_is_multiple_devices = prev_child_count > multi_device_dsp_threshold
+        new_is_multiple_devices = new_child_count > multi_device_dsp_threshold
+
+        if prev_is_multiple_devices == new_is_multiple_devices:
+            return  # no change in multi-device status
+
+        supports_multi_device_dsp = (
+            PlayerFeature.MULTI_DEVICE_DSP in player.state.supported_features
+        )
+
+        dsp_enabled: bool
+        if player.state.type == PlayerType.GROUP:
+            # Since player groups do not have leaders, we will use the only child
+            # that was in the group before and after the change
+            if prev_is_multiple_devices:
+                if childs := new_group_members:
+                    # We shrank the group from multiple players to a single player
+                    # So the now only child will control the DSP
+                    dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
+                else:
+                    dsp_enabled = False
+            elif childs := prev_group_members:
+                # We grew the group from a single player to multiple players,
+                # let's see if the previous single player had DSP enabled
+                dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
+            else:
+                dsp_enabled = False
+        else:
+            dsp_enabled = self.mass.config.get_player_dsp_config(player.player_id).enabled
+
+        if dsp_enabled and not supports_multi_device_dsp:
+            # We now know that the group configuration has changed so:
+            # - multi-device DSP is not supported
+            # - we switched from a group with multiple players to a single player
+            #   (or vice versa)
+            # - the leader has DSP enabled
+            self.mass.create_task(self.mass.players.on_player_dsp_change(player.player_id))
+
+    def _schedule_update_all_players(self, delay: float = 2.0) -> None:
+        """
+        Schedule a debounced update of all players' state.
+
+        Used when a new player is registered to ensure all existing players
+        update their dynamic properties (like can_group_with) that may have changed.
+
+        :param delay: Delay in seconds before triggering updates (default 2.0).
+        """
+        if self.mass.closing:
+            return
+
+        async def _update_all_players() -> None:
+            if self.mass.closing:
+                return
+
+            for player in self.all_players(
+                return_unavailable=True,
+                return_disabled=False,
+                return_protocol_players=True,
+            ):
+                # Use call_soon to schedule updates without blocking
+                # This spreads the updates across event loop iterations
+                self.mass.loop.call_soon(player.update_state)
+
+        # Use mass.call_later with task_id for automatic debouncing
+        # Each call resets the timer, so rapid registrations only trigger one update
+        task_id = "update_all_players_on_registration"
+        self.mass.call_later(delay, _update_all_players, task_id=task_id)
+
+    async def _handle_set_members_with_protocols(
+        self,
+        parent_player: Player,
+        player_ids_to_add: list[str],
+        player_ids_to_remove: list[str],
+    ) -> None:
+        """
+        Handle set_members considering protocol and native members.
+
+        Translates visible player IDs to protocol player IDs when appropriate,
+        and forwards to the correct player's set_members.
+
+        :param parent_player: The parent player to add/remove members to/from.
+        :param player_ids_to_add: List of visible player IDs to add as members.
+        :param player_ids_to_remove: List of visible player IDs to remove from members.
+        """
+        # Get parent's active protocol domain and player if available
+        parent_protocol_domain = None
+        parent_protocol_player = None
+        if (
+            parent_player.active_output_protocol
+            and parent_player.active_output_protocol != "native"
+        ):
+            parent_protocol_player = self.get_player(parent_player.active_output_protocol)
+            if parent_protocol_player:
+                parent_protocol_domain = parent_protocol_player.provider.domain
+
+        self.logger.debug(
+            "set_members on %s: active_protocol=%s, adding=%s, removing=%s",
+            parent_player.state.name,
+            parent_protocol_domain or "none",
+            player_ids_to_add,
+            player_ids_to_remove,
+        )
+
+        # Translate members to add
+        (
+            protocol_members_to_add,
+            native_members_to_add,
+            parent_protocol_player,
+            parent_protocol_domain,
+        ) = self._translate_members_for_protocols(
+            parent_player, player_ids_to_add, parent_protocol_player, parent_protocol_domain
+        )
+
+        self.logger.debug(
+            "Translated members: protocol=%s (domain=%s), native=%s",
+            protocol_members_to_add,
+            parent_protocol_domain,
+            native_members_to_add,
+        )
+
+        # Translate members to remove
+        protocol_members_to_remove, native_members_to_remove = (
+            self._translate_members_to_remove_for_protocols(
+                parent_player, player_ids_to_remove, parent_protocol_player, parent_protocol_domain
+            )
+        )
+
+        # Forward protocol members to protocol player's set_members
+        if (protocol_members_to_add or protocol_members_to_remove) and parent_protocol_player:
+            await self._forward_protocol_set_members(
+                parent_player,
+                parent_protocol_player,
+                protocol_members_to_add,
+                protocol_members_to_remove,
+            )
+
+        # Forward native members to parent player's set_members
+        if native_members_to_add or native_members_to_remove:
+            filtered_native_add = self._filter_native_members(native_members_to_add, parent_player)
+            filtered_native_remove = [
+                pid
+                for pid in native_members_to_remove
+                if (p := self.get_player(pid)) and p.type != PlayerType.PROTOCOL
+            ]
+            self.logger.debug(
+                "Native grouping on %s: filtered_add=%s, filtered_remove=%s",
+                parent_player.state.name,
+                filtered_native_add,
+                filtered_native_remove,
+            )
+            if filtered_native_add or filtered_native_remove:
+                self.logger.info(
+                    "Calling set_members on native player %s with add=%s, remove=%s",
+                    parent_player.state.name,
+                    filtered_native_add,
+                    filtered_native_remove,
+                )
+                await parent_player.set_members(
+                    player_ids_to_add=filtered_native_add or None,
+                    player_ids_to_remove=filtered_native_remove or None,
+                )
+
+    # Private command handlers (no permission checks)
+
+    async def _handle_cmd_resume(
+        self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
+    ) -> None:
+        """
+        Handle resume playback command.
+
+        Skips the permission checks (internal use only).
+        """
+        player = self._get_player_with_redirect(player_id)
+        source = source or player.state.active_source
+        media = media or player.state.current_media
+        # power on the player if needed
+        if not player.state.powered and player.state.power_control != PLAYER_CONTROL_NONE:
+            await self._handle_cmd_power(player.player_id, True)
+        # Redirect to queue controller if it is active
+        if active_queue := self.mass.player_queues.get(source or player_id):
+            await self.mass.player_queues.resume(active_queue.queue_id)
+            return
+        # try to handle command on player directly
+        # TODO: check if player has an active source with native resume support
+        active_source = next((x for x in player.state.source_list if x.id == source), None)
+        if (
+            player.state.playback_state in (PlaybackState.IDLE, PlaybackState.PAUSED)
+            and active_source
+            and active_source.can_play_pause
+        ):
+            # player has some other source active and native resume support
+            await player.play()
+            return
+        if active_source and not active_source.passive:
+            await self.select_source(player_id, active_source.id)
+            return
+        if media:
+            # try to re-play the current media item
+            await player.play_media(media)
+            return
+        # fallback: just send play command - which will fail if nothing can be played
+        await player.play()
+
+    async def _handle_cmd_power(self, player_id: str, powered: bool) -> None:
+        """
+        Handle player power on/off command.
+
+        Skips the permission checks (internal use only).
+        """
+        player = self.get_player(player_id, True)
+        assert player is not None  # for type checking
+        player_state = player.state
+
+        if player_state.powered == powered:
+            self.logger.debug(
+                "Ignoring power %s command for player %s: already in state %s",
+                "ON" if powered else "OFF",
+                player_state.name,
+                "ON" if player_state.powered else "OFF",
+            )
+            return  # nothing to do
+
+        # ungroup player at power off
+        player_was_synced = player.state.synced_to is not None
+        if player.type == PlayerType.PLAYER and not powered:
+            # ungroup player if it is synced (or is a sync leader itself)
+            # NOTE: ungroup will be ignored if the player is not grouped or synced
+            await self.cmd_ungroup(player_id)
+
+        # always stop player at power off
+        if (
+            not powered
+            and not player_was_synced
+            and player_state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
+        ):
+            await self.cmd_stop(player_id)
+            # short sleep: allow the stop command to process and prevent race conditions
+            await asyncio.sleep(0.2)
+
+        # power off all synced childs when player is a sync leader
+        elif not powered and player_state.type == PlayerType.PLAYER and player_state.group_members:
+            async with TaskManager(self.mass) as tg:
+                for member in self.iter_group_members(player, True):
+                    if member.power_control == PLAYER_CONTROL_NONE:
+                        continue
+                    tg.create_task(self._handle_cmd_power(member.player_id, False))
+
+        # handle actual power command
+        if player_state.power_control == PLAYER_CONTROL_NONE:
+            raise UnsupportedFeaturedException(
+                f"Player {player.state.name} does not support power control"
+            )
+        if player_state.power_control == PLAYER_CONTROL_NATIVE:
+            # player supports power command natively: forward to player provider
+            await player.power(powered)
+        elif player_state.power_control == PLAYER_CONTROL_FAKE:
+            # user wants to use fake power control - so we (optimistically) update the state
+            # and store the state in the cache
+            player.extra_data[ATTR_FAKE_POWER] = powered
+            player.update_state()  # trigger update of the player state
+            await self.mass.cache.set(
+                key=player_id,
+                data=powered,
+                provider=self.domain,
+                category=CACHE_CATEGORY_PLAYER_POWER,
+            )
+        else:
+            # handle external player control
+            player_control = self._controls.get(player.state.power_control)
+            control_name = player_control.name if player_control else player.state.power_control
+            self.logger.debug("Redirecting power command to PlayerControl %s", control_name)
+            if not player_control or not player_control.supports_power:
+                raise UnsupportedFeaturedException(
+                    f"Player control {control_name} is not available"
+                )
+            if powered:
+                assert player_control.power_on is not None  # for type checking
+                await player_control.power_on()
+            else:
+                assert player_control.power_off is not None  # for type checking
+                await player_control.power_off()
+
+        # always trigger a state update to update the UI
+        player.update_state()
+
+        # handle 'auto play on power on' feature
+        if (
+            not player_state.active_group
+            and not player_state.synced_to
+            and powered
+            and player.config.get_value(CONF_AUTO_PLAY)
+            and player_state.active_source in (None, player_id)
+            and not player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS)
+        ):
+            await self.mass.player_queues.resume(player_id)
+
+    async def _handle_cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+        """
+        Handle Player volume set command.
+
+        Skips the permission checks (internal use only).
+        """
+        player = self.get_player(player_id, True)
+        assert player is not None  # for type checker
+        if player.type == PlayerType.GROUP:
+            # redirect to special group volume control
+            await self.cmd_group_volume(player_id, volume_level)
+            return
+
+        # Check if player has mute lock (set when individually muted in a group)
+        # If locked, don't auto-unmute when volume changes
+        has_mute_lock = player.extra_data.get(ATTR_MUTE_LOCK, False)
+        if (
+            not has_mute_lock
+            # use player.state here to get accumulated mute control from any linked protocol players
+            and player.state.mute_control not in (PLAYER_CONTROL_NONE, PLAYER_CONTROL_FAKE)
+            and player.state.volume_muted
+        ):
+            # if player is muted and not locked, we unmute it first
+            # skip this for fake mute since it uses volume to simulate mute
+            self.logger.debug(
+                "Unmuting player %s before setting volume",
+                player.state.name,
+            )
+            await self.cmd_volume_mute(player_id, False)
+
+        # Check if a plugin source is active with a volume callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.on_volume:
+                await plugin_source.on_volume(volume_level)
+        # Handle native volume control support
+        if player.volume_control == PLAYER_CONTROL_NATIVE:
+            # player supports volume command natively: forward to player
+            await player.volume_set(volume_level)
+            return
+        # Handle fake volume control support
+        if player.volume_control == PLAYER_CONTROL_FAKE:
+            # user wants to use fake volume control - so we (optimistically) update the state
+            # and store the state in the cache
+            player.extra_data[ATTR_FAKE_VOLUME] = volume_level
+            # trigger update
+            player.update_state()
+            return
+        # player has no volume support at all
+        if player.volume_control == PLAYER_CONTROL_NONE:
+            raise UnsupportedFeaturedException(
+                f"Player {player.state.name} does not support volume control"
+            )
+        # handle external player control
+        if player_control := self._controls.get(player.state.volume_control):
+            control_name = player_control.name if player_control else player.state.volume_control
+            self.logger.debug("Redirecting volume command to PlayerControl %s", control_name)
+            if not player_control or not player_control.supports_volume:
+                raise UnsupportedFeaturedException(
+                    f"Player control {control_name} is not available"
+                )
+            assert player_control.volume_set is not None
+            await player_control.volume_set(volume_level)
+            return
+        if protocol_player := self.get_player(player.state.volume_control):
+            # redirect to protocol player volume control
+            self.logger.debug(
+                "Redirecting volume command to protocol player %s",
+                protocol_player.provider.manifest.name,
+            )
+            await self._handle_cmd_volume_set(protocol_player.player_id, volume_level)
+            return
+
+    async def _handle_play_media(self, player_id: str, media: PlayerMedia) -> None:
+        """
+        Handle play media command without group redirect.
+
+        Skips permission checks and all redirect logic (internal use only).
+
+        :param player_id: player_id of the player to handle the command.
+        :param media: The Media that needs to be played on the player.
+        """
+        player = self.get_player(player_id, raise_unavailable=True)
+        assert player is not None
+        # set active source if media has a source_id (e.g. plugin source or mass queue source)
+        if media.source_id:
+            player.set_active_mass_source(media.source_id)
+
+        # Select best output protocol for playback
+        target_player, output_protocol = self._select_best_output_protocol(player)
+
+        if target_player.player_id != player.player_id:
+            # Playing via linked protocol - update active output protocol
+            # output_protocol is guaranteed to be non-None when target_player != player
+            assert output_protocol is not None
+            self.logger.debug(
+                "Starting playback on %s via protocol %s (target=%s), group_members=%s",
+                player.state.name,
+                output_protocol.output_protocol_id,
+                target_player.display_name,
+                target_player.state.group_members,
+            )
+            player.set_active_output_protocol(output_protocol.output_protocol_id)
+            # if the (protocol)player has power control and is currently powered off,
+            # we need to power it on before playback
+            if (
+                target_player.state.powered is False
+                and target_player.power_control != PLAYER_CONTROL_NONE
+            ):
+                await self._handle_cmd_power(target_player.player_id, True)
+            # forward play media command to protocol player
+            await target_player.play_media(media)
+            # notify the native player that protocol playback started
+            await player.on_protocol_playback(output_protocol=output_protocol)
+        else:
+            # Native playback
+            self.logger.debug(
+                "Starting playback on %s via native, group_members=%s",
+                player.state.name,
+                player.state.group_members,
+            )
+            player.set_active_output_protocol("native")
+            await player.play_media(media)
+
+    async def _handle_enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
+        """
+        Handle enqueue next media command without group redirect.
+
+        Skips permission checks and all redirect logic (internal use only).
+
+        :param player_id: player_id of the player to handle the command.
+        :param media: The Media that needs to be enqueued on the player.
+        """
+        player = self.get_player(player_id, raise_unavailable=True)
+        assert player is not None
+        if target_player := self._get_control_target(
+            player, required_feature=PlayerFeature.ENQUEUE, require_active=True, allow_native=False
+        ):
+            self.logger.debug(
+                "Redirecting enqueue command to protocol player %s",
+                target_player.provider.manifest.name,
+            )
+            await self._handle_enqueue_next_media(target_player.player_id, media)
+            return
+
+        if PlayerFeature.ENQUEUE not in player.state.supported_features:
+            raise UnsupportedFeaturedException(
+                f"Player {player.state.name} does not support enqueueing"
+            )
+        await player.enqueue_next_media(media)
+
+    async def _handle_select_source(self, player_id: str, source: str | None) -> None:
+        """
+        Handle select source command without group redirect.
+
+        Skips permission checks and all redirect logic (internal use only).
+
+        :param player_id: player_id of the player to handle the command.
+        :param source: The ID of the source that needs to be activated/selected.
+        """
+        if source is None:
+            source = player_id  # default to MA queue source
+        player = self.get_player(player_id, True)
+        assert player is not None
+        # check if player is already playing and source is different
+        # in that case we need to stop the player first
+        prev_source = player.state.active_source
+        if prev_source and source != prev_source:
+            with suppress(PlayerCommandFailed, RuntimeError):
+                # just try to stop (regardless of state)
+                await self._handle_cmd_stop(player_id)
+                await asyncio.sleep(2)  # small delay to allow stop to process
+        # check if source is a pluginsource
+        # in that case the source id is the instance_id of the plugin provider
+        if plugin_prov := self.mass.get_provider(source):
+            player.set_active_mass_source(source)
+            await self._handle_select_plugin_source(player, cast("PluginProvider", plugin_prov))
+            return
+        # check if source is a mass queue
+        # this can be used to restore the queue after a source switch
+        if self.mass.player_queues.get(source):
+            player.set_active_mass_source(source)
+            return
+        # basic check if player supports source selection
+        if PlayerFeature.SELECT_SOURCE not in player.state.supported_features:
+            raise UnsupportedFeaturedException(
+                f"Player {player.state.name} does not support source selection"
+            )
+        # basic check if source is valid for player
+        if not any(x for x in player.state.source_list if x.id == source):
+            raise PlayerCommandFailed(
+                f"{source} is an invalid source for player {player.state.name}"
+            )
+        # forward to player
+        await player.select_source(source)
+
+    async def _handle_cmd_stop(self, player_id: str) -> None:
+        """
+        Handle stop command without any redirects.
+
+        Skips permission checks and all redirect logic (internal use only).
+
+        :param player_id: player_id of the player to handle the command.
+        """
+        player = self.get_player(player_id, raise_unavailable=True)
+        assert player is not None
+        player.mark_stop_called()
+        # Delegate to active protocol player if one is active
+        target_player = player
+        if (
+            player.active_output_protocol
+            and player.active_output_protocol != "native"
+            and (protocol_player := self.get_player(player.active_output_protocol))
+        ):
+            target_player = protocol_player
+            if PlayerFeature.POWER in target_player.supported_features:
+                # if protocol player supports/requires power,
+                # we power it off instead of just stopping (which also stops playback)
+                await self._handle_cmd_power(target_player.player_id, False)
+                return
+
+        # handle command on player(protocol) directly
+        await target_player.stop()
+
+    async def _handle_cmd_play(self, player_id: str) -> None:
+        """
+        Handle play command without group redirect.
+
+        Skips permission checks and all redirect logic (internal use only).
+
+        :param player_id: player_id of the player to handle the command.
+        """
+        player = self.get_player(player_id, raise_unavailable=True)
+        assert player is not None
+        if player.state.playback_state == PlaybackState.PLAYING:
+            self.logger.info(
+                "Ignore PLAY request to player %s: player is already playing", player.state.name
+            )
+            return
+        # Check if a plugin source is active with a play callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.can_play_pause and plugin_source.on_play:
+                await plugin_source.on_play()
+                return
+        # handle unpause (=play if player is paused)
+        if player.state.playback_state == PlaybackState.PAUSED:
+            active_source = next(
+                (x for x in player.state.source_list if x.id == player.state.active_source), None
+            )
+            # raise if active source does not support play/pause
+            if active_source and not active_source.can_play_pause:
+                msg = (
+                    f"The active source ({active_source.name}) on player "
+                    f"{player.state.name} does not support play/pause"
+                )
+                raise PlayerCommandFailed(msg)
+            # Delegate to active protocol player if one is active
+            if target_player := self._get_control_target(player, PlayerFeature.PAUSE, True):
+                await target_player.play()
+                return
+
+        # player is not paused: try to resume the player
+        # Note: We handle resume inline here without calling _handle_cmd_resume
+        source = player.state.active_source
+        media = player.state.current_media
+        # power on the player if needed
+        if not player.state.powered and player.state.power_control != PLAYER_CONTROL_NONE:
+            await self._handle_cmd_power(player.player_id, True)
+        # try to handle command on player directly
+        active_source = next((x for x in player.state.source_list if x.id == source), None)
+        if (
+            player.state.playback_state in (PlaybackState.IDLE, PlaybackState.PAUSED)
+            and active_source
+            and active_source.can_play_pause
+        ):
+            # player has some other source active and native resume support
+            await player.play()
+            return
+        if active_source and not active_source.passive:
+            await self._handle_select_source(player_id, active_source.id)
+            return
+        if media:
+            # try to re-play the current media item
+            await player.play_media(media)
+            return
+        # fallback: just send play command - which will fail if nothing can be played
+        await player.play()
+
+    async def _handle_cmd_pause(self, player_id: str) -> None:
+        """
+        Handle pause command without any redirects.
+
+        Skips permission checks and all redirect logic (internal use only).
+
+        :param player_id: player_id of the player to handle the command.
+        """
+        player = self.get_player(player_id, raise_unavailable=True)
+        assert player is not None
+        # Check if a plugin source is active with a pause callback
+        if plugin_source := self._get_active_plugin_source(player):
+            if plugin_source.can_play_pause and plugin_source.on_pause:
+                await plugin_source.on_pause()
+                return
+        # handle command on player/source directly
+        active_source = next(
+            (x for x in player.state.source_list if x.id == player.state.active_source), None
+        )
+        if active_source and not active_source.can_play_pause:
+            # raise if active source does not support play/pause
+            msg = (
+                f"The active source ({active_source.name}) on player "
+                f"{player.state.name} does not support play/pause"
+            )
+            raise PlayerCommandFailed(msg)
+        # Delegate to active protocol player if one is active
+        if not (target_player := self._get_control_target(player, PlayerFeature.PAUSE, True)):
+            # if player(protocol) does not support pause, we need to send stop
+            self.logger.debug(
+                "Player/protocol %s does not support pause, using STOP instead",
+                player.state.name,
+            )
+            await self._handle_cmd_stop(player.player_id)
+            return
+        # handle command on player(protocol) directly
+        await target_player.pause()
+
+    def __iter__(self) -> Iterator[Player]:
+        """Iterate over all players."""
+        return iter(self._players.values())
diff --git a/music_assistant/controllers/players/helpers.py b/music_assistant/controllers/players/helpers.py
new file mode 100644 (file)
index 0000000..0fa3c20
--- /dev/null
@@ -0,0 +1,125 @@
+"""
+Helper utilities for the Player Controller.
+
+Contains decorators, type definitions, and utility functions used by the
+PlayerController that don't need direct access to the controller class.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import functools
+from collections.abc import Awaitable, Callable, Coroutine
+from typing import TYPE_CHECKING, Any, Concatenate, TypedDict, overload
+
+from music_assistant_models.errors import InsufficientPermissions, PlayerCommandFailed
+
+from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
+
+if TYPE_CHECKING:
+    from .controller import PlayerController
+
+
+class AnnounceData(TypedDict):
+    """Announcement data for play_announcement command."""
+
+    announcement_url: str
+    pre_announce: bool
+    pre_announce_url: str
+
+
+@overload
+def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
+    func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]],
+) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]: ...
+
+
+@overload
+def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
+    func: None = None,
+    *,
+    lock: bool = False,
+) -> Callable[
+    [Callable[Concatenate[PlayerControllerT, P], Awaitable[R]]],
+    Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]],
+]: ...
+
+
+def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
+    func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]] | None = None,
+    *,
+    lock: bool = False,
+) -> (
+    Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]
+    | Callable[
+        [Callable[Concatenate[PlayerControllerT, P], Awaitable[R]]],
+        Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]],
+    ]
+):
+    """
+    Decorator to check and log commands to players.
+
+    Validates that the player exists and is available before executing the command.
+    Also checks user permissions and optionally acquires a per-player lock.
+
+    :param func: The function to wrap (when used without parentheses).
+    :param lock: If True, acquire a lock per player_id and function name before executing.
+    """  # noqa: D401
+
+    def decorator(
+        fn: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]],
+    ) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]:
+        @functools.wraps(fn)
+        async def wrapper(self: PlayerControllerT, *args: P.args, **kwargs: P.kwargs) -> None:
+            """Log and handle_player_command commands to players."""
+            player_id = kwargs.get("player_id") or args[0]
+            assert isinstance(player_id, str)  # for type checking
+            if (player := self._players.get(player_id)) is None or not player.available:
+                self.logger.warning(
+                    "Ignoring command %s for unavailable player %s",
+                    fn.__name__,
+                    player_id,
+                )
+                return
+
+            current_user = get_current_user()
+            if (
+                current_user
+                and current_user.player_filter
+                and player.player_id not in current_user.player_filter
+            ):
+                msg = (
+                    f"{current_user.username} does not have access to player {player.display_name}"
+                )
+                raise InsufficientPermissions(msg)
+
+            self.logger.debug(
+                "Handling command %s for player %s (%s)",
+                fn.__name__,
+                player.display_name,
+                f"by user {current_user.username}" if current_user else "unauthenticated",
+            )
+
+            async def execute() -> None:
+                async with self._player_throttlers[player_id]:
+                    try:
+                        await fn(self, *args, **kwargs)
+                    except Exception as err:
+                        raise PlayerCommandFailed(str(err)) from err
+
+            if lock:
+                # Acquire a lock specific to player_id and function name
+                lock_key = f"{fn.__name__}_{player_id}"
+                if lock_key not in self._player_command_locks:
+                    self._player_command_locks[lock_key] = asyncio.Lock()
+                async with self._player_command_locks[lock_key]:
+                    await execute()
+            else:
+                await execute()
+
+        return wrapper
+
+    # Support both @handle_player_command and @handle_player_command(lock=True)
+    if func is not None:
+        return decorator(func)
+    return decorator
diff --git a/music_assistant/controllers/players/player_controller.py b/music_assistant/controllers/players/player_controller.py
deleted file mode 100644 (file)
index f858399..0000000
+++ /dev/null
@@ -1,2547 +0,0 @@
-"""
-MusicAssistant PlayerController.
-
-Handles all logic to control supported players,
-which are provided by Player Providers.
-
-Note that the PlayerController has a concept of a 'player' and a 'playerstate'.
-The Player is the actual object that is provided by the provider,
-which incorporates the actual state of the player (e.g. volume, state, etc)
-and functions for controlling the player (e.g. play, pause, etc).
-
-The playerstate is the (final) state of the player, including any user customizations
-and transformations that are applied to the player.
-The playerstate is the object that is exposed to the outside world (via the API).
-"""
-
-from __future__ import annotations
-
-import asyncio
-import functools
-import time
-from collections.abc import Awaitable, Callable, Coroutine
-from contextlib import suppress
-from typing import TYPE_CHECKING, Any, Concatenate, TypedDict, cast, overload
-
-from music_assistant_models.auth import UserRole
-from music_assistant_models.constants import (
-    PLAYER_CONTROL_FAKE,
-    PLAYER_CONTROL_NATIVE,
-    PLAYER_CONTROL_NONE,
-)
-from music_assistant_models.enums import (
-    EventType,
-    MediaType,
-    PlaybackState,
-    PlayerFeature,
-    PlayerType,
-    ProviderFeature,
-    ProviderType,
-)
-from music_assistant_models.errors import (
-    AlreadyRegisteredError,
-    InsufficientPermissions,
-    MusicAssistantError,
-    PlayerCommandFailed,
-    PlayerUnavailableError,
-    ProviderUnavailableError,
-    UnsupportedFeaturedException,
-)
-from music_assistant_models.player import PlayerOptionValueType  # noqa: TC002
-from music_assistant_models.player_control import PlayerControl  # noqa: TC002
-
-from music_assistant.constants import (
-    ANNOUNCE_ALERT_FILE,
-    ATTR_ANNOUNCEMENT_IN_PROGRESS,
-    ATTR_AVAILABLE,
-    ATTR_ELAPSED_TIME,
-    ATTR_ENABLED,
-    ATTR_FAKE_MUTE,
-    ATTR_FAKE_POWER,
-    ATTR_FAKE_VOLUME,
-    ATTR_GROUP_MEMBERS,
-    ATTR_LAST_POLL,
-    ATTR_MUTE_LOCK,
-    ATTR_PREVIOUS_VOLUME,
-    CONF_AUTO_PLAY,
-    CONF_ENTRY_ANNOUNCE_VOLUME,
-    CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
-    CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
-    CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
-    CONF_ENTRY_TTS_PRE_ANNOUNCE,
-    CONF_ENTRY_ZEROCONF_INTERFACES,
-    CONF_PLAYER_DSP,
-    CONF_PLAYERS,
-    CONF_PRE_ANNOUNCE_CHIME_URL,
-    SYNCGROUP_PREFIX,
-)
-from music_assistant.controllers.webserver.helpers.auth_middleware import (
-    get_current_user,
-    get_sendspin_player_id,
-)
-from music_assistant.helpers.api import api_command
-from music_assistant.helpers.tags import async_parse_tags
-from music_assistant.helpers.throttle_retry import Throttler
-from music_assistant.helpers.util import TaskManager, validate_announcement_chime_url
-from music_assistant.models.core_controller import CoreController
-from music_assistant.models.player import Player, PlayerMedia, PlayerState
-from music_assistant.models.player_provider import PlayerProvider
-from music_assistant.models.plugin import PluginProvider, PluginSource
-
-from .sync_groups import SyncGroupController, SyncGroupPlayer
-
-if TYPE_CHECKING:
-    from collections.abc import Iterator
-
-    from music_assistant_models.config_entries import (
-        ConfigEntry,
-        ConfigValueType,
-        CoreConfig,
-        PlayerConfig,
-    )
-    from music_assistant_models.player_queue import PlayerQueue
-
-    from music_assistant import MusicAssistant
-
-CACHE_CATEGORY_PLAYER_POWER = 1
-
-
-class AnnounceData(TypedDict):
-    """Announcement data."""
-
-    announcement_url: str
-    pre_announce: bool
-    pre_announce_url: str
-
-
-@overload
-def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
-    func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]],
-) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]: ...
-
-
-@overload
-def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
-    func: None = None,
-    *,
-    lock: bool = False,
-) -> Callable[
-    [Callable[Concatenate[PlayerControllerT, P], Awaitable[R]]],
-    Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]],
-]: ...
-
-
-def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
-    func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]] | None = None,
-    *,
-    lock: bool = False,
-) -> (
-    Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]
-    | Callable[
-        [Callable[Concatenate[PlayerControllerT, P], Awaitable[R]]],
-        Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]],
-    ]
-):
-    """Check and log commands to players.
-
-    :param func: The function to wrap (when used without parentheses).
-    :param lock: If True, acquire a lock per player_id and function name before executing.
-    """
-
-    def decorator(
-        fn: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]],
-    ) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]:
-        @functools.wraps(fn)
-        async def wrapper(self: PlayerControllerT, *args: P.args, **kwargs: P.kwargs) -> None:
-            """Log and handle_player_command commands to players."""
-            player_id = kwargs.get("player_id") or args[0]
-            assert isinstance(player_id, str)  # for type checking
-            if (player := self._players.get(player_id)) is None or not player.available:
-                # player not existent
-                self.logger.warning(
-                    "Ignoring command %s for unavailable player %s",
-                    fn.__name__,
-                    player_id,
-                )
-                return
-
-            current_user = get_current_user()
-            current_sendspin_player = get_sendspin_player_id()
-            if (
-                current_user
-                and current_user.player_filter
-                and player.player_id not in current_user.player_filter
-                and player.player_id != current_sendspin_player
-            ):
-                msg = (
-                    f"{current_user.username} does not have access to player {player.display_name}"
-                )
-                raise InsufficientPermissions(msg)
-
-            self.logger.debug(
-                "Handling command %s for player %s (%s)",
-                fn.__name__,
-                player.display_name,
-                f"by user {current_user.username}" if current_user else "unauthenticated",
-            )
-
-            async def execute() -> None:
-                try:
-                    await fn(self, *args, **kwargs)
-                except Exception as err:
-                    raise PlayerCommandFailed(str(err)) from err
-
-            if lock:
-                # Acquire a lock specific to player_id and function name
-                lock_key = f"{fn.__name__}_{player_id}"
-                if lock_key not in self._player_command_locks:
-                    self._player_command_locks[lock_key] = asyncio.Lock()
-                async with self._player_command_locks[lock_key]:
-                    await execute()
-            else:
-                await execute()
-
-        return wrapper
-
-    # Support both @handle_player_command and @handle_player_command(lock=True)
-    if func is not None:
-        return decorator(func)
-    return decorator
-
-
-class PlayerController(CoreController):
-    """Controller holding all logic to control registered players."""
-
-    domain: str = "players"
-
-    def __init__(self, mass: MusicAssistant) -> None:
-        """Initialize core controller."""
-        super().__init__(mass)
-        self._players: dict[str, Player] = {}
-        self._controls: dict[str, PlayerControl] = {}
-        self.manifest.name = "Player Controller"
-        self.manifest.description = (
-            "Music Assistant's core controller which manages all players from all providers."
-        )
-        self.manifest.icon = "speaker-multiple"
-        self._poll_task: asyncio.Task[None] | None = None
-        self._player_throttlers: dict[str, Throttler] = {}
-        self._player_command_locks: dict[str, asyncio.Lock] = {}
-        self._sync_groups: SyncGroupController = SyncGroupController(self)
-
-    async def get_config_entries(
-        self,
-        action: str | None = None,
-        values: dict[str, ConfigValueType] | None = None,
-    ) -> tuple[ConfigEntry, ...]:
-        """Return Config Entries for the Player Controller."""
-        return (CONF_ENTRY_ZEROCONF_INTERFACES,)
-
-    async def setup(self, config: CoreConfig) -> None:
-        """Async initialize of module."""
-        self._poll_task = self.mass.create_task(self._poll_players())
-
-    async def close(self) -> None:
-        """Cleanup on exit."""
-        if self._poll_task and not self._poll_task.done():
-            self._poll_task.cancel()
-
-    async def on_provider_loaded(self, provider: PlayerProvider) -> None:
-        """Handle logic when a provider is loaded."""
-        if ProviderFeature.SYNC_PLAYERS in provider.supported_features:
-            await self._sync_groups.on_provider_loaded(provider)
-
-    async def on_provider_unload(self, provider: PlayerProvider) -> None:
-        """Handle logic when a provider is (about to get) unloaded."""
-        if ProviderFeature.SYNC_PLAYERS in provider.supported_features:
-            await self._sync_groups.on_provider_unload(provider)
-
-    @property
-    def providers(self) -> list[PlayerProvider]:
-        """Return all loaded/running MusicProviders."""
-        return cast("list[PlayerProvider]", self.mass.get_providers(ProviderType.PLAYER))
-
-    def all(
-        self,
-        return_unavailable: bool = True,
-        return_disabled: bool = False,
-        provider_filter: str | None = None,
-        return_sync_groups: bool = True,
-    ) -> list[Player]:
-        """
-        Return all registered players.
-
-        Note that this applies user filters for players (for non admin users).
-
-        :param return_unavailable [bool]: Include unavailable players.
-        :param return_disabled [bool]: Include disabled players.
-        :param provider_filter [str]: Optional filter by provider lookup key.
-
-        :return: List of Player objects.
-        """
-        current_user = get_current_user()
-        user_filter = (
-            current_user.player_filter
-            if current_user and current_user.role != UserRole.ADMIN
-            else None
-        )
-        current_sendspin_player = get_sendspin_player_id()
-        return [
-            player
-            for player in self._players.values()
-            if (player.available or return_unavailable)
-            and (player.enabled or return_disabled)
-            and (provider_filter is None or player.provider.instance_id == provider_filter)
-            and (
-                not user_filter
-                or player.player_id in user_filter
-                or player.player_id == current_sendspin_player
-            )
-            and (return_sync_groups or not isinstance(player, SyncGroupPlayer))
-        ]
-
-    @api_command("players/all")
-    def all_states(
-        self,
-        return_unavailable: bool = True,
-        return_disabled: bool = False,
-        provider_filter: str | None = None,
-    ) -> list[PlayerState]:
-        """
-        Return PlayerState for all registered players.
-
-        :param return_unavailable [bool]: Include unavailable players.
-        :param return_disabled [bool]: Include disabled players.
-        :param provider_filter [str]: Optional filter by provider lookup key.
-
-        :return: List of PlayerState objects.
-        """
-        return [
-            player.state
-            for player in self.all(
-                return_unavailable=return_unavailable,
-                return_disabled=return_disabled,
-                provider_filter=provider_filter,
-            )
-        ]
-
-    def get(
-        self,
-        player_id: str,
-        raise_unavailable: bool = False,
-    ) -> Player | None:
-        """
-        Return Player by player_id.
-
-        :param player_id [str]: ID of the player.
-        :param raise_unavailable [bool]: Raise if player is unavailable.
-
-        :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
-        :return: Player object or None.
-        """
-        if player := self._players.get(player_id):
-            if (not player.available or not player.enabled) and raise_unavailable:
-                msg = f"Player {player_id} is not available"
-                raise PlayerUnavailableError(msg)
-            return player
-        if raise_unavailable:
-            msg = f"Player {player_id} is not available"
-            raise PlayerUnavailableError(msg)
-        return None
-
-    @api_command("players/get")
-    def get_state(
-        self,
-        player_id: str,
-        raise_unavailable: bool = False,
-    ) -> PlayerState | None:
-        """
-        Return PlayerState by player_id.
-
-        :param player_id [str]: ID of the player.
-        :param raise_unavailable [bool]: Raise if player is unavailable.
-
-        :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
-        :return: Player object or None.
-        """
-        current_user = get_current_user()
-        user_filter = (
-            current_user.player_filter
-            if current_user and current_user.role != UserRole.ADMIN
-            else None
-        )
-        current_sendspin_player = get_sendspin_player_id()
-        if (
-            current_user
-            and user_filter
-            and player_id not in user_filter
-            and player_id != current_sendspin_player
-        ):
-            msg = f"{current_user.username} does not have access to player {player_id}"
-            raise InsufficientPermissions(msg)
-        if player := self.get(player_id, raise_unavailable):
-            return player.state
-        return None
-
-    def get_player_by_name(self, name: str) -> Player | None:
-        """
-        Return Player by name.
-
-        Performs case-insensitive matching against the player's state name
-        (the final name visible in clients and API).
-        If multiple players match, logs a warning and returns the first match.
-
-        :param name: Name of the player.
-        :return: Player object or None.
-        """
-        name_normalized = name.strip().lower()
-        matches: list[Player] = []
-
-        for player in self._players.values():
-            if player.state.name.strip().lower() == name_normalized:
-                matches.append(player)
-
-        if not matches:
-            return None
-
-        if len(matches) > 1:
-            player_ids = [p.player_id for p in matches]
-            self.logger.warning(
-                "players/get_by_name: Multiple players found with name '%s': %s - "
-                "returning first match (%s). "
-                "Consider using the players/get API with player_id instead "
-                "for unambiguous lookups.",
-                name,
-                player_ids,
-                matches[0].player_id,
-            )
-
-        return matches[0]
-
-    @api_command("players/get_by_name")
-    def get_player_state_by_name(self, name: str) -> PlayerState | None:
-        """
-        Return PlayerState by name.
-
-        :param name: Name of the player.
-        :return: PlayerState object or None.
-        """
-        current_user = get_current_user()
-        user_filter = (
-            current_user.player_filter
-            if current_user and current_user.role != UserRole.ADMIN
-            else None
-        )
-        current_sendspin_player = get_sendspin_player_id()
-        if player := self.get_player_by_name(name):
-            if (
-                current_user
-                and user_filter
-                and player.player_id not in user_filter
-                and player.player_id != current_sendspin_player
-            ):
-                msg = f"{current_user.username} does not have access to player {player.player_id}"
-                raise InsufficientPermissions(msg)
-            return player.state
-        return None
-
-    @api_command("players/player_controls")
-    def player_controls(
-        self,
-    ) -> list[PlayerControl]:
-        """Return all registered playercontrols."""
-        return list(self._controls.values())
-
-    @api_command("players/player_control")
-    def get_player_control(
-        self,
-        control_id: str,
-    ) -> PlayerControl | None:
-        """
-        Return PlayerControl by control_id.
-
-        :param control_id: ID of the player control.
-        :return: PlayerControl object or None.
-        """
-        if control := self._controls.get(control_id):
-            return control
-        return None
-
-    @api_command("players/plugin_sources")
-    def get_plugin_sources(self) -> list[PluginSource]:
-        """Return all available plugin sources."""
-        return [
-            plugin_prov.get_source()
-            for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN)
-            if isinstance(plugin_prov, PluginProvider)
-            and ProviderFeature.AUDIO_SOURCE in plugin_prov.supported_features
-        ]
-
-    @api_command("players/plugin_source")
-    def get_plugin_source(
-        self,
-        source_id: str,
-    ) -> PluginSource | None:
-        """
-        Return PluginSource by source_id.
-
-        :param source_id: ID of the plugin source.
-        :return: PluginSource object or None.
-        """
-        for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN):
-            assert isinstance(plugin_prov, PluginProvider)  # for type checking
-            if ProviderFeature.AUDIO_SOURCE not in plugin_prov.supported_features:
-                continue
-            if (source := plugin_prov.get_source()) and source.id == source_id:
-                return source
-        return None
-
-    # Player commands
-
-    @api_command("players/cmd/stop")
-    @handle_player_command
-    async def cmd_stop(self, player_id: str) -> None:
-        """Send STOP command to given player.
-
-        - player_id: player_id of the player to handle the command.
-        """
-        player = self._get_player_with_redirect(player_id)
-        player.mark_stop_called()
-        # Redirect to queue controller if it is active
-        if active_queue := self.get_active_queue(player):
-            await self.mass.player_queues.stop(active_queue.queue_id)
-        else:
-            # handle command on player directly
-            async with self._player_throttlers[player.player_id]:
-                await player.stop()
-
-    @api_command("players/cmd/play")
-    @handle_player_command
-    async def cmd_play(self, player_id: str) -> None:
-        """Send PLAY (unpause) command to given player.
-
-        - player_id: player_id of the player to handle the command.
-        """
-        player = self._get_player_with_redirect(player_id)
-        if player.playback_state == PlaybackState.PLAYING:
-            self.logger.info(
-                "Ignore PLAY request to player %s: player is already playing", player.display_name
-            )
-            return
-
-        # Check if a plugin source is active with a play callback
-        if plugin_source := self._get_active_plugin_source(player):
-            if plugin_source.can_play_pause and plugin_source.on_play:
-                await plugin_source.on_play()
-                return
-
-        if player.playback_state == PlaybackState.PAUSED:
-            # handle command on player/source directly
-            active_source = next(
-                (x for x in player.source_list if x.id == player.active_source), None
-            )
-            if active_source and not active_source.can_play_pause:
-                raise PlayerCommandFailed(
-                    "The active source (%s) on player %s does not support play/pause",
-                    active_source.name,
-                    player.display_name,
-                )
-            async with self._player_throttlers[player.player_id]:
-                await player.play()
-        else:
-            # try to resume the player
-            await self._handle_cmd_resume(player.player_id)
-
-    @api_command("players/cmd/pause")
-    @handle_player_command
-    async def cmd_pause(self, player_id: str) -> None:
-        """Send PAUSE command to given player.
-
-        - player_id: player_id of the player to handle the command.
-        """
-        player = self._get_player_with_redirect(player_id)
-
-        # Check if a plugin source is active with a pause callback
-        if plugin_source := self._get_active_plugin_source(player):
-            if plugin_source.can_play_pause and plugin_source.on_pause:
-                await plugin_source.on_pause()
-                return
-
-        # Redirect to queue controller if it is active
-        if active_queue := self.get_active_queue(player):
-            await self.mass.player_queues.pause(active_queue.queue_id)
-            return
-
-        # handle command on player/source directly
-        active_source = next((x for x in player.source_list if x.id == player.active_source), None)
-        if active_source and not active_source.can_play_pause:
-            raise PlayerCommandFailed(
-                "The active source (%s) on player %s does not support play/pause",
-                active_source.name,
-                player.display_name,
-            )
-        if PlayerFeature.PAUSE not in player.supported_features:
-            # if player does not support pause, we need to send stop
-            self.logger.debug(
-                "Player %s does not support pause, using STOP instead",
-                player.display_name,
-            )
-            await self.cmd_stop(player.player_id)
-            return
-        # handle command on player directly
-        await player.pause()
-
-    @api_command("players/cmd/play_pause")
-    async def cmd_play_pause(self, player_id: str) -> None:
-        """Toggle play/pause on given player.
-
-        - player_id: player_id of the player to handle the command.
-        """
-        player = self._get_player_with_redirect(player_id)
-        if player.playback_state == PlaybackState.PLAYING:
-            await self.cmd_pause(player.player_id)
-        else:
-            await self.cmd_play(player.player_id)
-
-    @api_command("players/cmd/resume")
-    @handle_player_command
-    async def cmd_resume(
-        self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
-    ) -> None:
-        """Send RESUME command to given player.
-
-        Resume (or restart) playback on the player.
-
-        :param player_id: player_id of the player to handle the command.
-        :param source: Optional source to resume.
-        :param media: Optional media to resume.
-        """
-        await self._handle_cmd_resume(player_id, source, media)
-
-    @api_command("players/cmd/seek")
-    async def cmd_seek(self, player_id: str, position: int) -> None:
-        """Handle SEEK command for given player.
-
-        - player_id: player_id of the player to handle the command.
-        - position: position in seconds to seek to in the current playing item.
-        """
-        player = self._get_player_with_redirect(player_id)
-
-        # Check if a plugin source is active with a seek callback
-        if plugin_source := self._get_active_plugin_source(player):
-            if plugin_source.can_seek and plugin_source.on_seek:
-                await plugin_source.on_seek(position)
-                return
-
-        # Redirect to queue controller if it is active
-        if active_queue := self.get_active_queue(player):
-            await self.mass.player_queues.seek(active_queue.queue_id, position)
-            return
-
-        # handle command on player/source directly
-        active_source = next((x for x in player.source_list if x.id == player.active_source), None)
-        if active_source and not active_source.can_seek:
-            raise PlayerCommandFailed(
-                "The active source (%s) on player %s does not support seeking",
-                active_source.name,
-                player.display_name,
-            )
-        if PlayerFeature.SEEK not in player.supported_features:
-            msg = f"Player {player.display_name} does not support seeking"
-            raise UnsupportedFeaturedException(msg)
-        # handle command on player directly
-        await player.seek(position)
-
-    @api_command("players/cmd/next")
-    async def cmd_next_track(self, player_id: str) -> None:
-        """Handle NEXT TRACK command for given player."""
-        player = self._get_player_with_redirect(player_id)
-        active_source_id = player.active_source or player.player_id
-
-        # Check if a plugin source is active with a next callback
-        if plugin_source := self._get_active_plugin_source(player):
-            if plugin_source.can_next_previous and plugin_source.on_next:
-                await plugin_source.on_next()
-                return
-
-        # Redirect to queue controller if it is active
-        if active_queue := self.get_active_queue(player):
-            await self.mass.player_queues.next(active_queue.queue_id)
-            return
-
-        if PlayerFeature.NEXT_PREVIOUS in player.supported_features:
-            # player has some other source active and native next/previous support
-            active_source = next((x for x in player.source_list if x.id == active_source_id), None)
-            if active_source and active_source.can_next_previous:
-                await player.next_track()
-                return
-            msg = "This action is (currently) unavailable for this source."
-            raise PlayerCommandFailed(msg)
-
-        msg = f"Player {player.display_name} does not support skipping to the next track."
-        raise UnsupportedFeaturedException(msg)
-
-    @api_command("players/cmd/previous")
-    async def cmd_previous_track(self, player_id: str) -> None:
-        """Handle PREVIOUS TRACK command for given player."""
-        player = self._get_player_with_redirect(player_id)
-        active_source_id = player.active_source or player.player_id
-
-        # Check if a plugin source is active with a previous callback
-        if plugin_source := self._get_active_plugin_source(player):
-            if plugin_source.can_next_previous and plugin_source.on_previous:
-                await plugin_source.on_previous()
-                return
-
-        # Redirect to queue controller if it is active
-        if active_queue := self.get_active_queue(player):
-            await self.mass.player_queues.previous(active_queue.queue_id)
-            return
-
-        if PlayerFeature.NEXT_PREVIOUS in player.supported_features:
-            # player has some other source active and native next/previous support
-            active_source = next((x for x in player.source_list if x.id == active_source_id), None)
-            if active_source and active_source.can_next_previous:
-                await player.previous_track()
-                return
-            msg = "This action is (currently) unavailable for this source."
-            raise PlayerCommandFailed(msg)
-
-        msg = f"Player {player.display_name} does not support skipping to the previous track."
-        raise UnsupportedFeaturedException(msg)
-
-    @api_command("players/cmd/power")
-    @handle_player_command
-    async def cmd_power(self, player_id: str, powered: bool) -> None:
-        """Send POWER command to given player.
-
-        :param player_id: player_id of the player to handle the command.
-        :param powered: bool if player should be powered on or off.
-        """
-        await self._handle_cmd_power(player_id, powered)
-
-    @api_command("players/cmd/volume_set")
-    @handle_player_command
-    async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
-        """Send VOLUME_SET command to given player.
-
-        :param player_id: player_id of the player to handle the command.
-        :param volume_level: volume level (0..100) to set on the player.
-        """
-        await self._handle_cmd_volume_set(player_id, volume_level)
-
-    @api_command("players/cmd/volume_up")
-    @handle_player_command
-    async def cmd_volume_up(self, player_id: str) -> None:
-        """Send VOLUME_UP command to given player.
-
-        - player_id: player_id of the player to handle the command.
-        """
-        if not (player := self.get(player_id)):
-            return
-        current_volume = player.volume_level or 0
-        if current_volume < 5 or current_volume > 95:
-            step_size = 1
-        elif current_volume < 20 or current_volume > 80:
-            step_size = 2
-        else:
-            step_size = 5
-        new_volume = min(100, current_volume + step_size)
-        await self.cmd_volume_set(player_id, new_volume)
-
-    @api_command("players/cmd/volume_down")
-    @handle_player_command
-    async def cmd_volume_down(self, player_id: str) -> None:
-        """Send VOLUME_DOWN command to given player.
-
-        - player_id: player_id of the player to handle the command.
-        """
-        if not (player := self.get(player_id)):
-            return
-        current_volume = player.volume_level or 0
-        if current_volume < 5 or current_volume > 95:
-            step_size = 1
-        elif current_volume < 20 or current_volume > 80:
-            step_size = 2
-        else:
-            step_size = 5
-        new_volume = max(0, current_volume - step_size)
-        await self.cmd_volume_set(player_id, new_volume)
-
-    @api_command("players/cmd/group_volume")
-    @handle_player_command
-    async def cmd_group_volume(
-        self,
-        player_id: str,
-        volume_level: int,
-    ) -> None:
-        """
-        Handle adjusting the overall/group volume to a playergroup (or synced players).
-
-        Will set a new (overall) volume level to a group player or syncgroup.
-
-        :param group_player: dedicated group player or syncleader to handle the command.
-        :param volume_level: volume level (0..100) to set to the group.
-        """
-        player = self.get(player_id, True)
-        assert player is not None  # for type checker
-        if player.type == PlayerType.GROUP or player.group_members:
-            # dedicated group player or sync leader
-            await self.set_group_volume(player, volume_level)
-            return
-        if player.synced_to and (sync_leader := self.get(player.synced_to)):
-            # redirect to sync leader
-            await self.set_group_volume(sync_leader, volume_level)
-            return
-        # treat as normal player volume change
-        await self.cmd_volume_set(player_id, volume_level)
-
-    @api_command("players/cmd/group_volume_up")
-    @handle_player_command
-    async def cmd_group_volume_up(self, player_id: str) -> None:
-        """Send VOLUME_UP command to given playergroup.
-
-        - player_id: player_id of the player to handle the command.
-        """
-        group_player = self.get(player_id, True)
-        assert group_player
-        cur_volume = group_player.group_volume
-        if cur_volume < 5 or cur_volume > 95:
-            step_size = 1
-        elif cur_volume < 20 or cur_volume > 80:
-            step_size = 2
-        else:
-            step_size = 5
-        new_volume = min(100, cur_volume + step_size)
-        await self.cmd_group_volume(player_id, new_volume)
-
-    @api_command("players/cmd/group_volume_down")
-    @handle_player_command
-    async def cmd_group_volume_down(self, player_id: str) -> None:
-        """Send VOLUME_DOWN command to given playergroup.
-
-        - player_id: player_id of the player to handle the command.
-        """
-        group_player = self.get(player_id, True)
-        assert group_player
-        cur_volume = group_player.group_volume
-        if cur_volume < 5 or cur_volume > 95:
-            step_size = 1
-        elif cur_volume < 20 or cur_volume > 80:
-            step_size = 2
-        else:
-            step_size = 5
-        new_volume = max(0, cur_volume - step_size)
-        await self.cmd_group_volume(player_id, new_volume)
-
-    @api_command("players/cmd/group_volume_mute")
-    @handle_player_command
-    async def cmd_group_volume_mute(self, player_id: str, muted: bool) -> None:
-        """Send VOLUME_MUTE command to all players in a group.
-
-        - player_id: player_id of the group player or sync leader.
-        - muted: bool if group should be muted.
-        """
-        player = self.get(player_id, True)
-        assert player is not None  # for type checker
-        if player.type == PlayerType.GROUP or player.group_members:
-            # dedicated group player or sync leader
-            coros = []
-            for child_player in self.iter_group_members(
-                player, only_powered=True, exclude_self=False
-            ):
-                coros.append(self.cmd_volume_mute(child_player.player_id, muted))
-            await asyncio.gather(*coros)
-
-    @api_command("players/cmd/volume_mute")
-    @handle_player_command
-    async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
-        """Send VOLUME_MUTE command to given player.
-
-        - player_id: player_id of the player to handle the command.
-        - muted: bool if player should be muted.
-        """
-        player = self.get(player_id, True)
-        assert player
-
-        # Set/clear mute lock for players in a group
-        # This prevents auto-unmute when group volume changes
-        is_in_group = bool(player.synced_to or player.group_members)
-        if muted and is_in_group:
-            player.extra_data[ATTR_MUTE_LOCK] = True
-        elif not muted:
-            player.extra_data.pop(ATTR_MUTE_LOCK, None)
-
-        if player.mute_control == PLAYER_CONTROL_NONE:
-            raise UnsupportedFeaturedException(
-                f"Player {player.display_name} does not support muting"
-            )
-        if player.mute_control == PLAYER_CONTROL_NATIVE:
-            # player supports mute command natively: forward to player
-            async with self._player_throttlers[player_id]:
-                await player.volume_mute(muted)
-        elif player.mute_control == PLAYER_CONTROL_FAKE:
-            # user wants to use fake mute control - so we use volume instead
-            self.logger.debug(
-                "Using volume for muting for player %s",
-                player.display_name,
-            )
-            if muted:
-                player.extra_data[ATTR_PREVIOUS_VOLUME] = player.volume_level
-                player.extra_data[ATTR_FAKE_MUTE] = True
-                await self._handle_cmd_volume_set(player_id, 0)
-                player.update_state()
-            else:
-                prev_volume = player.extra_data.get(ATTR_PREVIOUS_VOLUME, 1)
-                player.extra_data[ATTR_FAKE_MUTE] = False
-                player.update_state()
-                await self._handle_cmd_volume_set(player_id, prev_volume)
-        else:
-            # handle external player control
-            player_control = self._controls.get(player.mute_control)
-            control_name = player_control.name if player_control else player.mute_control
-            self.logger.debug("Redirecting mute command to PlayerControl %s", control_name)
-            if not player_control or not player_control.supports_mute:
-                raise UnsupportedFeaturedException(
-                    f"Player control {control_name} is not available"
-                )
-            async with self._player_throttlers[player_id]:
-                assert player_control.mute_set is not None
-                await player_control.mute_set(muted)
-
-    @api_command("players/cmd/play_announcement")
-    @handle_player_command(lock=True)
-    async def play_announcement(
-        self,
-        player_id: str,
-        url: str,
-        pre_announce: bool | None = None,
-        volume_level: int | None = None,
-        pre_announce_url: str | None = None,
-    ) -> None:
-        """
-        Handle playback of an announcement (url) on given player.
-
-        - player_id: player_id of the player to handle the command.
-        - url: URL of the announcement to play.
-        - pre_announce: optional bool if pre-announce should be used.
-        - volume_level: optional volume level to set for the announcement.
-        - pre_announce_url: optional custom URL to use for the pre-announce chime.
-        """
-        player = self.get(player_id, True)
-        assert player is not None  # for type checking
-        if not url.startswith("http"):
-            raise PlayerCommandFailed("Only URLs are supported for announcements")
-        if (
-            pre_announce
-            and pre_announce_url
-            and not validate_announcement_chime_url(pre_announce_url)
-        ):
-            raise PlayerCommandFailed("Invalid pre-announce chime URL specified.")
-        try:
-            # mark announcement_in_progress on player
-            player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = True
-            # determine if the player has native announcements support
-            native_announce_support = PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features
-            # determine pre-announce from (group)player config
-            if pre_announce is None and "tts" in url:
-                conf_pre_announce = self.mass.config.get_raw_player_config_value(
-                    player_id,
-                    CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
-                    CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value,
-                )
-                pre_announce = cast("bool", conf_pre_announce)
-            if pre_announce_url is None:
-                if conf_pre_announce_url := self.mass.config.get_raw_player_config_value(
-                    player_id,
-                    CONF_PRE_ANNOUNCE_CHIME_URL,
-                ):
-                    # player default custom chime url
-                    pre_announce_url = cast("str", conf_pre_announce_url)
-                else:
-                    # use global default chime url
-                    pre_announce_url = ANNOUNCE_ALERT_FILE
-            # if player type is group with all members supporting announcements,
-            # we forward the request to each individual player
-            if player.type == PlayerType.GROUP and (
-                all(
-                    PlayerFeature.PLAY_ANNOUNCEMENT in x.supported_features
-                    for x in self.iter_group_members(player)
-                )
-            ):
-                # forward the request to each individual player
-                async with TaskManager(self.mass) as tg:
-                    for group_member in player.group_members:
-                        tg.create_task(
-                            self.play_announcement(
-                                group_member,
-                                url=url,
-                                pre_announce=pre_announce,
-                                volume_level=volume_level,
-                                pre_announce_url=pre_announce_url,
-                            )
-                        )
-                return
-            self.logger.info(
-                "Playback announcement to player %s (with pre-announce: %s): %s",
-                player.display_name,
-                pre_announce,
-                url,
-            )
-            # create a PlayerMedia object for the announcement so
-            # we can send a regular play-media call downstream
-            announce_data = AnnounceData(
-                announcement_url=url,
-                pre_announce=bool(pre_announce),
-                pre_announce_url=pre_announce_url,
-            )
-            announcement = PlayerMedia(
-                uri=self.mass.streams.get_announcement_url(player_id, announce_data=announce_data),
-                media_type=MediaType.ANNOUNCEMENT,
-                title="Announcement",
-                custom_data=dict(announce_data),
-            )
-            # handle native announce support
-            if native_announce_support:
-                announcement_volume = self.get_announcement_volume(player_id, volume_level)
-                await player.play_announcement(announcement, announcement_volume)
-                return
-            # use fallback/default implementation
-            await self._play_announcement(player, announcement, volume_level)
-        finally:
-            player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = False
-
-    @handle_player_command(lock=True)
-    async def play_media(self, player_id: str, media: PlayerMedia) -> None:
-        """Handle PLAY MEDIA on given player.
-
-        - player_id: player_id of the player to handle the command.
-        - media: The Media that needs to be played on the player.
-        """
-        player = self._get_player_with_redirect(player_id)
-        # power on the player if needed
-        if player.powered is False and player.power_control != PLAYER_CONTROL_NONE:
-            await self._handle_cmd_power(player.player_id, True)
-        if media.source_id:
-            player.set_active_mass_source(media.source_id)
-        await player.play_media(media)
-
-    @api_command("players/cmd/select_sound_mode")
-    @handle_player_command
-    async def select_sound_mode(self, player_id: str, sound_mode: str) -> None:
-        """
-        Handle SELECT SOUND MODE command on given player.
-
-        - player_id: player_id of the player to handle the command
-        - sound_mode: The ID of the sound mode that needs to be activated/selected.
-        """
-        player = self.get(player_id, True)
-        assert player is not None  # for type checking
-
-        if PlayerFeature.SELECT_SOUND_MODE not in player.supported_features:
-            raise UnsupportedFeaturedException(
-                f"Player {player.display_name} does not support sound mode selection"
-            )
-
-        prev_sound_mode = player.active_sound_mode
-        if sound_mode == prev_sound_mode:
-            return
-
-        # basic check if sound mode is valid for player
-        if not any(x for x in player.sound_mode_list if x.id == sound_mode):
-            raise PlayerCommandFailed(
-                f"{sound_mode} is an invalid sound_mode for player {player.display_name}"
-            )
-
-        # forward to player
-        await player.select_sound_mode(sound_mode)
-
-    @api_command("players/cmd/set_option")
-    @handle_player_command
-    async def set_option(
-        self, player_id: str, option_key: str, option_value: PlayerOptionValueType
-    ) -> None:
-        """
-        Handle SET_OPTION command on given player.
-
-        - player_id: player_id of the player to handle the command
-        - option_key: The key of the player option that needs to be activated/selected.
-        - option_value: The new value of the player option.
-        """
-        player = self.get(player_id, True)
-        assert player is not None  # for type checking
-
-        if PlayerFeature.OPTIONS not in player.supported_features:
-            raise UnsupportedFeaturedException(
-                f"Player {player.display_name} does not support set_option"
-            )
-
-        prev_player_option = next((x for x in player.options if x.key == option_key), None)
-        if not prev_player_option:
-            return
-        if prev_player_option.value == option_value:
-            return
-
-        if prev_player_option.read_only:
-            raise UnsupportedFeaturedException(
-                f"Player {player.display_name} option {option_key} is read-only"
-            )
-
-        # forward to player
-        await player.set_option(option_key=option_key, option_value=option_value)
-
-    @api_command("players/cmd/select_source")
-    @handle_player_command
-    async def select_source(self, player_id: str, source: str | None) -> None:
-        """
-        Handle SELECT SOURCE command on given player.
-
-        - player_id: player_id of the player to handle the command.
-        - source: The ID of the source that needs to be activated/selected.
-        """
-        if source is None:
-            source = player_id  # default to MA queue source
-        player = self.get(player_id, True)
-        assert player is not None  # for type checking
-        if player.synced_to or player.active_group:
-            raise PlayerCommandFailed(f"Player {player.display_name} is currently grouped")
-        # check if player is already playing and source is different
-        # in that case we need to stop the player first
-        prev_source = player.active_source
-        if prev_source and source != prev_source:
-            with suppress(PlayerCommandFailed, RuntimeError):
-                # just try to stop (regardless of state)
-                await self.cmd_stop(player_id)
-                await asyncio.sleep(2)  # small delay to allow stop to process
-        # check if source is a pluginsource
-        # in that case the source id is the instance_id of the plugin provider
-        if plugin_prov := self.mass.get_provider(source):
-            player.set_active_mass_source(source)
-            await self._handle_select_plugin_source(player, cast("PluginProvider", plugin_prov))
-            return
-        # check if source is a mass queue
-        # this can be used to restore the queue after a source switch
-        if self.mass.player_queues.get(source):
-            player.set_active_mass_source(source)
-            return
-        # basic check if player supports source selection
-        if PlayerFeature.SELECT_SOURCE not in player.supported_features:
-            raise UnsupportedFeaturedException(
-                f"Player {player.display_name} does not support source selection"
-            )
-        # basic check if source is valid for player
-        if not any(x for x in player.source_list if x.id == source):
-            raise PlayerCommandFailed(
-                f"{source} is an invalid source for player {player.display_name}"
-            )
-        # forward to player
-        await player.select_source(source)
-
-    @handle_player_command(lock=True)
-    async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
-        """
-        Handle enqueuing of a next media item on the player.
-
-        :param player_id: player_id of the player to handle the command.
-        :param media: The Media that needs to be enqueued on the player.
-        :raises UnsupportedFeaturedException: if the player does not support enqueueing.
-        :raises PlayerUnavailableError: if the player is not available.
-        """
-        player = self.get(player_id, raise_unavailable=True)
-        assert player is not None  # for type checking
-        if PlayerFeature.ENQUEUE not in player.supported_features:
-            raise UnsupportedFeaturedException(
-                f"Player {player.display_name} does not support enqueueing"
-            )
-        async with self._player_throttlers[player_id]:
-            await player.enqueue_next_media(media)
-
-    @api_command("players/cmd/set_members")
-    async def cmd_set_members(
-        self,
-        target_player: str,
-        player_ids_to_add: list[str] | None = None,
-        player_ids_to_remove: list[str] | None = None,
-    ) -> None:
-        """
-        Join/unjoin given player(s) to/from target player.
-
-        Will add the given player(s) to the target player (sync leader or group player).
-
-        :param target_player: player_id of the syncgroup leader or group player.
-        :param player_ids_to_add: List of player_id's to add to the target player.
-        :param player_ids_to_remove: List of player_id's to remove from the target player.
-
-        :raises UnsupportedFeaturedException: if the target player does not support grouping.
-        :raises PlayerUnavailableError: if the target player is not available.
-        """
-        parent_player: Player | None = self.get(target_player, True)
-        assert parent_player is not None  # for type checking
-        if PlayerFeature.SET_MEMBERS not in parent_player.supported_features:
-            msg = f"Player {parent_player.name} does not support group commands"
-            raise UnsupportedFeaturedException(msg)
-
-        if parent_player.synced_to:
-            # guard edge case: player already synced to another player
-            raise PlayerCommandFailed(
-                f"Player {parent_player.name} is already synced to another player on its own, "
-                "you need to ungroup it first before you can join other players to it.",
-            )
-
-        # filter all player ids on compatibility and availability
-        final_player_ids_to_add: list[str] = []
-        for child_player_id in player_ids_to_add or []:
-            if child_player_id == target_player:
-                continue
-            if child_player_id in final_player_ids_to_add:
-                continue
-            if not (child_player := self.get(child_player_id)) or not child_player.available:
-                self.logger.warning("Player %s is not available", child_player_id)
-                continue
-
-            # check if player can be synced/grouped with the target player
-            if not (
-                child_player_id in parent_player.can_group_with
-                or child_player.provider.instance_id in parent_player.can_group_with
-                or "*" in parent_player.can_group_with
-            ):
-                raise UnsupportedFeaturedException(
-                    f"Player {child_player.name} can not be grouped with {parent_player.name}"
-                )
-
-            if (
-                child_player.synced_to
-                and child_player.synced_to == target_player
-                and child_player_id in parent_player.group_members
-            ):
-                continue  # already synced to this target
-
-            # Check if player is already part of another group and try to automatically ungroup it
-            # first. If that fails, power off the group
-            if child_player.active_group and child_player.active_group != target_player:
-                if (
-                    other_group := self.get(child_player.active_group)
-                ) and PlayerFeature.SET_MEMBERS in other_group.supported_features:
-                    self.logger.warning(
-                        "Player %s is already part of another group (%s), "
-                        "removing from that group first",
-                        child_player.name,
-                        child_player.active_group,
-                    )
-                    if child_player.player_id in other_group.static_group_members:
-                        self.logger.warning(
-                            "Player %s is a static member of group %s: removing is not possible, "
-                            "powering the group off instead",
-                            child_player.name,
-                            child_player.active_group,
-                        )
-                        await self._handle_cmd_power(child_player.active_group, False)
-                    else:
-                        await other_group.set_members(player_ids_to_remove=[child_player.player_id])
-                else:
-                    self.logger.warning(
-                        "Player %s is already part of another group (%s), powering it off first",
-                        child_player.name,
-                        child_player.active_group,
-                    )
-                    await self._handle_cmd_power(child_player.active_group, False)
-            elif child_player.synced_to and child_player.synced_to != target_player:
-                self.logger.warning(
-                    "Player %s is already synced to another player, ungrouping first",
-                    child_player.name,
-                )
-                await self.cmd_ungroup(child_player.player_id)
-
-            # power on the player if needed
-            if not child_player.powered and child_player.power_control != PLAYER_CONTROL_NONE:
-                await self._handle_cmd_power(child_player.player_id, True)
-            # if we reach here, all checks passed
-            final_player_ids_to_add.append(child_player_id)
-
-        final_player_ids_to_remove: list[str] = []
-        if player_ids_to_remove:
-            static_members = set(parent_player.static_group_members)
-            for child_player_id in player_ids_to_remove:
-                if child_player_id == target_player:
-                    raise UnsupportedFeaturedException(
-                        f"Cannot remove {parent_player.name} from itself as a member!"
-                    )
-                if child_player_id not in parent_player.group_members:
-                    continue
-                if child_player_id in static_members:
-                    raise UnsupportedFeaturedException(
-                        f"Cannot remove {child_player_id} from {parent_player.name} "
-                        "as it is a static member of this group"
-                    )
-                final_player_ids_to_remove.append(child_player_id)
-
-        # forward command to the player after all (base) sanity checks
-        async with self._player_throttlers[target_player]:
-            await parent_player.set_members(
-                player_ids_to_add=final_player_ids_to_add or None,
-                player_ids_to_remove=final_player_ids_to_remove or None,
-            )
-
-    @api_command("players/cmd/group")
-    @handle_player_command
-    async def cmd_group(self, player_id: str, target_player: str) -> None:
-        """Handle GROUP command for given player.
-
-        Join/add the given player(id) to the given (leader) player/sync group.
-        If the target player itself is already synced to another player, this may fail.
-        If the player can not be synced with the given target player, this may fail.
-
-        :param player_id: player_id of the player to handle the command.
-        :param target_player: player_id of the syncgroup leader or group player.
-
-        :raises UnsupportedFeaturedException: if the target player does not support grouping.
-        :raises PlayerCommandFailed: if the target player is already synced to another player.
-        :raises PlayerUnavailableError: if the target player is not available.
-        :raises PlayerCommandFailed: if the player is already grouped to another player.
-        """
-        await self.cmd_set_members(target_player, player_ids_to_add=[player_id])
-
-    @api_command("players/cmd/group_many")
-    async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None:
-        """
-        Join given player(s) to target player.
-
-        Will add the given player(s) to the target player (sync leader or group player).
-        NOTE: This is a (deprecated) alias for cmd_set_members.
-        """
-        await self.cmd_set_members(target_player, player_ids_to_add=child_player_ids)
-
-    @api_command("players/cmd/ungroup")
-    @handle_player_command
-    async def cmd_ungroup(self, player_id: str) -> None:
-        """Handle UNGROUP command for given player.
-
-        Remove the given player from any (sync)groups it currently is synced to.
-        If the player is not currently grouped to any other player,
-        this will silently be ignored.
-
-        NOTE: This is a (deprecated) alias for cmd_set_members.
-        """
-        if not (player := self.get(player_id)):
-            self.logger.warning("Player %s is not available", player_id)
-            return
-
-        if (
-            player.active_group
-            and (group_player := self.get(player.active_group))
-            and (PlayerFeature.SET_MEMBERS in group_player.supported_features)
-        ):
-            # the player is part of a (permanent) groupplayer and the user tries to ungroup
-            if player_id in group_player.static_group_members:
-                raise UnsupportedFeaturedException(
-                    f"Player {player.name}  is a static member of group {group_player.name} "
-                    "and cannot be removed from that group!"
-                )
-            await group_player.set_members(player_ids_to_remove=[player_id])
-            return
-
-        if player.synced_to and (synced_player := self.get(player.synced_to)):
-            # player is a sync member
-            await synced_player.set_members(player_ids_to_remove=[player_id])
-            return
-
-        if not (player.synced_to or player.group_members):
-            return  # nothing to do
-
-        if PlayerFeature.SET_MEMBERS not in player.supported_features:
-            self.logger.warning("Player %s does not support (un)group commands", player.name)
-            return
-
-        # forward command to the player once all checks passed
-        await player.ungroup()
-
-    @api_command("players/cmd/ungroup_many")
-    async def cmd_ungroup_many(self, player_ids: list[str]) -> None:
-        """Handle UNGROUP command for all the given players."""
-        for player_id in list(player_ids):
-            await self.cmd_ungroup(player_id)
-
-    @api_command("players/create_group_player", required_role="admin")
-    async def create_group_player(
-        self, provider: str, name: str, members: list[str], dynamic: bool = True
-    ) -> Player:
-        """
-        Create a new (permanent) Group Player.
-
-        :param provider: The provider(id) to create the group player for
-        :param name: Name of the new group player
-        :param members: List of player ids to add to the group
-        :param dynamic: Whether the group is dynamic (members can change)
-        """
-        if not (provider_instance := self.mass.get_provider(provider)):
-            raise ProviderUnavailableError(f"Provider {provider} not found")
-        provider_instance = cast("PlayerProvider", provider_instance)
-        if ProviderFeature.CREATE_GROUP_PLAYER in provider_instance.supported_features:
-            return await provider_instance.create_group_player(name, members, dynamic)
-        if ProviderFeature.SYNC_PLAYERS in provider_instance.supported_features:
-            # provider supports syncing but not dedicated group players
-            # create a sync group instead
-            return await self._sync_groups.create_group_player(
-                provider_instance, name, members, dynamic=dynamic
-            )
-        raise UnsupportedFeaturedException(
-            f"Provider {provider} does not support creating group players"
-        )
-
-    @api_command("players/remove_group_player", required_role="admin")
-    async def remove_group_player(self, player_id: str) -> None:
-        """
-        Remove a group player.
-
-        :param player_id: ID of the group player to remove.
-        """
-        if not (player := self.get(player_id)):
-            # we simply permanently delete the player by wiping its config
-            self.mass.config.remove(f"players/{player_id}")
-            return
-        if player.type != PlayerType.GROUP:
-            raise UnsupportedFeaturedException(
-                f"Player {player.display_name} is not a group player"
-            )
-        player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
-        await player.provider.remove_group_player(player_id)
-
-    @api_command("players/add_currently_playing_to_favorites")
-    async def add_currently_playing_to_favorites(self, player_id: str) -> None:
-        """
-        Add the currently playing item/track on given player to the favorites.
-
-        This tries to resolve the currently playing media to an actual media item
-        and add that to the favorites in the library.
-
-        Will raise an error if the player is not currently playing anything
-        or if the currently playing media can not be resolved to a media item.
-        """
-        player = self._get_player_with_redirect(player_id)
-        # handle mass player queue active
-        if mass_queue := self.get_active_queue(player):
-            if not (current_item := mass_queue.current_item) or not current_item.media_item:
-                raise PlayerCommandFailed("No current item to add to favorites")
-            # if we're playing a radio station, try to resolve the currently playing track
-            if current_item.media_item.media_type == MediaType.RADIO:
-                if not (
-                    (streamdetails := mass_queue.current_item.streamdetails)
-                    and (stream_title := streamdetails.stream_title)
-                    and " - " in stream_title
-                ):
-                    # no stream title available, so we can't resolve the track
-                    # this can happen if the radio station does not provide metadata
-                    # or there's a commercial break
-                    # Possible future improvement could be to actually detect the song with a
-                    # shazam-like approach.
-                    raise PlayerCommandFailed("No current item to add to favorites")
-                # send the streamtitle into a global search query
-                search_artist, search_title_title = stream_title.split(" - ", 1)
-                # strip off any additional comments in the title (such as from Radio Paradise)
-                search_title_title = search_title_title.split(" | ")[0].strip()
-                if track := await self.mass.music.get_track_by_name(
-                    search_title_title, search_artist
-                ):
-                    # we found a track, so add it to the favorites
-                    await self.mass.music.add_item_to_favorites(track)
-                    return
-                # we could not resolve the track, so raise an error
-                raise PlayerCommandFailed("No current item to add to favorites")
-
-            # else: any other media item, just add it to the favorites directly
-            await self.mass.music.add_item_to_favorites(current_item.media_item)
-            return
-
-        # guard for player with no active source
-        if not player.active_source:
-            raise PlayerCommandFailed("Player has no active source")
-        # handle other source active using the current_media with uri
-        if current_media := player.current_media:
-            # prefer the uri of the current media item
-            if current_media.uri:
-                with suppress(MusicAssistantError):
-                    await self.mass.music.add_item_to_favorites(current_media.uri)
-                    return
-            # fallback to search based on artist and title (and album if available)
-            if current_media.artist and current_media.title:
-                if track := await self.mass.music.get_track_by_name(
-                    current_media.title,
-                    current_media.artist,
-                    current_media.album,
-                ):
-                    # we found a track, so add it to the favorites
-                    await self.mass.music.add_item_to_favorites(track)
-                    return
-        # if we reach here, we could not resolve the currently playing item
-        raise PlayerCommandFailed("No current item to add to favorites")
-
-    async def register(self, player: Player) -> None:
-        """Register a player on the Player Controller."""
-        if self.mass.closing:
-            return
-        player_id = player.player_id
-
-        if player_id in self._players:
-            msg = f"Player {player_id} is already registered!"
-            raise AlreadyRegisteredError(msg)
-
-        # ignore disabled players
-        if not player.enabled:
-            return
-
-        # register throttler for this player
-        self._player_throttlers[player_id] = Throttler(1, 0.05)
-
-        # restore 'fake' power state from cache if available
-        cached_value = await self.mass.cache.get(
-            key=player.player_id,
-            provider=self.domain,
-            category=CACHE_CATEGORY_PLAYER_POWER,
-            default=False,
-        )
-        if cached_value is not None:
-            player.extra_data[ATTR_FAKE_POWER] = cached_value
-
-        # finally actually register it
-        self._players[player_id] = player
-
-        # ensure we fetch and set the latest/full config for the player
-        player_config = await self.mass.config.get_player_config(player_id)
-        player.set_config(player_config)
-        # call hook after the player is registered and config is set
-        await player.on_config_updated()
-
-        self.logger.info(
-            "Player registered: %s/%s",
-            player_id,
-            player.display_name,
-        )
-        # signal event that a player was added
-        # update state without signaling event first (to ensure all attributes are set correctly)
-        player.update_state(signal_event=False)
-        self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player)
-
-        # register playerqueue for this player
-        await self.mass.player_queues.on_player_register(player)
-        # always call update to fix special attributes like display name, group volume etc.
-        player.update_state()
-
-    async def register_or_update(self, player: Player) -> None:
-        """Register a new player on the controller or update existing one."""
-        if self.mass.closing:
-            return
-
-        if player.player_id in self._players:
-            self._players[player.player_id] = player
-            player.update_state()
-            return
-
-        await self.register(player)
-
-    def trigger_player_update(self, player_id: str, force_update: bool = False) -> None:
-        """Trigger an update for the given player."""
-        if self.mass.closing:
-            return
-        if not (player := self.get(player_id)):
-            return
-        self.mass.loop.call_soon(player.update_state, force_update)
-
-    async def unregister(self, player_id: str, permanent: bool = False) -> None:
-        """
-        Unregister a player from the player controller.
-
-        Called (by a PlayerProvider) when a player is removed
-        or no longer available (for a longer period of time).
-
-        This will remove the player from the player controller and
-        optionally remove the player's config from the mass config.
-
-        - player_id: player_id of the player to unregister.
-        - permanent: if True, remove the player permanently by deleting
-        the player's config from the mass config. If False, the player config will not be removed,
-        allowing for re-registration (with the same config) later.
-
-        If the player is not registered, this will silently be ignored.
-        """
-        player = self._players.get(player_id)
-        if player is None:
-            return
-        await self._cleanup_player_memberships(player_id)
-        del self._players[player_id]
-        self.mass.player_queues.on_player_remove(player_id, permanent=permanent)
-        await player.on_unload()
-        if permanent:
-            # player permanent removal: delete its config
-            # and signal PLAYER_REMOVED event
-            self.delete_player_config(player_id)
-            self.logger.info("Player removed: %s", player.name)
-            self.mass.signal_event(EventType.PLAYER_REMOVED, player_id)
-        else:
-            # temporary unavailable: mark player as unavailable
-            # note: the player will be re-registered later if it comes back online
-            player.state.available = False
-            self.logger.info("Player unavailable: %s", player.name)
-            self.mass.signal_event(
-                EventType.PLAYER_UPDATED, object_id=player.player_id, data=player.state
-            )
-
-    @api_command("players/remove", required_role="admin")
-    async def remove(self, player_id: str) -> None:
-        """
-        Remove a player from a provider.
-
-        Can only be called when a PlayerProvider supports ProviderFeature.REMOVE_PLAYER.
-        """
-        player = self.get(player_id)
-        if player is None:
-            # we simply permanently delete the player config since it is not registered
-            self.delete_player_config(player_id)
-            return
-        if player.type == PlayerType.GROUP and player_id.startswith(SYNCGROUP_PREFIX):
-            await self._sync_groups.remove_group_player(player_id)
-            return
-        if player.type == PlayerType.GROUP:
-            # Handle group player removal
-            player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
-            await player.provider.remove_group_player(player_id)
-            return
-        player.provider.check_feature(ProviderFeature.REMOVE_PLAYER)
-        await player.provider.remove_player(player_id)
-        # check for group memberships that need to be updated
-        if player.active_group and (group_player := self.mass.players.get(player.active_group)):
-            # try to remove from the group
-            with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
-                await group_player.set_members(
-                    player_ids_to_remove=[player_id],
-                )
-        # We removed the player and can now clean up its config
-        self.delete_player_config(player_id)
-
-    def delete_player_config(self, player_id: str) -> None:
-        """
-        Permanently delete a player's configuration.
-
-        Should only be called for players that are not registered by the player controller.
-        """
-        # we simply permanently delete the player by wiping its config
-        conf_key = f"{CONF_PLAYERS}/{player_id}"
-        dsp_conf_key = f"{CONF_PLAYER_DSP}/{player_id}"
-        for key in (conf_key, dsp_conf_key):
-            self.mass.config.remove(key)
-
-    def signal_player_state_update(
-        self,
-        player: Player,
-        changed_values: dict[str, tuple[Any, Any]],
-        force_update: bool = False,
-        skip_forward: bool = False,
-    ) -> None:
-        """
-        Signal a player state update.
-
-        Called by a Player when its state has changed.
-        This will update the player state in the controller and signal the event bus.
-        """
-        player_id = player.player_id
-        if self.mass.closing:
-            return
-
-        # ignore updates for disabled players
-        if not player.enabled and ATTR_ENABLED not in changed_values:
-            return
-
-        if len(changed_values) == 0 and not force_update:
-            # nothing changed
-            return
-
-        # always signal update to the playerqueue
-        self.mass.player_queues.on_player_update(player, changed_values)
-
-        if changed_values.keys() == {ATTR_ELAPSED_TIME} and not force_update:
-            # ignore small changes in elapsed time
-            prev_value = changed_values[ATTR_ELAPSED_TIME][0] or 0
-            new_value = changed_values[ATTR_ELAPSED_TIME][1] or 0
-            if abs(prev_value - new_value) < 5:
-                return
-
-        # handle DSP reload of the leader when grouping/ungrouping
-        if ATTR_GROUP_MEMBERS in changed_values:
-            prev_group_members, new_group_members = changed_values[ATTR_GROUP_MEMBERS]
-            self._handle_group_dsp_change(player, prev_group_members or [], new_group_members)
-
-        if ATTR_GROUP_MEMBERS in changed_values:
-            # Removed group members also need to be updated since they are no longer part
-            # of this group and are available for playback again
-            prev_group_members = changed_values[ATTR_GROUP_MEMBERS][0] or []
-            new_group_members = changed_values[ATTR_GROUP_MEMBERS][1] or []
-            removed_members = set(prev_group_members) - set(new_group_members)
-            for _removed_player_id in removed_members:
-                if removed_player := self.get(_removed_player_id):
-                    removed_player.update_state()
-
-        became_inactive = False
-        if ATTR_AVAILABLE in changed_values:
-            became_inactive = changed_values[ATTR_AVAILABLE][1] is False
-        if not became_inactive and ATTR_ENABLED in changed_values:
-            became_inactive = changed_values[ATTR_ENABLED][1] is False
-        if became_inactive and (player.active_group or player.synced_to):
-            self.mass.create_task(self._cleanup_player_memberships(player.player_id))
-
-        # signal player update on the eventbus
-        self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player)
-
-        # signal a separate PlayerOptionsUpdated event
-        if options := changed_values.get("options"):
-            self.mass.signal_event(
-                EventType.PLAYER_OPTIONS_UPDATED, object_id=player_id, data=options
-            )
-
-        if skip_forward and not force_update:
-            return
-
-        # update/signal group player(s) child's when group updates
-        for child_player in self.iter_group_members(player, exclude_self=True):
-            child_player.update_state()
-        # update/signal group player(s) when child updates
-        for group_player in self._get_player_groups(player, powered_only=False):
-            group_player.update_state()
-        # update/signal manually synced to player when child updates
-        if (synced_to := player.synced_to) and (synced_to_player := self.get(synced_to)):
-            synced_to_player.update_state()
-        # update/signal active groups when a group member updates
-        if (active_group := player.active_group) and (
-            active_group_player := self.get(active_group)
-        ):
-            active_group_player.update_state()
-
-    async def register_player_control(self, player_control: PlayerControl) -> None:
-        """Register a new PlayerControl on the controller."""
-        if self.mass.closing:
-            return
-        control_id = player_control.id
-
-        if control_id in self._controls:
-            msg = f"PlayerControl {control_id} is already registered"
-            raise AlreadyRegisteredError(msg)
-
-        # make sure that the playercontrol's provider is set to the instance_id
-        prov = self.mass.get_provider(player_control.provider)
-        if not prov or prov.instance_id != player_control.provider:
-            raise RuntimeError(f"Invalid provider ID given: {player_control.provider}")
-
-        self._controls[control_id] = player_control
-
-        self.logger.info(
-            "PlayerControl registered: %s/%s",
-            control_id,
-            player_control.name,
-        )
-
-        # always call update to update any attached players etc.
-        self.update_player_control(player_control.id)
-
-    async def register_or_update_player_control(self, player_control: PlayerControl) -> None:
-        """Register a new playercontrol on the controller or update existing one."""
-        if self.mass.closing:
-            return
-        if player_control.id in self._controls:
-            self._controls[player_control.id] = player_control
-            self.update_player_control(player_control.id)
-            return
-        await self.register_player_control(player_control)
-
-    def update_player_control(self, control_id: str) -> None:
-        """Update playercontrol state."""
-        if self.mass.closing:
-            return
-        # update all players that are using this control
-        for player in self._players.values():
-            if control_id in (player.power_control, player.volume_control, player.mute_control):
-                self.mass.loop.call_soon(player.update_state)
-
-    def remove_player_control(self, control_id: str) -> None:
-        """Remove a player_control from the player manager."""
-        control = self._controls.pop(control_id, None)
-        if control is None:
-            return
-        self._controls.pop(control_id, None)
-        self.logger.info("PlayerControl removed: %s", control.name)
-
-    def get_player_provider(self, player_id: str) -> PlayerProvider:
-        """Return PlayerProvider for given player."""
-        player = self._players[player_id]
-        assert player  # for type checker
-        return player.provider
-
-    def get_active_queue(self, player: Player) -> PlayerQueue | None:
-        """Return the current active queue for a player (if any)."""
-        # account for player that is synced (sync child)
-        if player.synced_to and player.synced_to != player.player_id:
-            if sync_leader := self.get(player.synced_to):
-                return self.get_active_queue(sync_leader)
-        # handle active group player
-        if player.active_group and player.active_group != player.player_id:
-            if group_player := self.get(player.active_group):
-                return self.get_active_queue(group_player)
-        # active_source may be filled queue id (or None)
-        active_source = player.active_source or player.player_id
-        if active_queue := self.mass.player_queues.get(active_source):
-            return active_queue
-        return None
-
-    async def set_group_volume(self, group_player: Player, volume_level: int) -> None:
-        """Handle adjusting the overall/group volume to a playergroup (or synced players)."""
-        cur_volume = group_player.state.group_volume
-        volume_dif = volume_level - cur_volume
-        coros = []
-        # handle group volume by only applying the volume to powered members
-        for child_player in self.iter_group_members(
-            group_player, only_powered=True, exclude_self=False
-        ):
-            if child_player.volume_control == PLAYER_CONTROL_NONE:
-                continue
-            cur_child_volume = child_player.volume_level or 0
-            new_child_volume = int(cur_child_volume + volume_dif)
-            new_child_volume = max(0, new_child_volume)
-            new_child_volume = min(100, new_child_volume)
-            # Use private method to skip permission check - already validated on group
-            # ATTR_MUTE_LOCK on muted players prevents auto-unmute during group volume changes
-            coros.append(self._handle_cmd_volume_set(child_player.player_id, new_child_volume))
-        await asyncio.gather(*coros)
-
-    def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None:
-        """Get the (player specific) volume for a announcement."""
-        volume_strategy = self.mass.config.get_raw_player_config_value(
-            player_id,
-            CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key,
-            CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value,
-        )
-        volume_strategy_volume = self.mass.config.get_raw_player_config_value(
-            player_id,
-            CONF_ENTRY_ANNOUNCE_VOLUME.key,
-            CONF_ENTRY_ANNOUNCE_VOLUME.default_value,
-        )
-        if volume_strategy == "none":
-            return None
-        volume_level = volume_override
-        if volume_level is None and volume_strategy == "absolute":
-            volume_level = int(cast("float", volume_strategy_volume))
-        elif volume_level is None and volume_strategy == "relative":
-            if (player := self.get(player_id)) and player.volume_level is not None:
-                volume_level = int(player.volume_level + cast("float", volume_strategy_volume))
-        elif volume_level is None and volume_strategy == "percentual":
-            if (player := self.get(player_id)) and player.volume_level is not None:
-                percentual = (player.volume_level / 100) * cast("float", volume_strategy_volume)
-                volume_level = int(player.volume_level + percentual)
-        if volume_level is not None:
-            announce_volume_min = cast(
-                "float",
-                self.mass.config.get_raw_player_config_value(
-                    player_id,
-                    CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key,
-                    CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value,
-                ),
-            )
-            volume_level = max(int(announce_volume_min), volume_level)
-            announce_volume_max = cast(
-                "float",
-                self.mass.config.get_raw_player_config_value(
-                    player_id,
-                    CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key,
-                    CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value,
-                ),
-            )
-            volume_level = min(int(announce_volume_max), volume_level)
-        return None if volume_level is None else int(volume_level)
-
-    def iter_group_members(
-        self,
-        group_player: Player,
-        only_powered: bool = False,
-        only_playing: bool = False,
-        active_only: bool = False,
-        exclude_self: bool = True,
-    ) -> Iterator[Player]:
-        """Get (child) players attached to a group player or syncgroup."""
-        for child_id in list(group_player.group_members):
-            if child_player := self.get(child_id, False):
-                if not child_player.available or not child_player.enabled:
-                    continue
-                if only_powered and child_player.powered is False:
-                    continue
-                if active_only and child_player.active_group != group_player.player_id:
-                    continue
-                if exclude_self and child_player.player_id == group_player.player_id:
-                    continue
-                if only_playing and child_player.playback_state not in (
-                    PlaybackState.PLAYING,
-                    PlaybackState.PAUSED,
-                ):
-                    continue
-                yield child_player
-
-    async def wait_for_state(
-        self,
-        player: Player,
-        wanted_state: PlaybackState,
-        timeout: float = 60.0,
-        minimal_time: float = 0,
-    ) -> None:
-        """Wait for the given player to reach the given state."""
-        start_timestamp = time.time()
-        self.logger.debug(
-            "Waiting for player %s to reach state %s", player.display_name, wanted_state
-        )
-        try:
-            async with asyncio.timeout(timeout):
-                while player.playback_state != wanted_state:
-                    await asyncio.sleep(0.1)
-
-        except TimeoutError:
-            self.logger.debug(
-                "Player %s did not reach state %s within the timeout of %s seconds",
-                player.display_name,
-                wanted_state,
-                timeout,
-            )
-        elapsed_time = round(time.time() - start_timestamp, 2)
-        if elapsed_time < minimal_time:
-            self.logger.debug(
-                "Player %s reached state %s too soon (%s vs %s seconds) - add fallback sleep...",
-                player.display_name,
-                wanted_state,
-                elapsed_time,
-                minimal_time,
-            )
-            await asyncio.sleep(minimal_time - elapsed_time)
-        else:
-            self.logger.debug(
-                "Player %s reached state %s within %s seconds",
-                player.display_name,
-                wanted_state,
-                elapsed_time,
-            )
-
-    async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
-        """Call (by config manager) when the configuration of a player changes."""
-        player = self.get(config.player_id)
-        player_provider = self.mass.get_provider(config.provider)
-        player_disabled = ATTR_ENABLED in changed_keys and not config.enabled
-        player_enabled = ATTR_ENABLED in changed_keys and config.enabled
-
-        if player_disabled and player and player.available:
-            # edge case: ensure that the player is powered off if the player gets disabled
-            if player.power_control != PLAYER_CONTROL_NONE:
-                await self._handle_cmd_power(config.player_id, False)
-            elif player.playback_state != PlaybackState.IDLE:
-                await self.cmd_stop(config.player_id)
-
-        # signal player provider that the player got enabled/disabled
-        if (player_enabled or player_disabled) and player_provider:
-            assert isinstance(player_provider, PlayerProvider)  # for type checking
-            if player_disabled:
-                player_provider.on_player_disabled(config.player_id)
-            elif player_enabled:
-                player_provider.on_player_enabled(config.player_id)
-            return  # enabling/disabling a player will be handled by the provider
-
-        if not player:
-            return  # guard against player not being registered (yet)
-
-        resume_queue: PlayerQueue | None = (
-            self.mass.player_queues.get(player.active_source) if player.active_source else None
-        )
-
-        # ensure player state gets updated with any updated config
-        player.set_config(config)
-        await player.on_config_updated()
-        player.update_state()
-        # if the PlayerQueue was playing, restart playback
-        if resume_queue and resume_queue.state == PlaybackState.PLAYING:
-            requires_restart = any(
-                v for v in config.values.values() if v.key in changed_keys and v.requires_reload
-            )
-            if requires_restart:
-                # always stop first to ensure the player uses the new config
-                await self.mass.player_queues.stop(resume_queue.queue_id)
-                self.mass.call_later(
-                    1, self.mass.player_queues.resume, resume_queue.queue_id, False
-                )
-
-    async def on_player_dsp_change(self, player_id: str) -> None:
-        """Call (by config manager) when the DSP settings of a player change."""
-        # signal player provider that the config changed
-        if not (player := self.get(player_id)):
-            return
-        if player.playback_state == PlaybackState.PLAYING:
-            self.logger.info("Restarting playback of Player %s after DSP change", player_id)
-            # this will restart the queue stream/playback
-            if player.mass_queue_active:
-                self.mass.call_later(0, self.mass.player_queues.resume, player.active_source, False)
-                return
-            # if the player is not using a queue, we need to stop and start playback
-            await self.cmd_stop(player_id)
-            await self.cmd_play(player_id)
-
-    async def _cleanup_player_memberships(self, player_id: str) -> None:
-        """Ensure a player is detached from any groups or syncgroups."""
-        if not (player := self.get(player_id)):
-            return
-
-        if (
-            player.active_group
-            and (group := self.get(player.active_group))
-            and group.supports_feature(PlayerFeature.SET_MEMBERS)
-        ):
-            # Ungroup the player if its part of an active group, this will ignore
-            # static_group_members since that is only checked when using cmd_set_members
-            with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
-                await group.set_members(player_ids_to_remove=[player_id])
-        elif player.synced_to and player.supports_feature(PlayerFeature.SET_MEMBERS):
-            # Remove the player if it was synced, otherwise it will still show as
-            # synced to the other player after it gets registered again
-            with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
-                await player.ungroup()
-
-    def _get_player_with_redirect(self, player_id: str) -> Player:
-        """Get player with check if playback related command should be redirected."""
-        player = self.get(player_id, True)
-        assert player is not None  # for type checking
-        if player.synced_to and (sync_leader := self.get(player.synced_to)):
-            self.logger.info(
-                "Player %s is synced to %s and can not accept "
-                "playback related commands itself, "
-                "redirected the command to the sync leader.",
-                player.name,
-                sync_leader.name,
-            )
-            return sync_leader
-        if player.active_group and (active_group := self.get(player.active_group)):
-            self.logger.info(
-                "Player %s is part of a playergroup and can not accept "
-                "playback related commands itself, "
-                "redirected the command to the group leader.",
-                player.name,
-            )
-            return active_group
-        return player
-
-    def _get_active_plugin_source(self, player: Player) -> PluginSource | None:
-        """Get the active PluginSource for a player if any."""
-        # Check if any plugin source is in use by this player
-        for plugin_source in self.get_plugin_sources():
-            if plugin_source.in_use_by == player.player_id:
-                return plugin_source
-            if player.active_source == plugin_source.id:
-                return plugin_source
-        return None
-
-    def _get_player_groups(
-        self, player: Player, available_only: bool = True, powered_only: bool = False
-    ) -> Iterator[Player]:
-        """Return all groupplayers the given player belongs to."""
-        for _player in self.all(return_unavailable=not available_only):
-            if _player.player_id == player.player_id:
-                continue
-            if _player.type != PlayerType.GROUP:
-                continue
-            if powered_only and _player.powered is False:
-                continue
-            if player.player_id in _player.group_members:
-                yield _player
-
-    async def _play_announcement(  # noqa: PLR0915
-        self,
-        player: Player,
-        announcement: PlayerMedia,
-        volume_level: int | None = None,
-    ) -> None:
-        """Handle (default/fallback) implementation of the play announcement feature.
-
-        This default implementation will;
-        - stop playback of the current media (if needed)
-        - power on the player (if needed)
-        - raise the volume a bit
-        - play the announcement (from given url)
-        - wait for the player to finish playing
-        - restore the previous power and volume
-        - restore playback (if needed and if possible)
-
-        This default implementation will only be used if the player
-        (provider) has no native support for the PLAY_ANNOUNCEMENT feature.
-        """
-        prev_state = player.playback_state
-        prev_power = player.powered or prev_state != PlaybackState.IDLE
-        prev_synced_to = player.synced_to
-        prev_group = self.get(player.active_group) if player.active_group else None
-        prev_source = player.active_source
-        prev_media = player.current_media
-        prev_media_name = prev_media.title or prev_media.uri if prev_media else None
-        if prev_synced_to:
-            # ungroup player if its currently synced
-            self.logger.debug(
-                "Announcement to player %s - ungrouping player from %s...",
-                player.display_name,
-                prev_synced_to,
-            )
-            await self.cmd_ungroup(player.player_id)
-        elif prev_group:
-            # if the player is part of a group player, we need to ungroup it
-            if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
-                self.logger.debug(
-                    "Announcement to player %s - ungrouping from group player %s...",
-                    player.display_name,
-                    prev_group.display_name,
-                )
-                await prev_group.set_members(player_ids_to_remove=[player.player_id])
-            else:
-                # if the player is part of a group player that does not support ungrouping,
-                # we need to power off the groupplayer instead
-                self.logger.debug(
-                    "Announcement to player %s - turning off group player %s...",
-                    player.display_name,
-                    prev_group.display_name,
-                )
-                await self._handle_cmd_power(player.player_id, False)
-        elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
-            # normal/standalone player: stop player if its currently playing
-            self.logger.debug(
-                "Announcement to player %s - stop existing content (%s)...",
-                player.display_name,
-                prev_media_name,
-            )
-            await self.cmd_stop(player.player_id)
-            # wait for the player to stop
-            await self.wait_for_state(player, PlaybackState.IDLE, 10, 0.4)
-        # adjust volume if needed
-        # in case of a (sync) group, we need to do this for all child players
-        prev_volumes: dict[str, int] = {}
-        async with TaskManager(self.mass) as tg:
-            for volume_player_id in player.group_members or (player.player_id,):
-                if not (volume_player := self.get(volume_player_id)):
-                    continue
-                # catch any players that have a different source active
-                if (
-                    volume_player.active_source
-                    not in (
-                        player.active_source,
-                        volume_player.player_id,
-                        None,
-                    )
-                    and volume_player.playback_state == PlaybackState.PLAYING
-                ):
-                    self.logger.warning(
-                        "Detected announcement to playergroup %s while group member %s is playing "
-                        "other content, this may lead to unexpected behavior.",
-                        player.display_name,
-                        volume_player.display_name,
-                    )
-                    tg.create_task(self.cmd_stop(volume_player.player_id))
-                if volume_player.volume_control == PLAYER_CONTROL_NONE:
-                    continue
-                if (prev_volume := volume_player.volume_level) is None:
-                    continue
-                announcement_volume = self.get_announcement_volume(volume_player_id, volume_level)
-                if announcement_volume is None:
-                    continue
-                temp_volume = announcement_volume or player.volume_level
-                if temp_volume != prev_volume:
-                    prev_volumes[volume_player_id] = prev_volume
-                    self.logger.debug(
-                        "Announcement to player %s - setting temporary volume (%s)...",
-                        volume_player.display_name,
-                        announcement_volume,
-                    )
-                    tg.create_task(
-                        self._handle_cmd_volume_set(volume_player.player_id, announcement_volume)
-                    )
-        # play the announcement
-        self.logger.debug(
-            "Announcement to player %s - playing the announcement on the player...",
-            player.display_name,
-        )
-        await self.play_media(player_id=player.player_id, media=announcement)
-        # wait for the player(s) to play
-        await self.wait_for_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1)
-        # wait for the player to stop playing
-        if not announcement.duration:
-            if not announcement.custom_data:
-                raise ValueError("Announcement missing duration and custom_data")
-            media_info = await async_parse_tags(
-                announcement.custom_data["announcement_url"], require_duration=True
-            )
-            announcement.duration = int(media_info.duration) if media_info.duration else None
-
-        if announcement.duration is None:
-            raise ValueError("Announcement duration could not be determined")
-
-        await self.wait_for_state(
-            player,
-            PlaybackState.IDLE,
-            timeout=announcement.duration + 10,
-            minimal_time=float(announcement.duration) + 2,
-        )
-        self.logger.debug(
-            "Announcement to player %s - restore previous state...", player.display_name
-        )
-        # restore volume
-        async with TaskManager(self.mass) as tg:
-            for volume_player_id, prev_volume in prev_volumes.items():
-                tg.create_task(self._handle_cmd_volume_set(volume_player_id, prev_volume))
-        await asyncio.sleep(0.2)
-        # either power off the player or resume playing
-        if not prev_power:
-            if player.power_control != PLAYER_CONTROL_NONE:
-                self.logger.debug(
-                    "Announcement to player %s - turning player off again...", player.display_name
-                )
-                await self._handle_cmd_power(player.player_id, False)
-            # nothing to do anymore, player was not previously powered
-            # and does not support power control
-            return
-        if prev_synced_to:
-            self.logger.debug(
-                "Announcement to player %s - syncing back to %s...",
-                player.display_name,
-                prev_synced_to,
-            )
-            await self.cmd_set_members(prev_synced_to, player_ids_to_add=[player.player_id])
-        elif prev_group:
-            if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
-                self.logger.debug(
-                    "Announcement to player %s - grouping back to group player %s...",
-                    player.display_name,
-                    prev_group.display_name,
-                )
-                await prev_group.set_members(player_ids_to_add=[player.player_id])
-            elif prev_state == PlaybackState.PLAYING:
-                # if the player is part of a group player that does not support set_members,
-                # we need to restart the groupplayer
-                self.logger.debug(
-                    "Announcement to player %s - restarting playback on group player %s...",
-                    player.display_name,
-                    prev_group.display_name,
-                )
-                await self.cmd_play(prev_group.player_id)
-        elif prev_state == PlaybackState.PLAYING:
-            # player was playing something before the announcement - try to resume that here
-            await self._handle_cmd_resume(player.player_id, prev_source, prev_media)
-
-    async def _poll_players(self) -> None:
-        """Background task that polls players for updates."""
-        while True:
-            for player in list(self._players.values()):
-                # if the player is playing, update elapsed time every tick
-                # to ensure the queue has accurate details
-                player_playing = player.playback_state == PlaybackState.PLAYING
-                if player_playing:
-                    self.mass.loop.call_soon(
-                        self.mass.player_queues.on_player_update,
-                        player,
-                        {"corrected_elapsed_time": (None, player.corrected_elapsed_time)},
-                    )
-                # Poll player;
-                if not player.needs_poll:
-                    continue
-                try:
-                    last_poll: float = player.extra_data[ATTR_LAST_POLL]
-                except KeyError:
-                    last_poll = 0.0
-                if (self.mass.loop.time() - last_poll) < player.poll_interval:
-                    continue
-                player.extra_data[ATTR_LAST_POLL] = self.mass.loop.time()
-                try:
-                    await player.poll()
-                except Exception as err:
-                    self.logger.warning(
-                        "Error while requesting latest state from player %s: %s",
-                        player.display_name,
-                        str(err),
-                        exc_info=err if self.logger.isEnabledFor(10) else None,
-                    )
-                # Yield to event loop to prevent blocking
-                await asyncio.sleep(0)
-            await asyncio.sleep(1)
-
-    async def _handle_select_plugin_source(
-        self, player: Player, plugin_prov: PluginProvider
-    ) -> None:
-        """Handle playback/select of given plugin source on player."""
-        plugin_source = plugin_prov.get_source()
-        if plugin_source.in_use_by and plugin_source.in_use_by != player.player_id:
-            self.logger.debug(
-                "Plugin source %s is already in use by player %s, stopping playback there first.",
-                plugin_source.name,
-                plugin_source.in_use_by,
-            )
-            with suppress(PlayerCommandFailed):
-                await self.cmd_stop(plugin_source.in_use_by)
-        stream_url = await self.mass.streams.get_plugin_source_url(plugin_source, player.player_id)
-        plugin_source.in_use_by = player.player_id
-        # Call on_select callback if available
-        if plugin_source.on_select:
-            await plugin_source.on_select()
-        await self.play_media(
-            player_id=player.player_id,
-            media=PlayerMedia(
-                uri=stream_url,
-                media_type=MediaType.PLUGIN_SOURCE,
-                title=plugin_source.name,
-                custom_data={
-                    "provider": plugin_prov.instance_id,
-                    "source_id": plugin_source.id,
-                    "player_id": player.player_id,
-                    "audio_format": plugin_source.audio_format,
-                },
-            ),
-        )
-        # trigger player update to ensure the source is set
-        self.trigger_player_update(player.player_id)
-
-    def _handle_group_dsp_change(
-        self, player: Player, prev_group_members: list[str], new_group_members: list[str]
-    ) -> None:
-        """Handle DSP reload when group membership changes."""
-        prev_child_count = len(prev_group_members)
-        new_child_count = len(new_group_members)
-        is_player_group = player.type == PlayerType.GROUP
-
-        # handle special case for PlayerGroups: since there are no leaders,
-        # DSP still always work with a single player in the group.
-        multi_device_dsp_threshold = 1 if is_player_group else 0
-
-        prev_is_multiple_devices = prev_child_count > multi_device_dsp_threshold
-        new_is_multiple_devices = new_child_count > multi_device_dsp_threshold
-
-        if prev_is_multiple_devices == new_is_multiple_devices:
-            return  # no change in multi-device status
-
-        supports_multi_device_dsp = PlayerFeature.MULTI_DEVICE_DSP in player.supported_features
-
-        dsp_enabled: bool
-        if player.type == PlayerType.GROUP:
-            # Since player groups do not have leaders, we will use the only child
-            # that was in the group before and after the change
-            if prev_is_multiple_devices:
-                if childs := new_group_members:
-                    # We shrank the group from multiple players to a single player
-                    # So the now only child will control the DSP
-                    dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
-                else:
-                    dsp_enabled = False
-            elif childs := prev_group_members:
-                # We grew the group from a single player to multiple players,
-                # let's see if the previous single player had DSP enabled
-                dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
-            else:
-                dsp_enabled = False
-        else:
-            dsp_enabled = self.mass.config.get_player_dsp_config(player.player_id).enabled
-
-        if dsp_enabled and not supports_multi_device_dsp:
-            # We now know that the group configuration has changed so:
-            # - multi-device DSP is not supported
-            # - we switched from a group with multiple players to a single player
-            #   (or vice versa)
-            # - the leader has DSP enabled
-            self.mass.create_task(self.mass.players.on_player_dsp_change(player.player_id))
-
-    # Private command handlers (no permission checks)
-
-    async def _handle_cmd_resume(
-        self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
-    ) -> None:
-        """
-        Handle resume playback command.
-
-        Skips the permission checks (internal use only).
-        """
-        player = self._get_player_with_redirect(player_id)
-        source = source or player.active_source
-        media = media or player.current_media
-        # power on the player if needed
-        if not player.powered and player.power_control != PLAYER_CONTROL_NONE:
-            await self._handle_cmd_power(player.player_id, True)
-        # Redirect to queue controller if it is active
-        if active_queue := self.mass.player_queues.get(source or player_id):
-            await self.mass.player_queues.resume(active_queue.queue_id)
-            return
-        # try to handle command on player directly
-        # TODO: check if player has an active source with native resume support
-        active_source = next((x for x in player.source_list if x.id == source), None)
-        if (
-            player.playback_state in (PlaybackState.IDLE, PlaybackState.PAUSED)
-            and active_source
-            and active_source.can_play_pause
-        ):
-            # player has some other source active and native resume support
-            await player.play()
-            return
-        if active_source and not active_source.passive:
-            await self.select_source(player_id, active_source.id)
-            return
-        if media:
-            # try to re-play the current media item
-            await player.play_media(media)
-            return
-        # fallback: just send play command - which will fail if nothing can be played
-        await player.play()
-
-    async def _handle_cmd_power(self, player_id: str, powered: bool) -> None:
-        """
-        Handle player power on/off command.
-
-        Skips the permission checks (internal use only).
-        """
-        player = self.get(player_id, True)
-        assert player is not None  # for type checking
-        player_state = player.state
-
-        if player_state.powered == powered:
-            self.logger.debug(
-                "Ignoring power %s command for player %s: already in state %s",
-                "ON" if powered else "OFF",
-                player_state.name,
-                "ON" if player_state.powered else "OFF",
-            )
-            return  # nothing to do
-
-        # ungroup player at power off
-        player_was_synced = player.synced_to is not None
-        if player.type == PlayerType.PLAYER and not powered:
-            # ungroup player if it is synced (or is a sync leader itself)
-            # NOTE: ungroup will be ignored if the player is not grouped or synced
-            await self.cmd_ungroup(player_id)
-
-        # always stop player at power off
-        if (
-            not powered
-            and not player_was_synced
-            and player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
-        ):
-            await self.cmd_stop(player_id)
-            # short sleep: allow the stop command to process and prevent race conditions
-            await asyncio.sleep(0.2)
-
-        # power off all synced childs when player is a sync leader
-        elif not powered and player.type == PlayerType.PLAYER and player.group_members:
-            async with TaskManager(self.mass) as tg:
-                for member in self.iter_group_members(player, True):
-                    if member.power_control == PLAYER_CONTROL_NONE:
-                        continue
-                    # Use private method to skip permission check for child players
-                    tg.create_task(self._handle_cmd_power(member.player_id, False))
-
-        # handle actual power command
-        if player.power_control == PLAYER_CONTROL_NONE:
-            raise UnsupportedFeaturedException(
-                f"Player {player.display_name} does not support power control"
-            )
-        if player.power_control == PLAYER_CONTROL_NATIVE:
-            # player supports power command natively: forward to player provider
-            async with self._player_throttlers[player_id]:
-                await player.power(powered)
-        elif player.power_control == PLAYER_CONTROL_FAKE:
-            # user wants to use fake power control - so we (optimistically) update the state
-            # and store the state in the cache
-            player.extra_data[ATTR_FAKE_POWER] = powered
-            player.update_state()  # trigger update of the player state
-            await self.mass.cache.set(
-                key=player_id,
-                data=powered,
-                provider=self.domain,
-                category=CACHE_CATEGORY_PLAYER_POWER,
-            )
-        else:
-            # handle external player control
-            player_control = self._controls.get(player.power_control)
-            control_name = player_control.name if player_control else player.power_control
-            self.logger.debug("Redirecting power command to PlayerControl %s", control_name)
-            if not player_control or not player_control.supports_power:
-                raise UnsupportedFeaturedException(
-                    f"Player control {control_name} is not available"
-                )
-            if powered:
-                assert player_control.power_on is not None  # for type checking
-                await player_control.power_on()
-            else:
-                assert player_control.power_off is not None  # for type checking
-                await player_control.power_off()
-
-        # always trigger a state update to update the UI
-        player.update_state()
-
-        # handle 'auto play on power on' feature
-        if (
-            not player.active_group
-            and powered
-            and player.config.get_value(CONF_AUTO_PLAY)
-            and player.active_source in (None, player_id)
-            and not player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS)
-        ):
-            await self.mass.player_queues.resume(player_id)
-
-    async def _handle_cmd_volume_set(self, player_id: str, volume_level: int) -> None:
-        """
-        Handle Player volume set command.
-
-        Skips the permission checks (internal use only).
-        """
-        player = self.get(player_id, True)
-        assert player is not None  # for type checker
-        if player.type == PlayerType.GROUP:
-            # redirect to special group volume control
-            await self.cmd_group_volume(player_id, volume_level)
-            return
-
-        if player.volume_control == PLAYER_CONTROL_NONE:
-            raise UnsupportedFeaturedException(
-                f"Player {player.display_name} does not support volume control"
-            )
-
-        # Check if player has mute lock (set when individually muted in a group)
-        # If locked, don't auto-unmute when volume changes
-        has_mute_lock = player.extra_data.get(ATTR_MUTE_LOCK, False)
-        if (
-            not has_mute_lock
-            and player.mute_control not in (PLAYER_CONTROL_NONE, PLAYER_CONTROL_FAKE)
-            and player.volume_muted
-        ):
-            # if player is muted and not locked, we unmute it first
-            # skip this for fake mute since it uses volume to simulate mute
-            self.logger.debug(
-                "Unmuting player %s before setting volume",
-                player.display_name,
-            )
-            await self.cmd_volume_mute(player_id, False)
-
-        # Check if a plugin source is active with a volume callback
-        if plugin_source := self._get_active_plugin_source(player):
-            if plugin_source.on_volume:
-                await plugin_source.on_volume(volume_level)
-
-        if player.volume_control == PLAYER_CONTROL_NATIVE:
-            # player supports volume command natively: forward to player
-            async with self._player_throttlers[player_id]:
-                await player.volume_set(volume_level)
-            return
-        if player.volume_control == PLAYER_CONTROL_FAKE:
-            # user wants to use fake volume control - so we (optimistically) update the state
-            # and store the state in the cache
-            player.extra_data[ATTR_FAKE_VOLUME] = volume_level
-            # trigger update
-            player.update_state()
-            return
-        # else: handle external player control
-        player_control = self._controls.get(player.volume_control)
-        control_name = player_control.name if player_control else player.volume_control
-        self.logger.debug("Redirecting volume command to PlayerControl %s", control_name)
-        if not player_control or not player_control.supports_volume:
-            raise UnsupportedFeaturedException(f"Player control {control_name} is not available")
-        async with self._player_throttlers[player_id]:
-            assert player_control.volume_set is not None
-            await player_control.volume_set(volume_level)
-
-    def __iter__(self) -> Iterator[Player]:
-        """Iterate over all players."""
-        return iter(self._players.values())
diff --git a/music_assistant/controllers/players/protocol_linking.py b/music_assistant/controllers/players/protocol_linking.py
new file mode 100644 (file)
index 0000000..f05fc75
--- /dev/null
@@ -0,0 +1,1321 @@
+"""
+Protocol Linking Mixin for the Player Controller.
+
+Handles all logic for linking protocol players (AirPlay, Chromecast, DLNA) to
+native players or wrapping them in Universal Players.
+
+This module provides the ProtocolLinkingMixin class which is inherited by
+PlayerController to add protocol linking capabilities.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import TYPE_CHECKING, cast
+
+from music_assistant_models.enums import (
+    IdentifierType,
+    PlaybackState,
+    PlayerFeature,
+    PlayerType,
+    ProviderType,
+)
+from music_assistant_models.errors import PlayerCommandFailed
+from music_assistant_models.player import OutputProtocol
+
+from music_assistant.constants import (
+    CONF_LINKED_PROTOCOL_PLAYER_IDS,
+    CONF_PLAYERS,
+    CONF_PREFERRED_OUTPUT_PROTOCOL,
+    CONF_PROTOCOL_PARENT_ID,
+    PROTOCOL_PRIORITY,
+    VERBOSE_LOG_LEVEL,
+)
+from music_assistant.helpers.util import is_locally_administered_mac, resolve_real_mac_address
+from music_assistant.models.player import Player
+from music_assistant.providers.universal_player import UniversalPlayer, UniversalPlayerProvider
+
+if TYPE_CHECKING:
+    from collections.abc import Coroutine
+    from typing import Any
+
+    from music_assistant import MusicAssistant
+
+
+class ProtocolLinkingMixin:
+    """
+    Mixin class providing protocol linking functionality for PlayerController.
+
+    Handles the complex logic of:
+    - Matching protocol players to native players via device identifiers
+    - Creating Universal Players for devices without native support
+    - Managing protocol links and their lifecycle
+    - Selecting the best output protocol for playback
+
+    This mixin expects to be mixed with a class that provides:
+    - mass: MusicAssistant instance
+    - _players: dict of registered players
+    - _pending_protocol_evaluations: dict of pending protocol evaluations
+    - logger: logging.Logger instance
+    - all(): method to get all players
+    - get(): method to get a player by ID
+    - unregister(): method to unregister a player
+    """
+
+    # Type hints for attributes provided by the class this mixin is used with
+    if TYPE_CHECKING:
+        mass: MusicAssistant
+        _players: dict[str, Player]
+        _pending_protocol_evaluations: dict[str, asyncio.TimerHandle]
+        logger: logging.Logger
+
+        def all_players(  # noqa: D102
+            self,
+            return_unavailable: bool = True,
+            return_disabled: bool = False,
+            provider_filter: str | None = None,
+            return_protocol_players: bool = False,
+        ) -> list[Player]: ...
+
+        def get_player(self, player_id: str) -> Player | None: ...  # noqa: D102
+
+        def unregister(  # noqa: D102
+            self, player_id: str, permanent: bool = False
+        ) -> Coroutine[Any, Any, None]: ...
+
+    def _is_protocol_player(self, player: Player) -> bool:
+        """
+        Check if a player is a generic protocol player without native support.
+
+        Protocol players have PlayerType.PROTOCOL set by their provider, indicating
+        they are generic streaming endpoints (e.g., AirPlay receiver, Chromecast device)
+        without vendor-specific native support in Music Assistant.
+        """
+        return player.state.type == PlayerType.PROTOCOL
+
+    async def _enrich_player_identifiers(self, player: Player) -> None:
+        """
+        Enrich player identifiers with real MAC address if needed.
+
+        Some devices report different virtual/locally administered MAC addresses per protocol
+        (AirPlay, DLNA, Chromecast may all have different MACs for the same device).
+        This also applies to native players that may report virtual MACs.
+        This method tries to resolve the actual hardware MAC via ARP and adds it as an
+        additional identifier to enable proper matching between protocols and native players.
+        """
+        identifiers = player.device_info.identifiers
+        reported_mac = identifiers.get(IdentifierType.MAC_ADDRESS)
+        ip_address = identifiers.get(IdentifierType.IP_ADDRESS)
+
+        # Skip if no IP available (can't do ARP lookup)
+        if not ip_address:
+            return
+
+        # Skip if MAC already looks like a real one (not locally administered)
+        if reported_mac and not is_locally_administered_mac(reported_mac):
+            return
+
+        # Try to resolve real MAC via ARP
+        real_mac = await resolve_real_mac_address(reported_mac, ip_address)
+        if real_mac and real_mac.upper() != (reported_mac or "").upper():
+            # Replace the virtual MAC with the real MAC address
+            # (add_identifier will store multiple values if the implementation supports it)
+            player.device_info.add_identifier(IdentifierType.MAC_ADDRESS, real_mac)
+            self.logger.debug(
+                "Resolved real MAC for %s: %s -> %s",
+                player.state.name,
+                reported_mac,
+                real_mac,
+            )
+
+    def _evaluate_protocol_links(self, player: Player) -> None:
+        """
+        Evaluate and establish protocol links for a player.
+
+        Called when a player is registered to:
+        1. If it's from a protocol provider - try to link to a native player.
+        2. If it's a native player - try to link any existing protocol players.
+        """
+        if player.state.type == PlayerType.PROTOCOL:
+            # Protocol player: try to find a native parent
+            self._try_link_protocol_to_native(player)
+        else:
+            # Native player: try to find protocol players to link
+            self._try_link_protocols_to_native(player)
+
+    def _try_link_protocol_to_native(self, protocol_player: Player) -> None:
+        """Try to link a protocol player to a native player."""
+        protocol_domain = protocol_player.provider.domain
+
+        # Check for cached parent_id from previous session and restore link immediately
+        cached_parent_id = self._get_cached_protocol_parent_id(protocol_player.player_id)
+        if cached_parent_id:
+            protocol_player.set_protocol_parent_id(cached_parent_id)
+            if parent_player := self.get_player(cached_parent_id):
+                if not any(
+                    link.output_protocol_id == protocol_player.player_id
+                    for link in parent_player.linked_output_protocols
+                ):
+                    self._add_protocol_link(parent_player, protocol_player, protocol_domain)
+                    protocol_player.update_state()
+                    parent_player.update_state()
+                return
+            # Parent not registered yet - skip evaluation (no universal player created)
+            return
+
+        # Look for a matching native player
+        # Protocol players should only link to:
+        # 1. True native players (Sonos, etc.)
+        # 2. Universal players
+        # NOT to other protocol players (they get merged via universal_player)
+        for native_player in self.all_players(return_protocol_players=False):
+            if native_player.player_id == protocol_player.player_id:
+                continue
+            # Skip all protocol players - they should be handled via universal_player
+            if native_player.state.type == PlayerType.PROTOCOL:
+                continue
+
+            # For universal players, check if this protocol player is in its stored list
+            if native_player.provider.domain == "universal_player":
+                if isinstance(native_player, UniversalPlayer):
+                    if protocol_player.player_id in native_player._protocol_player_ids:
+                        self._add_protocol_link(native_player, protocol_player, protocol_domain)
+                        # Copy identifiers from protocol player to universal player
+                        # This is important for restored universal players which start
+                        # with empty identifiers
+                        for conn_type, value in protocol_player.device_info.identifiers.items():
+                            native_player.device_info.add_identifier(conn_type, value)
+                        # Update model/manufacturer if universal player has generic values
+                        self._update_universal_device_info(native_player, protocol_player)
+                        # Update availability from protocol players
+                        native_player.update_from_protocol_players()
+                        # Persist updated data to config (async via task)
+                        self._save_universal_player_data(native_player)
+                        protocol_player.update_state()
+                        native_player.update_state()
+                        return
+                continue
+
+            # Check cached protocol IDs first for fast matching on restart
+            cached_ids = self._get_cached_protocol_ids(native_player.player_id)
+            if protocol_player.player_id in cached_ids:
+                self._add_protocol_link(native_player, protocol_player, protocol_domain)
+                protocol_player.update_state()
+                native_player.update_state()
+                return
+
+            # Fallback to identifier matching
+            if self._identifiers_match(native_player, protocol_player, protocol_domain):
+                self._add_protocol_link(native_player, protocol_player, protocol_domain)
+                protocol_player.update_state()
+                native_player.update_state()
+                return
+
+        # No native player found - schedule delayed evaluation to allow other protocols to register
+        if not protocol_player.protocol_parent_id:
+            self._schedule_protocol_evaluation(protocol_player)
+
+    def _schedule_protocol_evaluation(self, protocol_player: Player) -> None:
+        """
+        Schedule a delayed protocol evaluation.
+
+        Delays evaluation to allow other protocol players and native players to register.
+        Uses a longer delay (30s) if this protocol player was previously linked to a native
+        player that hasn't registered yet, giving native providers time to start up.
+        """
+        player_id = protocol_player.player_id
+
+        # Cancel any existing pending evaluation for this player
+        if player_id in self._pending_protocol_evaluations:
+            self._pending_protocol_evaluations[player_id].cancel()
+
+        # Check if this protocol player has a cached parent (was previously linked)
+        cached_parent_id = self._get_cached_protocol_parent_id(player_id)
+        if cached_parent_id and not self.get_player(cached_parent_id):
+            # Previously linked to a native player that hasn't registered yet
+            # Use longer delay to give native providers time to start up
+            delay = 30.0
+            self.logger.debug(
+                "Protocol player %s waiting for cached parent %s (30s delay)",
+                player_id,
+                cached_parent_id,
+            )
+        else:
+            # Standard delay for protocol player discovery
+            # Allows time for other protocols and native players to register
+            delay = 10.0
+
+        # Schedule evaluation after the delay
+        handle = self.mass.loop.call_later(
+            delay,
+            lambda: self.mass.create_task(self._delayed_protocol_evaluation(player_id)),
+        )
+        self._pending_protocol_evaluations[player_id] = handle
+
+    async def _delayed_protocol_evaluation(self, player_id: str) -> None:
+        """
+        Perform delayed protocol evaluation.
+
+        Called after a delay to allow all protocol players for a device to register.
+        Decides whether to create a universal player, join an existing one, or
+        promote a single protocol player directly.
+        """
+        self._pending_protocol_evaluations.pop(player_id, None)
+
+        protocol_player = self.get_player(player_id)
+        if not protocol_player or protocol_player.protocol_parent_id:
+            return
+
+        protocol_domain = protocol_player.provider.domain
+
+        # Check if there's an existing universal player we should join
+        if existing_universal := self._find_matching_universal_player(protocol_player):
+            await self._add_protocol_to_existing_universal(
+                existing_universal, protocol_player, protocol_domain
+            )
+            return
+
+        # Find all protocol players that match this device's identifiers
+        matching_protocols = self._find_matching_protocol_players(protocol_player)
+
+        # Create or update UniversalPlayer for all protocol players
+        await self._create_or_update_universal_player(matching_protocols)
+
+    def _find_matching_protocol_players(self, protocol_player: Player) -> list[Player]:
+        """
+        Find all protocol players that match the same device as the given player.
+
+        Searches through all registered protocol players to find ones that share
+        identifiers (MAC, IP, UUID) with the given player, indicating they represent
+        the same physical device.
+        """
+        matching = [protocol_player]
+
+        for other_player in self.all_players(return_protocol_players=True):
+            if other_player.player_id == protocol_player.player_id:
+                continue
+            if other_player.state.type != PlayerType.PROTOCOL:
+                continue
+            if other_player.protocol_parent_id:
+                continue
+            if self._identifiers_match(protocol_player, other_player):
+                matching.append(other_player)
+
+        return matching
+
+    def _find_matching_universal_player(self, protocol_player: Player) -> Player | None:
+        """Find an existing universal player that matches this protocol player."""
+        for player in self._players.values():
+            if player.provider.domain != "universal_player":
+                continue
+            if self._identifiers_match(protocol_player, player, ""):
+                return player
+        return None
+
+    async def _add_protocol_to_existing_universal(
+        self, universal_player: Player, protocol_player: Player, protocol_domain: str
+    ) -> None:
+        """Add a protocol player to an existing universal player."""
+        self._add_protocol_link(universal_player, protocol_player, protocol_domain)
+
+        if isinstance(universal_player, UniversalPlayer):
+            universal_player.add_protocol_player(protocol_player.player_id)
+            for conn_type, value in protocol_player.device_info.identifiers.items():
+                universal_player.device_info.add_identifier(conn_type, value)
+            # Update model/manufacturer if universal player has generic values
+            self._update_universal_device_info(universal_player, protocol_player)
+            # Update availability from protocol players
+            universal_player.update_from_protocol_players()
+
+            # Persist all player data (protocol IDs, identifiers, device info) to config
+            for provider in self.mass.get_providers(ProviderType.PLAYER):
+                if provider.domain == "universal_player":
+                    await cast("UniversalPlayerProvider", provider)._save_player_data(
+                        universal_player.player_id, universal_player
+                    )
+                    break
+
+        protocol_player.update_state()
+        universal_player.update_state()
+
+    def _update_universal_device_info(
+        self, universal_player: UniversalPlayer, protocol_player: Player
+    ) -> None:
+        """
+        Update universal player's device info from protocol player if needed.
+
+        When a universal player is restored from config, it has generic device info
+        (model="Universal Player", manufacturer="Music Assistant"). This method
+        updates those values from a protocol player that has real device info.
+        """
+        # Check if universal player has generic device info (from restore)
+        device_info = universal_player.device_info
+        protocol_info = protocol_player.device_info
+
+        # Update model if universal player has generic value
+        if device_info.model in (None, "Universal Player") and protocol_info.model:
+            device_info.model = protocol_info.model
+
+        # Update manufacturer if universal player has generic value
+        if device_info.manufacturer in (None, "Music Assistant") and protocol_info.manufacturer:
+            device_info.manufacturer = protocol_info.manufacturer
+
+    def _save_universal_player_data(self, universal_player: UniversalPlayer) -> None:
+        """
+        Save universal player data to config via background task.
+
+        This is a helper to persist player data from synchronous code.
+        """
+
+        async def _do_save() -> None:
+            for provider in self.mass.get_providers(ProviderType.PLAYER):
+                if provider.domain == "universal_player":
+                    await cast("UniversalPlayerProvider", provider)._save_player_data(
+                        universal_player.player_id, universal_player
+                    )
+                    break
+
+        self.mass.create_task(_do_save())
+
+    def _link_protocols_to_universal(
+        self, universal_player: Player, protocol_players: list[Player]
+    ) -> None:
+        """Link protocol players to a universal player, cleaning up existing links."""
+        for player in protocol_players:
+            # Clean up if linked to another player
+            if player.protocol_parent_id:
+                if parent := self.get_player(player.protocol_parent_id):
+                    self._remove_protocol_link(parent, player.player_id)
+                player.set_protocol_parent_id(None)
+            # Link to universal player
+            self._add_protocol_link(universal_player, player, player.provider.domain)
+            player.update_state()
+
+        # Update availability from protocol players
+        if isinstance(universal_player, UniversalPlayer):
+            universal_player.update_from_protocol_players()
+
+    async def _create_or_update_universal_player(self, protocol_players: list[Player]) -> None:
+        """
+        Create or update a UniversalPlayer for a set of protocol players.
+
+        Delegates to the universal player provider which handles orchestration,
+        locking, and player creation. The controller then links the protocols
+        to the universal player.
+        """
+        # Get the universal_player provider
+        universal_provider: UniversalPlayerProvider | None = None
+        for provider in self.mass.get_providers(ProviderType.PLAYER):
+            if provider.domain == "universal_player":
+                universal_provider = cast("UniversalPlayerProvider", provider)
+                break
+
+        if not universal_provider:
+            return
+
+        # Delegate to provider - it handles locking, create/update decision, etc.
+        universal_player = await universal_provider.ensure_universal_player_for_protocols(
+            protocol_players
+        )
+
+        if not universal_player:
+            return
+
+        # Link the protocols to the universal player (controller manages cross-provider state)
+        self._link_protocols_to_universal(universal_player, protocol_players)
+        universal_player.update_state()
+
+    def _try_link_protocols_to_native(self, native_player: Player) -> None:
+        """Try to link protocol players to a native player."""
+        # First, check if there's a universal player for this device that should be replaced
+        self._check_replace_universal_player(native_player)
+
+        # Look for protocol players that should be linked
+        for protocol_player in self.all_players(return_protocol_players=True):
+            if protocol_player.state.type != PlayerType.PROTOCOL:
+                continue
+            if protocol_player.protocol_parent_id:
+                # Already linked to a parent (could be this native player after replacement)
+                continue
+
+            protocol_domain = protocol_player.provider.domain
+            if self._identifiers_match(native_player, protocol_player, protocol_domain):
+                self._add_protocol_link(native_player, protocol_player, protocol_domain)
+                protocol_player.update_state()
+                native_player.update_state()
+
+        # Proactively recover disabled/missing protocols from config
+        # This ensures disabled protocols show up in the UI so they can be re-enabled
+        self._recover_cached_protocol_links(native_player)
+
+    def _check_replace_universal_player(self, native_player: Player) -> None:
+        """Check if a universal player should be replaced by this native player."""
+        # Skip if native_player is itself a universal player (prevent self-replacement)
+        if native_player.provider.domain == "universal_player":
+            return
+
+        # Look for universal players that match this native player
+        for player in list(self._players.values()):
+            if player.provider.domain != "universal_player":
+                continue
+            if not self._identifiers_match(native_player, player, ""):
+                continue
+
+            # Transfer all protocol links from universal player to native player
+            for linked in list(player.linked_output_protocols):
+                if protocol_player := self.get_player(linked.output_protocol_id):
+                    protocol_player.set_protocol_parent_id(None)
+                    domain = linked.protocol_domain or protocol_player.provider.domain
+                    self._add_protocol_link(native_player, protocol_player, domain)
+                    protocol_player.update_state()
+
+            player.set_linked_output_protocols([])
+            native_player.update_state()
+
+            # Remove the now-obsolete universal player
+            self.mass.create_task(self.unregister(player.player_id, permanent=True))
+
+    def _add_protocol_link(
+        self, native_player: Player, protocol_player: Player, protocol_domain: str
+    ) -> None:
+        """Add a protocol link from native player to protocol player."""
+        # Remove any existing link for the same protocol domain
+        updated_protocols = [
+            link
+            for link in native_player.linked_output_protocols
+            if link.protocol_domain != protocol_domain
+        ]
+
+        # Get priority for this protocol
+        priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
+
+        # Add the new link
+        updated_protocols.append(
+            OutputProtocol(
+                output_protocol_id=protocol_player.player_id,
+                name=protocol_player.provider.name,
+                protocol_domain=protocol_domain,
+                priority=priority,
+            )
+        )
+        native_player.set_linked_output_protocols(updated_protocols)
+
+        # Set protocol player's parent
+        protocol_player.set_protocol_parent_id(native_player.player_id)
+
+        # Persist linked protocol IDs to config for fast restart
+        # (only for non-universal players, as universal players handle this themselves)
+        if native_player.provider.domain != "universal_player":
+            self._save_linked_protocol_ids(native_player)
+            # Also save the parent ID on the protocol player for reverse lookup on restart
+            self._save_protocol_parent_id(protocol_player.player_id, native_player.player_id)
+
+    def _remove_protocol_link(
+        self, native_player: Player, protocol_player_id: str, permanent: bool = False
+    ) -> None:
+        """
+        Remove a protocol link.
+
+        :param native_player: The parent player to remove the link from.
+        :param protocol_player_id: The protocol player ID to unlink.
+        :param permanent: If True, also removes the protocol ID from the cached list.
+            Use this when the protocol player config is being deleted. If False,
+            the protocol ID remains in the cache so it can be shown as disabled
+            and re-enabled later.
+        """
+        updated_protocols = [
+            link
+            for link in native_player.linked_output_protocols
+            if link.output_protocol_id != protocol_player_id
+        ]
+        native_player.set_linked_output_protocols(updated_protocols)
+
+        # Clear parent reference on protocol player if it still exists
+        if protocol_player := self.get_player(protocol_player_id):
+            if protocol_player.protocol_parent_id == native_player.player_id:
+                protocol_player.set_protocol_parent_id(None)
+
+        # Update persisted linked protocol IDs and clear cached parent
+        if native_player.provider.domain != "universal_player":
+            if permanent:
+                # Permanently remove from cache (player config is being deleted)
+                self._remove_protocol_id_from_cache(native_player.player_id, protocol_player_id)
+            # Note: we don't call _save_linked_protocol_ids here anymore for non-permanent
+            # removals because the merge approach will preserve the ID in the cache
+            self._clear_protocol_parent_id(protocol_player_id)
+
+    def _save_linked_protocol_ids(self, native_player: Player) -> None:
+        """
+        Save linked protocol IDs to config for persistence across restarts.
+
+        This method merges active protocol IDs with existing cached IDs to preserve
+        disabled protocol players in the cache. This allows disabled protocols to be
+        shown in the UI so they can be re-enabled.
+        """
+        conf_key = (
+            f"{CONF_PLAYERS}/{native_player.player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
+        )
+        # Get existing cached IDs to preserve disabled protocols
+        existing_ids: list[str] = self.mass.config.get(conf_key, [])
+        # Get currently active protocol IDs
+        active_ids = {link.output_protocol_id for link in native_player.linked_output_protocols}
+        # Merge: keep existing IDs and add any new active ones
+        merged_ids = list(existing_ids)
+        for protocol_id in active_ids:
+            if protocol_id not in merged_ids:
+                merged_ids.append(protocol_id)
+        self.mass.config.set(conf_key, merged_ids)
+
+    def _get_cached_protocol_ids(self, player_id: str) -> list[str]:
+        """Get cached linked protocol IDs from config."""
+        conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
+        result = self.mass.config.get(conf_key, [])
+        return list(result) if result else []
+
+    def _remove_protocol_id_from_cache(
+        self, parent_player_id: str, protocol_player_id: str
+    ) -> None:
+        """
+        Permanently remove a protocol player ID from the cached linked protocol IDs.
+
+        Use this when a protocol player config is being deleted, not just disabled.
+        """
+        conf_key = f"{CONF_PLAYERS}/{parent_player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
+        cached_ids: list[str] = self.mass.config.get(conf_key, [])
+        if protocol_player_id in cached_ids:
+            cached_ids.remove(protocol_player_id)
+            self.mass.config.set(conf_key, cached_ids)
+
+    def _save_protocol_parent_id(self, protocol_player_id: str, parent_id: str) -> None:
+        """Save the parent ID for a protocol player for persistence across restarts."""
+        conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
+        self.mass.config.set(conf_key, parent_id)
+
+    def _get_cached_protocol_parent_id(self, protocol_player_id: str) -> str | None:
+        """Get cached parent ID for a protocol player from config."""
+        conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
+        result = self.mass.config.get(conf_key, None)
+        return str(result) if result else None
+
+    def _clear_protocol_parent_id(self, protocol_player_id: str) -> None:
+        """Clear the cached parent ID for a protocol player."""
+        conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
+        self.mass.config.set(conf_key, None)
+
+    def _recover_cached_protocol_links(self, native_player: Player) -> None:
+        """
+        Recover protocol links from config for disabled/missing protocols.
+
+        This ensures that disabled protocols show up in the output_protocols list
+        so they can be re-enabled by the user. It also handles the case where
+        protocol players haven't registered yet during startup.
+        """
+        # Get currently linked protocol IDs
+        linked_protocol_ids = {
+            link.output_protocol_id for link in native_player.linked_output_protocols
+        }
+
+        # Get cached protocol IDs from config (includes protocols that were explicitly linked)
+        cached_protocol_ids = self._get_cached_protocol_ids(native_player.player_id)
+
+        # Also check all protocol players that have protocol_parent_id pointing to this player
+        # (this handles disabled protocols that may not be in linked_protocol_player_ids)
+        all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
+        for protocol_id, protocol_config in all_player_configs.items():
+            # Skip if not a protocol player
+            if protocol_config.get("player_type") != "protocol":
+                continue
+            # Check if this protocol has a parent_id pointing to this native player
+            protocol_values = protocol_config.get("values", {})
+            protocol_parent_id = protocol_values.get(CONF_PROTOCOL_PARENT_ID)
+            if protocol_parent_id == native_player.player_id:
+                if protocol_id not in cached_protocol_ids:
+                    cached_protocol_ids.append(protocol_id)
+
+        if not cached_protocol_ids:
+            return
+
+        # Add OutputProtocol entries for any cached protocols that aren't currently linked
+        for protocol_id in cached_protocol_ids:
+            if protocol_id in linked_protocol_ids:
+                continue  # Already linked
+
+            # Get protocol player config to determine the protocol domain and availability
+            protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
+            if not protocol_config:
+                continue
+
+            # Determine protocol domain from provider
+            protocol_provider = protocol_config.get("provider")
+            if not protocol_provider:
+                continue
+
+            # Get provider name for display
+            provider_name = "Protocol"  # Default fallback
+            for provider in self.mass.get_providers(ProviderType.PLAYER):
+                if provider.domain == protocol_provider:
+                    provider_name = provider.name
+                    break
+
+            # Get priority for this protocol
+            priority = PROTOCOL_PRIORITY.get(protocol_provider, 100)
+
+            # Check if protocol player is available (registered)
+            protocol_player = self.get_player(protocol_id)
+            is_available = protocol_player is not None and protocol_player.available
+
+            # Add the OutputProtocol entry
+            native_player.linked_output_protocols.append(
+                OutputProtocol(
+                    output_protocol_id=protocol_id,
+                    name=provider_name,
+                    protocol_domain=protocol_provider,
+                    priority=priority,
+                    is_native=False,
+                    available=is_available,
+                )
+            )
+            self.logger.debug(
+                "Recovered cached protocol link %s -> %s (available: %s)",
+                native_player.player_id,
+                protocol_id,
+                is_available,
+            )
+
+    def _cleanup_protocol_links(self, player: Player) -> None:
+        """Clean up protocol links when a player is permanently removed."""
+        if player.state.type == PlayerType.PROTOCOL:
+            # Protocol player being removed: remove link from parent
+            if parent_id := player.protocol_parent_id:
+                if parent_player := self.get_player(parent_id):
+                    # Use permanent=True to also remove from cached protocol IDs
+                    self._remove_protocol_link(parent_player, player.player_id, permanent=True)
+                    if (
+                        parent_player.provider.domain == "universal_player"
+                        and len(parent_player.linked_output_protocols) == 0
+                    ):
+                        # No protocols left - remove universal player
+                        self.logger.info(
+                            "Universal player %s has no protocols left, removing",
+                            parent_id,
+                        )
+                        self.mass.create_task(
+                            self.mass.players.unregister(parent_id, permanent=True)
+                        )
+                    else:
+                        parent_player.update_state()
+        else:
+            # Native player being removed: schedule protocol evaluation for linked protocols
+            # so they can be assigned to a universal player
+            for linked in player.linked_output_protocols:
+                if protocol_player := self.get_player(linked.output_protocol_id):
+                    protocol_player.set_protocol_parent_id(None)
+                    protocol_player.update_state()
+                    self.logger.debug(
+                        "Native player %s removed - scheduling evaluation for %s",
+                        player.player_id,
+                        protocol_player.player_id,
+                    )
+                    self._schedule_protocol_evaluation(protocol_player)
+
+    def _identifiers_match(
+        self, player_a: Player, player_b: Player, protocol_domain: str = ""
+    ) -> bool:
+        """
+        Check if identifiers match between two players.
+
+        Matching is done by comparing connection identifiers (MAC, serial, UUID).
+        IP address is used as a fallback for protocol players only, because some
+        devices report different virtual MAC addresses per protocol (e.g., DLNA vs
+        AirPlay vs Chromecast may all have different MACs for the same device).
+        """
+        identifiers_a = player_a.device_info.identifiers
+        identifiers_b = player_b.device_info.identifiers
+
+        # Check identifiers in order of reliability
+        # MAC_ADDRESS > SERIAL_NUMBER > UUID
+        for conn_type in (
+            IdentifierType.MAC_ADDRESS,
+            IdentifierType.SERIAL_NUMBER,
+            IdentifierType.UUID,
+        ):
+            val_a = identifiers_a.get(conn_type)
+            val_b = identifiers_b.get(conn_type)
+
+            if not val_a or not val_b:
+                continue
+
+            # Normalize values for comparison
+            val_a_norm = val_a.lower().replace(":", "").replace("-", "")
+            val_b_norm = val_b.lower().replace(":", "").replace("-", "")
+
+            # Direct match
+            if val_a_norm == val_b_norm:
+                return True
+
+            # Special case: Sonos UUID matching with DLNA _MR suffix
+            # Sonos uses RINCON_xxx, DLNA uses RINCON_xxx_MR for Media Renderer
+            if conn_type == IdentifierType.UUID:
+                if val_b_norm.endswith("_mr") and val_b_norm[:-3] == val_a_norm:
+                    return True
+                if val_a_norm.endswith("_mr") and val_a_norm[:-3] == val_b_norm:
+                    return True
+
+        # Fallback: IP address matching for protocol players only
+        # Some devices report different virtual MAC addresses per protocol,
+        # but the IP address remains the same. Only use this for protocol-to-protocol
+        # or protocol-to-universal matching to avoid false positives.
+        if self._can_use_ip_matching(player_a, player_b):
+            ip_a = identifiers_a.get(IdentifierType.IP_ADDRESS)
+            ip_b = identifiers_b.get(IdentifierType.IP_ADDRESS)
+            if ip_a and ip_b and ip_a == ip_b:
+                return True
+
+        return False
+
+    def _can_use_ip_matching(self, player_a: Player, player_b: Player) -> bool:
+        """
+        Check if IP address matching can be used between two players.
+
+        IP matching is only allowed when at least one player is a protocol player
+        or universal player, to avoid false positives between unrelated devices.
+        """
+        # Check if at least one is a protocol player or universal player
+        a_is_protocol = (
+            player_a.type == PlayerType.PROTOCOL or player_a.provider.domain == "universal_player"
+        )
+        b_is_protocol = (
+            player_b.type == PlayerType.PROTOCOL or player_b.provider.domain == "universal_player"
+        )
+        return a_is_protocol or b_is_protocol
+
+    def _select_best_output_protocol(self, player: Player) -> tuple[Player, OutputProtocol | None]:
+        """
+        Select the best available output protocol for a player.
+
+        Selection priority:
+        1. Output protocol that is currently grouped/synced with other players.
+        2. User's preferred output protocol (from player settings).
+        3. Native playback (if player supports PLAY_MEDIA).
+        4. Best available protocol by priority.
+
+        Returns tuple of (target_player, output_protocol).
+        output_protocol is None when using native playback.
+        """
+        self.logger.log(
+            VERBOSE_LOG_LEVEL,
+            "Selecting output protocol for %s",
+            player.state.name,
+        )
+
+        # 1. Check if any output protocol is currently grouped
+        for linked in player.linked_output_protocols:
+            if protocol_player := self.get_player(linked.output_protocol_id):
+                if protocol_player.available and self._is_protocol_grouped(protocol_player):
+                    self.logger.log(
+                        VERBOSE_LOG_LEVEL,
+                        "Selected protocol for %s: %s (grouped)",
+                        player.state.name,
+                        protocol_player.state.name,
+                    )
+                    return protocol_player, linked
+
+        # 2. Check for user's preferred output protocol
+        preferred = self.mass.config.get_raw_player_config_value(
+            player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL, "auto"
+        )
+        if preferred and preferred != "auto":
+            if preferred == "native":
+                if PlayerFeature.PLAY_MEDIA in player.supported_features:
+                    self.logger.log(
+                        VERBOSE_LOG_LEVEL,
+                        "Selected protocol for %s: native (user preference)",
+                        player.state.name,
+                    )
+                    return player, None
+            else:
+                for linked in player.linked_output_protocols:
+                    if linked.output_protocol_id == preferred:
+                        if protocol_player := self.get_player(linked.output_protocol_id):
+                            if protocol_player.available:
+                                self.logger.log(
+                                    VERBOSE_LOG_LEVEL,
+                                    "Selected protocol for %s: %s (user preference)",
+                                    player.state.name,
+                                    protocol_player.state.name,
+                                )
+                                return protocol_player, linked
+                        break
+
+        # 3. Use native playback if available
+        if PlayerFeature.PLAY_MEDIA in player.supported_features:
+            self.logger.log(
+                VERBOSE_LOG_LEVEL, "Selected protocol for %s: native", player.state.name
+            )
+            return player, None
+
+        # 4. Fall back to best protocol by priority
+        for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
+            if protocol_player := self.get_player(linked.output_protocol_id):
+                if protocol_player.available:
+                    self.logger.log(
+                        VERBOSE_LOG_LEVEL,
+                        "Selected protocol for %s: %s (priority-based)",
+                        player.state.name,
+                        protocol_player.state.name,
+                    )
+                    return protocol_player, linked
+
+        raise PlayerCommandFailed(f"Player {player.state.name} has no available output protocols")
+
+    def _get_control_target(
+        self,
+        player: Player,
+        required_feature: PlayerFeature,
+        require_active: bool = False,
+        allow_native: bool = True,
+    ) -> Player | None:
+        """
+        Get the best player(protocol) to send control commands to.
+
+        Prefers the active output protocol, otherwise uses the first available
+        protocol player that supports the needed feature.
+        """
+        # If we have an active protocol, use that
+        if (
+            player.active_output_protocol
+            and player.active_output_protocol != "native"
+            and (protocol_player := self.mass.players.get_player(player.active_output_protocol))
+            and required_feature in protocol_player.supported_features
+        ):
+            return protocol_player
+
+        # if the player natively supports the required feature, use that
+        if allow_native and required_feature in player.supported_features:
+            return player
+
+        # If require_active is set, and no active protocol found, return None
+        if require_active:
+            return None
+
+        # Otherwise, use the first available linked protocol
+        for linked in player.linked_output_protocols:
+            if (
+                (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
+                and protocol_player.available
+                and required_feature in protocol_player.supported_features
+            ):
+                return protocol_player
+
+        return None
+
+    def _is_protocol_grouped(self, protocol_player: Player) -> bool:
+        """
+        Check if a protocol player is currently grouped/synced with other players.
+
+        Used to prefer protocols that are actively participating in a group,
+        ensuring consistent playback across grouped players.
+        """
+        is_grouped = bool(
+            protocol_player.state.synced_to
+            or (
+                protocol_player.state.group_members and len(protocol_player.state.group_members) > 1
+            )
+            or protocol_player.state.active_group
+        )
+        if is_grouped:
+            self.logger.log(
+                VERBOSE_LOG_LEVEL,
+                "Protocol player %s is grouped",
+                protocol_player.state.name,
+            )
+        return is_grouped
+
+    def _translate_members_to_remove_for_protocols(
+        self,
+        parent_player: Player,
+        player_ids: list[str],
+        parent_protocol_player: Player | None,
+        parent_protocol_domain: str | None,
+    ) -> tuple[list[str], list[str]]:
+        """
+        Translate member IDs to remove into protocol and native lists.
+
+        :param parent_player: The parent player to remove members from.
+        :param player_ids: List of visible player IDs to remove.
+        :param parent_protocol_player: The parent's protocol player if available.
+        :param parent_protocol_domain: The parent's protocol domain if available.
+        """
+        self.logger.debug(
+            "Translating members to remove for %s: player_ids=%s, parent_protocol_domain=%s",
+            parent_player.state.name,
+            player_ids,
+            parent_protocol_domain,
+        )
+        protocol_members: list[str] = []
+        native_members: list[str] = []
+
+        for child_player_id in player_ids:
+            child_player = self.get_player(child_player_id)
+            if not child_player:
+                continue
+
+            # Check if this member is in the parent's group via protocol
+            if parent_protocol_domain and parent_protocol_player:
+                child_protocol = child_player.get_linked_protocol(parent_protocol_domain)
+                if (
+                    child_protocol
+                    and child_protocol.output_protocol_id in parent_protocol_player.group_members
+                ):
+                    self.logger.debug(
+                        "Translating removal: %s -> protocol %s",
+                        child_player_id,
+                        child_protocol.output_protocol_id,
+                    )
+                    protocol_members.append(child_protocol.output_protocol_id)
+                    continue
+
+            native_members.append(child_player_id)
+
+        return protocol_members, native_members
+
+    def _filter_protocol_members(self, member_ids: list[str], protocol_player: Player) -> list[str]:
+        """Filter member IDs to only include protocol players from the same domain."""
+        return [
+            pid
+            for pid in member_ids
+            if (p := self.get_player(pid))
+            and p.type == PlayerType.PROTOCOL
+            and p.provider.domain == protocol_player.provider.domain
+        ]
+
+    def _filter_native_members(self, member_ids: list[str], parent_player: Player) -> list[str]:
+        """Filter member IDs to only include players compatible with the parent."""
+        return [
+            pid
+            for pid in member_ids
+            if (p := self.get_player(pid))
+            and (
+                p.provider.instance_id == parent_player.provider.instance_id
+                or pid in parent_player._attr_can_group_with
+                or p.provider.instance_id in parent_player._attr_can_group_with
+            )
+        ]
+
+    def _try_child_preferred_protocol(
+        self,
+        child_player: Player,
+        parent_player: Player,
+    ) -> tuple[str | None, str | None]:
+        """
+        Try to use child's preferred output protocol for grouping.
+
+        Returns tuple of (child_protocol_id, protocol_domain) or (None, None).
+        """
+        child_preferred = self.mass.config.get_raw_player_config_value(
+            child_player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL, "auto"
+        )
+        if not child_preferred or child_preferred in {"auto", "native"}:
+            return None, None
+
+        # Find child's preferred protocol in linked protocols
+        child_protocol = None
+        for linked in child_player.linked_output_protocols:
+            if linked.output_protocol_id == child_preferred:
+                child_protocol = linked
+                break
+
+        if not child_protocol or not child_protocol.available:
+            return None, None
+
+        # Check if parent supports this protocol
+        parent_protocol = parent_player.get_linked_protocol(child_protocol.protocol_domain)
+        if not parent_protocol or not parent_protocol.available:
+            return None, None
+
+        # Check if this protocol supports set_members
+        protocol_player = self.get_player(parent_protocol.output_protocol_id)
+        if (
+            not protocol_player
+            or PlayerFeature.SET_MEMBERS not in protocol_player.state.supported_features
+        ):
+            return None, None
+
+        return child_protocol.output_protocol_id, child_protocol.protocol_domain
+
+    def _can_use_native_grouping(
+        self,
+        child_player: Player,
+        parent_player: Player,
+        parent_supports_native: bool,
+    ) -> bool:
+        """Check if child can be grouped with parent using native grouping."""
+        if not parent_supports_native:
+            return False
+        return (
+            child_player.provider.instance_id == parent_player.provider.instance_id
+            or child_player.player_id in parent_player._attr_can_group_with
+            or child_player.provider.instance_id in parent_player._attr_can_group_with
+        )
+
+    def _try_find_common_protocol(
+        self, child_player: Player, parent_player: Player
+    ) -> tuple[OutputProtocol | None, OutputProtocol | None]:
+        """
+        Find common protocol that supports set_members.
+
+        Returns tuple of (parent_protocol, child_protocol) or (None, None).
+        """
+        for parent_output_protocol in parent_player.output_protocols:
+            if not parent_output_protocol.available:
+                continue
+            child_protocol = child_player.get_linked_protocol(
+                parent_output_protocol.protocol_domain
+            )
+            if not child_protocol or not child_protocol.available:
+                continue
+            protocol_player = self.get_player(parent_output_protocol.output_protocol_id)
+            if (
+                protocol_player
+                and PlayerFeature.SET_MEMBERS in protocol_player.state.supported_features
+            ):
+                return parent_output_protocol, child_protocol
+        return None, None
+
+    def _translate_members_for_protocols(
+        self,
+        parent_player: Player,
+        player_ids: list[str],
+        parent_protocol_player: Player | None,
+        parent_protocol_domain: str | None,
+    ) -> tuple[list[str], list[str], Player | None, str | None]:
+        """
+        Translate member IDs to protocol or native IDs.
+
+        Selection priority when grouping:
+        1. Try child's preferred output protocol (from player settings)
+        2. Try native grouping (if parent and child are compatible)
+        3. Try parent's active output protocol (if any and child supports it)
+        4. Search for common protocol that supports set_members
+        5. Log warning if no option works
+
+        Returns tuple of (protocol_members, native_members, protocol_player, protocol_domain).
+        """
+        protocol_members: list[str] = []
+        native_members: list[str] = []
+        parent_supports_native_grouping = (
+            PlayerFeature.SET_MEMBERS in parent_player.supported_features
+        )
+
+        self.logger.log(
+            VERBOSE_LOG_LEVEL,
+            "Translating members for %s: parent_supports_native=%s, parent_protocol=%s (%s)",
+            parent_player.state.name,
+            parent_supports_native_grouping,
+            parent_protocol_player.state.name if parent_protocol_player else "none",
+            parent_protocol_domain or "none",
+        )
+
+        for child_player_id in player_ids:
+            child_player = self.get_player(child_player_id)
+            if not child_player:
+                continue
+
+            self.logger.log(
+                VERBOSE_LOG_LEVEL,
+                "Processing child %s (type=%s, protocols=%s)",
+                child_player.state.name,
+                child_player.state.type,
+                [p.protocol_domain for p in child_player.output_protocols],
+            )
+
+            # Priority 1: Try child's preferred output protocol
+            # (only if no active protocol or if it matches the active protocol)
+            child_protocol_id, protocol_domain = self._try_child_preferred_protocol(
+                child_player, parent_player
+            )
+            if (
+                child_protocol_id
+                and protocol_domain
+                and (not parent_protocol_domain or protocol_domain == parent_protocol_domain)
+            ):
+                if not parent_protocol_player or parent_protocol_domain != protocol_domain:
+                    parent_protocol = parent_player.get_linked_protocol(protocol_domain)
+                    if parent_protocol:
+                        parent_protocol_player = self.get_player(parent_protocol.output_protocol_id)
+                        parent_protocol_domain = protocol_domain
+                protocol_members.append(child_protocol_id)
+                self.logger.log(
+                    VERBOSE_LOG_LEVEL,
+                    "Using child's preferred protocol %s for %s",
+                    protocol_domain,
+                    child_player.state.name,
+                )
+                continue
+
+            # Priority 2: Try native grouping
+            if self._can_use_native_grouping(
+                child_player, parent_player, parent_supports_native_grouping
+            ):
+                native_members.append(child_player_id)
+                self.logger.log(
+                    VERBOSE_LOG_LEVEL,
+                    "Using native grouping for %s",
+                    child_player.state.name,
+                )
+                continue
+
+            # Priority 3: Try parent's active output protocol (if it supports SET_MEMBERS)
+            if parent_protocol_domain and parent_protocol_player:
+                # Verify the active protocol supports SET_MEMBERS
+                if PlayerFeature.SET_MEMBERS in parent_protocol_player.state.supported_features:
+                    child_protocol = child_player.get_linked_protocol(parent_protocol_domain)
+                    if child_protocol and child_protocol.available:
+                        protocol_members.append(child_protocol.output_protocol_id)
+                        self.logger.log(
+                            VERBOSE_LOG_LEVEL,
+                            "Using parent's active protocol %s for %s",
+                            parent_protocol_domain,
+                            child_player.state.name,
+                        )
+                        continue
+                else:
+                    self.logger.log(
+                        VERBOSE_LOG_LEVEL,
+                        "Parent's active protocol %s does not support SET_MEMBERS, "
+                        "will search for alternative",
+                        parent_protocol_domain,
+                    )
+                    # Clear the parent protocol so Priority 4 can select a new one
+                    parent_protocol_player = None
+                    parent_protocol_domain = None
+
+            # Priority 4: Search for common protocol that supports set_members
+            parent_protocol, child_protocol = self._try_find_common_protocol(
+                child_player, parent_player
+            )
+            if parent_protocol and child_protocol:
+                if (
+                    not parent_protocol_player
+                    or parent_protocol_domain != parent_protocol.protocol_domain
+                ):
+                    parent_protocol_player = self.get_player(parent_protocol.output_protocol_id)
+                    if parent_protocol_player:
+                        parent_protocol_domain = parent_protocol_player.provider.domain
+                protocol_members.append(child_protocol.output_protocol_id)
+                self.logger.log(
+                    VERBOSE_LOG_LEVEL,
+                    "Selected common protocol %s for grouping %s with %s",
+                    parent_protocol.protocol_domain,
+                    child_player.state.name,
+                    parent_player.state.name,
+                )
+                continue
+
+            # Priority 5: No option worked - log warning
+            self.logger.warning(
+                "Cannot group %s with %s: no compatible grouping method found "
+                "(tried: child preferred protocol, native grouping, "
+                "parent active protocol, common protocols)",
+                child_player.state.name,
+                parent_player.state.name,
+            )
+
+        return protocol_members, native_members, parent_protocol_player, parent_protocol_domain
+
+    async def _forward_protocol_set_members(
+        self,
+        parent_player: Player,
+        parent_protocol_player: Player,
+        protocol_members_to_add: list[str],
+        protocol_members_to_remove: list[str],
+    ) -> None:
+        """
+        Forward protocol members to protocol player's set_members and manage active output protocol.
+
+        :param parent_player: The parent player (native/universal).
+        :param parent_protocol_player: The protocol player to forward commands to.
+        :param protocol_members_to_add: Protocol player IDs to add.
+        :param protocol_members_to_remove: Protocol player IDs to remove.
+        """
+        filtered_protocol_add = self._filter_protocol_members(
+            protocol_members_to_add, parent_protocol_player
+        )
+        filtered_protocol_remove = self._filter_protocol_members(
+            protocol_members_to_remove, parent_protocol_player
+        )
+        self.logger.debug(
+            "Protocol grouping on %s: filtered_add=%s, filtered_remove=%s",
+            parent_protocol_player.state.name,
+            filtered_protocol_add,
+            filtered_protocol_remove,
+        )
+
+        if not filtered_protocol_add and not filtered_protocol_remove:
+            return
+
+        # Safety check: verify protocol player supports SET_MEMBERS
+        if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
+            self.logger.error(
+                "Protocol player %s does not support SET_MEMBERS, cannot perform grouping. "
+                "This should have been caught earlier in the flow.",
+                parent_protocol_player.state.name,
+            )
+            return
+
+        self.logger.debug(
+            "Calling set_members on protocol player %s with add=%s, remove=%s",
+            parent_protocol_player.state.name,
+            filtered_protocol_add,
+            filtered_protocol_remove,
+        )
+        await parent_protocol_player.set_members(
+            player_ids_to_add=filtered_protocol_add or None,
+            player_ids_to_remove=filtered_protocol_remove or None,
+        )
+
+        # If we added members via this protocol, set it as the active output protocol
+        # This ensures playback will be restarted on the correct protocol if needed
+        if (
+            filtered_protocol_add
+            and parent_player.active_output_protocol != parent_protocol_player.player_id
+        ):
+            self.logger.debug(
+                "Setting active output protocol to %s after grouping members",
+                parent_protocol_player.player_id,
+            )
+            parent_player.set_active_output_protocol(parent_protocol_player.player_id)
+        self.logger.debug(
+            "After set_members, protocol player %s state: group_members=%s, synced_to=%s",
+            parent_protocol_player.state.name,
+            parent_protocol_player.group_members,
+            parent_protocol_player.synced_to,
+        )
+
+        # Clear active protocol if all protocol members were removed
+        if (
+            filtered_protocol_remove
+            and not filtered_protocol_add
+            and parent_protocol_player.player_id == parent_player.active_output_protocol
+        ):
+            # Check group_members count to see if we should clear
+            members_count = len(parent_protocol_player.group_members)
+            self.logger.debug(
+                "Checking if should clear active protocol on %s: "
+                "protocol_members_count=%s, removing=%s",
+                parent_player.state.name,
+                members_count,
+                filtered_protocol_remove,
+            )
+            if members_count <= 1 and parent_player.state.playback_state == PlaybackState.IDLE:
+                parent_player.set_active_output_protocol(None)
+
+        # Clear active output protocol on removed child players
+        if filtered_protocol_remove:
+            for child_protocol_id in filtered_protocol_remove:
+                if child_protocol := self.get_player(child_protocol_id):
+                    if child_protocol.protocol_parent_id:
+                        if child_player := self.get_player(child_protocol.protocol_parent_id):
+                            if child_player.active_output_protocol == child_protocol_id:
+                                child_player.set_active_output_protocol(None)
diff --git a/music_assistant/controllers/players/sync_groups.py b/music_assistant/controllers/players/sync_groups.py
deleted file mode 100644 (file)
index 7ded94d..0000000
+++ /dev/null
@@ -1,608 +0,0 @@
-"""
-Controller for (provider specific) SyncGroup players.
-
-A SyncGroup player is a virtual player that automatically groups multiple players
-together in a sync group, where one player is the sync leader
-and the other players are synced to that leader.
-"""
-
-from __future__ import annotations
-
-import asyncio
-from copy import deepcopy
-from typing import TYPE_CHECKING, cast
-
-import shortuuid
-from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
-from music_assistant_models.constants import PLAYER_CONTROL_NONE
-from music_assistant_models.enums import (
-    ConfigEntryType,
-    PlaybackState,
-    PlayerFeature,
-    PlayerType,
-    ProviderFeature,
-)
-from music_assistant_models.errors import UnsupportedFeaturedException
-from music_assistant_models.player import DeviceInfo, PlayerMedia, PlayerSource
-from propcache import under_cached_property as cached_property
-
-from music_assistant.constants import (
-    CONF_CROSSFADE_DURATION,
-    CONF_DYNAMIC_GROUP_MEMBERS,
-    CONF_ENABLE_ICY_METADATA,
-    CONF_FLOW_MODE,
-    CONF_GROUP_MEMBERS,
-    CONF_HTTP_PROFILE,
-    CONF_OUTPUT_CODEC,
-    CONF_SAMPLE_RATES,
-    CONF_SMART_FADES_MODE,
-    SYNCGROUP_PREFIX,
-)
-from music_assistant.models.player import GroupPlayer, Player
-
-if TYPE_CHECKING:
-    from music_assistant.models.player_provider import PlayerProvider
-
-    from .player_controller import PlayerController
-
-
-SUPPORT_DYNAMIC_LEADER = {
-    # providers that support dynamic leader selection in a syncgroup
-    # meaning that if you would remove the current leader from the group,
-    # the provider will automatically select a new leader from the remaining members
-    # and the music keeps playing uninterrupted.
-    "airplay",
-    "squeezelite",
-    "snapcast",
-    # TODO: Get this working with Sonos as well (need to handle range requests)
-}
-
-OPTIONAL_FEATURES = {
-    PlayerFeature.ENQUEUE,
-    PlayerFeature.GAPLESS_PLAYBACK,
-    PlayerFeature.NEXT_PREVIOUS,
-    PlayerFeature.PAUSE,
-    PlayerFeature.PLAY_ANNOUNCEMENT,
-    PlayerFeature.SEEK,
-    PlayerFeature.SELECT_SOURCE,
-    PlayerFeature.VOLUME_MUTE,
-    PlayerFeature.MULTI_DEVICE_DSP,
-}
-
-
-class SyncGroupPlayer(GroupPlayer):
-    """Helper class for a (provider specific) SyncGroup player."""
-
-    _attr_type: PlayerType = PlayerType.GROUP
-    sync_leader: Player | None = None
-    """The active sync leader player for this syncgroup."""
-
-    @cached_property
-    def is_dynamic(self) -> bool:
-        """Return if the player is a dynamic group player."""
-        return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False))
-
-    def __init__(
-        self,
-        provider: PlayerProvider,
-        player_id: str,
-    ) -> None:
-        """Initialize GroupPlayer instance."""
-        super().__init__(provider, player_id)
-        self._attr_name = self.config.name or self.config.default_name or f"SyncGroup {player_id}"
-        self._attr_available = True
-        self._attr_powered = False  # group players are always powered off by default
-        self._attr_device_info = DeviceInfo(model="Sync Group", manufacturer=provider.name)
-        self._attr_supported_features = {
-            PlayerFeature.POWER,
-            PlayerFeature.VOLUME_SET,
-        }
-
-    async def on_config_updated(self) -> None:
-        """Handle logic when the player is loaded or updated."""
-        # Config is only available after the player was registered
-        self._cache.clear()  # clear to prevent loading old is_dynamic
-        static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
-        if self.is_dynamic:
-            self._attr_static_group_members = []
-            self._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
-        else:
-            self._attr_static_group_members = static_members.copy()
-            self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS)
-        if not self.powered:
-            self._attr_group_members = static_members.copy()
-
-    @property
-    def supported_features(self) -> set[PlayerFeature]:
-        """Return the supported features of the player."""
-        members = self.group_members
-        reference_player: Player | None = self.sync_leader or (
-            self.mass.players.get(members[0]) if members else None
-        )
-        if reference_player:
-            base_features = self._attr_supported_features.copy()
-            # add features supported by the sync leader
-            for feature in OPTIONAL_FEATURES:
-                if feature in reference_player.supported_features:
-                    base_features.add(feature)
-            return base_features
-        return self._attr_supported_features
-
-    @property
-    def playback_state(self) -> PlaybackState:
-        """Return the current playback state of the player."""
-        if self.powered:
-            return self.sync_leader.playback_state if self.sync_leader else PlaybackState.IDLE
-        return PlaybackState.IDLE
-
-    @property
-    def requires_flow_mode(self) -> bool:
-        """Return if the player needs flow mode."""
-        if leader := self.sync_leader:
-            return leader.requires_flow_mode
-        return False
-
-    @property
-    def elapsed_time(self) -> float | None:
-        """Return the elapsed time in (fractional) seconds of the current track (if any)."""
-        return self.sync_leader.elapsed_time if self.sync_leader else None
-
-    @property
-    def elapsed_time_last_updated(self) -> float | None:
-        """Return when the elapsed time was last updated."""
-        return self.sync_leader.elapsed_time_last_updated if self.sync_leader else None
-
-    @property
-    def _current_media(self) -> PlayerMedia | None:
-        """Return the current media item (if any) loaded in the player."""
-        return self.sync_leader._current_media if self.sync_leader else self._attr_current_media
-
-    @property
-    def _active_source(self) -> str | None:
-        """Return the active source id (if any) of the player."""
-        return self.sync_leader._active_source if self.sync_leader else self._attr_active_source
-
-    @property
-    def _source_list(self) -> list[PlayerSource]:
-        """Return list of available (native) sources for this player."""
-        if self.sync_leader:
-            return self.sync_leader._source_list
-        return []
-
-    @property
-    def can_group_with(self) -> set[str]:
-        """
-        Return the id's of players this player can group with.
-
-        This should return set of player_id's this player can group/sync with
-        or just the provider's instance_id if all players can group with each other.
-        """
-        if self.is_dynamic and (leader := self.sync_leader):
-            return leader.can_group_with
-        if self.is_dynamic:
-            return {self.provider.instance_id}
-        return set()
-
-    async def get_config_entries(
-        self,
-        action: str | None = None,
-        values: dict[str, ConfigValueType] | None = None,
-    ) -> list[ConfigEntry]:
-        """Return all (provider/player specific) Config Entries for the given player (if any)."""
-        entries: list[ConfigEntry] = [
-            # syncgroup specific entries
-            ConfigEntry(
-                key=CONF_GROUP_MEMBERS,
-                type=ConfigEntryType.STRING,
-                multi_value=True,
-                label="Group members",
-                default_value=[],
-                description="Select all players you want to be part of this group",
-                required=False,  # needed for dynamic members (which allows empty members list)
-                options=[
-                    ConfigValueOption(x.display_name, x.player_id)
-                    for x in self.provider.players
-                    if x.type != PlayerType.GROUP
-                ],
-            ),
-            ConfigEntry(
-                key="dynamic_members",
-                type=ConfigEntryType.BOOLEAN,
-                label="Enable dynamic members",
-                description="Allow (un)joining members dynamically, so the group more or less "
-                "behaves the same like manually syncing players together, "
-                "with the main difference being that the group player will hold the queue.",
-                default_value=False,
-                required=False,
-            ),
-        ]
-        # combine base group entries with (base) player entries for this player type
-        child_player = next((x for x in self.provider.players if x.type == PlayerType.PLAYER), None)
-        if child_player:
-            allowed_conf_entries = (
-                CONF_HTTP_PROFILE,
-                CONF_ENABLE_ICY_METADATA,
-                CONF_CROSSFADE_DURATION,
-                CONF_OUTPUT_CODEC,
-                CONF_FLOW_MODE,
-                CONF_SAMPLE_RATES,
-                CONF_SMART_FADES_MODE,
-            )
-            child_config_entries = await child_player.get_config_entries()
-            entries.extend(
-                [entry for entry in child_config_entries if entry.key in allowed_conf_entries]
-            )
-        return entries
-
-    async def stop(self) -> None:
-        """Send STOP command to given player."""
-        if sync_leader := self.sync_leader:
-            await sync_leader.stop()
-
-    async def play(self) -> None:
-        """Send PLAY command to given player."""
-        if sync_leader := self.sync_leader:
-            await sync_leader.play()
-
-    async def pause(self) -> None:
-        """Send PAUSE command to given player."""
-        if sync_leader := self.sync_leader:
-            await sync_leader.pause()
-
-    async def power(self, powered: bool) -> None:
-        """Handle POWER command to group player."""
-        prev_power = self._attr_powered
-
-        # always stop at power off
-        if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
-            await self.stop()
-            self._attr_current_media = None
-
-        # optimistically set the group state
-        self._attr_powered = powered
-        if prev_power != powered:
-            self.update_state()
-
-        if powered:
-            # ensure static members are present when powering on
-            for static_group_member in self._attr_static_group_members:
-                member_player = self.mass.players.get(static_group_member)
-                if not member_player or not member_player.available or not member_player.enabled:
-                    if static_group_member in self._attr_group_members:
-                        self._attr_group_members.remove(static_group_member)
-                    continue
-                if static_group_member not in self._attr_group_members:
-                    # Always add static members when power(true) is called,
-                    # this will ensure that static members that just became available are added
-                    self._attr_group_members.append(static_group_member)
-            # Select sync leader and handle turn on
-            new_leader = self._select_sync_leader()
-            # handle TURN_ON of the group player by turning on all members
-            for member in self.mass.players.iter_group_members(
-                self, only_powered=False, active_only=False
-            ):
-                await self._handle_member_collisions(member)
-                if not member.powered and member.power_control != PLAYER_CONTROL_NONE:
-                    await self.mass.players._handle_cmd_power(member.player_id, True)
-            # Set up the sync group with the new leader
-            if prev_power and new_leader == self.sync_leader:
-                # Already powered on with same leader, just re-sync members without full transition
-                await self._form_syncgroup()
-            else:
-                await self._handle_leader_transition(new_leader)
-        elif prev_power and not powered:
-            # handle TURN_OFF of the group player by dissolving group and turning off all members
-            await self._dissolve_syncgroup()
-            # turn off all group members
-            for member in self.mass.players.iter_group_members(
-                self, only_powered=True, active_only=True
-            ):
-                if member.powered and member.power_control != PLAYER_CONTROL_NONE:
-                    await self.mass.players._handle_cmd_power(member.player_id, False)
-
-        if not powered:
-            configured_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
-            self._attr_group_members = configured_members.copy()
-            self.sync_leader = None
-        self.update_state()
-
-    async def volume_set(self, volume_level: int) -> None:
-        """Send VOLUME_SET command to given player."""
-        # group volume is already handled in the player manager
-
-    async def play_media(self, media: PlayerMedia) -> None:
-        """Handle PLAY MEDIA on given player."""
-        # power on (which will also resync and add static members if needed)
-        await self.power(True)
-        # simply forward the command to the sync leader
-        if sync_leader := self.sync_leader:
-            await sync_leader.play_media(media)
-            self._attr_current_media = deepcopy(media)
-            self.update_state()
-        else:
-            raise RuntimeError("an empty group cannot play media, consider adding members first")
-
-    async def enqueue_next_media(self, media: PlayerMedia) -> None:
-        """Handle enqueuing of a next media item on the player."""
-        if sync_leader := self.sync_leader:
-            await sync_leader.enqueue_next_media(media)
-
-    async def select_source(self, source: str) -> None:
-        """
-        Handle SELECT SOURCE command on the player.
-
-        Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
-
-        :param source: The source(id) to select, as defined in the source_list.
-        """
-        if sync_leader := self.sync_leader:
-            await sync_leader.select_source(source)
-            self.update_state()
-
-    async def set_members(
-        self,
-        player_ids_to_add: list[str] | None = None,
-        player_ids_to_remove: list[str] | None = None,
-    ) -> None:
-        """Handle SET_MEMBERS command on the player."""
-        if not self.is_dynamic:
-            raise UnsupportedFeaturedException(
-                f"Group {self.display_name} does not allow dynamically adding/removing members!"
-            )
-        # handle additions
-        final_players_to_add: list[str] = []
-        for player_id in player_ids_to_add or []:
-            if player_id in self._attr_group_members:
-                continue
-            if player_id == self.player_id:
-                raise UnsupportedFeaturedException(
-                    f"Cannot add {self.display_name} to itself as a member!"
-                )
-            self._attr_group_members.append(player_id)
-            final_players_to_add.append(player_id)
-        # handle removals
-        final_players_to_remove: list[str] = []
-        for player_id in player_ids_to_remove or []:
-            if player_id not in self._attr_group_members:
-                continue
-            if player_id == self.player_id:
-                raise UnsupportedFeaturedException(
-                    f"Cannot remove {self.display_name} from itself as a member!"
-                )
-            self._attr_group_members.remove(player_id)
-            final_players_to_remove.append(player_id)
-        self.update_state()
-        if not self.powered:
-            # Don't need to do anything else if the group is powered off
-            # The syncing will be done once powered on
-            return
-        next_leader = self._select_sync_leader()
-        prev_leader = self.sync_leader
-
-        if prev_leader and next_leader is None:
-            # Edge case: we no longer have any members in the group (and thus no leader)
-            await self._handle_leader_transition(None)
-        elif prev_leader != next_leader:
-            # Edge case: we had changed the leader (or just got one)
-            await self._handle_leader_transition(next_leader)
-        elif self.sync_leader and (player_ids_to_add or player_ids_to_remove):
-            # if the group still has the same leader, we need to (re)sync the members
-            # Handle collisions for newly added players
-            for player_id in final_players_to_add:
-                if player := self.mass.players.get(player_id):
-                    await self._handle_member_collisions(player)
-
-            await self.sync_leader.set_members(
-                player_ids_to_add=final_players_to_add,
-                player_ids_to_remove=final_players_to_remove,
-            )
-
-    async def _form_syncgroup(self) -> None:
-        """Form syncgroup by syncing all (possible) members."""
-        if self.sync_leader is None:
-            # This is an empty group, leader will be selected once a member is added
-            self._attr_group_members = []
-            self.update_state()
-            return
-        # ensure the sync leader is first in the list
-        self._attr_group_members = [
-            self.sync_leader.player_id,
-            *[x for x in self._attr_group_members if x != self.sync_leader.player_id],
-        ]
-        self.update_state()
-        members_to_sync: list[str] = []
-        members_to_remove: list[str] = []
-        for member in self.mass.players.iter_group_members(self, active_only=False):
-            # Handle collisions before attempting to sync
-            await self._handle_member_collisions(member)
-
-            if member.synced_to and member.synced_to != self.sync_leader.player_id:
-                # ungroup first
-                await member.ungroup()
-            if member.player_id == self.sync_leader.player_id:
-                # skip sync leader
-                continue
-            # Always add to members_to_sync to prevent them from being removed below
-            members_to_sync.append(member.player_id)
-        for former_members in self.sync_leader.group_members:
-            if (
-                former_members not in members_to_sync
-            ) and former_members != self.sync_leader.player_id:
-                members_to_remove.append(former_members)
-        if members_to_sync or members_to_remove:
-            await self.sync_leader.set_members(members_to_sync, members_to_remove)
-
-    async def _dissolve_syncgroup(self) -> None:
-        """Dissolve the current syncgroup by ungrouping all members and restoring leader queue."""
-        if sync_leader := self.sync_leader:
-            # dissolve the temporary syncgroup from the sync leader
-            sync_children = [x for x in sync_leader.group_members if x != sync_leader.player_id]
-            if sync_children:
-                await sync_leader.set_members(player_ids_to_remove=sync_children)
-            # Reset the leaders queue since it is no longer part of this group
-            sync_leader.update_state()
-
-    async def _handle_leader_transition(self, new_leader: Player | None) -> None:
-        """Handle transition from current leader to new leader."""
-        prev_leader = self.sync_leader
-        was_playing = False
-
-        if (
-            prev_leader
-            and new_leader
-            and prev_leader != new_leader
-            and self.provider.domain in SUPPORT_DYNAMIC_LEADER
-        ):
-            # provider supports dynamic leader selection, so just remove/add members
-            await prev_leader.ungroup()
-            self.sync_leader = new_leader
-            # allow some time to propagate the changes before resyncing
-            await asyncio.sleep(2)
-            await self._form_syncgroup()
-            return
-
-        if prev_leader:
-            # Save current media and playback state for potential restart
-            was_playing = self.playback_state == PlaybackState.PLAYING
-            # Stop current playback and dissolve existing group
-            await self.stop()
-            await self._dissolve_syncgroup()
-            # allow some time to propagate the changes before resyncing
-            await asyncio.sleep(2)
-
-        # Set new leader
-        self.sync_leader = new_leader
-
-        if new_leader:
-            # form a syncgroup with the new leader
-            await self._form_syncgroup()
-
-            # Restart playback if requested and we have media to play
-            if was_playing:
-                await self.mass.players._handle_cmd_resume(self.player_id)
-        else:
-            # We have no leader anymore, send update since we stopped playback
-            self.update_state()
-
-    def _select_sync_leader(self) -> Player | None:
-        """Select the active sync leader player for a syncgroup."""
-        if self.sync_leader and self.sync_leader.player_id in self.group_members:
-            # Don't change the sync leader if we already have one
-            return self.sync_leader
-        for prefer_sync_leader in (True, False):
-            for child_player in self.mass.players.iter_group_members(self):
-                if prefer_sync_leader and child_player.synced_to:
-                    # prefer the first player that already has sync children
-                    continue
-                if child_player.active_group not in (
-                    None,
-                    self.player_id,
-                    child_player.player_id,
-                ):
-                    # this should not happen (because its already handled in the power on logic),
-                    # but guard it just in case bad things happen
-                    continue
-                return child_player
-        return None
-
-    async def _handle_member_collisions(self, member: Player) -> None:
-        """Handle collisions when adding a member to the sync group."""
-        active_groups = member.active_groups
-        for group in active_groups:
-            if group == self.player_id:
-                continue
-            # collision: child player is part another group that is already active !
-            # solve this by trying to leave the group first
-            if other_group := self.mass.players.get(group):
-                if (
-                    other_group.supports_feature(PlayerFeature.SET_MEMBERS)
-                    and member.player_id not in other_group.static_group_members
-                ):
-                    await other_group.set_members(player_ids_to_remove=[member.player_id])
-                else:
-                    # if the other group does not support SET_MEMBERS or it is a static
-                    # member, we need to power it off to leave the group
-                    await other_group.power(False)
-        if (
-            member.synced_to is not None
-            and self.sync_leader
-            and member.synced_to != self.sync_leader.player_id
-            and (synced_to_player := self.mass.players.get(member.synced_to))
-            and member.player_id in synced_to_player.group_members
-        ):
-            # collision: child player is synced to another player and still in that group
-            # ungroup it first
-            await synced_to_player.set_members(player_ids_to_remove=[member.player_id])
-
-
-class SyncGroupController:
-    """Controller managing SyncGroup players."""
-
-    def __init__(self, player_controller: PlayerController) -> None:
-        """Initialize SyncGroupController."""
-        self.player_controller = player_controller
-        self.mass = player_controller.mass
-
-    async def create_group_player(
-        self, provider: PlayerProvider, name: str, members: list[str], dynamic: bool = True
-    ) -> Player:
-        """
-        Create new SyncGroup Player.
-
-        :param provider: The provider to create the group player for
-        :param name: Name of the group player
-        :param members: List of player ids to add to the group
-        :param dynamic: Whether the group is dynamic (members can change)
-        """
-        # default implementation for providers that support syncing players
-        if ProviderFeature.SYNC_PLAYERS not in provider.supported_features:
-            # the frontend should already prevent this, but just in case
-            raise UnsupportedFeaturedException(
-                f"Provider {provider.name} does not support player syncing!"
-            )
-        # Create a new syncgroup player with the given members
-        members = [x for x in members if x in [y.player_id for y in provider.players]]
-        player_id = f"{SYNCGROUP_PREFIX}{shortuuid.random(8).lower()}"
-        self.mass.config.create_default_player_config(
-            player_id=player_id,
-            provider=provider.instance_id,
-            player_type=PlayerType.GROUP,
-            name=name,
-            enabled=True,
-            values={
-                CONF_GROUP_MEMBERS: members,
-                CONF_DYNAMIC_GROUP_MEMBERS: dynamic,
-            },
-        )
-        return await self._register_syncgroup_player(player_id, provider)
-
-    async def remove_group_player(self, player_id: str) -> None:
-        """
-        Remove a group player.
-
-        :param player_id: ID of the group player to remove.
-        """
-        # we simply permanently unregister the syncgroup player and wipe its config
-        await self.mass.players.unregister(player_id, True)
-
-    async def _register_syncgroup_player(self, player_id: str, provider: PlayerProvider) -> Player:
-        """Register a syncgroup player."""
-        syncgroup = SyncGroupPlayer(provider, player_id)
-        await self.mass.players.register_or_update(syncgroup)
-        return syncgroup
-
-    async def on_provider_loaded(self, provider: PlayerProvider) -> None:
-        """Handle logic when a provider is loaded."""
-        # register existing syncgroup players for this provider
-        for player_conf in await self.mass.config.get_player_configs(provider.instance_id):
-            if player_conf.player_id.startswith(SYNCGROUP_PREFIX):
-                await self._register_syncgroup_player(player_conf.player_id, provider)
-
-    async def on_provider_unload(self, provider: PlayerProvider) -> None:
-        """Handle logic when a provider is (about to get) unloaded."""
-        # unregister existing syncgroup players for this provider
-        for player in self.mass.players.all(
-            provider_filter=provider.instance_id, return_sync_groups=True
-        ):
-            if player.player_id.startswith(SYNCGROUP_PREFIX):
-                await self.mass.players.unregister(player.player_id, False)
index 85dcdc02f5fb43b06a40b1fb2b7abcaad29f1b36..713499c1db393dad9b87ef52e7fd8202daea5d46 100644 (file)
@@ -61,7 +61,7 @@ from music_assistant.constants import (
     SILENCE_FILE,
     VERBOSE_LOG_LEVEL,
 )
-from music_assistant.controllers.players.player_controller import AnnounceData
+from music_assistant.controllers.players.helpers import AnnounceData
 from music_assistant.controllers.streams.smart_fades import SmartFadesMixer
 from music_assistant.controllers.streams.smart_fades.analyzer import SmartFadesAnalyzer
 from music_assistant.controllers.streams.smart_fades.fades import SMART_CROSSFADE_DURATION
@@ -336,12 +336,12 @@ class StreamsController(CoreController):
             static_routes=[
                 (
                     "*",
-                    "/flow/{session_id}/{queue_id}/{queue_item_id}.{fmt}",
+                    "/flow/{session_id}/{queue_id}/{queue_item_id}/{player_id}.{fmt}",
                     self.serve_queue_flow_stream,
                 ),
                 (
                     "*",
-                    "/single/{session_id}/{queue_id}/{queue_item_id}.{fmt}",
+                    "/single/{session_id}/{queue_id}/{queue_item_id}/{player_id}.{fmt}",
                     self.serve_queue_item_stream,
                 ),
                 (
@@ -371,14 +371,10 @@ class StreamsController(CoreController):
 
     async def resolve_stream_url(
         self,
-        session_id: str,
-        queue_item: QueueItem,
-        flow_mode: bool = False,
-        player_id: str | None = None,
+        player_id: str,
+        media: PlayerMedia,
     ) -> str:
-        """Resolve the stream URL for the given QueueItem."""
-        if not player_id:
-            player_id = queue_item.queue_id
+        """Resolve the stream URL for the given PlayerMedia."""
         conf_output_codec = await self.mass.config.get_player_config_value(
             player_id, CONF_OUTPUT_CODEC, default="flac", return_type=str
         )
@@ -387,8 +383,15 @@ class StreamsController(CoreController):
         # handle raw pcm without exact format specifiers
         if output_codec.is_pcm() and ";" not in fmt:
             fmt += f";codec=pcm;rate={44100};bitrate={16};channels={2}"
+        extra_data = media.custom_data or {}
+        flow_mode = extra_data.get("flow_mode", False)
+        session_id = extra_data.get("session_id")
+        queue_item_id = media.queue_item_id
+        if not session_id or not queue_item_id:
+            raise InvalidDataError("Can not resolve stream URL: Invalid PlayerMedia data")
+        queue_id = media.source_id
         base_path = "flow" if flow_mode else "single"
-        return f"{self._server.base_url}/{base_path}/{session_id}/{queue_item.queue_id}/{queue_item.queue_item_id}.{fmt}"  # noqa: E501
+        return f"{self._server.base_url}/{base_path}/{session_id}/{queue_id}/{queue_item_id}/{player_id}.{fmt}"  # noqa: E501
 
     async def get_plugin_source_url(
         self,
@@ -406,13 +409,14 @@ class StreamsController(CoreController):
         """Stream single queueitem audio to a player."""
         self._log_request(request)
         queue_id = request.match_info["queue_id"]
-        queue = self.mass.player_queues.get(queue_id)
-        if not queue:
+        player_id = request.match_info["player_id"]
+        if not (queue := self.mass.player_queues.get(queue_id)):
             raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}")
         session_id = request.match_info["session_id"]
         if queue.session_id and session_id != queue.session_id:
             raise web.HTTPNotFound(reason=f"Unknown (or invalid) session: {session_id}")
-        queue_player = self.mass.players.get(queue_id)
+        if not (player := self.mass.players.get_player(player_id)):
+            raise web.HTTPNotFound(reason=f"Unknown Player: {player_id}")
         queue_item_id = request.match_info["queue_item_id"]
         queue_item = self.mass.player_queues.get_item(queue_id, queue_item_id)
         if not queue_item:
@@ -430,18 +434,14 @@ class StreamsController(CoreController):
                 raise web.HTTPNotFound(reason=f"No streamdetails for Queue item: {queue_item_id}")
 
         # pick output format based on the streamdetails and player capabilities
-        if not queue_player:
-            raise web.HTTPNotFound(reason=f"Unknown Player: {queue_id}")
-
-        # work out pcm format based on streamdetails
         pcm_format = await self._select_pcm_format(
-            player=queue_player,
+            player=player,
             streamdetails=queue_item.streamdetails,
             smartfades_enabled=True,
         )
         output_format = await self.get_output_format(
             output_format_str=request.match_info["fmt"],
-            player=queue_player,
+            player=player,
             content_sample_rate=pcm_format.sample_rate,
             content_bit_depth=pcm_format.bit_depth,
         )
@@ -492,13 +492,13 @@ class StreamsController(CoreController):
             )
         if (
             smart_fades_mode != SmartFadesMode.DISABLED
-            and PlayerFeature.GAPLESS_PLAYBACK not in queue_player.supported_features
+            and PlayerFeature.GAPLESS_PLAYBACK not in player.state.supported_features
         ):
             # crossfade is not supported on this player due to missing gapless playback
             self.logger.warning(
                 "Crossfade disabled: Player %s does not support gapless playback, "
                 "consider enabling flow mode to enable crossfade on this player.",
-                queue_player.display_name if queue_player else "Unknown Player",
+                player.state.name if player else "Unknown Player",
             )
             smart_fades_mode = SmartFadesMode.DISABLED
 
@@ -543,7 +543,7 @@ class StreamsController(CoreController):
             output_format=output_format,
             filter_params=get_player_filter_params(
                 self.mass,
-                player_id=queue_player.player_id,
+                player_id=player.player_id,
                 input_format=pcm_format,
                 output_format=output_format,
             ),
@@ -560,7 +560,7 @@ class StreamsController(CoreController):
                         queue_item.queue_id, queue_item.queue_item_id
                     )
             except (BrokenPipeError, ConnectionResetError, ConnectionError) as err:
-                if first_chunk_received and not queue_player.stop_called:
+                if first_chunk_received and not player.stop_called:
                     # Player disconnected (unexpected) after receiving at least some data
                     # This could indicate buffering issues, network problems,
                     # or player-specific issues
@@ -591,11 +591,11 @@ class StreamsController(CoreController):
         """Stream Queue Flow audio to player."""
         self._log_request(request)
         queue_id = request.match_info["queue_id"]
-        queue = self.mass.player_queues.get(queue_id)
-        if not queue:
+        player_id = request.match_info["player_id"]
+        if not (queue := self.mass.player_queues.get(queue_id)):
             raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}")
-        if not (queue_player := self.mass.players.get(queue_id)):
-            raise web.HTTPNotFound(reason=f"Unknown Player: {queue_id}")
+        if not (player := self.mass.players.get_player(player_id)):
+            raise web.HTTPNotFound(reason=f"Unknown Player: {player_id}")
         start_queue_item_id = request.match_info["queue_item_id"]
         start_queue_item = self.mass.player_queues.get_item(queue_id, start_queue_item_id)
         if not start_queue_item:
@@ -604,12 +604,12 @@ class StreamsController(CoreController):
         queue.flow_mode_stream_log = []
 
         # select the highest possible PCM settings for this player
-        flow_pcm_format = await self._select_flow_format(queue_player)
+        flow_pcm_format = await self._select_flow_format(player)
 
         # work out output format/details
         output_format = await self.get_output_format(
             output_format_str=request.match_info["fmt"],
-            player=queue_player,
+            player=player,
             content_sample_rate=flow_pcm_format.sample_rate,
             content_bit_depth=flow_pcm_format.bit_depth,
         )
@@ -668,7 +668,7 @@ class StreamsController(CoreController):
             input_format=flow_pcm_format,
             output_format=output_format,
             filter_params=get_player_filter_params(
-                self.mass, queue_player.player_id, flow_pcm_format, output_format
+                self.mass, player.player_id, flow_pcm_format, output_format
             ),
             # we need to slowly feed the music to avoid the player stopping and later
             # restarting (or completely failing) the audio stream by keeping the buffer short.
@@ -773,7 +773,7 @@ class StreamsController(CoreController):
         self.logger.debug(
             "Start serving audio stream for Announcement %s to %s",
             announce_data["announcement_url"],
-            player.display_name,
+            player.state.name,
         )
         async for chunk in self.get_announcement_stream(
             announcement_url=announce_data["announcement_url"],
@@ -789,7 +789,7 @@ class StreamsController(CoreController):
         self.logger.debug(
             "Finished serving audio stream for Announcement %s to %s",
             announce_data["announcement_url"],
-            player.display_name,
+            player.state.name,
         )
 
         return resp
@@ -803,7 +803,7 @@ class StreamsController(CoreController):
             raise ProviderUnavailableError(f"Unknown PluginSource: {plugin_source_id}")
         # work out output format/details
         player_id = request.match_info["player_id"]
-        player = self.mass.players.get(player_id)
+        player = self.mass.players.get_player(player_id)
         if not player:
             raise web.HTTPNotFound(reason=f"Unknown Player: {player_id}")
         plugin_source = provider.get_source()
@@ -914,7 +914,7 @@ class StreamsController(CoreController):
         ):
             # special case: member player accessing UGP stream
             # Check URI to distinguish from the UGP accessing its own stream
-            ugp_player = cast("UniversalGroupPlayer", self.mass.players.get(media.source_id))
+            ugp_player = cast("UniversalGroupPlayer", self.mass.players.get_player(media.source_id))
             ugp_stream = ugp_player.stream
             assert ugp_stream is not None  # for type checker
             if ugp_stream.base_pcm_format == pcm_format:
@@ -1679,7 +1679,7 @@ class StreamsController(CoreController):
             queue.index_in_buffer = self.mass.player_queues.index_by_id(
                 queue.queue_id, next_queue_item.queue_item_id
             )
-            queue_player = self.mass.players.get(queue.queue_id)
+            queue_player = self.mass.players.get_player(queue.queue_id)
             assert queue_player is not None
             next_queue_item_pcm_format = await self._select_pcm_format(
                 player=queue_player,
@@ -1923,7 +1923,7 @@ class StreamsController(CoreController):
         """Get the crossfade config for a queue item."""
         if smart_fades_mode == SmartFadesMode.DISABLED:
             return False
-        if not (self.mass.players.get(queue_item.queue_id)):
+        if not (self.mass.players.get_player(queue_item.queue_id)):
             return False  # just a guard
         if queue_item.media_type != MediaType.TRACK:
             self.logger.debug("Skipping crossfade: current item is not a track")
index 4a7e707dc2ae2fc898db3cd392f524aecb14697b..bdf1a6886cc349bc3eeb24b6b88350d57f14c5a4 100644 (file)
@@ -482,4 +482,8 @@ When contributing to the webserver/auth system:
 3. Update this README if adding significant new features
 4. Test authentication flows thoroughly
 5. Consider security implications of all changes
-6. Update API documentation if adding new commands
+6. The API documentation will be auto updated if adding new commands (based on docstrings and type hints)
+
+---
+
+*This architecture document is maintained alongside the code and should be updated when significant changes are made to the provider's design or functionality.*
index 49fa92e00f05500e522159d2aa5d0757c5d32397..ccf51e2b7c2e524e8b9a02b1d44999be41e68771 100644 (file)
@@ -46,10 +46,10 @@ from music_assistant.constants import (
     MASS_LOGGER_NAME,
     VERBOSE_LOG_LEVEL,
 )
-from music_assistant.controllers.players.sync_groups import SyncGroupPlayer
 from music_assistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads
 from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER
 from music_assistant.helpers.util import clean_stream_title, remove_file
+from music_assistant.providers.sync_group.constants import SGP_PREFIX
 
 from .audio_buffer import AudioBuffer
 from .dsp import filter_to_ffmpeg_params
@@ -66,6 +66,7 @@ if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
     from music_assistant.models.music_provider import MusicProvider
     from music_assistant.models.player import Player
+    from music_assistant.providers.sync_group import SyncGroupPlayer
 
 LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.audio")
 
@@ -187,16 +188,17 @@ def get_stream_dsp_details(
     queue_id: str,
 ) -> dict[str, DSPDetails]:
     """Return DSP details of all players playing this queue, keyed by player_id."""
-    player = mass.players.get(queue_id)
+    player = mass.players.get_player(queue_id)
     dsp: dict[str, DSPDetails] = {}
     assert player is not None  # for type checking
     group_preventing_dsp = is_grouping_preventing_dsp(player)
     output_format = None
     is_external_group = False
 
-    if player.type == PlayerType.GROUP and isinstance(player, SyncGroupPlayer):
+    if player.player_id.startswith(SGP_PREFIX):
         if group_preventing_dsp:
-            if sync_leader := player.sync_leader:
+            sgp_player = cast("SyncGroupPlayer", player)
+            if sync_leader := sgp_player.sync_leader:
                 output_format = sync_leader.extra_data.get("output_format", None)
     else:
         # We only add real players (so skip the PlayerGroups as they only sync containing players)
@@ -206,17 +208,17 @@ def get_stream_dsp_details(
             # The leader is responsible for sending the (combined) audio stream, so get
             # the output format from the leader.
             output_format = player.extra_data.get("output_format", None)
-        is_external_group = player.type in (PlayerType.GROUP, PlayerType.STEREO_PAIR)
+        is_external_group = player.state.type in (PlayerType.GROUP, PlayerType.STEREO_PAIR)
 
     # We don't enumerate all group members in case this group is externally created
     # (e.g. a Chromecast group from the Google Home app)
-    if player and player.group_members and not is_external_group:
+    if player and player.state.group_members and not is_external_group:
         # grouped playback, get DSP details for each player in the group
-        for child_id in player.group_members:
+        for child_id in player.state.group_members:
             # skip if we already have the details (so if it's the group leader)
             if child_id in dsp:
                 continue
-            if child_player := mass.players.get(child_id):
+            if child_player := mass.players.get_player(child_id):
                 dsp[child_id] = get_player_dsp_details(
                     mass, child_player, group_preventing_dsp=group_preventing_dsp
                 )
@@ -1351,17 +1353,17 @@ def is_grouping_preventing_dsp(player: Player) -> bool:
     If this returns True, no DSP should be applied to the player.
     This function will not check if the Player is in a group, the caller should do that first.
     """
-    # We require the caller to handle non-leader cases themselves since player.synced_to
+    # We require the caller to handle non-leader cases themselves since player.state.synced_to
     # can be unreliable in some edge cases
-    multi_device_dsp_supported = PlayerFeature.MULTI_DEVICE_DSP in player.supported_features
-    child_count = len(player.group_members) if player.group_members else 0
+    multi_device_dsp_supported = PlayerFeature.MULTI_DEVICE_DSP in player.state.supported_features
+    child_count = len(player.state.group_members) if player.state.group_members else 0
 
     is_multiple_devices: bool
     if player.provider.domain == "player_group":
         # PlayerGroups have no leader, so having a child count of 1 means
         # the group actually contains only a single player.
         is_multiple_devices = child_count > 1
-    elif player.type == PlayerType.GROUP:
+    elif player.state.type == PlayerType.GROUP:
         # This is an group player external to Music Assistant.
         is_multiple_devices = True
     else:
@@ -1377,12 +1379,12 @@ def is_output_limiter_enabled(mass: MusicAssistant, player: Player) -> bool:
     decides if the limiter should be turned on or not.
     """
     deciding_player_id = player.player_id
-    if player.active_group:
+    if player.state.active_group:
         # Syncgroup, get from the group player
-        deciding_player_id = player.active_group
-    elif player.synced_to:
+        deciding_player_id = player.state.active_group
+    elif player.state.synced_to:
         # Not in sync group, but synced, get from the leader
-        deciding_player_id = player.synced_to
+        deciding_player_id = player.state.synced_to
     output_limiter_enabled = mass.config.get_raw_player_config_value(
         deciding_player_id,
         CONF_ENTRY_OUTPUT_LIMITER.key,
@@ -1403,20 +1405,20 @@ def get_player_filter_params(
     dsp = mass.config.get_player_dsp_config(player_id)
     limiter_enabled = True
 
-    if player := mass.players.get(player_id):
+    if player := mass.players.get_player(player_id):
         if is_grouping_preventing_dsp(player):
             # We can not correctly apply DSP to a grouped player without multi-device DSP support,
             # so we disable it.
             dsp.enabled = False
         elif player.provider.domain == "player_group" and (
-            PlayerFeature.MULTI_DEVICE_DSP not in player.supported_features
+            PlayerFeature.MULTI_DEVICE_DSP not in player.state.supported_features
         ):
             # This is a special case! We have a player group where:
             # - The group leader does not support MULTI_DEVICE_DSP
             # - But only contains a single player (since nothing is preventing DSP)
             # We can still apply the DSP of that single player.
-            if player.group_members:
-                child_player = mass.players.get(player.group_members[0])
+            if player.state.group_members:
+                child_player = mass.players.get_player(player.state.group_members[0])
                 assert child_player is not None  # for type checking
                 dsp = mass.config.get_player_dsp_config(child_player.player_id)
             else:
index 834b0cee03a55fdf734e00bf4e298f7746476ca5..b2977e7f2d6d816e50053f17926cc12f415064b9 100644 (file)
@@ -733,10 +733,65 @@ def validate_announcement_chime_url(url: str) -> bool:
 
 
 async def get_mac_address(ip_address: str) -> str | None:
-    """Get MAC address for given IP address."""
-    from getmac import get_mac_address  # noqa: PLC0415
+    """Get MAC address for given IP address via ARP lookup."""
+    try:
+        from getmac import get_mac_address as getmac_lookup  # noqa: PLC0415
+
+        return await asyncio.to_thread(getmac_lookup, ip=ip_address)
+    except ImportError:
+        LOGGER.debug("getmac module not available, cannot resolve MAC from IP")
+        return None
+    except Exception as err:
+        LOGGER.debug("Failed to resolve MAC address for %s: %s", ip_address, err)
+        return None
+
+
+def is_locally_administered_mac(mac_address: str) -> bool:
+    """
+    Check if a MAC address is locally administered (virtual/randomized).
+
+    Locally administered addresses have bit 1 of the first octet set to 1.
+    These are often used by devices for virtual interfaces or protocol-specific
+    addresses (e.g., AirPlay, DLNA may use different virtual MACs than the real hardware MAC).
+
+    :param mac_address: MAC address in any common format (with :, -, or no separator).
+    :return: True if locally administered, False if globally unique (real hardware MAC).
+    """
+    # Normalize MAC address
+    mac_clean = mac_address.upper().replace(":", "").replace("-", "")
+    if len(mac_clean) < 2:
+        return False
 
-    return await asyncio.to_thread(get_mac_address, ip=ip_address)
+    # Get first octet and check bit 1 (second bit from right)
+    try:
+        first_octet = int(mac_clean[:2], 16)
+        return bool(first_octet & 0x02)
+    except ValueError:
+        return False
+
+
+async def resolve_real_mac_address(reported_mac: str | None, ip_address: str | None) -> str | None:
+    """
+    Resolve the real MAC address for a device.
+
+    Some devices report different virtual MAC addresses per protocol (AirPlay, DLNA,
+    Chromecast). This function tries to resolve the actual hardware MAC via ARP
+    when the reported MAC appears to be locally administered (virtual).
+
+    :param reported_mac: The MAC address reported by the protocol.
+    :param ip_address: The IP address of the device (for ARP lookup).
+    :return: The real MAC address if found, or None if it couldn't be resolved.
+    """
+    if not ip_address:
+        return None
+
+    # If no MAC reported or it's a locally administered one, try ARP lookup
+    if not reported_mac or is_locally_administered_mac(reported_mac):
+        real_mac = await get_mac_address(ip_address)
+        if real_mac and real_mac.lower() not in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"):
+            return real_mac.upper()
+
+    return None
 
 
 class TaskManager:
index e2fdb55068e11c774eb4db5266f43c67afeedea4..ab36005591a90656561c61545d9b9018d3e5e534 100644 (file)
@@ -32,10 +32,12 @@ from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZerocon
 
 from music_assistant.constants import (
     API_SCHEMA_VERSION,
+    CONF_DEFAULT_PROVIDERS_SETUP,
     CONF_PROVIDERS,
     CONF_SERVER_ID,
     CONF_ZEROCONF_INTERFACES,
     CONFIGURABLE_CORE_CONTROLLERS,
+    DEFAULT_PROVIDERS,
     MASS_LOGGER_NAME,
     MIN_SCHEMA_VERSION,
     VERBOSE_LOG_LEVEL,
@@ -45,7 +47,7 @@ from music_assistant.controllers.config import ConfigController
 from music_assistant.controllers.metadata import MetaDataController
 from music_assistant.controllers.music import MusicController
 from music_assistant.controllers.player_queues import PlayerQueuesController
-from music_assistant.controllers.players.player_controller import PlayerController
+from music_assistant.controllers.players import PlayerController
 from music_assistant.controllers.streams import StreamsController
 from music_assistant.controllers.webserver import WebserverController
 from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
@@ -526,16 +528,20 @@ class MusicAssistant:
             self._tracked_timers.pop(task_id)
             self.create_task(_target, *args, task_id=task_id, abort_existing=True, **kwargs)
 
+        def _call_sync(_target: Callable[..., _R]) -> None:
+            self._tracked_timers.pop(task_id)
+            _target(*args, **kwargs)
+
         if inspect.iscoroutinefunction(target) or inspect.iscoroutine(target):
             # coroutine function
             if TYPE_CHECKING:
                 target = cast("Coroutine[Any, Any, _R]", target)
             handle = self.loop.call_later(delay, _create_task, target)
         else:
-            # regular callable
+            # regular sync callable
             if TYPE_CHECKING:
                 target = cast("Callable[..., _R]", target)
-            handle = self.loop.call_later(delay, target, *args)
+            handle = self.loop.call_later(delay, _call_sync, target)
         self._tracked_timers[task_id] = handle
         return handle
 
@@ -772,12 +778,63 @@ class MusicAssistant:
             if not prov_manifest.builtin:
                 continue
             await self.config.create_builtin_provider_config(prov_manifest.domain)
+        # handle default providers setup
+        self.config.set_default(CONF_DEFAULT_PROVIDERS_SETUP, set())
+        default_providers_setup = cast("set[str]", self.config.get(CONF_DEFAULT_PROVIDERS_SETUP))
+        changes_made = False
+        for default_provider, require_mdns in DEFAULT_PROVIDERS:
+            if default_provider in default_providers_setup:
+                # already processed/setup before, skip
+                continue
+            if not (manifest := self._provider_manifests.get(default_provider)):
+                continue
+            if require_mdns:
+                # if mdns discovery is required, check if we have seen any mdns entries
+                # for this provider before setting it up
+                for mdns_name in set(self.aiozc.zeroconf.cache.cache):
+                    if manifest.mdns_discovery and any(
+                        mdns_type in mdns_name for mdns_type in manifest.mdns_discovery
+                    ):
+                        break
+                else:
+                    continue
+            await self.config.create_builtin_provider_config(manifest.domain)
+            changes_made = True
+            # TEMP: migration - to be removed after 2.8 release
+            # enable all existing players of the default providers if they are not already enabled
+            # due to the linked protocol feature we introduced
+            for player_config in await self.config.get_player_configs(
+                provider=default_provider, include_disabled=True
+            ):
+                if player_config.enabled:
+                    continue
+                await self.config.save_player_config(player_config.player_id, {"enabled": True})
+            default_providers_setup.add(default_provider)
+        if changes_made:
+            self.config.set(CONF_DEFAULT_PROVIDERS_SETUP, default_providers_setup)
+            self.config.save(True)
 
         # load all configured (and enabled) providers
+        # builtin providers are loaded first (and awaited) before loading the rest
         prov_configs = await self.config.get_provider_configs(include_values=True)
+        builtin_configs: list[ProviderConfig] = []
+        other_configs: list[ProviderConfig] = []
         for prov_conf in prov_configs:
             if not prov_conf.enabled:
                 continue
+            manifest = self._provider_manifests.get(prov_conf.domain)
+            if manifest and manifest.builtin:
+                builtin_configs.append(prov_conf)
+            else:
+                other_configs.append(prov_conf)
+
+        # load builtin providers first and wait for them to complete
+        await asyncio.gather(
+            *[self.load_provider(conf.instance_id, allow_retry=True) for conf in builtin_configs]
+        )
+
+        # load remaining providers concurrently via tasks
+        for prov_conf in other_configs:
             # Use a task so we can load multiple providers at once.
             # If a provider fails, that will not block the loading of other providers.
             self.create_task(self.load_provider(prov_conf.instance_id, allow_retry=True))
index da5808e816e5702183ddfe680082047ccd158bce..a03d92ad5c294e57f11796a7fa0816f41e30f6ca 100644 (file)
@@ -3,14 +3,17 @@ Base class/model for a Player within Music Assistant.
 
 All providerspecific players should inherit from this class and implement the required methods.
 
-Note that the serverside Player object is not the same as the clientside Player object,
-which is a dataclass in the models package containing the player state.
+Note that this is NOT the final state of the player,
+as it may be overridden by (sync)group memberships, configuration options, or other factors.
+This final state will be calculated and snapshotted in the PlayerState dataclass,
+which is what is also what is sent over the API.
+The final active source can be retrieved by using the 'state' property.
 """
 
 from __future__ import annotations
 
 import time
-from abc import ABC, abstractmethod
+from abc import ABC
 from collections.abc import Callable
 from copy import deepcopy
 from typing import TYPE_CHECKING, Any, cast, final
@@ -21,15 +24,11 @@ from music_assistant_models.constants import (
     PLAYER_CONTROL_NATIVE,
     PLAYER_CONTROL_NONE,
 )
-from music_assistant_models.enums import (
-    MediaType,
-    PlaybackState,
-    PlayerFeature,
-    PlayerType,
-)
+from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType
 from music_assistant_models.errors import UnsupportedFeaturedException
 from music_assistant_models.player import (
     DeviceInfo,
+    OutputProtocol,
     PlayerMedia,
     PlayerOption,
     PlayerOptionValueType,
@@ -41,6 +40,7 @@ from music_assistant_models.unique_list import UniqueList
 from propcache import under_cached_property as cached_property
 
 from music_assistant.constants import (
+    ACTIVE_PROTOCOL_FEATURES,
     ATTR_ANNOUNCEMENT_IN_PROGRESS,
     ATTR_FAKE_MUTE,
     ATTR_FAKE_POWER,
@@ -49,10 +49,14 @@ from music_assistant.constants import (
     CONF_EXPOSE_PLAYER_TO_HA,
     CONF_FLOW_MODE,
     CONF_HIDE_IN_UI,
+    CONF_LINKED_PROTOCOL_PLAYER_IDS,
     CONF_MUTE_CONTROL,
+    CONF_PLAYERS,
     CONF_POWER_CONTROL,
     CONF_SMART_FADES_MODE,
     CONF_VOLUME_CONTROL,
+    PROTOCOL_FEATURES,
+    PROTOCOL_PRIORITY,
 )
 from music_assistant.helpers.util import get_changed_dataclass_values
 
@@ -111,6 +115,9 @@ class Player(ABC):
         self._attr_options = []
         # do not override/overwrite these private attributes below!
         self._cache: dict[str, Any] = {}  # storage dict for cached properties
+        self.__attr_linked_protocols: list[OutputProtocol] = []
+        self.__attr_protocol_parent_id: str | None = None
+        self.__attr_active_output_protocol: str | None = None
         self._player_id = player_id
         self._provider = provider
         self.mass.config.create_default_player_config(
@@ -136,16 +143,16 @@ class Player(ABC):
             playback_state=self.playback_state,
         )
 
-    @property
-    def type(self) -> PlayerType:
-        """Return the type of the player."""
-        return self._attr_type
-
     @property
     def available(self) -> bool:
         """Return if the player is available."""
         return self._attr_available
 
+    @property
+    def type(self) -> PlayerType:
+        """Return the type of the player."""
+        return self._attr_type
+
     @property
     def name(self) -> str | None:
         """Return the name of the player."""
@@ -166,8 +173,8 @@ class Player(ABC):
         """
         Return if the player needs flow mode.
 
-        Will by default be set to True if the player does not support PlayerFeature.ENQUEUE
-        or has crossfade enabled without gapless support.
+        Default implementation: True if the player does not support PlayerFeature.ENQUEUE
+        or has crossfade enabled without gapless support. Can be overridden by providers if needed.
         """
         if PlayerFeature.ENQUEUE not in self.supported_features:
             # without enqueue support, flow mode is required
@@ -198,47 +205,6 @@ class Player(ABC):
         """
         return self._attr_elapsed_time_last_updated
 
-    @property
-    def group_members(self) -> list[str]:
-        """
-        Return the group members of the player.
-
-        If there are other players synced/grouped with this player,
-        this should return the id's of players synced to this player,
-        and this should include the player's own id (as first item in the list).
-
-        If there are currently no group members, this should return an empty list.
-        """
-        if self.type == PlayerType.PLAYER and (
-            len(self._attr_group_members) >= 1 and self.player_id not in self._attr_group_members
-        ):
-            # always ensure the player_id is in the group_members list for players
-            return [self.player_id, *self._attr_group_members]
-        if self._attr_group_members == [self.player_id]:
-            return []
-        return self._attr_group_members
-
-    @property
-    def static_group_members(self) -> list[str]:
-        """
-        Return the static group members for a player group.
-
-        For PlayerType.GROUP return the player_ids of members that must not be removed by
-        the user.
-        For all other player types return an empty list.
-        """
-        return self._attr_static_group_members
-
-    @property
-    def can_group_with(self) -> set[str]:
-        """
-        Return the id's of players this player can group with.
-
-        This should return set of player_id's this player can group/sync with
-        or just the provider's instance_id if all players can group with each other.
-        """
-        return self._attr_can_group_with
-
     @property
     def needs_poll(self) -> bool:
         """Return if the player needs to be polled for state updates."""
@@ -270,52 +236,47 @@ class Player(ABC):
         return self._attr_enabled_by_default
 
     @property
-    def _powered(self) -> bool | None:
+    def static_group_members(self) -> list[str]:
+        """
+        Return the static group members for a player group.
+
+        For PlayerType.GROUP return the player_ids of members that must/can not be removed by
+        the user. For all other player types return an empty list.
+        """
+        return self._attr_static_group_members
+
+    @property
+    def powered(self) -> bool | None:
         """
         Return if the player is powered on.
 
         If the player does not support PlayerFeature.POWER,
         or the state is (currently) unknown, this property may return None.
-
-        Note that this is NOT the final power state of the player,
-        as it may be overridden by a playercontrol.
-        Hence it's marked as a private property.
-        The final power state can be retrieved by using the 'powered' property.
         """
         return self._attr_powered
 
     @property
-    def _volume_level(self) -> int | None:
+    def volume_level(self) -> int | None:
         """
         Return the current volume level (0..100) of the player.
 
         If the player does not support PlayerFeature.VOLUME_SET,
         or the state is (currently) unknown, this property may return None.
-
-        Note that this is NOT the final volume level state of the player,
-        as it may be overridden by a playercontrol.
-        Hence it's marked as a private property.
-        The final volume level state can be retrieved by using the 'volume_level' property.
         """
         return self._attr_volume_level
 
     @property
-    def _volume_muted(self) -> bool | None:
+    def volume_muted(self) -> bool | None:
         """
         Return the current mute state of the player.
 
         If the player does not support PlayerFeature.VOLUME_MUTE,
         or the state is (currently) unknown, this property may return None.
-
-        Note that this is NOT the final muted state of the player,
-        as it may be overridden by a playercontrol.
-        Hence it's marked as a private property.
-        The final muted state can be retrieved by using the 'volume_muted' property.
         """
         return self._attr_volume_muted
 
     @property
-    def _active_source(self) -> str | None:
+    def active_source(self) -> str | None:
         """
         Return the (id of) the active source of the player.
 
@@ -323,38 +284,73 @@ class Player(ABC):
 
         Set to None if the player is not currently playing a source or
         the player_id if the player is currently playing a MA queue.
-
-        Note that this is NOT the final active source of the player,
-        as it may be overridden by a active group/sync membership.
-        Hence it's marked as a private property.
-        The final active source can be retrieved by using the 'active_source' property.
         """
         return self._attr_active_source
 
     @property
-    def _current_media(self) -> PlayerMedia | None:
+    def group_members(self) -> list[str]:
         """
-        Return the current media being played by the player.
+        Return the group members of the player.
+
+        If there are other players synced/grouped with this player,
+        this should return the id's of players synced to this player,
+        and this should include the player's own id (as first item in the list).
 
-        Note that this is NOT the final current media of the player,
-        as it may be overridden by a active group/sync membership.
-        Hence it's marked as a private property.
-        The final current media can be retrieved by using the 'current_media' property.
+        If there are currently no group members, this should return an empty list.
         """
-        return self._attr_current_media
+        return self._attr_group_members
 
     @property
-    def _source_list(self) -> list[PlayerSource]:
+    def can_group_with(self) -> set[str]:
         """
-        Return list of available (native) sources for this player.
+        Return the id's of players this player can group with.
 
-        Note that this is NOT the final source list of the player,
-        as we inject the MA queue source if the player is currently playing a MA queue.
-        Hence it's marked as a private property.
-        The final source list can be retrieved by using the 'source_list' property.
+        This should return set of player_id's this player can group/sync with
+        or just the provider's instance_id if all players can group with each other.
         """
+        return self._attr_can_group_with
+
+    @cached_property
+    def synced_to(self) -> str | None:
+        """Return the id of the player this player is synced to (sync leader)."""
+        # default implementation, feel free to override if your
+        # provider has a more efficient way to determine this
+        if self.group_members and self.group_members[0] != self.player_id:
+            return self.group_members[0]
+        for player in self.mass.players.all_players(
+            return_unavailable=False, return_protocol_players=True
+        ):
+            if player.type == PlayerType.GROUP:
+                continue
+            if self.player_id in player.group_members and player.player_id != self.player_id:
+                return player.player_id
+        return None
+
+    @property
+    def current_media(self) -> PlayerMedia | None:
+        """Return the current media being played by the player."""
+        return self._attr_current_media
+
+    @property
+    def source_list(self) -> list[PlayerSource]:
+        """Return list of available (native) sources for this player."""
         return self._attr_source_list
 
+    @property
+    def active_sound_mode(self) -> str | None:
+        """Return active sound mode of this player."""
+        return self._attr_active_sound_mode
+
+    @cached_property
+    def sound_mode_list(self) -> UniqueList[PlayerSoundMode]:
+        """Return available PlayerSoundModes for Player."""
+        return UniqueList(self._attr_sound_mode_list)
+
+    @cached_property
+    def options(self) -> UniqueList[PlayerOption]:
+        """Return all PlayerOptions for Player."""
+        return UniqueList(self._attr_options)
+
     async def power(self, powered: bool) -> None:
         """
         Handle POWER command on the player.
@@ -393,15 +389,15 @@ class Player(ABC):
         """Handle PLAY command on the player."""
         raise NotImplementedError("play needs to be implemented")
 
-    @abstractmethod
     async def stop(self) -> None:
         """
         Handle STOP command on the player.
 
-        Will only be called if the player reports PlayerFeature.PAUSE is supported or
-        player supports resuming of stopped playback.
+        Will be called to stop the stream/playback if the player has play_media support.
         """
-        raise NotImplementedError("stop needs to be implemented")
+        raise NotImplementedError(
+            "stop needs to be implemented when PlayerFeature.PLAY_MEDIA is set"
+        )
 
     async def pause(self) -> None:
         """
@@ -416,7 +412,7 @@ class Player(ABC):
         Handle NEXT_TRACK command on the player.
 
         Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
-        is supported and the player is not currently playing a MA queue.
+        is supported and the player's currently selected source supports it.
         """
         raise NotImplementedError(
             "next_track needs to be implemented when PlayerFeature.NEXT_PREVIOUS is set"
@@ -427,7 +423,7 @@ class Player(ABC):
         Handle PREVIOUS_TRACK command on the player.
 
         Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
-        is supported and the player is not currently playing a MA queue.
+        is supported and the player's currently selected source supports it.
         """
         raise NotImplementedError(
             "previous_track needs to be implemented when PlayerFeature.NEXT_PREVIOUS is set"
@@ -445,7 +441,6 @@ class Player(ABC):
         """
         raise NotImplementedError("seek needs to be implemented when PlayerFeature.SEEK is set")
 
-    @abstractmethod
     async def play_media(
         self,
         media: PlayerMedia,
@@ -459,7 +454,26 @@ class Player(ABC):
 
         :param media: Details of the item that needs to be played on the player.
         """
-        raise NotImplementedError("play_media needs to be implemented")
+        raise NotImplementedError(
+            "play_media needs to be implemented when PlayerFeature.PLAY_MEDIA is set"
+        )
+
+    async def on_protocol_playback(
+        self,
+        output_protocol: OutputProtocol,
+    ) -> None:
+        """
+        Handle callback when playback starts on a protocol output.
+
+        Called by the Player Controller after play_media is executed on a protocol player.
+        Allows the native player implementation to perform special logic when protocol
+        playback starts.
+
+        Optional - providers can override to implement protocol-specific logic.
+
+        :param output_protocol: The OutputProtocol object containing protocol details.
+        """
+        return  # Optional callback - no-op by default
 
     async def enqueue_next_media(self, media: PlayerMedia) -> None:
         """
@@ -614,7 +628,7 @@ class Player(ABC):
         # no need to implement unless your player/provider has an optimized way to execute this
         # default implementation will simply call set_members
         # to add the target player to the group.
-        target_player = self.mass.players.get(target_player_id, raise_unavailable=True)
+        target_player = self.mass.players.get_player(target_player_id, raise_unavailable=True)
         assert target_player  # for type checking
         await target_player.set_members(player_ids_to_add=[self.player_id])
 
@@ -632,46 +646,12 @@ class Player(ABC):
         # no need to implement unless your player/provider has an optimized way to execute this
         # default implementation will simply call set_members
         if self.synced_to:
-            if parent_player := self.mass.players.get(self.synced_to):
+            if parent_player := self.mass.players.get_player(self.synced_to):
                 # if this player is synced to another player, remove self from that group
                 await parent_player.set_members(player_ids_to_remove=[self.player_id])
         elif self.group_members:
             await self.set_members(player_ids_to_remove=self.group_members)
 
-    @property
-    def synced_to(self) -> str | None:
-        """
-        Return the id of the player this player is synced to (sync leader).
-
-        If this player is not synced to another player (or is the sync leader itself),
-        this should return None.
-        If it is part of a (permanent) group, this should also return None.
-        """
-        # default implementation: feel free to override
-        for player in self.mass.players.all():
-            if player.player_id == self.player_id:
-                # skip self
-                continue
-            if player.type == PlayerType.PLAYER and self.player_id in player.group_members:
-                # this player is synced to another player, but not part of a (permanent) group
-                return player.player_id
-        return None
-
-    @property
-    def active_sound_mode(self) -> str | None:
-        """Return active sound mode of this player."""
-        return self._attr_active_sound_mode
-
-    @cached_property
-    def sound_mode_list(self) -> UniqueList[PlayerSoundMode]:
-        """Return available PlayerSoundModes for Player."""
-        return UniqueList(self._attr_sound_mode_list)
-
-    @cached_property
-    def options(self) -> UniqueList[PlayerOption]:
-        """Return all PlayerOptions for Player."""
-        return UniqueList(self._attr_options)
-
     def _on_player_media_updated(self) -> None:  # noqa: B027
         """Handle callback when the current media of the player is updated."""
         # optional callback for players that want to be informed when the final
@@ -734,111 +714,12 @@ class Player(ABC):
     @cached_property
     @final
     def display_name(self) -> str:
-        """Return the display name of the player."""
+        """Return the (FINAL) display name of the player."""
         if custom_name := self._config.name:
             # always prefer the custom name over the default name
             return custom_name
         return self.name or self._config.default_name or self.player_id
 
-    @property
-    @final
-    def powered(self) -> bool | None:
-        """
-        Return the FINAL power state of the player.
-
-        This is a convenience property which calculates the final power state
-        based on the playercontrol which may have been set-up.
-        """
-        power_control = self.power_control
-        if power_control == PLAYER_CONTROL_FAKE:
-            return bool(self.extra_data.get(ATTR_FAKE_POWER, False))
-        if power_control == PLAYER_CONTROL_NATIVE:
-            return self._powered
-        if power_control == PLAYER_CONTROL_NONE:
-            return None
-        if control := self.mass.players.get_player_control(power_control):
-            return control.power_state
-        return None
-
-    @property
-    @final
-    def volume_level(self) -> int | None:
-        """
-        Return the FINAL volume level of the player.
-
-        This is a convenience property which calculates the final volume level
-        based on the playercontrol which may have been set-up.
-        """
-        volume_control = self.volume_control
-        if volume_control == PLAYER_CONTROL_FAKE:
-            return int(self.extra_data.get(ATTR_FAKE_VOLUME, 0))
-        if volume_control == PLAYER_CONTROL_NATIVE:
-            return self._volume_level
-        if volume_control == PLAYER_CONTROL_NONE:
-            return None
-        if control := self.mass.players.get_player_control(volume_control):
-            return control.volume_level
-        return None
-
-    @property
-    @final
-    def volume_muted(self) -> bool | None:
-        """
-        Return the FINAL mute state of the player.
-
-        This is a convenience property which calculates the final mute state
-        based on the playercontrol which may have been set-up.
-        """
-        mute_control = self.mute_control
-        if mute_control == PLAYER_CONTROL_FAKE:
-            return bool(self.extra_data.get(ATTR_FAKE_MUTE, False))
-        if mute_control == PLAYER_CONTROL_NATIVE:
-            return self._volume_muted
-        if mute_control == PLAYER_CONTROL_NONE:
-            return None
-        if control := self.mass.players.get_player_control(mute_control):
-            return control.volume_muted
-        return None
-
-    @property
-    @final
-    def active_source(self) -> str | None:
-        """
-        Return the FINAL active source of the player.
-
-        This is a convenience property which calculates the final active source
-        based on any group memberships or source plugins that can be active.
-        """
-        # if the player is grouped/synced, use the active source of the group/parent player
-        if parent_player_id := (self.active_group or self.synced_to):
-            if parent_player_id != self.player_id and (
-                parent_player := self.mass.players.get(parent_player_id)
-            ):
-                return parent_player.active_source
-        for plugin_source in self.mass.players.get_plugin_sources():
-            if plugin_source.in_use_by == self.player_id:
-                return plugin_source.id
-        if (
-            self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
-            and self._active_source
-        ):
-            # active source as reported by the player itself
-            # but only if playing/paused, otherwise we always prefer the MA source
-            return self._active_source
-        # return the (last) known MA source
-        return self.__active_mass_source
-
-    @cached_property
-    @final
-    def source_list(self) -> UniqueList[PlayerSource]:
-        """
-        Return the FINAL source list of the player.
-
-        This is a convenience property with the calculated final source list
-        based on any group memberships or source plugins that can be active.
-        """
-        return self.__attr_source_list or UniqueList()
-
     @cached_property
     @final
     def enabled(self) -> bool:
@@ -854,40 +735,6 @@ class Player(ABC):
             return self.elapsed_time + (time.time() - self.elapsed_time_last_updated)
         return self.elapsed_time
 
-    @property
-    @final
-    def active_groups(self) -> list[str]:
-        """
-        Return the player ids of all playergroups that are currently active for this player.
-
-        This will return the ids of the groupplayers if any groups are active.
-        If no groups are currently active, this will return an empty list.
-        """
-        return self.__attr_active_groups or []
-
-    @property
-    @final
-    def active_group(self) -> str | None:
-        """
-        Return the player id of the (first) playergroup that is currently active for this player.
-
-        This will return the id of the groupplayer if a group is active.
-        If no group is currently active, this will return None.
-        """
-        active_groups = self.active_groups
-        return active_groups[0] if active_groups else None
-
-    @property
-    @final
-    def current_media(self) -> PlayerMedia | None:
-        """
-        Return the current media being played by the player.
-
-        This is a convenience property with the calculates current media
-        based on any group memberships or source plugins that can be active.
-        """
-        return self.__attr_current_media
-
     @cached_property
     @final
     def icon(self) -> str:
@@ -898,27 +745,47 @@ class Player(ABC):
     @final
     def power_control(self) -> str:
         """Return the power control type."""
-        if conf := self._config.get_value(CONF_POWER_CONTROL):
+        if conf := self.mass.config.get_raw_player_config_value(self.player_id, CONF_POWER_CONTROL):
             return str(conf)
+        # not explicitly set, use native if supported
+        if PlayerFeature.POWER in self.supported_features:
+            return PLAYER_CONTROL_NATIVE
+        # note that we do not try to use protocol players for power control,
+        # as this is very unlikely to be provided by a generic protocol and if it does,
+        # it will be handled automatically on stream start/stop.
         return PLAYER_CONTROL_NONE
 
     @cached_property
     @final
     def volume_control(self) -> str:
         """Return the volume control type."""
-        if conf := self._config.get_value(CONF_VOLUME_CONTROL):
+        if conf := self.mass.config.get_raw_player_config_value(
+            self.player_id, CONF_VOLUME_CONTROL
+        ):
             return str(conf)
+        # not explicitly set, use native if supported
+        if PlayerFeature.VOLUME_SET in self.supported_features:
+            return PLAYER_CONTROL_NATIVE
+        # check for protocol player with volume support, and use that if found
+        if protocol_player := self._get_protocol_player_for_feature(PlayerFeature.VOLUME_SET):
+            return protocol_player.player_id
         return PLAYER_CONTROL_NONE
 
     @cached_property
     @final
     def mute_control(self) -> str:
         """Return the mute control type."""
-        if conf := self._config.get_value(CONF_MUTE_CONTROL):
+        if conf := self.mass.config.get_raw_player_config_value(self.player_id, CONF_MUTE_CONTROL):
             return str(conf)
+        # not explicitly set, use native if supported
+        if PlayerFeature.VOLUME_MUTE in self.supported_features:
+            return PLAYER_CONTROL_NATIVE
+        # check for protocol player with volume mute support, and use that if found
+        if protocol_player := self._get_protocol_player_for_feature(PlayerFeature.VOLUME_MUTE):
+            return protocol_player.player_id
         return PLAYER_CONTROL_NONE
 
-    @property
+    @cached_property
     @final
     def group_volume(self) -> int:
         """
@@ -929,16 +796,16 @@ class Player(ABC):
         If the player is not a group player or syncgroup, this will return the volume level
         of the player itself (if set), or 0 if not set.
         """
-        if len(self.group_members) == 0:
+        if len(self.state.group_members) == 0:
             # player is not a group or syncgroup
-            return self.volume_level or 0
+            return self.state.volume_level or 0
         # calculate group volume from all (turned on) players
         group_volume = 0
         active_players = 0
         for child_player in self.mass.players.iter_group_members(
             self, only_powered=True, exclude_self=self.type != PlayerType.PLAYER
         ):
-            if (child_volume := child_player.volume_level) is None:
+            if (child_volume := child_player.state.volume_level) is None:
                 continue
             group_volume += child_volume
             active_players += 1
@@ -978,29 +845,251 @@ class Player(ABC):
         """
         return bool(self.mass.players.get_active_queue(self))
 
-    @property
+    @cached_property
     @final
     def flow_mode(self) -> bool:
         """
         Return if the player needs flow mode.
 
         Will use 'requires_flow_mode' unless overridden by flow_mode config.
+        Considers the active output protocol's flow_mode if a protocol is active.
         """
+        # If an output protocol is active (and not native), use the protocol player's flow_mode
+        # The protocol player will handle its own config check
+        if (
+            self.__attr_active_output_protocol
+            and self.__attr_active_output_protocol != "native"
+            and (
+                protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol)
+            )
+        ):
+            return protocol_player.flow_mode
+        # Check native player's config override
         if bool(self._config.get_value(CONF_FLOW_MODE)) is True:
             # flow mode explicitly enabled in config
             return True
         return self.requires_flow_mode
 
+    @property
+    @final
+    def supports_enqueue(self) -> bool:
+        """
+        Return if the player supports enqueueing tracks.
+
+        This considers the active output protocol's capabilities if one is active.
+        If a protocol player is active, checks that protocol's ENQUEUE feature.
+        Otherwise checks the native player's ENQUEUE feature.
+        """
+        return self._check_feature_with_active_protocol(PlayerFeature.ENQUEUE)
+
     @property
     @final
     def state(self) -> PlayerState:
-        """Return the current PlayerState of the player."""
+        """Return the current (and FINAL) PlayerState of the player."""
         return self._state
 
+    # Protocol-related properties and helpers
+
+    @cached_property
+    @final
+    def is_native_player(self) -> bool:
+        """Return True if this player is a native player."""
+        is_universal_player = self.provider.domain == "universal_player"
+        has_play_media = PlayerFeature.PLAY_MEDIA in self.supported_features
+        return self.type != PlayerType.PROTOCOL and not is_universal_player and has_play_media
+
+    @cached_property
+    @final
+    def output_protocols(self) -> list[OutputProtocol]:
+        """
+        Return all output options for this player.
+
+        Includes:
+        - Native playback (if player supports PLAY_MEDIA and is not a protocol/universal player)
+        - Active protocol players from linked_output_protocols
+        - Disabled protocols from cached linked_protocol_player_ids in config
+
+        Each entry has an available flag indicating current availability.
+        """
+        result: list[OutputProtocol] = []
+
+        # Add native playback option if applicable
+        if self.is_native_player:
+            result.append(
+                OutputProtocol(
+                    output_protocol_id="native",
+                    name=self.provider.name,
+                    protocol_domain=self.provider.domain,
+                    priority=0,  # Native is always highest priority
+                    available=self.available,
+                    is_native=True,
+                )
+            )
+
+        # Add active protocol players
+        active_ids: set[str] = set()
+        for linked in self.__attr_linked_protocols:
+            active_ids.add(linked.output_protocol_id)
+            # Check if the protocol player is actually available
+            protocol_player = self.mass.players.get_player(linked.output_protocol_id)
+            is_available = protocol_player.available if protocol_player else False
+            if protocol_player and not is_available:
+                self.logger.debug(
+                    "Protocol player %s (%s) is unavailable for %s",
+                    linked.output_protocol_id,
+                    linked.protocol_domain,
+                    self.display_name,
+                )
+            # Use provider name if available, else domain title
+            if protocol_player:
+                name = protocol_player.provider.name
+            else:
+                name = linked.protocol_domain.title() if linked.protocol_domain else "Unknown"
+            result.append(
+                OutputProtocol(
+                    output_protocol_id=linked.output_protocol_id,
+                    name=name,
+                    protocol_domain=linked.protocol_domain,
+                    priority=linked.priority,
+                    available=is_available,
+                )
+            )
+
+        # Add disabled protocols from cache
+        cached_protocol_ids: list[str] = self.mass.config.get(
+            f"{CONF_PLAYERS}/{self.player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}",
+            [],
+        )
+        for protocol_id in cached_protocol_ids:
+            if protocol_id in active_ids:
+                continue  # Already included above
+            # Get stored config to determine protocol domain
+            if raw_conf := self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}"):
+                provider_id = raw_conf.get("provider", "")
+                protocol_domain = provider_id.split("--")[0] if provider_id else "unknown"
+                priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
+                result.append(
+                    OutputProtocol(
+                        output_protocol_id=protocol_id,
+                        name=protocol_domain.title(),
+                        protocol_domain=protocol_domain,
+                        priority=priority,
+                        available=False,  # Disabled protocols are not available
+                    )
+                )
+
+        # Sort by priority (lower = more preferred)
+        result.sort(key=lambda o: o.priority)
+        return result
+
+    @property
+    @final
+    def linked_output_protocols(self) -> list[OutputProtocol]:
+        """Return the list of actively linked output protocol players."""
+        return self.__attr_linked_protocols
+
+    @property
+    @final
+    def protocol_parent_id(self) -> str | None:
+        """Return the parent player_id if this is a protocol player linked to a native player."""
+        return self.__attr_protocol_parent_id
+
+    @property
+    @final
+    def active_output_protocol(self) -> str | None:
+        """Return the currently active output protocol ID."""
+        return self.__attr_active_output_protocol
+
+    @final
+    def set_active_output_protocol(self, protocol_id: str | None) -> None:
+        """
+        Set the currently active output protocol ID.
+
+        :param protocol_id: The protocol player_id to set as active, "native" for native playback,
+            or None to clear the active protocol.
+        """
+        if self.__attr_active_output_protocol == protocol_id:
+            return  # No change
+        if protocol_id == self.player_id:
+            protocol_id = "native"  # Normalize to "native" for native player
+        if protocol_id:
+            protocol_name = protocol_id
+            if protocol_id == "native":
+                protocol_name = "Native"
+            elif protocol_player := self.mass.players.get_player(protocol_id):
+                protocol_name = protocol_player.provider.name
+            self.logger.info(
+                "Setting active output protocol on %s to %s",
+                self.display_name,
+                protocol_name,
+            )
+        else:
+            self.logger.info(
+                "Clearing active output protocol on %s",
+                self.display_name,
+            )
+        self.__attr_active_output_protocol = protocol_id
+        self.update_state()
+
+    @final
+    def set_linked_output_protocols(self, protocols: list[OutputProtocol]) -> None:
+        """
+        Set the actively linked output protocol players.
+
+        :param protocols: List of OutputProtocol objects representing active protocol players.
+        """
+        self.__attr_linked_protocols = protocols
+        self.mass.players.trigger_player_update(self.player_id)
+
+    @final
+    def set_protocol_parent_id(self, parent_id: str | None) -> None:
+        """
+        Set the parent player_id for protocol players.
+
+        :param parent_id: The player_id of the parent player, or None to clear.
+        """
+        self.__attr_protocol_parent_id = parent_id
+        self.mass.players.trigger_player_update(self.player_id)
+
+    @final
+    def get_linked_protocol(self, protocol_domain: str) -> OutputProtocol | None:
+        """Get a linked protocol by domain with current availability."""
+        for linked in self.__attr_linked_protocols:
+            if linked.protocol_domain == protocol_domain:
+                protocol_player = self.mass.players.get_player(linked.output_protocol_id)
+                current_available = protocol_player.available if protocol_player else False
+                return OutputProtocol(
+                    output_protocol_id=linked.output_protocol_id,
+                    name=protocol_player.provider.name
+                    if protocol_player
+                    else linked.protocol_domain.title(),
+                    protocol_domain=linked.protocol_domain,
+                    priority=linked.priority,
+                    available=current_available,
+                    is_native=False,
+                )
+        return None
+
+    @final
+    def get_protocol_player(self, player_id: str) -> Player | None:
+        """Get the protocol Player for a given player_id."""
+        if player_id == "native":
+            return self if PlayerFeature.PLAY_MEDIA in self.supported_features else None
+        return self.mass.players.get_player(player_id)
+
+    @final
+    def get_preferred_protocol_player(self) -> Player | None:
+        """Get the best available protocol player by priority."""
+        for linked in sorted(self.__attr_linked_protocols, key=lambda x: x.priority):
+            if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
+                if protocol_player.available:
+                    return protocol_player
+        return None
+
     @final
     def update_state(self, force_update: bool = False, signal_event: bool = True) -> None:
         """
-        Update the PlayerState with the current state of the player.
+        Update the PlayerState from the current state of the player.
 
         This method should be called to update the player's state
         and signal any changes to the PlayerController.
@@ -1014,7 +1103,7 @@ class Player(ABC):
         self._cache.clear()
         # calculate the new state
         prev_media_checksum = self._get_player_media_checksum()
-        changed_values = self.__calculate_state()
+        changed_values = self.__calculate_player_state()
         if prev_media_checksum != self._get_player_media_checksum():
             # current media changed, call the media updated callback
             self._on_player_media_updated()
@@ -1032,6 +1121,7 @@ class Player(ABC):
         # return early if nothing changed (unless force_update is True)
         if len(changed_values) == 0 and not force_update:
             return
+
         # signal the state update to the PlayerController
         if signal_event:
             self.mass.players.signal_player_state_update(self, changed_values)
@@ -1090,6 +1180,7 @@ class Player(ABC):
         """
         # TODO: validate that caller is the PlayerController ?
         self._config = config
+        self.mass.players.trigger_player_update(self.player_id)
 
     @final
     def to_dict(self) -> dict[str, Any]:
@@ -1109,20 +1200,67 @@ class Player(ABC):
                 f"Player {self.display_name} does not support feature {feature.name}"
             )
 
+    @final
     def _get_player_media_checksum(self) -> str:
         """Return a checksum for the current media."""
-        if not (media := self.current_media):
+        if not (media := self.state.current_media):
             return ""
         return (
             f"{media.uri}|{media.title}|{media.source_id}|{media.queue_item_id}|"
             f"{media.image_url}|{media.duration}|{media.elapsed_time}"
         )
 
-    def __calculate_state(
+    @final
+    def _check_feature_with_active_protocol(
+        self, feature: PlayerFeature, active_only: bool = False
+    ) -> bool:
+        """
+        Check if a feature is supported considering the active output protocol.
+
+        If an active output protocol is set (and not native), checks that protocol
+        player's features. Otherwise checks the native player's features.
+
+        :param feature: The PlayerFeature to check.
+        :return: True if the feature is supported by the active protocol or native player.
+        """
+        # If active output protocol is set and not native, check protocol player's features
+        if (
+            self.__attr_active_output_protocol
+            and self.__attr_active_output_protocol != "native"
+            and (
+                protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol)
+            )
+        ):
+            return feature in protocol_player.supported_features
+        # Otherwise check native player's features
+        return feature in self.supported_features
+
+    @final
+    def _get_protocol_player_for_feature(
+        self,
+        feature: PlayerFeature,
+    ) -> Player | None:
+        """Get player(protocol) which has the given PlayerFeature."""
+        # prefer native player
+        if feature in self.supported_features:
+            return self
+        # Otherwise, use the first available linked protocol
+        for linked in self.linked_output_protocols:
+            if (
+                (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
+                and protocol_player.available
+                and feature in protocol_player.supported_features
+            ):
+                return protocol_player
+
+        return None
+
+    @final
+    def __calculate_player_state(
         self,
     ) -> dict[str, tuple[Any, Any]]:
         """
-        Calculate the (current) PlayerState.
+        Calculate the (current) and FINAL PlayerState.
 
         This method is called when we're updating the player,
         and we compare the current state with the previous state to determine
@@ -1130,9 +1268,7 @@ class Player(ABC):
 
         Returns a dict with the state attributes that have changed.
         """
-        self.__attr_active_groups = self.__calculate_active_groups()
-        self.__attr_current_media = self.__calculate_current_media()
-        self.__attr_source_list = self.__calculate_source_list()
+        playback_state, elapsed_time, elapsed_time_last_updated = self.__final_playback_state
         prev_state = deepcopy(self._state)
         self._state = PlayerState(
             player_id=self.player_id,
@@ -1140,24 +1276,24 @@ class Player(ABC):
             type=self.type,
             available=self.enabled and self.available,
             device_info=self.device_info,
-            supported_features=self.supported_features,
-            playback_state=self.playback_state,
-            elapsed_time=self.elapsed_time,
-            elapsed_time_last_updated=self.elapsed_time_last_updated,
-            powered=self.powered,
-            volume_level=self.volume_level,
-            volume_muted=self.volume_muted,
-            group_members=UniqueList(self.group_members),
+            supported_features=self.__final_supported_features,
+            playback_state=playback_state,
+            elapsed_time=elapsed_time,
+            elapsed_time_last_updated=elapsed_time_last_updated,
+            powered=self.__final_power_state,
+            volume_level=self.__final_volume_level,
+            volume_muted=self.__final_volume_muted_state,
+            group_members=UniqueList(self.__final_group_members),
             static_group_members=UniqueList(self.static_group_members),
-            can_group_with=self.can_group_with,
-            synced_to=self.synced_to,
-            active_source=self.active_source,
-            source_list=self.source_list,
+            can_group_with=self.__final_can_group_with,
+            synced_to=self.__final_synced_to,
+            active_source=self.__final_active_source,
+            source_list=self.__final_source_list,
+            active_group=self.__final_active_group,
+            current_media=self.__final_current_media,
             active_sound_mode=self.active_sound_mode,
             sound_mode_list=self.sound_mode_list,
             options=self.options,
-            active_group=self.active_group,
-            current_media=self.current_media,
             name=self.display_name,
             enabled=self.enabled,
             hide_in_ui=self.hide_in_ui,
@@ -1168,18 +1304,10 @@ class Player(ABC):
             power_control=self.power_control,
             volume_control=self.volume_control,
             mute_control=self.mute_control,
+            output_protocols=self.output_protocols,
+            active_output_protocol=self.__attr_active_output_protocol,
         )
 
-        # correct group_members if needed
-        if self._state.group_members == [self.player_id]:
-            self._state.group_members.clear()
-        elif (
-            self._state.group_members
-            and self.player_id not in self._state.group_members
-            and self.type == PlayerType.PLAYER
-        ):
-            self._state.group_members.set([self.player_id, *self._state.group_members])
-
         # track stop called state
         if (
             prev_state.playback_state == PlaybackState.IDLE
@@ -1191,14 +1319,7 @@ class Player(ABC):
             and self._state.playback_state == PlaybackState.IDLE
         ):
             self.__stop_called = True
-
-        # 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.
-        if self._state.synced_to and (sync_leader := self.mass.players.get(self._state.synced_to)):
-            self._state.playback_state = sync_leader.playback_state
-            self._state.elapsed_time = sync_leader.elapsed_time
-            self._state.elapsed_time_last_updated = sync_leader.elapsed_time_last_updated
+            self.__active_mass_source = None
 
         return get_changed_dataclass_values(
             prev_state,
@@ -1206,26 +1327,121 @@ class Player(ABC):
             recursive=True,
         )
 
-    __attr_active_groups: list[str] | None = None
+    @cached_property
+    @final
+    def __final_playback_state(self) -> tuple[PlaybackState, float | None, float | None]:
+        """
+        Return the FINAL playback state based on the playercontrol which may have been set-up.
+
+        Returns a tuple of (playback_state, elapsed_time, elapsed_time_last_updated).
+        """
+        # If an output protocol is active (and not native), use the protocol player's state
+        if (
+            self.__attr_active_output_protocol
+            and self.__attr_active_output_protocol != "native"
+            and (
+                protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol)
+            )
+        ):
+            return (
+                protocol_player.state.playback_state,
+                protocol_player.state.elapsed_time,
+                protocol_player.state.elapsed_time_last_updated,
+            )
+        # if we're synced/grouped, use the parent player's state
+        parent_id = self.__final_synced_to or self.__final_active_group
+        if parent_id and (parent_player := self.mass.players.get_player(parent_id)):
+            return (
+                parent_player.state.playback_state,
+                parent_player.state.elapsed_time,
+                parent_player.state.elapsed_time_last_updated,
+            )
+        return (self.playback_state, self.elapsed_time, self.elapsed_time_last_updated)
+
+    @cached_property
+    @final
+    def __final_power_state(self) -> bool | None:
+        """Return the FINAL power state based on the playercontrol which may have been set-up."""
+        power_control = self.power_control
+        if power_control == PLAYER_CONTROL_FAKE:
+            return bool(self.extra_data.get(ATTR_FAKE_POWER, False))
+        if power_control == PLAYER_CONTROL_NATIVE:
+            return self.powered
+        if power_control == PLAYER_CONTROL_NONE:
+            return None
+        # handle player control for power if set
+        if control := self.mass.players.get_player_control(power_control):
+            return control.power_state
+        return None
+
+    @cached_property
+    @final
+    def __final_volume_level(self) -> int | None:
+        """Return the FINAL volume level based on the playercontrol which may have been set-up."""
+        volume_control = self.volume_control
+        if volume_control == PLAYER_CONTROL_FAKE:
+            return int(self.extra_data.get(ATTR_FAKE_VOLUME, 0))
+        if volume_control == PLAYER_CONTROL_NATIVE:
+            return self.volume_level
+        if volume_control == PLAYER_CONTROL_NONE:
+            return None
+        # handle protocol player as volume control
+        if control := self.mass.players.get_player(volume_control):
+            return control.volume_level
+        # handle player control for volume if set
+        if player_control := self.mass.players.get_player_control(volume_control):
+            return player_control.volume_level
+        return None
+
+    @cached_property
+    @final
+    def __final_volume_muted_state(self) -> bool | None:
+        """Return the FINAL mute state based on any playercontrol which may have been set-up."""
+        mute_control = self.mute_control
+        if mute_control == PLAYER_CONTROL_FAKE:
+            return bool(self.extra_data.get(ATTR_FAKE_MUTE, False))
+        if mute_control == PLAYER_CONTROL_NATIVE:
+            return self.volume_muted
+        if mute_control == PLAYER_CONTROL_NONE:
+            return None
+        # handle protocol player as mute control
+        if control := self.mass.players.get_player(mute_control):
+            return control.volume_muted
+        # handle player control for mute if set
+        if player_control := self.mass.players.get_player_control(mute_control):
+            return player_control.volume_muted
+        return None
+
+    @cached_property
+    @final
+    def __final_active_group(self) -> str | None:
+        """
+        Return the player id of any playergroup that is currently active for this player.
 
-    def __calculate_active_groups(self) -> list[str]:
-        """Calculate the active groups for the player."""
-        active_groups = []
-        for player in self.mass.players.all(return_unavailable=False, return_disabled=False):
-            if player.type != PlayerType.GROUP:
+        This will return the id of the groupplayer if any groups are active.
+        If no groups are currently active, this will return None.
+        """
+        if self.type == PlayerType.PROTOCOL:
+            # protocol players should not have an active group,
+            # they follow the group state of their parent player
+            return None
+        for group_player in self.mass.players.all_players(
+            return_unavailable=False, return_disabled=False
+        ):
+            if group_player.type != PlayerType.GROUP:
                 continue
-            if player.player_id == self.player_id:
+            if group_player.player_id == self.player_id:
                 continue
-            if not (player.powered or player.playback_state == PlaybackState.PLAYING):
+            if group_player.playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
                 continue
-            if self.player_id in player.group_members:
-                active_groups.append(player.player_id)
-        return active_groups
-
-    __attr_current_media: PlayerMedia | None = None
+            if self.player_id in group_player.group_members:
+                return group_player.player_id
+        return None
 
-    def __calculate_current_media(self) -> PlayerMedia | None:
-        """Calculate the current media for the player."""
+    @cached_property
+    @final
+    def __final_current_media(self) -> PlayerMedia | None:
+        """Return the FINAL current media for the player."""
         if self.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
             # if an announcement is in progress, return announcement details
             return PlayerMedia(
@@ -1233,16 +1449,22 @@ class Player(ABC):
                 media_type=MediaType.ANNOUNCEMENT,
                 title="ANNOUNCEMENT",
             )
+
         # if the player is grouped/synced, use the current_media of the group/parent player
-        if parent_player_id := (self.active_group or self.synced_to):
+        if parent_player_id := (self.__final_active_group or self.__final_synced_to):
             if parent_player_id != self.player_id and (
-                parent_player := self.mass.players.get(parent_player_id)
+                parent_player := self.mass.players.get_player(parent_player_id)
             ):
-                return parent_player.current_media
+                return parent_player.state.current_media
+        # if this is a protocol player, use the current_media of the parent player
+        if self.type == PlayerType.PROTOCOL and self.__attr_protocol_parent_id:
+            if parent_player := self.mass.players.get_player(self.__attr_protocol_parent_id):
+                return parent_player.state.current_media
         # if a pluginsource is currently active, return those details
+        active_source = self.__final_active_source
         if (
-            self.active_source
-            and (source := self.mass.players.get_plugin_source(self.active_source))
+            active_source
+            and (source := self.mass.players.get_plugin_source(active_source))
             and source.metadata
         ):
             return PlayerMedia(
@@ -1259,11 +1481,11 @@ class Player(ABC):
             )
         # if MA queue is active, return those details
         active_queue = None
-        if self._current_media and self._current_media.source_id:
-            active_queue = self.mass.player_queues.get(self._current_media.source_id)
-        if not active_queue and self.active_source:
-            active_queue = self.mass.player_queues.get(self.active_source)
-        if not active_queue and self._active_source is None:
+        if self.current_media and self.current_media.source_id:
+            active_queue = self.mass.player_queues.get(self.current_media.source_id)
+        if not active_queue and active_source:
+            active_queue = self.mass.player_queues.get(active_source)
+        if not active_queue and self.active_source is None:
             active_queue = self.mass.player_queues.get(self.player_id)
 
         if active_queue and (current_item := active_queue.current_item):
@@ -1335,30 +1557,32 @@ class Player(ABC):
             # queue is active but no current item
             return None
         # return native current media if no group/queue is active
-        if self._current_media:
+        if self.current_media:
             return PlayerMedia(
-                uri=self._current_media.uri,
-                media_type=self._current_media.media_type,
-                title=self._current_media.title,
-                artist=self._current_media.artist,
-                album=self._current_media.album,
-                image_url=self._current_media.image_url,
-                duration=self._current_media.duration,
-                source_id=self._current_media.source_id or self._active_source,
-                queue_item_id=self._current_media.queue_item_id,
-                elapsed_time=self._current_media.elapsed_time or int(self.elapsed_time)
+                uri=self.current_media.uri,
+                media_type=self.current_media.media_type,
+                title=self.current_media.title,
+                artist=self.current_media.artist,
+                album=self.current_media.album,
+                image_url=self.current_media.image_url,
+                duration=self.current_media.duration,
+                source_id=self.current_media.source_id or active_source,
+                queue_item_id=self.current_media.queue_item_id,
+                elapsed_time=self.current_media.elapsed_time or int(self.elapsed_time)
                 if self.elapsed_time
                 else None,
-                elapsed_time_last_updated=self._current_media.elapsed_time_last_updated
+                elapsed_time_last_updated=self.current_media.elapsed_time_last_updated
                 or self.elapsed_time_last_updated,
             )
         return None
 
-    __attr_source_list: UniqueList[PlayerSource] | None = None
-
-    def __calculate_source_list(self) -> UniqueList[PlayerSource]:
-        """Calculate the source list for the player."""
-        sources = UniqueList(self._source_list)
+    @cached_property
+    @final
+    def __final_source_list(self) -> UniqueList[PlayerSource]:
+        """Return the FINAL source list for the player."""
+        sources = UniqueList(self.source_list)
+        if self.type == PlayerType.PROTOCOL:
+            return sources
         # always ensure the Music Assistant Queue is in the source list
         mass_source = next((x for x in sources if x.id == self.player_id), None)
         if mass_source is None:
@@ -1381,28 +1605,251 @@ class Player(ABC):
                 sources.append(plugin_source)
         return sources
 
+    @cached_property
+    @final
+    def __final_group_members(self) -> list[str]:
+        """Return the FINAL group members of this player."""
+        if self.__final_synced_to:
+            # If player is synced to another player, it has no group members itself
+            return []
+
+        members = self.group_members.copy()
+        # If there's an active linked protocol, include its group members (translated)
+        if self.__attr_active_output_protocol and self.__attr_active_output_protocol != "native":
+            if protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol):
+                # Translate protocol player IDs to visible player IDs
+                protocol_members = self._translate_protocol_ids_to_visible(
+                    set(protocol_player.group_members)
+                )
+                for member in protocol_members:
+                    if member.player_id not in members:
+                        members.append(member.player_id)
+
+        if self.type != PlayerType.GROUP:
+            # Ensure the player_id is first in the group_members list
+            if len(members) > 0 and members[0] != self.player_id:
+                members = [self.player_id, *[m for m in members if m != self.player_id]]
+            # If the only member is self, return empty list
+            if members == [self.player_id]:
+                return []
+        return members
+
+    @cached_property
+    @final
+    def __final_synced_to(self) -> str | None:
+        """
+        Return the FINAL synced_to state.
+
+        This checks both native sync state and protocol player sync state,
+        translating protocol player IDs to visible player IDs.
+        """
+        # First check the native synced_to from the property
+        if native_synced_to := self.synced_to:
+            return native_synced_to
+
+        for linked in self.__attr_linked_protocols:
+            if not (protocol_player := self.mass.players.get_player(linked.output_protocol_id)):
+                continue
+            if protocol_player.synced_to:
+                # Protocol player is synced, translate to visible player
+                if proto_sync_parent := self.mass.players.get_player(protocol_player.synced_to):
+                    if proto_sync_parent.protocol_parent_id and (
+                        parent := self.mass.players.get_player(proto_sync_parent.protocol_parent_id)
+                    ):
+                        return parent.player_id
+
+        return None
+
+    @cached_property
+    @final
+    def __final_supported_features(self) -> set[PlayerFeature]:
+        """Return the FINAL supported features based supported output protocol(s)."""
+        base_features = self.supported_features.copy()
+        if self.__attr_active_output_protocol and self.__attr_active_output_protocol != "native":
+            # Active linked protocol: add from that specific protocol
+            if protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol):
+                for feature in protocol_player.supported_features:
+                    if feature in ACTIVE_PROTOCOL_FEATURES:
+                        base_features.add(feature)
+        # Append (allowed features) from all linked protocols
+        for linked in self.__attr_linked_protocols:
+            if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
+                for feature in protocol_player.supported_features:
+                    if feature in PROTOCOL_FEATURES:
+                        base_features.add(feature)
+        return base_features
+
+    @cached_property
+    @final
+    def __final_can_group_with(self) -> set[str]:
+        """
+        Return the FINAL set of player id's this player can group with.
+
+        This is a convenience property which calculates the final can_group_with set
+        based on any linked protocol players and current player/grouped state.
+
+        If player is synced to a native parent: return empty set (already grouped).
+        If player is synced to a protocol: can still group with other players.
+        If no active linked protocol: return can_group_with from all active output protocols.
+        If active linked protocol: return native can_group_with + active protocol's.
+
+        All protocol player IDs are translated to their visible parent player IDs.
+        """
+        result: set[str] = set()
+
+        def _should_include_player(player: Player) -> bool:
+            """Check if a player should be included in the can-group-with set."""
+            if not player.available:
+                return False
+            if player.player_id == self.player_id:
+                return False  # Don't include self
+            # Don't include (playing) players that have group members (they are group leaders)
+            if (
+                player.state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
+                and player.group_members
+                and player.type != PlayerType.PROTOCOL
+            ):
+                return False  # Regular native group leader - exclude
+            # Don't include players that are currently grouped/synced to OTHER players
+            # But DO include players grouped to THIS player (so they can be ungrouped)
+            grouped_to = player.state.synced_to or player.state.active_group
+            return grouped_to is None or grouped_to == self.player_id
+
+        if self.__final_synced_to:
+            # player is already synced/grouped, cannot group with others
+            return result
+
+        # always start with the native can_group_with options (expanded for provider instance IDs)
+        for player in self._expand_can_group_with():
+            if not _should_include_player(player):
+                continue
+            result.add(player.player_id)
+
+        # Scenario 1: Player is a protocol player - just return the (expanded) result
+        if self.type == PlayerType.PROTOCOL:
+            return result
+
+        # Translate can_group_with from active linked protocol(s) and add to result
+        for linked in self.__attr_linked_protocols:
+            if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
+                for player in self._translate_protocol_ids_to_visible(
+                    protocol_player.state.can_group_with
+                ):
+                    if not _should_include_player(player):
+                        continue
+                    result.add(player.player_id)
+        return result
+
+    @cached_property
+    @final
+    def __final_active_source(self) -> str | None:
+        """
+        Calculate the final active source based on any group memberships, source plugins etc.
+
+        Note: When an output protocol is active, the source remains the parent player's
+        source since protocol players don't have their own queue/source - they only
+        handle the actual streaming/playback.
+        """
+        # if the player is grouped/synced, use the active source of the group/parent player
+        if parent_player_id := (self.__final_synced_to or self.__final_active_group):
+            if parent_player := self.mass.players.get_player(parent_player_id):
+                return parent_player.state.active_source
+        # always prioritize active MA source
+        # (it is set on playback start and cleared on stop)
+        if self.__active_mass_source:
+            return self.__active_mass_source
+        # if a plugin source is active that belongs to this player, return that
+        for plugin_source in self.mass.players.get_plugin_sources():
+            if plugin_source.in_use_by == self.player_id:
+                return plugin_source.id
+        # active source as reported by the player itself, but only if playing/paused
+        if self.playback_state != PlaybackState.IDLE and self.active_source:
+            return self.active_source
+        # return the (last) known MA source
+        return self.__last_active_mass_source
+
+    @final
+    def _translate_protocol_ids_to_visible(self, player_ids: set[str]) -> set[Player]:
+        """
+        Translate protocol player IDs to their visible parent players.
+
+        Protocol players are hidden and users interact with visible players
+        (native or universal). This method translates protocol player IDs
+        back to the visible (parent) players.
+
+        :param player_ids: Set of player IDs (protocol player IDs).
+        :return: Set of visible players.
+        """
+        result: set[Player] = set()
+        if not player_ids:
+            return result
+        for player_id in player_ids:
+            target_player = self.mass.players.get_player(player_id)
+            if not target_player or target_player.type != PlayerType.PROTOCOL:
+                continue
+            # This is a protocol player - find its visible parent
+            if not target_player.protocol_parent_id:
+                continue
+            parent_player = self.mass.players.get_player(target_player.protocol_parent_id)
+            if not parent_player:
+                continue
+            result.add(parent_player)
+        return result
+
+    @final
+    def _expand_can_group_with(self) -> set[Player]:
+        """
+        Expand the 'can-group-with' to include all players from provider instance IDs.
+
+        This method expands any provider instance IDs (e.g., "airplay", "chromecast")
+        in the group members to all (available) players of that provider
+
+        :return: Set of available players in the can-group-with.
+        """
+        result = set()
+
+        for member_id in self.can_group_with:
+            if player := self.mass.players.get_player(member_id):
+                result.add(player)
+                continue  # already a player ID
+            # Check if member_id is a provider instance ID
+            if provider := self.mass.get_provider(member_id):
+                for player in self.mass.players.all_players(
+                    return_unavailable=False,  # Only include available players
+                    provider_filter=provider.instance_id,
+                    return_protocol_players=True,
+                ):
+                    result.add(player)
+        return result
+
     # The id of the (last) active mass source.
     # This is to keep track of the last active MA source for the player,
     # so we can restore it when needed (e.g. after switching to a plugin source).
-    __active_mass_source: str = ""
+    __active_mass_source: str | None = None
+    __last_active_mass_source: str | None = None
 
+    @final
     def set_active_mass_source(self, value: str) -> None:
         """
-        Set the id of the (last) active mass source.
+        Set the id of the active mass source.
 
         This is to keep track of the last active MA source for the player,
         so we can restore it when needed (e.g. after switching to a plugin source).
         """
         self.__active_mass_source = value
+        self.__last_active_mass_source = value
         self.update_state()
 
     __stop_called: bool = False
 
+    @final
     def mark_stop_called(self) -> None:
         """Mark that the STOP command was called on the player."""
         self.__stop_called = True
+        self.__active_mass_source = None
 
     @property
+    @final
     def stop_called(self) -> bool:
         """
         Return True if the STOP command was called on the player.
index b16d95f2d388c0c74b93c66bf65c4088cbc39845..8d7e34d4542015e97a2c47ec3c057601d26546a1 100644 (file)
@@ -71,4 +71,6 @@ class PlayerProvider(Provider):
     @property
     def players(self) -> list[Player]:
         """Return all players belonging to this provider."""
-        return self.mass.players.all(provider_filter=self.instance_id, return_sync_groups=False)
+        return self.mass.players.all_players(
+            provider_filter=self.instance_id, return_protocol_players=True
+        )
index 5caa6b2422cd4ef162350b5e72b88f080f5eac30..ad69138596c51606c24817d5fd3a54088b062263 100644 (file)
@@ -22,6 +22,10 @@ if TYPE_CHECKING:
 class Provider:
     """Base representation of a Provider implementation within Music Assistant."""
 
+    mass: MusicAssistant
+    manifest: ProviderManifest
+    config: ProviderConfig
+
     def __init__(
         self,
         mass: MusicAssistant,
index a3da28e647805b4b2e669e322f74a39eef3c9239..cb3dc2da6858acefd80b8391f654df3638199eff 100644 (file)
@@ -5,7 +5,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature
 from music_assistant_models.player import PlayerSource
 
 from music_assistant.models.player import Player, PlayerMedia
@@ -22,8 +22,8 @@ class DemoPlayer(Player):
         super().__init__(provider, player_id)
         # init some static variables
         self._attr_name = f"Demo Player {player_id}"
-        self._attr_type = PlayerType.PLAYER
         self._attr_supported_features = {
+            PlayerFeature.PLAY_MEDIA,
             PlayerFeature.POWER,
             PlayerFeature.VOLUME_SET,
             PlayerFeature.VOLUME_MUTE,
@@ -54,10 +54,10 @@ class DemoPlayer(Player):
         # OPTIONAL
         # used in conjunction with the needs_poll property.
         # this should return the interval in seconds to poll the player for state updates.
-        return 5 if self.playback_state == PlaybackState.PLAYING else 30
+        return 5 if self._attr_playback_state == PlaybackState.PLAYING else 30
 
     @property
-    def _source_list(self) -> list[PlayerSource]:
+    def source_list(self) -> list[PlayerSource]:
         """Return list of available (native) sources for this player."""
         # OPTIONAL - required only if you specified PlayerFeature.SELECT_SOURCE
         # this is an optional property that you can implement if your
@@ -267,10 +267,9 @@ class DemoPlayer(Player):
         # In this demo implementation we just optimistically set the state.
         # In a real implementation you actually send a command to the player
         # wait for the player to report a new state before updating the playback state.
+        url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
         logger = self.provider.logger.getChild(self.player_id)
-        logger.info(
-            "Received PLAY_MEDIA command on player %s with uri %s", self.display_name, media.uri
-        )
+        logger.info("Received PLAY_MEDIA command on player %s with url %s", self.display_name, url)
         self._attr_current_media = media
         self._attr_playback_state = PlaybackState.PLAYING
         self.update_state()
index 49617a44220a3923e77c532283117994240e0da0..9fc6bd7935a8fb77118fcb3d3c75b4ba6872a721 100644 (file)
@@ -117,7 +117,7 @@ class DemoPlayerprovider(PlayerProvider):
         # handle removed player
         if state_change == ServiceStateChange.Removed:
             # check if the player manager has an existing entry for this player
-            if mass_player := self.mass.players.get(player_id):
+            if mass_player := self.mass.players.get_player(player_id):
                 # the player has become unavailable
                 self.logger.debug("Player offline: %s", mass_player.display_name)
                 await self.mass.players.unregister(player_id)
@@ -127,7 +127,7 @@ class DemoPlayerprovider(PlayerProvider):
         # check if we have an existing player in the player manager
         # note that you can use this point to update the player connection info
         # if that changed (e.g. ip address)
-        if mass_player := self.mass.players.get(player_id):
+        if mass_player := self.mass.players.get_player(player_id):
             # existing player found in the player manager,
             # this is an existing player that has been updated/reconnected
             # or simply a re-announcement on mdns.
index 31b372f3a66a0f0c567c2890b24dade1c5aed8e6..8b1360de0f3227771a1258977b2e84f219fc2bb2 100644 (file)
@@ -130,6 +130,16 @@ def is_airplay2_preferred_model(manufacturer: str, model: str) -> bool:
     return False
 
 
+def is_apple_device(manufacturer: str) -> bool:
+    """Check if a device is an Apple device with native AirPlay support.
+
+    Apple devices (HomePod, Apple TV, Mac, etc.) have native AirPlay support
+    and should be exposed as PlayerType.PLAYER. Non-Apple devices with AirPlay
+    support should be exposed as PlayerType.PROTOCOL.
+    """
+    return manufacturer.lower() == "apple"
+
+
 async def get_cli_binary(protocol: StreamingProtocol) -> str:
     """Find the correct raop/airplay binary belonging to the platform.
 
index a8d7e7ababe7fdcd4f93f5c91a1544055d668464..8b03973141a65e2c3cb0aeff792b162b7e94f26e 100644 (file)
@@ -7,7 +7,13 @@ import time
 from typing import TYPE_CHECKING, cast
 
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.enums import (
+    ConfigEntryType,
+    IdentifierType,
+    PlaybackState,
+    PlayerFeature,
+    PlayerType,
+)
 
 from music_assistant.constants import CONF_ENTRY_SYNC_ADJUST, create_sample_rates_config_entry
 from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
@@ -34,6 +40,7 @@ from .constants import (
 from .helpers import (
     get_primary_ip_address_from_zeroconf,
     is_airplay2_preferred_model,
+    is_apple_device,
     is_broken_airplay_model,
     player_id_to_mac_address,
 )
@@ -88,16 +95,17 @@ class AirPlayPlayer(Player):
         self._active_pairing: AirPlayPairing | None = None
         self._transitioning = False  # Set during stream replacement to ignore stale DACP messages
         # Set (static) player attributes
-        self._attr_type = PlayerType.PLAYER
         self._attr_name = display_name
         self._attr_available = True
+        mac_address = player_id_to_mac_address(player_id)
         self._attr_device_info = DeviceInfo(
             model=model,
             manufacturer=manufacturer,
         )
-        self._attr_device_info.ip_address = address
-        self._attr_device_info.mac_address = player_id_to_mac_address(player_id)
+        self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
+        self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
         self._attr_supported_features = {
+            PlayerFeature.PLAY_MEDIA,
             PlayerFeature.PAUSE,
             PlayerFeature.SET_MEMBERS,
             PlayerFeature.MULTI_DEVICE_DSP,
@@ -107,6 +115,14 @@ class AirPlayPlayer(Player):
         self._attr_can_group_with = {provider.instance_id}
         self._attr_enabled_by_default = not is_broken_airplay_model(manufacturer, model)
 
+        # Set player type based on manufacturer:
+        # - Apple devices (HomePod, Apple TV, Mac) have native AirPlay support -> PLAYER
+        # - Non-Apple devices are generic AirPlay receivers -> PROTOCOL (wrapped in UniversalPlayer)
+        if is_apple_device(manufacturer):
+            self._attr_type = PlayerType.PLAYER
+        else:
+            self._attr_type = PlayerType.PROTOCOL
+
     @property
     def protocol(self) -> StreamingProtocol:
         """Get the streaming protocol to use/prefer for this player."""
@@ -128,18 +144,6 @@ class AirPlayPlayer(Player):
         """Return if the player requires flow mode."""
         return True
 
-    @property
-    def corrected_elapsed_time(self) -> float:
-        """Return the corrected elapsed time accounting for stream session restarts."""
-        if not self.stream or not self.stream.session:
-            return super().corrected_elapsed_time or 0.0
-        session = self.stream.session
-        elapsed = time.time() - session.start_time - session.total_pause_time
-        if session.last_paused is not None:
-            current_pause = time.time() - session.last_paused
-            elapsed -= current_pause
-        return max(0.0, elapsed)
-
     async def get_config_entries(
         self,
         action: str | None = None,
@@ -510,6 +514,8 @@ class AirPlayPlayer(Player):
         provider = cast("AirPlayProvider", self.provider)
         stream_session = AirPlayStreamSession(provider, sync_clients, AIRPLAY_FLOW_PCM_FORMAT)
         await stream_session.start(audio_source)
+        self._attr_elapsed_time = time.time() - stream_session.start_time
+        self._attr_elapsed_time_last_updated = time.time()
         self._transitioning = False
 
     async def volume_set(self, volume_level: int) -> None:
@@ -562,13 +568,21 @@ class AirPlayPlayer(Player):
                     if child_player.player_id in self._attr_group_members:
                         self._attr_group_members.remove(child_player.player_id)
 
+            # If group leader is left alone after removals, clear the group_members list
+            if (
+                self._attr_group_members
+                and len(self._attr_group_members) == 1
+                and self.player_id in self._attr_group_members
+            ):
+                self._attr_group_members = []
+
         # handle additions
         for player_id in player_ids_to_add or []:
             if player_id == self.player_id or player_id in self.group_members:
                 # nothing to do: player is already part of the group
                 continue
             child_player_to_add: AirPlayPlayer | None = cast(
-                "AirPlayPlayer | None", self.mass.players.get(player_id)
+                "AirPlayPlayer | None", self.mass.players.get_player(player_id)
             )
             if not child_player_to_add:
                 # should not happen, but guard against it
@@ -578,7 +592,7 @@ class AirPlayPlayer(Player):
 
             # ensure the child does not have an existing stream session active
             if child_player_to_add := cast(
-                "AirPlayPlayer | None", self.mass.players.get(player_id)
+                "AirPlayPlayer | None", self.mass.players.get_player(player_id)
             ):
                 if (
                     child_player_to_add.playback_state == PlaybackState.PAUSED
@@ -599,6 +613,11 @@ class AirPlayPlayer(Player):
             if stream_session:
                 await stream_session.add_client(child_player_to_add)
 
+        # Ensure group leader includes itself in group_members when it has members
+        # This is required for the synced_to property to work correctly
+        if self._attr_group_members and self.player_id not in self._attr_group_members:
+            self._attr_group_members.insert(0, self.player_id)
+
         # always update the state after modifying group members
         self.update_state()
 
@@ -606,7 +625,7 @@ class AirPlayPlayer(Player):
         """Handle callback when the current media of the player is updated."""
         if not self.stream or not self.stream.running or not self.stream.session:
             return
-        metadata = self.current_media
+        metadata = self.state.current_media
         if not metadata:
             return
         progress = int(metadata.corrected_elapsed_time or 0)
@@ -645,8 +664,8 @@ class AirPlayPlayer(Player):
             return
         if cur_address != new_address:
             self.logger.debug("Address updated from %s to %s", cur_address, new_address)
+            self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, new_address)
             self.address = new_address
-            self._attr_device_info.ip_address = new_address
         self.update_state()
 
     def set_state_from_stream(
@@ -664,18 +683,8 @@ class AirPlayPlayer(Player):
         # Ignore state updates from old/stale streams
         if stream is not None and stream != self.stream:
             return
-
         if state is not None:
-            prev_state = self._attr_playback_state
             self._attr_playback_state = state
-            if self.stream and self.stream.session:
-                if prev_state == PlaybackState.PLAYING and state != PlaybackState.PLAYING:
-                    self.stream.session.last_paused = time.time()
-                elif prev_state != PlaybackState.PLAYING and state == PlaybackState.PLAYING:
-                    if self.stream.session.last_paused is not None:
-                        pause_duration = time.time() - self.stream.session.last_paused
-                        self.stream.session.total_pause_time += pause_duration
-                        self.stream.session.last_paused = None
         if elapsed_time is not None:
             self._attr_elapsed_time = elapsed_time
             self._attr_elapsed_time_last_updated = time.time()
@@ -700,6 +709,6 @@ class AirPlayPlayer(Player):
         group_child_ids = {self.player_id}
         group_child_ids.update(self.group_members)
         for child_id in group_child_ids:
-            if client := cast("AirPlayPlayer | None", self.mass.players.get(child_id)):
+            if client := cast("AirPlayPlayer | None", self.mass.players.get_player(child_id)):
                 sync_clients.append(client)
         return sync_clients
index ca95fddb6c1d131fef545ae573a4a0dea47a3487..dba2f048c77160caf6c720a9f9175f02fb621f64 100644 (file)
@@ -118,6 +118,12 @@ class RaopStream(AirPlayProtocol):
                 player.set_state_from_stream(
                     state=PlaybackState.PLAYING, elapsed_time=0, stream=self
                 )
+            elif "elapsed milliseconds:" in line:
+                # this is received more or less every second while playing
+                millis = int(line.split("elapsed milliseconds: ")[1])
+                # note that this represents the total elapsed time of the streaming session
+                elapsed_time = millis / 1000
+                player.set_state_from_stream(elapsed_time=elapsed_time)
             if "lost packet out of backlog" in line:
                 lost_packets += 1
                 if lost_packets == 100:
index e01d4291e32ab4b8a94f7237e9fecbf9045fbfc1..2da0767a81e3da43a3418d6cda6d47185404dfe1 100644 (file)
@@ -91,7 +91,7 @@ class AirPlayProvider(PlayerProvider):
         player_id = f"ap{raw_id.lower()}"
         # handle removed player
         if state_change == ServiceStateChange.Removed:
-            if _player := self.mass.players.get(player_id):
+            if _player := self.mass.players.get_player(player_id):
                 # the player has become unavailable
                 self.logger.debug("Player offline: %s", _player.display_name)
                 await self.mass.players.unregister(player_id)
@@ -99,7 +99,7 @@ class AirPlayProvider(PlayerProvider):
         # handle update for existing device
         assert info is not None  # type guard
         player: AirPlayPlayer | None
-        if player := cast("AirPlayPlayer | None", self.mass.players.get(player_id)):
+        if player := cast("AirPlayPlayer | None", self.mass.players.get_player(player_id)):
             # update the latest discovery info for existing player
             player.set_discovery_info(info, display_name)
             return
@@ -174,15 +174,9 @@ class AirPlayProvider(PlayerProvider):
         ):
             volume = FALLBACK_VOLUME
 
-        # Append airplay to the default name for non-apple devices
-        # to make it easier for users to distinguish
-        is_apple = manufacturer.lower() == "apple"
-        if not is_apple and "airplay" not in display_name.lower():
-            display_name += " (AirPlay)"
-
         # Final check before registration to handle race conditions
         # (multiple MDNS events processed in parallel for same device)
-        if self.mass.players.get(player_id):
+        if self.mass.players.get_player(player_id):
             self.logger.debug(
                 "Player %s already registered during setup, skipping registration", player_id
             )
@@ -265,7 +259,7 @@ class AirPlayProvider(PlayerProvider):
                 self.mass.config.get_raw_player_config_value(player_id, CONF_IGNORE_VOLUME, False)
                 or player.device_info.manufacturer.lower() == "apple"
             )
-            active_queue = self.mass.player_queues.get_active_queue(player_id)
+            active_queue = self.mass.players.get_active_queue(player)
             if not active_queue:
                 self.logger.warning(
                     "DACP request for %s (%s) but no active queue found, ignoring request",
@@ -347,4 +341,4 @@ class AirPlayProvider(PlayerProvider):
 
     def get_player(self, player_id: str) -> AirPlayPlayer | None:
         """Return AirplayPlayer by id."""
-        return cast("AirPlayPlayer | None", self.mass.players.get(player_id))
+        return cast("AirPlayPlayer | None", self.mass.players.get_player(player_id))
index a6fc10a32eba0ba3bf3126478c231c9974102cd7..0d647dd56c1cbfb48396d19a494f4c7c83feea3f 100644 (file)
@@ -59,8 +59,6 @@ class AirPlayStreamSession:
         self.start_time: float = 0.0
         self.wait_start: float = 0.0
         self.seconds_streamed: float = 0
-        self.total_pause_time: float = 0.0
-        self.last_paused: float | None = None
         self._first_chunk_received = asyncio.Event()
 
     async def start(self, audio_source: AsyncGenerator[bytes, None]) -> None:
@@ -149,7 +147,7 @@ class AirPlayStreamSession:
         )
         if not allow_late_join:
             await self.stop()
-            if sync_leader.current_media:
+            if sync_leader.state.current_media:
                 self.mass.call_later(
                     0.5,
                     self.mass.players.cmd_resume(sync_leader.player_id),
index e9513a7739f2426093ae35b98f03d3637f5cfc66..dfa2104ffcff07dfd6ace945df67ca3246d96e04 100644 (file)
@@ -93,7 +93,7 @@ async def get_config_entries(
                 *(
                     ConfigValueOption(x.display_name, x.player_id)
                     for x in sorted(
-                        mass.players.all(False, False), key=lambda p: p.display_name.lower()
+                        mass.players.all_players(False, False), key=lambda p: p.display_name.lower()
                     )
                 ),
             ],
@@ -242,14 +242,14 @@ class AirPlayReceiverProvider(PluginProvider):
         # If there's an active player (source was selected on a player), use it
         if self._active_player_id:
             # Validate that the active player still exists
-            if self.mass.players.get(self._active_player_id):
+            if self.mass.players.get_player(self._active_player_id):
                 return self._active_player_id
             # Active player no longer exists, clear it
             self._active_player_id = None
 
         # Handle auto selection
         if self._default_player_id == PLAYER_ID_AUTO:
-            all_players = list(self.mass.players.all(False, False))
+            all_players = list(self.mass.players.all_players(False, False))
             # First, try to find a playing player
             for player in all_players:
                 if player.state.playback_state == PlaybackState.PLAYING:
@@ -266,7 +266,7 @@ class AirPlayReceiverProvider(PluginProvider):
             return None
 
         # Use the specific default player if configured and it still exists
-        if self.mass.players.get(self._default_player_id):
+        if self.mass.players.get_player(self._default_player_id):
             return self._default_player_id
         self.logger.warning(
             "Configured default player '%s' no longer exists", self._default_player_id
@@ -371,7 +371,7 @@ class AirPlayReceiverProvider(PluginProvider):
         # Convert player volume (0-100) to AirPlay volume (-30.0 to 0.0 dB)
         player_volume = 100  # Default to 100%
         if self._default_player_id and self._default_player_id != PLAYER_ID_AUTO:
-            if _player := self.mass.players.get(self._default_player_id):
+            if _player := self.mass.players.get_player(self._default_player_id):
                 if _player.volume_level is not None:
                     player_volume = _player.volume_level
         # Map 0-100 to -30.0...0.0
index a5b351642b35d6b41f08f302b6a76c572dc42901..1b430eb4f149af8dd8f9573085ebb61ffabfccf6 100644 (file)
@@ -267,6 +267,7 @@ class AlexaPlayer(Player):
         super().__init__(provider, player_id)
         self.device = device
         self._attr_supported_features = {
+            PlayerFeature.PLAY_MEDIA,
             PlayerFeature.VOLUME_SET,
             PlayerFeature.PAUSE,
         }
@@ -320,12 +321,14 @@ class AlexaPlayer(Player):
         if username is not None and password is not None:
             auth = BasicAuth(str(username), str(password))
 
+        stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+
         async with aiohttp.ClientSession() as session:
             try:
                 async with session.post(
                     f"{self.provider.config.get_value(CONF_API_URL)}/ma/push-url",
                     json={
-                        "streamUrl": media.uri,
+                        "streamUrl": stream_url,
                         "title": media.title,
                         "artist": media.artist,
                         "album": media.album,
index 8fc32228d82a36f2ec2c385dee2547df29735ee7..123df3722f4b5ddf07d87bb880593eb0eecf6e0a 100644 (file)
@@ -10,6 +10,7 @@ IDLE_POLL_INTERVAL = 30
 PLAYBACK_POLL_INTERVAL = 10
 
 PLAYER_FEATURES_BASE = {
+    PlayerFeature.PLAY_MEDIA,
     PlayerFeature.SET_MEMBERS,
     PlayerFeature.VOLUME_MUTE,
     PlayerFeature.PAUSE,
index f00e8c6011d8346fa6772c8177b7d4bbd2cc9a7f..1b9a4a006520f3a587b799c52771da2f068960cc 100644 (file)
@@ -6,7 +6,8 @@ import asyncio
 import time
 from typing import TYPE_CHECKING
 
-from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
+from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature
 from music_assistant_models.errors import PlayerCommandFailed
 from pyblu import Player as BluosPlayer
 from pyblu import Status, SyncStatus
@@ -61,14 +62,15 @@ class BluesoundPlayer(Player):
         self.dynamic_poll_count: int = 0
         self._listen_task: asyncio.Task | None = None
         # Set base player attributes
-        self._attr_type = PlayerType.PLAYER
         self._attr_supported_features = PLAYER_FEATURES_BASE.copy()
         self._attr_name = name
         self._attr_device_info = DeviceInfo(
             model=discovery_info.get("model", "BluOS Device"),
             manufacturer="BluOS",
         )
-        self._attr_device_info.ip_address = ip_address
+        self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
+        if mac_address := discovery_info.get("mac"):
+            self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
         self._attr_available = True
         self._attr_source_list = []
         self._attr_needs_poll = True
@@ -179,7 +181,8 @@ class BluesoundPlayer(Player):
         """Handle PLAY MEDIA for BluOS player using the provided URL."""
         self.logger.debug("Play_media called")
         self.logger.debug(media)
-        play_state = await self.client.play_url(media.uri, timeout=1)
+        url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+        play_state = await self.client.play_url(url, timeout=1)
 
         # Enable dynamic polling
         if play_state == "stream":
@@ -209,7 +212,7 @@ class BluesoundPlayer(Player):
             return
 
         def player_id_to_paired_player(player_id: str) -> PairedPlayer:
-            client = self.mass.players.get(player_id, raise_unavailable=True)
+            client = self.mass.players.get_player(player_id, raise_unavailable=True)
             return PairedPlayer(client.ip_address, client.port)
 
         if player_ids_to_remove:
@@ -222,7 +225,7 @@ class BluesoundPlayer(Player):
                 except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err:
                     self.logger.debug(f"Could not remove players: {err!s}")
                     continue
-                removed_player = self.mass.players.get(player_id)
+                removed_player = self.mass.players.get_player(player_id)
                 if removed_player:
                     removed_player._set_polling_dynamic()
                     removed_player._attr_current_media = None
@@ -237,7 +240,7 @@ class BluesoundPlayer(Player):
                     self.logger.debug(f"Could not add player {paired_player}: {err!s}")
                     continue
                 self._attr_group_members.append(player_id)
-                added_player = self.mass.players.get(player_id)
+                added_player = self.mass.players.get_player(player_id)
                 if added_player:
                     added_player._set_polling_dynamic()
                     added_player.update_state()
@@ -249,7 +252,7 @@ class BluesoundPlayer(Player):
         """Handle UNGROUP command for BluOS player."""
         leader = self.client.leader
         leader_player_id = self.client.provider.player_map((leader.ip, leader.port))
-        await self.mass.player.get(leader_player_id).set_members(None, [self.player_id])
+        await self.mass.players.get_player(leader_player_id).set_members(None, [self.player_id])
 
     async def poll(self) -> None:
         """Poll player for state updates."""
index d67f2aa8b2dc794666d8b86b03285a41b999da51..c821e2bc9ff88e04912f606d37126d8f32b1a015 100644 (file)
@@ -73,7 +73,7 @@ class BluesoundPlayerProvider(PlayerProvider):
 
         # Handle update of existing player
         assert player_id is not None  # for type checker
-        if bluos_player := self.mass.players.get(player_id):
+        if bluos_player := self.mass.players.get_player(player_id):
             bluos_player = cast("BluesoundPlayer", bluos_player)
             # Check if the IP address has changed
             if ip_address and ip_address != bluos_player.ip_address:
index 0313534e6ca7e46bf35014fa76df29a947cf05ec..1ce92cdb48078b6fc9a3e786dec0e0358c4b37a5 100644 (file)
@@ -43,6 +43,7 @@ class ChromecastInfo:
     is_dynamic_group: bool | None = None
     is_multichannel_group: bool = False  # group created for e.g. stereo pair
     is_multichannel_child: bool = False  # speaker that is part of multichannel setup
+    mac_address: str | None = None  # MAC address from eureka_info API
 
     @property
     def is_audio_group(self) -> bool:
@@ -91,6 +92,10 @@ class ChromecastInfo:
         ):
             self.is_multichannel_child = True
 
+        # Get MAC address for device matching (not available for groups)
+        if self.mac_address is None and self.cast_type != "group":
+            self.mac_address = get_mac_address(self.services, zconf)
+
 
 def get_multizone_info(services: list[ServiceInfo], zconf: Zeroconf, timeout=30):
     """Get multizone info from eureka endpoint."""
@@ -123,6 +128,35 @@ def get_multizone_info(services: list[ServiceInfo], zconf: Zeroconf, timeout=30)
     return (dynamic_groups, multichannel_groups)
 
 
+def get_mac_address(services: list[ServiceInfo], zconf: Zeroconf, timeout: int = 10) -> str | None:
+    """Get MAC address from Chromecast eureka_info API.
+
+    :param services: List of zeroconf service info.
+    :param zconf: Zeroconf instance.
+    :param timeout: Request timeout in seconds.
+    :return: MAC address string or None if not available.
+    """
+    try:
+        _, status = dial._get_status(
+            services,
+            zconf,
+            "/setup/eureka_info?options=detail",
+            True,
+            timeout,
+            None,
+        )
+        if mac_address := status.get("mac_address"):
+            # Normalize to uppercase with colons
+            mac = mac_address.upper().replace("-", ":")
+            # Ensure proper format
+            if ":" not in mac and len(mac) == 12:
+                mac = ":".join(mac[i : i + 2] for i in range(0, 12, 2))
+            return mac
+    except (urllib.error.HTTPError, urllib.error.URLError, OSError, KeyError, ValueError):
+        pass
+    return None
+
+
 class CastStatusListener:
     """
     Helper class to handle pychromecast status callbacks.
@@ -193,7 +227,7 @@ class CastStatusListener:
     def multizone_new_cast_status(self, group_uuid, cast_status) -> None:
         """Handle reception of a new CastStatus for a group."""
         mass = self.castplayer.mass
-        if group_player := mass.players.get(group_uuid):
+        if group_player := mass.players.get_player(group_uuid):
             if TYPE_CHECKING:
                 assert isinstance(group_player, ChromecastPlayer)
             if group_player.cc.media_controller.is_active:
index ae3f358cc7ac1bd32a6dde8fdf61b5d2561d6557..fd8d3e974c530fc3f549fd23e2026aa2e8d03ac1 100644 (file)
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
 from music_assistant_models.enums import (
     ConfigEntryType,
     EventType,
+    IdentifierType,
     MediaType,
     PlaybackState,
     PlayerFeature,
@@ -75,8 +76,13 @@ class ChromecastPlayer(Player):
             player_type = PlayerType.STEREO_PAIR
         elif cast_info.is_audio_group:
             player_type = PlayerType.GROUP
-        else:
+        elif self._is_google_device(cast_info):
+            # Google devices (Chromecast, Nest, Google Home) have native Cast support
             player_type = PlayerType.PLAYER
+        else:
+            # Non-Google devices are generic Chromecast receivers
+            # Will be wrapped in a UniversalPlayer
+            player_type = PlayerType.PROTOCOL
         self.cc = chromecast
         self.status_listener: CastStatusListener | None
         self.cast_info = cast_info
@@ -85,6 +91,7 @@ class ChromecastPlayer(Player):
         self.flow_meta_checksum: str | None = None
         # set static variables
         self._attr_supported_features = {
+            PlayerFeature.PLAY_MEDIA,
             PlayerFeature.POWER,
             PlayerFeature.VOLUME_SET,
             PlayerFeature.PAUSE,
@@ -109,7 +116,11 @@ class ChromecastPlayer(Player):
             model=self.cast_info.model_name,
             manufacturer=self.cast_info.manufacturer or "",
         )
-        self._attr_device_info.ip_address = self.cast_info.host
+        self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.cast_info.host)
+        self._attr_device_info.add_identifier(
+            IdentifierType.MAC_ADDRESS, self.cast_info.mac_address
+        )
+        self._attr_device_info.add_identifier(IdentifierType.UUID, str(self.cast_info.uuid))
         assert provider.mz_mgr is not None  # for type checking
         status_listener = CastStatusListener(self, provider.mz_mgr)
         self.status_listener = status_listener
@@ -137,6 +148,20 @@ class ChromecastPlayer(Player):
             )
         )
 
+    @staticmethod
+    def _is_google_device(cast_info: ChromecastInfo) -> bool:
+        """Check if a device is a Google device with native Cast support.
+
+        Google devices (Chromecast, Nest, Google Home) have native Cast support
+        and should be exposed as PlayerType.PLAYER. Non-Google devices with Cast
+        support should be exposed as PlayerType.PROTOCOL.
+        """
+        if not cast_info.manufacturer:
+            # If no manufacturer, check model name for Google devices
+            model = cast_info.model_name.lower() if cast_info.model_name else ""
+            return any(google in model for google in ("chromecast", "google", "nest", "home"))
+        return cast_info.manufacturer.lower() in ("google", "google inc.")
+
     @property
     def sendspin_mode_enabled(self) -> bool:
         """Return if sendspin mode is enabled for the player."""
@@ -150,14 +175,14 @@ class ChromecastPlayer(Player):
         """Return the linked sendspin player if available/enabled."""
         if enabled_only and not self.sendspin_mode_enabled:
             return None
-        if not (sendspin_player := self.mass.players.get(self.sendspin_player_id)):
+        if not (sendspin_player := self.mass.players.get_player(self.sendspin_player_id)):
             return None
         if not sendspin_player.available:
             return None
         return sendspin_player
 
     @property
-    def supported_features(self) -> set[PlayerFeature]:
+    def _supported_features(self) -> set[PlayerFeature]:
         """Return the supported features for this player."""
         try:
             if self.sendspin_mode_enabled:
@@ -178,7 +203,7 @@ class ChromecastPlayer(Player):
         if not sendspin_player_id.startswith("cast-"):
             return None
         # Search for a Chromecast player with matching sendspin_player_id
-        for player in self.mass.players.all():
+        for player in self.mass.players.all_players():
             if hasattr(player, "sendspin_player_id"):
                 if player.sendspin_player_id == sendspin_player_id:
                     return player.player_id
@@ -424,6 +449,7 @@ class ChromecastPlayer(Player):
             await self._play_media_sendspin(media)
             return
 
+        media.uri = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
         queuedata = {
             "type": "LOAD",
             "media": self._create_cc_media_item(media),
@@ -501,7 +527,7 @@ class ChromecastPlayer(Player):
             return
         if self.active_cast_group:
             return
-        if self.playback_state != PlaybackState.PLAYING:
+        if self._attr_playback_state != PlaybackState.PLAYING:
             return
         if not (current_media := self.current_media):
             return
@@ -623,13 +649,14 @@ class ChromecastPlayer(Player):
         # handle stereo pairs
         if self.cast_info.is_multichannel_group:
             self._attr_type = PlayerType.STEREO_PAIR
-            self.group_members.clear()
+            self._attr_group_members.clear()
         # handle cast groups
         if self.cast_info.is_audio_group and not self.cast_info.is_multichannel_group:
             assert self.mz_controller is not None  # for type checking
             self._attr_type = PlayerType.GROUP
             self._attr_group_members = [str(UUID(x)) for x in self.mz_controller.members]
             self._attr_supported_features = {
+                PlayerFeature.PLAY_MEDIA,
                 PlayerFeature.POWER,
                 PlayerFeature.VOLUME_SET,
                 PlayerFeature.PAUSE,
@@ -642,10 +669,10 @@ class ChromecastPlayer(Player):
         self._attr_volume_muted = status.volume_muted
         new_powered = self.cc.app_id is not None and self.cc.app_id != IDLE_APP_ID
         self._attr_powered = new_powered
-        if self._attr_powered and not new_powered and self._attr_type == PlayerType.GROUP:
+        if self._attr_powered and not new_powered and self.type == PlayerType.GROUP:
             # group is being powered off, update group childs
             for child_id in self.group_members:
-                if child := self.mass.players.get(child_id):
+                if child := self.mass.players.get_player(child_id):
                     self.mass.loop.call_soon_threadsafe(child.update_state)
         self.mass.loop.call_soon_threadsafe(self.update_state)
 
@@ -663,7 +690,7 @@ class ChromecastPlayer(Player):
         # handle player playing from a group
         group_player: ChromecastPlayer | None = None
         if self.active_cast_group is not None:
-            if not (group_player := self.mass.players.get(self.active_cast_group)):
+            if not (group_player := self.mass.players.get_player(self.active_cast_group)):
                 return
             if not isinstance(group_player, ChromecastPlayer):
                 return
@@ -732,15 +759,15 @@ class ChromecastPlayer(Player):
         # so we need to update the group child(s) manually
         if self.type == PlayerType.GROUP and self.powered:
             for child_id in self.group_members:
-                if child := self.mass.players.get(child_id):
+                if child := self.mass.players.get_player(child_id):
                     assert isinstance(child, ChromecastPlayer)  # for type checking
                     if not child.cast_info.is_multichannel_group:
                         continue
-                    child._attr_playback_state = self.playback_state
-                    child._attr_current_media = self.current_media
-                    child._attr_elapsed_time = self.elapsed_time
-                    child._attr_elapsed_time_last_updated = self.elapsed_time_last_updated
-                    child._attr_active_source = self.active_source
+                    child._attr_playback_state = self._attr_playback_state
+                    child._attr_current_media = self._attr_current_media
+                    child._attr_elapsed_time = self._attr_elapsed_time
+                    child._attr_elapsed_time_last_updated = self._attr_elapsed_time_last_updated
+                    child._attr_active_source = self._active_source
                     self.mass.loop.call_soon_threadsafe(child.update_state)
         self.mass.loop.call_soon_threadsafe(self.update_state)
 
@@ -770,7 +797,12 @@ class ChromecastPlayer(Player):
                 model=self.cast_info.model_name,
                 manufacturer=self.cast_info.manufacturer or "",
             )
-            self._attr_device_info.ip_address = self.cast_info.host
+            self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.cast_info.host)
+            self._attr_device_info.add_identifier(IdentifierType.UUID, str(self.cast_info.uuid))
+            if self.cast_info.mac_address:
+                self._attr_device_info.add_identifier(
+                    IdentifierType.MAC_ADDRESS, self.cast_info.mac_address
+                )
             self.mass.loop.call_soon_threadsafe(self.update_state)
 
             if new_available and self.type == PlayerType.PLAYER:
@@ -917,7 +949,7 @@ class ChromecastPlayer(Player):
         """Wait for the Sendspin player to connect and become available."""
         start_time = time.time()
         while (time.time() - start_time) < timeout:
-            if sendspin_player := self.mass.players.get(self.sendspin_player_id):
+            if sendspin_player := self.mass.players.get_player(self.sendspin_player_id):
                 if sendspin_player.available:
                     self.logger.debug(
                         "Sendspin player %s is now available", self.sendspin_player_id
index be72ed72dfae3942ffb7d90dae0832b359c73528..6cddefa3b9664552e3e1539fa344fbdd4bd3e75f 100644 (file)
@@ -118,7 +118,7 @@ class ChromecastProvider(PlayerProvider):
 
             self.logger.debug("Discovered new or updated chromecast %s", disc_info)
 
-            castplayer = self.mass.players.get(player_id)
+            castplayer = self.mass.players.get_player(player_id)
             if castplayer:
                 assert isinstance(castplayer, ChromecastPlayer)  # for type checking
                 # if player was already added, the player will take care of reconnects itself.
index 8e921d6120b39392c8125f792be5230427ac7eb7..fc83b982af2657aad1496bee5deea338fbf1913a 100644 (file)
@@ -8,7 +8,7 @@
   "credits": [
     "[Asyncio UPnP Client library by Steven Looman](https://github.com/StevenLooman/async_upnp_client)"
   ],
-  "requirements": ["async-upnp-client==0.46.2"],
+  "requirements": ["async-upnp-client==0.46.2", "defusedxml==0.7.1"],
   "documentation": "https://music-assistant.io/player-support/dlna/",
   "multi_instance": false,
   "builtin": false,
index a2966503caa6ea161144dd48852dc3c7a33acbd9..639c2ab6c153a75c2da82e9ed1e130a265cb66c8 100644 (file)
@@ -6,18 +6,20 @@ import time
 from collections.abc import Awaitable, Callable, Coroutine, Sequence
 from contextlib import suppress
 from typing import TYPE_CHECKING, Any, Concatenate
+from urllib.parse import urlparse
 
-from async_upnp_client.client import UpnpService, UpnpStateVariable
+import defusedxml.ElementTree as DefusedET
+from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable
 from async_upnp_client.exceptions import UpnpError, UpnpResponseError
 from async_upnp_client.profiles.dlna import DmrDevice, TransportState
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant_models.enums import PlaybackState, PlayerFeature
+from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature, PlayerType
 from music_assistant_models.errors import PlayerUnavailableError
-from music_assistant_models.player import DeviceInfo, PlayerMedia
+from music_assistant_models.player import PlayerMedia
 
 from music_assistant.constants import VERBOSE_LOG_LEVEL
 from music_assistant.helpers.upnp import create_didl_metadata
-from music_assistant.models.player import Player
+from music_assistant.models.player import DeviceInfo, Player
 
 from .constants import PLAYER_CONFIG_ENTRIES
 
@@ -57,7 +59,16 @@ def catch_request_errors[DLNAPlayerT: "DLNAPlayer", **P, R](
 
 
 class DLNAPlayer(Player):
-    """DLNA Player."""
+    """DLNA Player.
+
+    All DLNA players are considered generic protocol endpoints (PlayerType.PROTOCOL)
+    and will be wrapped in a UniversalPlayer. Devices with native provider support
+    (e.g., Sonos) are handled by their respective providers and will link to
+    the DLNA player as a protocol output.
+    """
+
+    # All DLNA devices are generic protocol endpoints - no vendor has native DLNA support in MA
+    _attr_type = PlayerType.PROTOCOL
 
     def __init__(
         self,
@@ -127,6 +138,26 @@ class DLNAPlayer(Player):
                     model=self.device.model_name,
                     manufacturer=self.device.manufacturer,
                 )
+                # Add UDN (player_id) as UUID identifier for matching with other protocols
+                # Strip the "uuid:" prefix if present for proper matching
+                uuid_value = self.player_id
+                if uuid_value.lower().startswith("uuid:"):
+                    uuid_value = uuid_value[5:]
+                self._attr_device_info.add_identifier(IdentifierType.UUID, uuid_value)
+                # Try to extract MAC address from UUID
+                # Many UPnP devices embed MAC in the last 12 chars of UUID
+                # e.g., uuid:4d691234-444c-164e-1234-001f33eaacf1 -> 00:1f:33:ea:ac:f1
+                mac_address = self._extract_mac_from_uuid(uuid_value)
+                if mac_address:
+                    self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
+                # Try to extract just the IP from the URL for matching
+                ip_address = self.device.device.presentation_url or self.description_url
+                with suppress(ValueError):
+                    parsed = urlparse(ip_address)
+                    if parsed.hostname:
+                        self._attr_device_info.add_identifier(
+                            IdentifierType.IP_ADDRESS, parsed.hostname
+                        )
 
     def _handle_event(
         self,
@@ -148,7 +179,8 @@ class DLNAPlayer(Player):
                 ):
                     self.force_poll = True
                     self.mass.create_task(self.poll())
-                    self.logger.debug(
+                    self.logger.log(
+                        VERBOSE_LOG_LEVEL,
                         "Received new state from event for Player %s: %s",
                         self.display_name,
                         state_variable.value,
@@ -178,13 +210,18 @@ class DLNAPlayer(Player):
     def _set_player_features(self) -> None:
         """Set Player Features based on config values and capabilities."""
         assert self.device is not None  # for type checking
-        supported_features: set[PlayerFeature] = {
+        supported_features: set[PlayerFeature] = set()
+
+        # Only add PLAY_MEDIA if the device actually supports playback
+        # Passive speakers (like stereo pair satellites) don't have play capability
+        if self.device.has_play_media:
+            supported_features.add(PlayerFeature.PLAY_MEDIA)
             # there is no way to check if a dlna player support enqueuing
             # so we simply assume it does and if it doesn't
             # you'll find out at playback time and we log a warning
-            PlayerFeature.ENQUEUE,
-            PlayerFeature.GAPLESS_PLAYBACK,
-        }
+            supported_features.add(PlayerFeature.ENQUEUE)
+            supported_features.add(PlayerFeature.GAPLESS_PLAYBACK)
+
         if self.device.has_volume_level:
             supported_features.add(PlayerFeature.VOLUME_SET)
         if self.device.has_volume_mute:
@@ -193,11 +230,103 @@ class DLNAPlayer(Player):
             supported_features.add(PlayerFeature.PAUSE)
         self._attr_supported_features = supported_features
 
-    async def setup(self) -> None:
-        """Set up player in MA."""
+    async def setup(self) -> bool:
+        """Set up player in MA.
+
+        :return: True if setup was successful, False if device should be ignored.
+        """
         await self._device_connect()
+
+        if self.device and not self.device.has_play_media:
+            self.logger.debug("Ignoring %s - no play capability", self.device.name)
+            return False
+
+        if self.device and await self._is_sonos_passive_speaker():
+            self.logger.debug("Ignoring %s - passive stereo pair speaker", self.device.name)
+            return False
+
         self.set_static_attributes()
         await self.mass.players.register_or_update(self)
+        return True
+
+    async def _is_sonos_passive_speaker(self) -> bool:
+        """Check if this is a Sonos passive stereo pair speaker.
+
+        Queries the device's own topology. If that returns 403, the device is
+        considered passive (passive satellites and speakers with UPnP disabled
+        block topology queries). If successful, checks for Invisible="1" attribute.
+        """
+        if not self.device:
+            return False
+
+        manufacturer = (self.device.manufacturer or "").lower()
+        if "sonos" not in manufacturer:
+            return False
+
+        # Extract base UUID (strip "uuid:" prefix and "_MR" suffix)
+        our_uuid = self.player_id.removeprefix("uuid:").removesuffix("_MR")
+
+        # Query this device's topology
+        upnp_device = self.device.profile_device.root_device
+        result = await self._check_invisible_in_topology(upnp_device, our_uuid)
+
+        # Return the result: True if passive/403, False if active or check failed
+        return result if result is not None else False
+
+    async def _check_invisible_in_topology(
+        self, upnp_device: UpnpDevice, our_uuid: str
+    ) -> bool | None:
+        """Check if our UUID is marked as Invisible in the topology.
+
+        :param upnp_device: UPnP device to query
+        :param our_uuid: Our device UUID to search for
+        :return: True if invisible/403 error, False if visible, None if check failed
+        """
+        zone_topology_service = None
+        for service in upnp_device.all_services:
+            if "ZoneGroupTopology" in service.service_type:
+                zone_topology_service = service
+                break
+
+        if not zone_topology_service:
+            return None
+
+        try:
+            action = zone_topology_service.action("GetZoneGroupState")
+            if not action:
+                return None
+
+            result = await action.async_call()
+            zone_group_state_xml = result.get("ZoneGroupState", "")
+            if not zone_group_state_xml:
+                return None
+
+            root = DefusedET.fromstring(zone_group_state_xml)
+            for member in root.iter("ZoneGroupMember"):
+                if member.get("UUID", "").upper() == our_uuid.upper():
+                    return str(member.get("Invisible", "0")) == "1"
+
+        except UpnpResponseError as err:
+            # 403 Forbidden indicates passive satellite (blocks topology queries)
+            if "403" in str(err):
+                self.logger.debug(
+                    "Sonos device %s returned 403 - treating as passive satellite",
+                    our_uuid,
+                )
+                return True
+            self.logger.log(
+                VERBOSE_LOG_LEVEL,
+                "Error checking Sonos zone topology: %s",
+                err,
+            )
+        except (UpnpError, DefusedET.ParseError) as err:
+            self.logger.log(
+                VERBOSE_LOG_LEVEL,
+                "Error checking Sonos zone topology: %s",
+                err,
+            )
+
+        return None
 
     def set_static_attributes(self) -> None:
         """Set static attributes."""
@@ -296,7 +425,8 @@ class DLNAPlayer(Player):
             await self.stop()
         didl_metadata = create_didl_metadata(media)
         title = media.title or media.uri
-        await self.device.async_set_transport_uri(media.uri, title, didl_metadata)
+        url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+        await self.device.async_set_transport_uri(url, title, didl_metadata)
         # Play it
         await self.device.async_wait_for_can_play(10)
         # optimistically set this timestamp to help in case of a player
@@ -374,3 +504,33 @@ class DLNAPlayer(Player):
             raise PlayerUnavailableError from err
         finally:
             self.force_poll = False
+
+    @staticmethod
+    def _extract_mac_from_uuid(uuid_value: str) -> str | None:
+        """Try to extract MAC address from UUID.
+
+        Many UPnP devices embed the MAC address in the last 12 hex characters of the UUID.
+        E.g., uuid:4d691234-444c-164e-1234-001f33eaacf1 -> 00:1f:33:ea:ac:f1
+
+        :param uuid_value: The UUID string (without 'uuid:' prefix).
+        :return: MAC address string in XX:XX:XX:XX:XX:XX format, or None if not extractable.
+        """
+        # Remove dashes and get last 12 hex characters
+        hex_chars = uuid_value.replace("-", "")
+        if len(hex_chars) < 12:
+            return None
+
+        mac_hex = hex_chars[-12:]
+
+        # Validate it looks like a MAC (all hex characters)
+        try:
+            int(mac_hex, 16)
+        except ValueError:
+            return None
+
+        # Check if it could be a valid MAC (not all zeros or all ones)
+        if mac_hex in ("000000000000", "ffffffffffff", "FFFFFFFFFFFF"):
+            return None
+
+        # Format as XX:XX:XX:XX:XX:XX
+        return ":".join(mac_hex[i : i + 2].upper() for i in range(0, 12, 2))
index 4216fc6ca8bf13b1a1b53cf4a3486049e481cd39..176a8c921f67e9fa6d4118e85933722ba2e3a062 100644 (file)
@@ -86,10 +86,6 @@ class DLNAPlayerProvider(PlayerProvider):
 
                 assert ssdp_udn is not None  # for type checking
 
-                if "rincon" in ssdp_udn.lower():
-                    # ignore Sonos devices
-                    return
-
                 discovered_devices.add(ssdp_udn)
 
                 await self._device_discovered(ssdp_udn, discovery_info["location"])
@@ -152,10 +148,14 @@ class DLNAPlayerProvider(PlayerProvider):
                     player_id=udn,
                     description_url=description_url,
                 )
-                # will be updated later.
+                # will be updated later when device connects
                 dlna_player._attr_device_info = DeviceInfo(
                     model="unknown",
                     manufacturer="unknown",
                 )
                 self.dlnaplayers[udn] = dlna_player
-            await dlna_player.setup()
+
+            # Setup will return False if the device should be ignored (e.g., passive speaker)
+            if not await dlna_player.setup():
+                # Remove from dict if it was just added
+                self.dlnaplayers.pop(udn, None)
index 30622d042d73c807cd60c3b5a32120a07c7d9a90..685e90fb7189f58f31f8e9db283e1466da12bb73 100644 (file)
@@ -6,7 +6,7 @@ import asyncio
 import time
 from typing import TYPE_CHECKING
 
-from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature
 from music_assistant_models.errors import PlayerCommandFailed, PlayerUnavailableError
 
 from music_assistant.constants import CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3
@@ -35,14 +35,13 @@ class FullyKioskPlayer(Player):
         super().__init__(provider, player_id)
         self.fully_kiosk = fully_kiosk
         # Set player attributes
-        self._attr_type = PlayerType.PLAYER
-        self._attr_supported_features = {PlayerFeature.VOLUME_SET}
+        self._attr_supported_features = {PlayerFeature.PLAY_MEDIA, PlayerFeature.VOLUME_SET}
         self._attr_name = self.fully_kiosk.deviceInfo["deviceName"]
         self._attr_device_info = DeviceInfo(
             model=self.fully_kiosk.deviceInfo["deviceModel"],
             manufacturer=self.fully_kiosk.deviceInfo["deviceManufacturer"],
         )
-        self._attr_device_info.ip_address = address
+        self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
         self._attr_available = True
         self._attr_needs_poll = True
         self._attr_poll_interval = 10
@@ -94,7 +93,8 @@ class FullyKioskPlayer(Player):
 
     async def play_media(self, media: PlayerMedia) -> None:
         """Handle PLAY MEDIA on given player."""
-        await self.fully_kiosk.playSound(media.uri, AUDIOMANAGER_STREAM_MUSIC)
+        url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+        await self.fully_kiosk.playSound(url, AUDIOMANAGER_STREAM_MUSIC)
         self._attr_current_media = media
         self._attr_elapsed_time = 0
         self._attr_elapsed_time_last_updated = time.time()
index f2d77111e79728ff1d7f86e5219792d7b43f10c7..33658d12a0c899c06024c369cb78aa0d27b5b048 100644 (file)
@@ -8,11 +8,11 @@ from typing import TYPE_CHECKING, Any, cast
 
 from hass_client.exceptions import FailedCommand
 from music_assistant_models.enums import (
+    IdentifierType,
     ImageType,
     MediaType,
     PlaybackState,
     PlayerFeature,
-    PlayerType,
 )
 from music_assistant_models.media_items import MediaItemImage
 
@@ -54,8 +54,6 @@ DEFAULT_PLAYER_CONFIG_ENTRIES = (CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,)
 class HomeAssistantPlayer(Player):
     """Home Assistant Player implementation."""
 
-    _attr_type = PlayerType.PLAYER
-
     def __init__(
         self,
         provider: PlayerProvider,
@@ -73,10 +71,16 @@ class HomeAssistantPlayer(Player):
         self._extra_data = extra_player_data
         # Set base attributes from Home Assistant state
         self._attr_available = hass_state["state"] not in UNAVAILABLE_STATES
-        self._attr_device_info = DeviceInfo.from_dict(dev_info)
+        self._attr_device_info = DeviceInfo(
+            model=dev_info.get("model", ""),
+            manufacturer=dev_info.get("manufacturer", ""),
+            software_version=dev_info.get("software_version"),
+        )
+        if mac_address := dev_info.get("mac_address"):
+            self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
         self._attr_playback_state = StateMap.get(hass_state["state"], PlaybackState.IDLE)
         # Work out supported features
-        self._attr_supported_features = set()
+        self._attr_supported_features = {PlayerFeature.PLAY_MEDIA}
         hass_supported_features = MediaPlayerEntityFeature(
             hass_state["attributes"]["supported_features"]
         )
@@ -263,6 +267,7 @@ class HomeAssistantPlayer(Player):
 
     async def play_media(self, media: PlayerMedia) -> None:
         """Handle PLAY MEDIA on given player."""
+        url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
         extra_data: dict[str, Any] = {
             # passing metadata to the player
             # so far only supported by google cast, but maybe others can follow
@@ -283,7 +288,7 @@ class HomeAssistantPlayer(Player):
             extra_data["bypass_proxy"] = True
 
         # stop the player if it is already playing
-        if self.playback_state == PlaybackState.PLAYING:
+        if self._attr_playback_state == PlaybackState.PLAYING:
             await self.stop()
 
         await self.hass.call_service(
@@ -291,7 +296,7 @@ class HomeAssistantPlayer(Player):
             service="play_media",
             target={"entity_id": self.player_id},
             service_data={
-                "media_content_id": media.uri,
+                "media_content_id": url,
                 "media_content_type": "music",
                 "enqueue": "replace",
                 "extra": extra_data,
index 4b6f10b68a38f4ea7178d3355b4a887296dfa71c..c74658292e3877b77533db72a71a13be856d6591 100644 (file)
@@ -137,7 +137,7 @@ class HomeAssistantPlayerProvider(PlayerProvider):
 
         def update_player_from_state_msg(entity_id: str, state: CompressedState) -> None:
             """Handle updating MA player with updated info in a HA CompressedState."""
-            player = cast("HomeAssistantPlayer | None", self.mass.players.get(entity_id))
+            player = cast("HomeAssistantPlayer | None", self.mass.players.get_player(entity_id))
             if player is None:
                 # edge case - one of our subscribed entities was not available at startup
                 # and now came available - we should still set it up
index 27b08dda3d024121dd4c5fce0d642fddd930a241..d46d31d8cfcd8e873a90f919b517e3529c994077 100644 (file)
@@ -254,7 +254,8 @@ class HeosPlayer(Player):
 
     async def play_media(self, media: PlayerMedia) -> None:
         """Handle PLAY MEDIA command on given player."""
-        await self._device.play_url(media.uri)
+        url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+        await self._device.play_url(url)
 
         self._attr_current_media = media
         self._attr_active_source = self.player_id
index afe8c9babc682ff9c8cb7860331b708a12a43dd3..40bca138f2495042944259634e0c126b7c1bb75b 100644 (file)
@@ -85,7 +85,7 @@ class HeosPlayerProvider(PlayerProvider):
         self.logger.debug("Controller event received: %s", event)
 
         if event == const.EVENT_GROUPS_CHANGED:
-            for player in self.mass.players.all(provider_filter=self.instance_id):
+            for player in self.mass.players.all_players(provider_filter=self.instance_id):
                 assert isinstance(player, HeosPlayer)  # for type checking
                 await player.build_group_list()
 
@@ -144,7 +144,7 @@ class HeosPlayerProvider(PlayerProvider):
             devices = await self._heos.get_players()
             for device in devices.values():
                 player_id = str(device.player_id)
-                if player := cast("HeosPlayer", self.mass.players.get(player_id)):
+                if player := cast("HeosPlayer", self.mass.players.get_player(player_id)):
                     self.logger.debug(
                         "Updating existing HEOS player: %s (%s)", device.name, player_id
                     )
index ed95ba180879ba79ed3661db20201c7479aba707..6d558d17b50c2162a6c59fbeb049c2816a00d728 100644 (file)
@@ -17,7 +17,12 @@ from aiomusiccast.capabilities import TextSensor as MCTextSensor
 from aiomusiccast.exceptions import MusicCastGroupException
 from aiomusiccast.pyamaha import MusicCastConnectionException
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature
+from music_assistant_models.enums import (
+    ConfigEntryType,
+    IdentifierType,
+    PlaybackState,
+    PlayerFeature,
+)
 from music_assistant_models.player import (
     DeviceInfo,
     PlayerMedia,
@@ -108,6 +113,7 @@ class MusicCastPlayer(Player):
     def set_static_attributes(self) -> None:
         """Set static properties."""
         self._attr_supported_features = {
+            PlayerFeature.PLAY_MEDIA,
             PlayerFeature.VOLUME_SET,
             PlayerFeature.VOLUME_MUTE,
             PlayerFeature.PAUSE,  # for non MA control, see pause method
@@ -126,6 +132,14 @@ class MusicCastPlayer(Player):
             model=self.physical_device.device.data.model_name or "unknown model",
             software_version=(self.physical_device.device.data.system_version or "unknown version"),
         )
+        if device_ip := self.physical_device.device.device.ip:
+            self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, device_ip)
+        if device_id := self.physical_device.device.data.device_id:
+            self._attr_device_info.add_identifier(IdentifierType.UUID, device_id)
+            # device_id is the MAC address (12 hex chars), format as XX:XX:XX:XX:XX:XX
+            if len(device_id) == 12:
+                mac = ":".join(device_id[i : i + 2].upper() for i in range(0, 12, 2))
+                self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
 
         # polling
         self._attr_needs_poll = True
@@ -258,7 +272,9 @@ class MusicCastPlayer(Player):
         elif self.zone_device.is_client:
             _server = self.zone_device.group_server
             _server_id = self._get_player_id_from_zone_device(_server)
-            _server_player = cast("MusicCastPlayer | None", self.mass.players.get(_server_id))
+            _server_player = cast(
+                "MusicCastPlayer | None", self.mass.players.get_player(_server_id)
+            )
             _server_update_helper: None | UpnpUpdateHelper = None
             if _server_player is not None:
                 _server_update_helper = _server_player.upnp_update_helper
@@ -292,7 +308,9 @@ class MusicCastPlayer(Player):
         elif self.zone_device.is_client:
             _server = self.zone_device.group_server
             _server_id = self._get_player_id_from_zone_device(_server)
-            _server_player = cast("MusicCastPlayer | None", self.mass.players.get(_server_id))
+            _server_player = cast(
+                "MusicCastPlayer | None", self.mass.players.get_player(_server_id)
+            )
             if _server_player is not None and _server_player.upnp_update_helper is not None:
                 self._attr_active_source = (
                     self.zone_device.source_id
@@ -466,7 +484,7 @@ class MusicCastPlayer(Player):
         )
         # verify that this source actually exists and is non net
         _allowed_sources = self._get_allowed_sources_zone_switch(zone_player)
-        mass_player = self.mass.players.get(player_id)
+        mass_player = self.mass.players.get_player(player_id)
         if mass_player is None:
             # Do not assert here, should the player not yet exist
             return
@@ -523,7 +541,7 @@ class MusicCastPlayer(Player):
 
         # set other zone unavailable
         for zone_device in self.zone_device.other_zones:
-            if zone_device_player := self.mass.players.get(
+            if zone_device_player := self.mass.players.get_player(
                 self._get_player_id_from_zone_device(zone_device)
             ):
                 assert isinstance(zone_device_player, MusicCastPlayer)  # for type checking
@@ -624,6 +642,7 @@ class MusicCastPlayer(Player):
             # just in case
             if self.zone_device.source_id != "server":
                 await self.select_source("server")
+            media.uri = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
             await avt_set_url(self.mass.http_session, self.physical_device, player_media=media)
             await avt_play(self.mass.http_session, self.physical_device)
 
@@ -707,7 +726,7 @@ class MusicCastPlayer(Player):
         # Removing players
         if player_ids_to_remove:
             for player_id in player_ids_to_remove:
-                if player := self.mass.players.get(player_id):
+                if player := self.mass.players.get_player(player_id):
                     assert isinstance(player, MusicCastPlayer)  # for type checking
                     await player.ungroup()
 
@@ -718,7 +737,7 @@ class MusicCastPlayer(Player):
         children_zones: list[str] = []  # list[ma_player_id]
         player_ids_to_add = [] if player_ids_to_add is None else player_ids_to_add
         for child_id in player_ids_to_add:
-            if child_player := self.mass.players.get(child_id):
+            if child_player := self.mass.players.get_player(child_id):
                 assert isinstance(child_player, MusicCastPlayer)  # for type checking
                 _other_zone_mc: MusicCastZoneDevice | None = None
                 for x in child_player.zone_device.other_zones:
@@ -737,7 +756,7 @@ class MusicCastPlayer(Player):
                     children.add(child_id)
 
         for child_id in children_zones:
-            child_player = self.mass.players.get(child_id)
+            child_player = self.mass.players.get_player(child_id)
             if TYPE_CHECKING:
                 child_player = cast("MusicCastPlayer", child_player)
             if child_player.zone_device.state == MusicCastPlayerState.OFF:
@@ -748,7 +767,7 @@ class MusicCastPlayer(Player):
 
         child_player_zone_devices: list[MusicCastZoneDevice] = []
         for child_id in children:
-            child_player = self.mass.players.get(child_id)
+            child_player = self.mass.players.get_player(child_id)
             if TYPE_CHECKING:
                 child_player = cast("MusicCastPlayer", child_player)
             child_player_zone_devices.append(child_player.zone_device)
index 45bcc0b7389995c908300853e14fc82ac177af21..77b9c35c75add2378dbda9e7eb1c973388fca5c0 100644 (file)
@@ -96,7 +96,7 @@ class MusicCastProvider(PlayerProvider):
 
     async def unload(self, is_removed: bool = False) -> None:
         """Call on unload."""
-        for mc_player in self.mass.players.all(provider_filter=self.instance_id):
+        for mc_player in self.mass.players.all_players(provider_filter=self.instance_id):
             assert isinstance(mc_player, MusicCastPlayer)  # for type checking
             mc_player.physical_device.remove()
 
@@ -159,7 +159,7 @@ class MusicCastProvider(PlayerProvider):
         if not check:
             return
 
-        if self.mass.players.get(device_id) is not None:
+        if self.mass.players.get_player(device_id) is not None:
             return
         mc_player_known = self.musiccast_player_helpers.get(device_id)
         if mc_player_known is not None and (
index b1232918b5201df2134fbc5219feff490c06f417..aa7af4d89d76a216e8034b34f15f2c9a49881b5a 100644 (file)
@@ -72,7 +72,7 @@ async def get_config_entries(
     player_name_default = None
     if values and values.get(CONF_MASS_PLAYER_ID):
         player_id = str(values.get(CONF_MASS_PLAYER_ID))
-        if player := mass.players.get(player_id):
+        if player := mass.players.get_player(player_id):
             player_name_default = player.display_name
 
     return (
@@ -96,7 +96,7 @@ async def get_config_entries(
             options=[
                 ConfigValueOption(x.display_name, x.player_id)
                 for x in sorted(
-                    mass.players.all(False, False), key=lambda p: p.display_name.lower()
+                    mass.players.all_players(False, False), key=lambda p: p.display_name.lower()
                 )
             ],
         ),
@@ -188,7 +188,7 @@ class PlexConnectProvider(PluginProvider):
         )
 
         # Now try to setup the player instance
-        player = self.mass.players.get(self.mass_player_id)
+        player = self.mass.players.get_player(self.mass_player_id)
         if not player:
             self.logger.info(
                 f"Player {self.mass_player_id} not found yet, waiting for PLAYER_ADDED event"
@@ -259,7 +259,7 @@ class PlexConnectProvider(PluginProvider):
             self.logger.error("Cannot setup player instance: Plex provider not available")
             return
 
-        player = self.mass.players.get(self.mass_player_id)
+        player = self.mass.players.get_player(self.mass_player_id)
         if not player:
             self.logger.warning(f"Player {self.mass_player_id} not found")
             return
index 9c6e4b7254bfe0c3a669bafab738e4dda498143e..082398b569c95933b6ab13ce4b08bfa0913bda72 100644 (file)
@@ -172,7 +172,7 @@ class PlexRemoteControlServer:
         # Flag to prevent circular updates when we modify the queue ourselves
         self._updating_from_plex = False
 
-        self.player = self.provider.mass.players.get(self._ma_player_id)  # type: ignore[arg-type]
+        self.player = self.provider.mass.players.get_player(self._ma_player_id)  # type: ignore[arg-type]
 
         self.device_name = f"{self.player.display_name}" if self.player else "Music Assistant"
 
@@ -284,7 +284,7 @@ class PlexRemoteControlServer:
         # Get player name
         player_name = "Music Assistant"
         if self._ma_player_id:
-            player = self.provider.mass.players.get(self._ma_player_id)
+            player = self.provider.mass.players.get_player(self._ma_player_id)
             if player:
                 player_name = player.display_name
 
@@ -351,28 +351,28 @@ class PlexRemoteControlServer:
 
     async def _ungroup_player_if_needed(self, player_id: str) -> None:
         """Ungroup player before playback if it's part of a group/sync."""
-        player = self.provider.mass.players.get(player_id)
+        player = self.provider.mass.players.get_player(player_id)
         if not player or player.type == PlayerType.GROUP:
             return
 
-        if not (player.synced_to or player.group_members or player.active_group):
+        if not (player.state.synced_to or player.state.group_members or player.state.active_group):
             return
 
         LOGGER.debug("Ungrouping player %s before starting playback from Plex", player.display_name)
         # Use set_members directly on the group to bypass static member check
         if (
-            player.active_group
-            and (group := self.provider.mass.players.get(player.active_group))
+            player.state.active_group
+            and (group := self.provider.mass.players.get_player(player.state.active_group))
             and group.supports_feature(PlayerFeature.SET_MEMBERS)
         ):
             await group.set_members(player_ids_to_remove=[player_id])
         elif (
-            player.synced_to
-            and (sync_leader := self.provider.mass.players.get(player.synced_to))
+            player.state.synced_to
+            and (sync_leader := self.provider.mass.players.get_player(player.state.synced_to))
             and sync_leader.supports_feature(PlayerFeature.SET_MEMBERS)
         ):
             await sync_leader.set_members(player_ids_to_remove=[player_id])
-        elif player.group_members and player.supports_feature(PlayerFeature.SET_MEMBERS):
+        elif player.state.group_members and player.supports_feature(PlayerFeature.SET_MEMBERS):
             await player.set_members(player_ids_to_remove=player.group_members)
 
     async def handle_play_media(self, request: web.Request) -> web.Response:
@@ -1322,14 +1322,14 @@ class PlexRemoteControlServer:
         # Get player name
         player_name = "Music Assistant"
         if self._ma_player_id:
-            player = self.provider.mass.players.get(self._ma_player_id)
+            player = self.provider.mass.players.get_player(self._ma_player_id)
             if player:
                 player_name = player.display_name
 
         # Get player state
         state = "stopped"
         if self._ma_player_id:
-            player = self.provider.mass.players.get(self._ma_player_id)
+            player = self.provider.mass.players.get_player(self._ma_player_id)
             if player and player.state:
                 state_value = (
                     player.state.value if hasattr(player.state, "value") else str(player.state)
@@ -1453,7 +1453,7 @@ class PlexRemoteControlServer:
         player_id = self._ma_player_id
 
         # Get MA player and queue
-        player = self.provider.mass.players.get(player_id) if player_id else None
+        player = self.provider.mass.players.get_player(player_id) if player_id else None
         queue = self.provider.mass.player_queues.get(player_id) if player_id else None
 
         # Controllable features for music
@@ -1757,7 +1757,7 @@ class PlexRemoteControlServer:
             return
 
         try:
-            player = self.provider.mass.players.get(self._ma_player_id)
+            player = self.provider.mass.players.get_player(self._ma_player_id)
             queue = self.provider.mass.player_queues.get(self._ma_player_id)
 
             if (
index 3f2885fc89752137098148214bca8763eddbf87b..557343215fdcbbe2cdbe299aefe6231a0c531b59 100644 (file)
@@ -7,7 +7,7 @@ import time
 from typing import TYPE_CHECKING, Any, cast
 from urllib.parse import urlencode
 
-from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature
 
 from music_assistant.constants import CONF_ENTRY_HTTP_PROFILE
 from music_assistant.models.player import Player, PlayerMedia
@@ -38,8 +38,8 @@ class MediaAssistantPlayer(Player):
         self.roku = roku
         self.queued = queued
         self._attr_name = roku_name
-        self._attr_type = PlayerType.PLAYER
         self._attr_supported_features = {
+            PlayerFeature.PLAY_MEDIA,
             PlayerFeature.POWER,  # if the player can be turned on/off
             PlayerFeature.PAUSE,
             PlayerFeature.VOLUME_MUTE,
@@ -168,6 +168,7 @@ class MediaAssistantPlayer(Player):
 
     async def play_media(self, media: PlayerMedia) -> None:
         """Play media command."""
+        stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
         try:
             device_info = await self.roku.update()
 
@@ -181,7 +182,7 @@ class MediaAssistantPlayer(Player):
                 )
 
             f_media = {
-                "u": media.uri,
+                "u": stream_url,
                 "t": "a",
                 "albumName": media.album or "",
                 "songName": media.title,
@@ -290,8 +291,8 @@ class MediaAssistantPlayer(Player):
                 if "position" in media_state:
                     try:
                         position = int(media_state["position"].split(" ", 1)[0]) / 1000
-                        if self.elapsed_time is not None:
-                            if abs(position - self.elapsed_time) > 10:
+                        if self._attr_elapsed_time is not None:
+                            if abs(position - self._attr_elapsed_time) > 10:
                                 self._attr_current_media = self.queued
                         self._attr_elapsed_time = position
                         self._attr_elapsed_time_last_updated = time.time()
@@ -302,14 +303,17 @@ class MediaAssistantPlayer(Player):
 
                 self.update_state()
 
-                if not self.current_media or self._attr_playback_state != PlaybackState.PLAYING:
+                if (
+                    not self.state.current_media
+                    or self._attr_playback_state != PlaybackState.PLAYING
+                ):
                     return
 
-                image_url = self.current_media.image_url or ""
+                image_url = self.state.current_media.image_url or ""
 
-                album_name = self.current_media.album or ""
-                song_name = self.current_media.title or ""
-                artist_name = self.current_media.artist or ""
+                album_name = self.state.current_media.album or ""
+                song_name = self.state.current_media.title or ""
+                artist_name = self.state.current_media.artist or ""
                 if app_running and self.flow_mode:
                     await self.roku_input(
                         {
index 9dbbe1edd2a8a7be0888e1518e9dfb07d8314d0d..0ca6bfceab7fa2e536c08adffc88e9408cfa5136 100644 (file)
@@ -7,6 +7,7 @@ import logging
 from typing import TYPE_CHECKING, cast
 
 from async_upnp_client.search import async_search
+from music_assistant_models.enums import IdentifierType
 from music_assistant_models.player import DeviceInfo
 from rokuecp import Roku
 
@@ -156,7 +157,7 @@ class MediaAssistantprovider(PlayerProvider):
                     # nothing to do, device is already connected
                     return
                 # update description url to newly discovered one
-                roku_player.device_info.ip_address = ip
+                roku_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, ip)
             else:
                 roku_player = MediaAssistantPlayer(
                     provider=self,
@@ -170,7 +171,18 @@ class MediaAssistantprovider(PlayerProvider):
                     model_id=device.info.model_number,
                     manufacturer=device.info.brand,
                 )
-                roku_player._attr_device_info.ip_address = ip
+                roku_player._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip)
+                roku_player._attr_device_info.add_identifier(
+                    IdentifierType.SERIAL_NUMBER, device.info.serial_number
+                )
+                if device.info.ethernet_mac:
+                    roku_player._attr_device_info.add_identifier(
+                        IdentifierType.MAC_ADDRESS, device.info.ethernet_mac
+                    )
+                elif device.info.wifi_mac:
+                    roku_player._attr_device_info.add_identifier(
+                        IdentifierType.MAC_ADDRESS, device.info.wifi_mac
+                    )
 
                 self.roku_players[player_id] = roku_player
             await roku_player.setup()
index c304f71dda2ce287b42df30919e3f1e5794dd62e..91987a35498768a9efc6157aabb5cf781c755c0a 100644 (file)
@@ -172,6 +172,8 @@ class MusicAssistantMediaStream(MediaStream):
 class SendspinPlayer(Player):
     """A sendspin audio player in Music Assistant."""
 
+    _attr_type = PlayerType.PROTOCOL
+
     api: SendspinClient
     unsub_event_cb: Callable[[], None]
     unsub_group_event_cb: Callable[[], None]
@@ -200,8 +202,8 @@ class SendspinPlayer(Player):
         self.logger = self.provider.logger.getChild(player_id)
         # init some static variables
         self._attr_name = sendspin_client.name
-        self._attr_type = PlayerType.PLAYER
         self._attr_supported_features = {
+            PlayerFeature.PLAY_MEDIA,
             PlayerFeature.SET_MEMBERS,
             PlayerFeature.MULTI_DEVICE_DSP,
             PlayerFeature.VOLUME_SET,
@@ -335,7 +337,7 @@ class SendspinPlayer(Player):
                 self._attr_group_members = []
                 # 3. assign new leader if there are members left
                 if len(group_members) > 0 and (
-                    new_leader := self.mass.players.get(group_members[0])
+                    new_leader := self.mass.players.get_player(group_members[0])
                 ):
                     new_leader = cast("SendspinPlayer", new_leader)
                     new_leader._attr_group_members = group_members[1:]
@@ -461,11 +463,11 @@ class SendspinPlayer(Player):
             "set_members called: adding %s, removing %s", player_ids_to_add, player_ids_to_remove
         )
         for player_id in player_ids_to_remove or []:
-            player = self.mass.players.get(player_id, True)
+            player = self.mass.players.get_player(player_id, True)
             player = cast("SendspinPlayer", player)  # For type checking
             await self.api.group.remove_client(player.api)
         for player_id in player_ids_to_add or []:
-            player = self.mass.players.get(player_id, True)
+            player = self.mass.players.get_player(player_id, True)
             player = cast("SendspinPlayer", player)  # For type checking
             await self.api.group.add_client(player.api)
         # self.group_members will be updated by the group event callback
@@ -535,7 +537,7 @@ class SendspinPlayer(Player):
             # Only leader sends metadata
             return
 
-        if self.current_media is None:
+        if self.state.current_media is None:
             # Clear metadata when no media loaded
             self.api.group.set_metadata(Metadata())
             return
@@ -545,7 +547,7 @@ class SendspinPlayer(Player):
         """Send the current media metadata to the sendspin group."""
         if not self.available:
             return
-        current_media = self.current_media
+        current_media = self.state.current_media
         if current_media is None:
             return
         # check if we are playing a MA queue item
index 6743b03387b531fce1f04264a5b842ccbaf92b0d..a84b925dc5f8e8e576f6d5c47e0159cf8801bccd 100644 (file)
@@ -6,27 +6,28 @@ import asyncio
 from contextlib import suppress
 from typing import TYPE_CHECKING, TypedDict, cast
 
-from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
+from music_assistant_models.enums import (
+    IdentifierType,
+    MediaType,
+    PlaybackState,
+    PlayerFeature,
+    PlayerType,
+)
 from music_assistant_models.player import DeviceInfo, PlayerMedia
 from propcache import under_cached_property as cached_property
 
-from music_assistant.constants import (
-    ATTR_ANNOUNCEMENT_IN_PROGRESS,
-    CONF_ENTRY_HTTP_PROFILE_HIDDEN,
-    SYNCGROUP_PREFIX,
-)
+from music_assistant.constants import ATTR_ANNOUNCEMENT_IN_PROGRESS, CONF_ENTRY_HTTP_PROFILE_HIDDEN
 from music_assistant.models.player import Player
 from music_assistant.providers.snapcast.constants import CONF_ENTRY_SAMPLE_RATES_SNAPCAST
 from music_assistant.providers.snapcast.ma_stream import SnapcastMAStream
+from music_assistant.providers.sync_group.constants import SGP_PREFIX
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
 
     from music_assistant.providers.snapcast.provider import SnapCastProvider
-    from music_assistant.providers.snapcast.snap_cntrl_proto import (
-        SnapclientProto,
-        SnapstreamProto,
-    )
+    from music_assistant.providers.snapcast.snap_cntrl_proto import SnapclientProto, SnapstreamProto
 
 
 class TrackedPlayerState(TypedDict, total=False):
@@ -58,6 +59,8 @@ class TrackedPlayerState(TypedDict, total=False):
 class SnapCastPlayer(Player):
     """SnapCastPlayer."""
 
+    _attr_type = PlayerType.PROTOCOL
+
     def __init__(
         self,
         provider: SnapCastProvider,
@@ -97,7 +100,7 @@ class SnapCastPlayer(Player):
         if len(grp_player_ids) < 2 or grp_name not in grp_player_ids:
             return None
 
-        if leader_player := self.mass.players.get(grp_name):
+        if leader_player := self.mass.players.get_player(grp_name):
             return grp_name if leader_player.available else None
 
         return None
@@ -168,13 +171,17 @@ class SnapCastPlayer(Player):
         self._attr_available = self.snap_client.connected
 
         host_dict = self.snap_client._client.get("host", {})
-        os, arch, ip = (host_dict.get(key, "") for key in ["os", "arch", "ip"])
+        os, arch, ip, mac = (host_dict.get(key, "") for key in ["os", "arch", "ip", "mac"])
         self._attr_device_info = DeviceInfo(
             model=os,
             manufacturer=arch,
         )
-        self._attr_device_info.ip_address = ip
+        if ip and (host := self.snap_client._client.get("host")):
+            self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, host.get("ip"))
+        if mac:
+            self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
         self._attr_supported_features = {
+            PlayerFeature.PLAY_MEDIA,
             PlayerFeature.SET_MEMBERS,
             PlayerFeature.VOLUME_SET,
             PlayerFeature.VOLUME_MUTE,
@@ -231,19 +238,17 @@ class SnapCastPlayer(Player):
         ]
 
         curr_stream_id = player_group.stream
-        sync_group_player = None
         if curr_ma_stream := self.snap_provider.get_snap_ma_stream(curr_stream_id):
             media = curr_ma_stream.media
             if media.media_type == MediaType.PLUGIN_SOURCE:
                 custom_data = media.custom_data or {}
                 assigned_player = custom_data.get("player_id", "")
-                if assigned_player.startswith(SYNCGROUP_PREFIX):
-                    sync_group_player = self.mass.players.get(assigned_player)
+                if assigned_player.startswith(SGP_PREFIX):
+                    sync_group_player = self.mass.players.get_player(assigned_player)
             else:
                 media_src_id = media.source_id or ""
-                if media_src_id.startswith(SYNCGROUP_PREFIX):
-                    sync_group_player = self.mass.players.get(media_src_id)
-
+                if media_src_id.startswith(SGP_PREFIX):
+                    sync_group_player = self.mass.players.get_player(media_src_id)
         if sync_group_player and self.player_id in (player_ids_to_remove or []):
             # players in sync_group_player.group_members will be rejoined
             # remove others first
@@ -429,7 +434,7 @@ class SnapCastPlayer(Player):
                 pl.available
                 for cl_id in members
                 if (pl_id := self.snap_provider._get_ma_id(cl_id))
-                and (pl := self.mass.players.get(pl_id))
+                and (pl := self.mass.players.get_player(pl_id))
             ],
         }
 
@@ -504,35 +509,16 @@ class SnapCastPlayer(Player):
             return ""
         return snap_group.name
 
-    @cached_property
-    def _current_media(self) -> PlayerMedia | None:
-        """
-        Return the current media being played by the player.
-
-        Note that this is NOT the final current media of the player,
-        as it may be overridden by a active group/sync membership.
-        Hence it's marked as a private property.
-        The final current media can be retrieved by using the 'current_media' property.
-        """
+    @property
+    def current_media(self) -> PlayerMedia | None:
+        """Return the current media being played by the player."""
         if snap_ma_stream := self.active_snap_ma_stream:
             return snap_ma_stream.media
         return None
 
     @property
-    def _active_source(self) -> str | None:
-        """
-        Return the (id of) the active source of the player.
-
-        Only required if the player supports PlayerFeature.SELECT_SOURCE.
-
-        Set to None if the player is not currently playing a source or
-        the player_id if the player is currently playing a MA queue.
-
-        Note that this is NOT the final active source of the player,
-        as it may be overridden by a active group/sync membership.
-        Hence it's marked as a private property.
-        The final active source can be retrieved by using the 'active_source' property.
-        """
+    def active_source(self) -> str | None:
+        """Return the (id of) the active source of the player."""
         grp = self.snap_client.group
         if grp is None or grp.stream is None:
             return None
@@ -567,5 +553,5 @@ class SnapCastPlayer(Player):
         return [
             ma_player
             for ma_id in self._get_player_ids_of_curr_group()
-            if (ma_player := self.mass.players.get(ma_id))
+            if (ma_player := self.mass.players.get_player(ma_id))
         ]
index 2e405b7d0ab7b953fcbf26ac39299e982454a47e..3acb8148b62c6ac361b42fdfd8c081941e246c05 100644 (file)
@@ -168,7 +168,7 @@ class SnapCastProvider(PlayerProvider):
 
         for snap_client in self._snapserver.clients:
             player_id = self._get_ma_id(snap_client.identifier)
-            if not (player := self.mass.players.get(player_id, raise_unavailable=False)):
+            if not (player := self.mass.players.get_player(player_id, raise_unavailable=False)):
                 continue
             if player.playback_state != PlaybackState.PLAYING:
                 continue
@@ -350,7 +350,7 @@ class SnapCastProvider(PlayerProvider):
     def _handle_player_init(self, snap_client: SnapclientProto) -> SnapCastPlayer:
         """Process Snapcast add to Player controller."""
         player_id = self._generate_and_register_id(snap_client.identifier)
-        player = self.mass.players.get(player_id, raise_unavailable=False)
+        player = self.mass.players.get_player(player_id, raise_unavailable=False)
         if not player:
             snap_client = self._snapserver.client(self._get_snapclient_id(player_id))
             player = SnapCastPlayer(
@@ -521,7 +521,7 @@ class SnapCastProvider(PlayerProvider):
             self._snapserver.synchronize(res)
             for client_id in group_members:
                 ma_player_id = self._get_ma_id(client_id)
-                if ma_player := cast("SnapCastPlayer", self.mass.players.get(ma_player_id)):
+                if ma_player := cast("SnapCastPlayer", self.mass.players.get_player(ma_player_id)):
                     client = self._snapserver.client(client_id)
                     if client is not None:
                         if client.group is not None:
@@ -691,7 +691,7 @@ class SnapCastProvider(PlayerProvider):
         if player_id is None:
             return None
 
-        if ma_player := self.mass.players.get(player_id):
+        if ma_player := self.mass.players.get_player(player_id):
             assert isinstance(ma_player, SnapCastPlayer)  # for type checking
             return ma_player
 
index bd86aee3a0bfde1fb6c1c8ac1cf5a6c56fa47a96..f292ec7c713ebe5cfe5f281846be3e907a624385 100644 (file)
@@ -31,8 +31,6 @@ SOURCE_UNKNOWN = "unknown"
 SOURCE_TV = "tv"
 SOURCE_RADIO = "radio"
 
-CONF_AIRPLAY_MODE = "airplay_mode"
-
 PLAYER_SOURCE_MAP = {
     SOURCE_LINE_IN: PlayerSource(
         id=SOURCE_LINE_IN,
index 954dadebae2d8684a138787e1c1fd309112ea237..d3ece7daaee61150357e9b9b17abd69fb54e4d19 100644 (file)
@@ -20,17 +20,15 @@ from aiosonos.client import SonosLocalApiClient
 from aiosonos.const import EventType as SonosEventType
 from aiosonos.const import SonosEvent
 from aiosonos.exceptions import ConnectionFailed, FailedCommand
-from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
 from music_assistant_models.enums import (
-    ConfigEntryType,
-    EventType,
+    IdentifierType,
     MediaType,
     PlaybackState,
     PlayerFeature,
     RepeatMode,
 )
 from music_assistant_models.errors import PlayerCommandFailed
-from music_assistant_models.player import PlayerMedia
+from music_assistant_models.player import OutputProtocol, PlayerMedia
 
 from music_assistant.constants import (
     CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
@@ -38,10 +36,8 @@ from music_assistant.constants import (
     create_sample_rates_config_entry,
 )
 from music_assistant.helpers.tags import async_parse_tags
-from music_assistant.helpers.upnp import get_xml_soap_set_next_url, get_xml_soap_set_url
 from music_assistant.models.player import Player
 from music_assistant.providers.sonos.const import (
-    CONF_AIRPLAY_MODE,
     PLAYBACK_STATE_MAP,
     PLAYER_SOURCE_MAP,
     SOURCE_AIRPLAY,
@@ -54,11 +50,12 @@ from music_assistant.providers.sonos.const import (
 
 if TYPE_CHECKING:
     from aiosonos.api.models import DiscoveryInfo as SonosDiscoveryInfo
-    from music_assistant_models.event import MassEvent
+    from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
 
     from .provider import SonosPlayerProvider
 
 SUPPORTED_FEATURES = {
+    PlayerFeature.PLAY_MEDIA,
     PlayerFeature.PAUSE,
     PlayerFeature.SEEK,
     PlayerFeature.SELECT_SOURCE,
@@ -89,30 +86,8 @@ class SonosPlayer(Player):
         self.discovery_info = discovery_info
         self.connected: bool = False
         self._listen_task: asyncio.Task | None = None
-        # Sonos speakers can optionally have airplay (most S2 speakers do)
-        # and this airplay player can also be a player within MA.
-        # We can do some smart stuff if we link them together where possible.
-        # The player we can just guess from the sonos player id (mac address).
-        self.airplay_player_id = f"ap{self.player_id[7:-5].lower()}"
         self.sonos_queue: SonosQueue = SonosQueue()
 
-    @property
-    def airplay_mode_enabled(self) -> bool:
-        """Return if airplay mode is enabled for the player."""
-        return self.mass.config.get_raw_player_config_value(
-            self.player_id, CONF_AIRPLAY_MODE, False
-        )
-
-    @property
-    def airplay_mode_active(self) -> bool:
-        """Return if airplay mode is active for the player."""
-        return (
-            self.airplay_mode_enabled
-            and self.client.player.is_coordinator
-            and (airplay_player := self.get_linked_airplay_player(False))
-            and airplay_player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
-        )
-
     @property
     def synced_to(self) -> str | None:
         """
@@ -147,10 +122,8 @@ class SonosPlayer(Player):
         if not self.client.player.has_fixed_volume:
             _supported_features.add(PlayerFeature.VOLUME_SET)
             _supported_features.add(PlayerFeature.VOLUME_MUTE)
-        if not self.get_linked_airplay_player(False):
-            _supported_features.add(PlayerFeature.NEXT_PREVIOUS)
-        if not self.get_linked_airplay_player(True):
-            _supported_features.add(PlayerFeature.ENQUEUE)
+        _supported_features.add(PlayerFeature.NEXT_PREVIOUS)
+        _supported_features.add(PlayerFeature.ENQUEUE)
         self._attr_supported_features = _supported_features
 
         self._attr_name = (
@@ -161,6 +134,15 @@ class SonosPlayer(Player):
         self._attr_device_info.manufacturer = self._provider.manifest.name
         self._attr_can_group_with = {self._provider.instance_id}
 
+        # Add identifiers for matching with other protocols (like AirPlay, DLNA)
+        # The player_id is the Sonos UUID (e.g., RINCON_xxxxxxxxxxxx)
+        self._attr_device_info.add_identifier(IdentifierType.UUID, self.player_id)
+        # Extract MAC address from Sonos player_id (RINCON_XXXXXXXXXXXX01400)
+        # The middle part contains the MAC address (last 6 bytes in hex)
+        mac_address = self._extract_mac_from_player_id()
+        if mac_address:
+            self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
+
         if SonosCapability.LINE_IN in self.discovery_info["device"]["capabilities"]:
             self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_LINE_IN])
         if SonosCapability.HT_PLAYBACK in self.discovery_info["device"]["capabilities"]:
@@ -181,14 +163,6 @@ class SonosPlayer(Player):
                 ),
             )
         )
-        # register callback for airplay player state changes
-        self._on_unload_callbacks.append(
-            self.mass.subscribe(
-                self._on_airplay_player_event,
-                (EventType.PLAYER_UPDATED, EventType.PLAYER_ADDED),
-                self.airplay_player_id,
-            )
-        )
 
     async def get_config_entries(
         self,
@@ -196,7 +170,7 @@ class SonosPlayer(Player):
         values: dict[str, ConfigValueType] | None = None,
     ) -> list[ConfigEntry]:
         """Return all (provider/player specific) Config Entries for the player."""
-        base_entries = [
+        return [
             CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
             create_sample_rates_config_entry(
                 # set safe max bit depth to 16 bits because the older Sonos players
@@ -207,46 +181,6 @@ class SonosPlayer(Player):
                 hidden=False,
             ),
         ]
-        return [
-            *base_entries,
-            ConfigEntry(
-                key="airplay_detected",
-                type=ConfigEntryType.BOOLEAN,
-                label="airplay_detected",
-                hidden=True,
-                required=False,
-                default_value=self.get_linked_airplay_player(False) is not None,
-            ),
-            ConfigEntry(
-                key=CONF_AIRPLAY_MODE,
-                type=ConfigEntryType.BOOLEAN,
-                label="Enable AirPlay mode",
-                description="Almost all newer Sonos speakers have AirPlay support. "
-                "If you have the AirPlay provider enabled in Music Assistant, "
-                "your Sonos speaker will also be detected as a AirPlay speaker, meaning "
-                "you can group them with other AirPlay speakers.\n\n"
-                "By default, Music Assistant uses the Sonos protocol for playback but with this "
-                "feature enabled, it will use the AirPlay protocol instead by redirecting "
-                "the playback related commands to the linked AirPlay player in Music Assistant, "
-                "allowing you to mix and match Sonos speakers with AirPlay speakers. \n\n"
-                "NOTE: You need to have the AirPlay provider enabled as well as "
-                "the AirPlay version of this player.",
-                required=False,
-                default_value=False,
-                depends_on="airplay_detected",
-                hidden=SonosCapability.AIRPLAY not in self.discovery_info["device"]["capabilities"],
-            ),
-        ]
-
-    def get_linked_airplay_player(self, enabled_only: bool = True) -> Player | None:
-        """Return the linked airplay player if available/enabled."""
-        if enabled_only and not self.airplay_mode_enabled:
-            return None
-        if not (airplay_player := self.mass.players.get(self.airplay_player_id)):
-            return None
-        if not airplay_player.available:
-            return None
-        return airplay_player
 
     async def volume_set(self, volume_level: int) -> None:
         """
@@ -257,10 +191,6 @@ class SonosPlayer(Player):
         :param volume_level: volume level (0..100) to set on the player.
         """
         await self.client.player.set_volume(volume_level)
-        # sync volume level with airplay player
-        if airplay_player := self.get_linked_airplay_player(False):
-            if airplay_player.playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
-                airplay_player._attr_volume_level = volume_level
 
     async def volume_mute(self, muted: bool) -> None:
         """
@@ -275,26 +205,16 @@ class SonosPlayer(Player):
     async def play(self) -> None:
         """Handle PLAY command on the player."""
         if self.client.player.is_passive:
-            self.logger.debug("Ignore STOP command: Player is synced to another player.")
+            self.logger.debug("Ignore PLAY command: Player is synced to another player.")
             return
-        if airplay_player := self.get_linked_airplay_player(True):
-            # linked airplay player is active, redirect the command
-            self.logger.debug("Redirecting PLAY command to linked airplay player.")
-            await airplay_player.play()
-        else:
-            await self.client.player.group.play()
+        await self.client.player.group.play()
 
     async def stop(self) -> None:
         """Handle STOP command on the player."""
         if self.client.player.is_passive:
             self.logger.debug("Ignore STOP command: Player is synced to another player.")
             return
-        if (airplay_player := self.get_linked_airplay_player(True)) and self.airplay_mode_active:
-            # linked airplay player is active, redirect the command
-            self.logger.debug("Redirecting STOP command to linked airplay player.")
-            await airplay_player.stop()
-        else:
-            await self.client.player.group.stop()
+        await self.client.player.group.stop()
         self.update_state()
 
     async def pause(self) -> None:
@@ -304,12 +224,7 @@ class SonosPlayer(Player):
         Will only be called if the player reports PlayerFeature.PAUSE is supported.
         """
         if self.client.player.is_passive:
-            self.logger.debug("Ignore STOP command: Player is synced to another player.")
-            return
-        if (airplay_player := self.get_linked_airplay_player(True)) and self.airplay_mode_active:
-            # linked airplay player is active, redirect the command
-            self.logger.debug("Redirecting PAUSE command to linked airplay player.")
-            await airplay_player.pause()
+            self.logger.debug("Ignore PAUSE command: Player is synced to another player.")
             return
         active_source = self._attr_active_source
         if self.mass.player_queues.get(active_source):
@@ -379,11 +294,6 @@ class SonosPlayer(Player):
             raise PlayerCommandFailed(msg)
         # for now always reset the active session
         self.client.player.group.active_session_id = None
-        if airplay_player := self.get_linked_airplay_player(True):
-            # airplay mode is enabled, redirect the command
-            self.logger.debug("Redirecting PLAY_MEDIA command to linked airplay player.")
-            await self._play_media_airplay(airplay_player, media)
-            return
         if media.source_id:
             await self._set_sonos_queue_from_mass_queue(media.source_id)
 
@@ -418,13 +328,14 @@ class SonosPlayer(Player):
 
         # play duration-less (long running) radio streams
         # enforce AAC here because Sonos really does not support FLAC streams without duration
-        media.uri = media.uri.replace(".flac", ".aac").replace(".wav", ".aac")
+        stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+        stream_url = stream_url.replace(".flac", ".aac").replace(".wav", ".aac")
         if media.source_id and media.queue_item_id:
             object_id = f"mass:{media.source_id}:{media.queue_item_id}"
         else:
-            object_id = media.uri
+            object_id = stream_url
         await self.client.player.group.play_stream_url(
-            media.uri,
+            stream_url,
             {
                 "name": media.title,
                 "type": "track",
@@ -491,23 +402,10 @@ class SonosPlayer(Player):
         """
         player_ids_to_add = player_ids_to_add or []
         player_ids_to_remove = player_ids_to_remove or []
-        if airplay_player := self.get_linked_airplay_player(False):
-            # if airplay mode is enabled, we could possibly receive child player id's that are
-            # not Sonos players, but AirPlay players. We redirect those.
-            airplay_player_ids_to_add = {x for x in player_ids_to_add if x.startswith("ap")}
-            airplay_player_ids_to_remove = {x for x in player_ids_to_remove if x.startswith("ap")}
-            if airplay_player_ids_to_add or airplay_player_ids_to_remove:
-                await self.mass.players.cmd_set_members(
-                    airplay_player.player_id,
-                    player_ids_to_add=list(airplay_player_ids_to_add),
-                    player_ids_to_remove=list(airplay_player_ids_to_remove),
-                )
-        sonos_player_ids_to_add = {x for x in player_ids_to_add if not x.startswith("ap")}
-        sonos_player_ids_to_remove = {x for x in player_ids_to_remove if not x.startswith("ap")}
-        if sonos_player_ids_to_add or sonos_player_ids_to_remove:
+        if player_ids_to_add or player_ids_to_remove:
             await self.client.player.group.modify_group_members(
-                player_ids_to_add=list(sonos_player_ids_to_add),
-                player_ids_to_remove=list(sonos_player_ids_to_remove),
+                player_ids_to_add=player_ids_to_add,
+                player_ids_to_remove=player_ids_to_remove,
             )
 
     async def ungroup(self) -> None:
@@ -573,31 +471,17 @@ class SonosPlayer(Player):
         self._attr_volume_muted = self.client.player.volume_muted
 
         group_parent = None
-        airplay_player = self.get_linked_airplay_player(False)
         if self.client.player.is_coordinator:
-            # player is group coordinator
+            # player is group coordinator - always report native group members
             active_group = self.client.player.group
             if len(self.client.player.group_members) > 1:
                 self._attr_group_members = list(self.client.player.group_members)
             else:
                 self._attr_group_members.clear()
-            # append airplay child's to group childs
-            if self.airplay_mode_enabled and airplay_player:
-                airplay_childs = [
-                    x for x in airplay_player._attr_group_members if x != airplay_player.player_id
-                ]
-                self._attr_group_members.extend(airplay_childs)
-                airplay_prov = airplay_player.provider
-                self._attr_can_group_with.update(
-                    x.player_id
-                    for x in airplay_prov.players
-                    if x.player_id != airplay_player.player_id
-                )
-            else:
-                self._attr_can_group_with = {self._provider.instance_id}
+            self._attr_can_group_with = {self._provider.instance_id}
         else:
             # player is group child (synced to another player)
-            group_parent: SonosPlayer = self.mass.players.get(
+            group_parent: SonosPlayer = self.mass.players.get_player(
                 self.client.player.group.coordinator_id
             )
             if not group_parent or not group_parent.client or not group_parent.client.player:
@@ -625,18 +509,6 @@ class SonosPlayer(Player):
         elif container_type in (ContainerType.HOME_THEATER_HDMI, ContainerType.HOME_THEATER_SPDIF):
             self._attr_active_source = SOURCE_TV
         elif container_type == ContainerType.AIRPLAY:
-            # check if the MA airplay player is active
-            if airplay_player and airplay_player.playback_state in (
-                PlaybackState.PLAYING,
-                PlaybackState.PAUSED,
-            ):
-                self._attr_playback_state = airplay_player.playback_state
-                self._attr_active_source = airplay_player.active_source
-                self._attr_elapsed_time = airplay_player.elapsed_time
-                self._attr_elapsed_time_last_updated = airplay_player.elapsed_time_last_updated
-                self._attr_current_media = airplay_player.current_media
-                # return early as we dont need further info
-                return
             self._attr_active_source = SOURCE_AIRPLAY
         elif (
             container_type == ContainerType.STATION
@@ -727,6 +599,53 @@ class SonosPlayer(Player):
 
         self._attr_current_media = current_media
 
+    async def on_protocol_playback(
+        self,
+        output_protocol: OutputProtocol,
+    ) -> None:
+        """Handle callback when playback starts on a protocol output."""
+        # Only handle AirPlay protocol
+        if output_protocol.protocol_domain != "airplay":
+            return
+
+        # Only if this player is a coordinator with group members
+        if not self.client.player.is_coordinator:
+            return
+
+        current_members = list(self.client.player.group_members)
+        if len(current_members) <= 1:
+            # No group members to worry about
+            return
+
+        # Workaround for Sonos AirPlay ungrouping bug: when AirPlay playback starts
+        # on a Sonos speaker that has native group members, Sonos dissolves the group.
+        # We capture the group state here and restore it via AirPlay protocol after a delay.
+
+        self.logger.debug(
+            "AirPlay playback starting on %s with native group members %s - "
+            "scheduling restoration to avoid Sonos ungrouping bug",
+            self.name,
+            current_members,
+        )
+        members_to_restore = [m for m in current_members if m != self.player_id]
+
+        async def _restore_airplay_group() -> None:
+            try:
+                # we call set_members on the PlayerController here so it
+                # can try to regroup via the preferred protocol (which may be AirPlay),
+                await self.mass.players.cmd_set_members(
+                    self.player_id, player_ids_to_add=members_to_restore
+                )
+            except Exception as err:
+                self.logger.warning("Failed to restore AirPlay group: %s", err)
+
+        # Schedule restoration after 4 seconds to let AirPlay settle
+        self.mass.call_later(
+            4,
+            _restore_airplay_group,
+            task_id=f"restore_airplay_group_{self.player_id}",
+        )
+
     def update_elapsed_time(self, elapsed_time: float | None = None) -> None:
         """Update the elapsed time of the current media."""
         if elapsed_time is not None:
@@ -746,7 +665,7 @@ class SonosPlayer(Player):
             await self.client.connect()
         except (ConnectionFailed, ClientConnectorError) as err:
             self.logger.warning("Failed to connect to Sonos player: %s", err)
-            if not retry_on_fail or not self.mass.players.get(self.player_id):
+            if not retry_on_fail or not self.mass.players.get_player(self.player_id):
                 raise
             self._attr_available = False
             self.update_state()
@@ -793,15 +712,6 @@ class SonosPlayer(Player):
             await self.client.disconnect()
         self.logger.debug("Disconnected from player API")
 
-    def _on_airplay_player_event(self, event: MassEvent) -> None:
-        """Handle incoming event from linked airplay player."""
-        if not self.mass.config.get_raw_player_config_value(self.player_id, CONF_AIRPLAY_MODE):
-            return
-        if event.object_id != self.airplay_player_id:
-            return
-        self.update_attributes()
-        self.update_state()
-
     async def sync_play_modes(self, queue_id: str) -> None:
         """Sync the play modes between MA and Sonos."""
         queue = self.mass.player_queues.get(queue_id)
@@ -824,85 +734,6 @@ class SonosPlayer(Player):
                     # this may happen at race conditions
                     raise
 
-    async def _play_media_airplay(
-        self,
-        airplay_player: Player,
-        media: PlayerMedia,
-    ) -> None:
-        """Handle PLAY MEDIA using the legacy upnp api."""
-        player_id = self.player_id
-        if (
-            airplay_player.playback_state == PlaybackState.PLAYING
-            and airplay_player.active_source == media.source_id
-        ):
-            # if the airplay player is already playing,
-            # the stream will be reused so no need to do the whole grouping thing below
-            await self.mass.players.play_media(airplay_player.player_id, media)
-            return
-
-        # Sonos has an annoying bug (for years already, and they dont seem to care),
-        # where it looses its sync childs when airplay playback is (re)started.
-        # Try to handle it here with this workaround.
-        org_group_childs = {x for x in self.client.player.group.player_ids if x != player_id}
-        if org_group_childs:
-            # ungroup all childs first
-            await self.client.player.group.modify_group_members(
-                player_ids_to_add=[], player_ids_to_remove=list(org_group_childs)
-            )
-        # start playback on the airplay player
-        await self.mass.players.play_media(airplay_player.player_id, media)
-        # re-add the original group childs to the sonos player if needed
-        if org_group_childs:
-            # wait a bit to let the airplay playback start
-            await asyncio.sleep(3)
-            await self.client.player.group.modify_group_members(
-                player_ids_to_add=list(org_group_childs),
-                player_ids_to_remove=[],
-            )
-
-    async def _play_media_legacy(
-        self,
-        media: PlayerMedia,
-    ) -> None:
-        """Handle PLAY MEDIA using the legacy upnp api."""
-        xml_data, soap_action = get_xml_soap_set_url(media)
-        player_ip = self.device_info.ip_address
-        async with self.mass.http_session_no_ssl.post(
-            f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control",
-            headers={
-                "SOAPACTION": soap_action,
-                "Content-Type": "text/xml; charset=utf-8",
-                "Connection": "close",
-            },
-            data=xml_data,
-        ) as resp:
-            if resp.status != 200:
-                raise PlayerCommandFailed(
-                    f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
-                )
-            await self.play()
-
-    async def _enqueue_next_legacy(
-        self,
-        media: PlayerMedia,
-    ) -> None:
-        """Handle enqueuing of the next (queue) item on the player using legacy upnp api."""
-        xml_data, soap_action = get_xml_soap_set_next_url(media)
-        player_ip = self.device_info.ip_address
-        async with self.mass.http_session_no_ssl.post(
-            f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control",
-            headers={
-                "SOAPACTION": soap_action,
-                "Content-Type": "text/xml; charset=utf-8",
-                "Connection": "close",
-            },
-            data=xml_data,
-        ) as resp:
-            if resp.status != 200:
-                raise PlayerCommandFailed(
-                    f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
-                )
-
     async def _set_sonos_queue_from_mass_queue(self, queue_id: str) -> None:
         """Set the SonosQueue items from the given MA PlayerQueue."""
         items: list[PlayerMedia] = []
@@ -923,6 +754,9 @@ class SonosPlayer(Player):
                     media = await self.mass.player_queues.player_media_from_queue_item(
                         queue_item, False
                     )
+                    media.uri = await self.provider.mass.streams.resolve_stream_url(
+                        self.player_id, media
+                    )
                     items.append(media)
 
         # Add the current item
@@ -931,6 +765,9 @@ class SonosPlayer(Player):
                 media = await self.mass.player_queues.player_media_from_queue_item(
                     current_item, False
                 )
+                media.uri = await self.provider.mass.streams.resolve_stream_url(
+                    self.player_id, media
+                )
                 items.append(media)
 
         # Use get_next_item to fetch next items, which accounts for repeat mode
@@ -940,6 +777,7 @@ class SonosPlayer(Player):
             if next_item is None:
                 break
             media = await self.mass.player_queues.player_media_from_queue_item(next_item, False)
+            media.uri = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
             items.append(media)
             last_index = next_item.queue_item_id
 
@@ -951,3 +789,30 @@ class SonosPlayer(Player):
             self.player_id,
             [x.title for x in self.sonos_queue.items],
         )
+
+    def _extract_mac_from_player_id(self) -> str | None:
+        """Extract MAC address from Sonos player_id.
+
+        Sonos player_ids follow the format RINCON_XXXXXXXXXXXX01400 where
+        the middle 12 hex characters represent the MAC address.
+
+        :return: MAC address string in XX:XX:XX:XX:XX:XX format, or None if not extractable.
+        """
+        # Remove RINCON_ prefix if present
+        player_id = self.player_id
+        player_id = player_id.removeprefix("RINCON_")  # Remove "RINCON_"
+
+        # Remove the 01400 suffix (or similar) - should be last 5 chars
+        if len(player_id) >= 17:  # 12 hex chars for MAC + 5 chars suffix
+            mac_hex = player_id[:12]
+        else:
+            return None
+
+        # Validate it looks like a MAC (all hex characters)
+        try:
+            int(mac_hex, 16)
+        except ValueError:
+            return None
+
+        # Format as XX:XX:XX:XX:XX:XX
+        return ":".join(mac_hex[i : i + 2].upper() for i in range(0, 12, 2))
index 9ae962dcba02b3c63c29c006dc5235c485489f41..8ad1ef2962358ffe4f4ad4831b1732bd6fabac60 100644 (file)
@@ -13,7 +13,7 @@ from aiohttp import web
 from aiohttp.client_exceptions import ClientError
 from aiosonos.api.models import SonosCapability
 from aiosonos.utils import get_discovery_info
-from music_assistant_models.enums import PlaybackState
+from music_assistant_models.enums import IdentifierType
 from zeroconf import ServiceStateChange
 
 from music_assistant.constants import (
@@ -27,7 +27,6 @@ from .helpers import get_primary_ip_address
 from .player import SonosPlayer
 
 if TYPE_CHECKING:
-    from music_assistant_models.config_entries import PlayerConfig
     from music_assistant_models.player import PlayerMedia
     from zeroconf.asyncio import AsyncServiceInfo
 
@@ -57,7 +56,7 @@ class SonosPlayerProvider(PlayerProvider):
                 continue
             player_id = discovery_info["device"]["id"]
             sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info)
-            sonos_player.device_info.ip_address = ip_address
+            sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
             await sonos_player.setup()
 
     async def unload(self, is_removed: bool = False) -> None:
@@ -78,7 +77,7 @@ class SonosPlayerProvider(PlayerProvider):
         name = name.split("@", 1)[1] if "@" in name else name
         player_id = info.decoded_properties["uuid"]
         # handle update for existing device
-        if sonos_player := self.mass.players.get(player_id):
+        if sonos_player := self.mass.players.get_player(player_id):
             assert isinstance(sonos_player, SonosPlayer), (
                 "Player ID already exists but is not a SonosPlayer"
             )
@@ -90,7 +89,7 @@ class SonosPlayerProvider(PlayerProvider):
                     sonos_player.device_info.ip_address,
                     cur_address,
                 )
-                sonos_player.device_info.ip_address = cur_address
+                sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, cur_address)
             if not sonos_player.connected:
                 self.logger.debug("Player back online: %s", sonos_player.display_name)
                 sonos_player.client.player_ip = cur_address
@@ -103,24 +102,9 @@ class SonosPlayerProvider(PlayerProvider):
         task_id = f"setup_sonos_{player_id}"
         self.mass.call_later(5, self._setup_player, player_id, name, info, task_id=task_id)
 
-    async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
-        """Call (by config manager) when the configuration of a player changes."""
-        await super().on_player_config_change(config, changed_keys)
-        if "values/airplay_mode" in changed_keys and (
-            (sonos_player := self.mass.players.get(config.player_id))
-            and (airplay_player := sonos_player.get_linked_airplay_player(False))
-            and airplay_player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
-        ):
-            # edge case: we switched from airplay mode to sonos mode (or vice versa)
-            # we need to make sure that playback gets stopped on the airplay player
-            await airplay_player.stop()
-            # We also need to run setup again on the Sonos player to ensure the supported
-            # features are updated.
-            await sonos_player.setup()
-
     async def _setup_player(self, player_id: str, name: str, info: AsyncServiceInfo) -> None:
         """Handle setup of a new player that is discovered using mdns."""
-        assert not self.mass.players.get(player_id)
+        assert not self.mass.players.get_player(player_id)
         address = get_primary_ip_address(info)
         if address is None:
             return
@@ -141,7 +125,7 @@ class SonosPlayerProvider(PlayerProvider):
             return
         self.logger.debug("Discovered Sonos device %s on %s", name, address)
         sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info)
-        sonos_player.device_info.ip_address = address
+        sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
         await sonos_player.setup()
 
     async def _handle_sonos_cloud_queue_request(self, request: web.Request) -> web.Response:
@@ -160,7 +144,7 @@ class SonosPlayerProvider(PlayerProvider):
         if len(path_parts) != 4 or path_parts[0] != "sonos_queue":
             return web.Response(status=404)
         player_id = path_parts[1]
-        if not (sonos_player := self.mass.players.get(player_id)):
+        if not (sonos_player := self.mass.players.get_player(player_id)):
             return web.Response(status=501)
         if TYPE_CHECKING:
             assert isinstance(sonos_player, SonosPlayer)
index 44dee4c3844df6af9d8c0fc064c25eae544bb9ec..a3a0d066da3342067c533ca31a35824cd035cd34 100644 (file)
@@ -19,6 +19,7 @@ CONF_HOUSEHOLD_ID = "household_id"
 
 # Player Features
 PLAYER_FEATURES = (
+    PlayerFeature.PLAY_MEDIA,
     PlayerFeature.SET_MEMBERS,
     PlayerFeature.VOLUME_MUTE,
     PlayerFeature.VOLUME_SET,
index 4dddd07f120a30d2b724acced9c949b225ddde21..51d12dab66821be2acdff056f761a4c151f83ecb 100644 (file)
@@ -14,7 +14,7 @@ import time
 from collections.abc import Callable, Coroutine
 from typing import TYPE_CHECKING, Any, cast
 
-from music_assistant_models.enums import MediaType, PlaybackState, PlayerState, PlayerType
+from music_assistant_models.enums import IdentifierType, MediaType, PlaybackState, PlayerState
 from music_assistant_models.errors import PlayerCommandFailed
 from soco import SoCoException
 from soco.core import MUSIC_SRC_RADIO, SoCo
@@ -70,14 +70,17 @@ class SonosPlayer(Player):
         self.subscriptions: list[SubscriptionBase] = []
 
         # Set player attributes
-        self._attr_type = PlayerType.PLAYER
         self._attr_supported_features = set(PLAYER_FEATURES)
         self._attr_name = soco.player_name
         self._attr_device_info = DeviceInfo(
             model=soco.speaker_info["model_name"],
             manufacturer="Sonos",
         )
-        self._attr_device_info.ip_address = soco.ip_address
+        self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, soco.ip_address)
+        self._attr_device_info.add_identifier(IdentifierType.UUID, soco.uid)
+        mac_address = self._extract_mac_from_player_id()
+        if mac_address:
+            self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
         self._attr_needs_poll = True
         self._attr_poll_interval = 5
         self._attr_available = True
@@ -95,6 +98,33 @@ class SonosPlayer(Player):
         subscribed_services = {sub.service.service_type for sub in self._subscriptions}
         return SUBSCRIPTION_SERVICES - subscribed_services
 
+    def _extract_mac_from_player_id(self) -> str | None:
+        """Extract MAC address from Sonos player_id.
+
+        Sonos player_ids follow the format RINCON_XXXXXXXXXXXX01400 where
+        the middle 12 hex characters represent the MAC address.
+
+        :return: MAC address string in XX:XX:XX:XX:XX:XX format, or None if not extractable.
+        """
+        # Remove RINCON_ prefix if present
+        player_id = self.player_id
+        player_id = player_id.removeprefix("RINCON_")
+
+        # Remove the 01400 suffix (or similar) - should be last 5 chars
+        if len(player_id) >= 17:  # 12 hex chars for MAC + 5 chars suffix
+            mac_hex = player_id[:12]
+        else:
+            return None
+
+        # Validate it looks like a MAC (all hex characters)
+        try:
+            int(mac_hex, 16)
+        except ValueError:
+            return None
+
+        # Format as XX:XX:XX:XX:XX:XX
+        return ":".join(mac_hex[i : i + 2].upper() for i in range(0, 12, 2))
+
     async def setup(self) -> None:
         """Set up the player."""
         self._attr_volume_level = self.soco.volume
@@ -213,8 +243,9 @@ class SonosPlayer(Player):
         is_announcement = media.media_type == MediaType.ANNOUNCEMENT
         force_radio = False if is_announcement else not media.duration
 
+        stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
         await asyncio.to_thread(
-            self.soco.play_uri, media.uri, meta=didl_metadata, force_radio=force_radio
+            self.soco.play_uri, stream_url, meta=didl_metadata, force_radio=force_radio
         )
         self.mass.call_later(2, self.poll)
 
@@ -259,13 +290,13 @@ class SonosPlayer(Player):
 
         if player_ids_to_remove:
             for player_id in player_ids_to_remove:
-                if player_to_remove := cast("SonosPlayer", self.mass.players.get(player_id)):
+                if player_to_remove := cast("SonosPlayer", self.mass.players.get_player(player_id)):
                     await asyncio.to_thread(player_to_remove.soco.unjoin)
                     self.mass.call_later(2, player_to_remove.poll)
 
         if player_ids_to_add:
             for player_id in player_ids_to_add:
-                if player_to_add := cast("SonosPlayer", self.mass.players.get(player_id)):
+                if player_to_add := cast("SonosPlayer", self.mass.players.get_player(player_id)):
                     await asyncio.to_thread(player_to_add.soco.join, self.soco)
                     self.mass.call_later(2, player_to_add.poll)
 
@@ -315,7 +346,11 @@ class SonosPlayer(Player):
             model=self._attr_device_info.model,
             manufacturer=self._attr_device_info.manufacturer,
         )
-        self._attr_device_info.ip_address = ip_address
+        self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
+        self._attr_device_info.add_identifier(IdentifierType.UUID, self.soco.uid)
+        mac_address = self._extract_mac_from_player_id()
+        if mac_address:
+            self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
         self.update_player()
 
     async def _check_availability(self) -> None:
@@ -668,7 +703,7 @@ class SonosPlayer(Player):
             group_members_ids = []
 
             for uid in group:
-                speaker = self.mass.players.get(uid)
+                speaker = self.mass.players.get_player(uid)
                 if speaker:
                     group_members_ids.append(uid)
                 else:
@@ -728,7 +763,7 @@ class SonosPlayer(Player):
         except TimeoutError:
             self.logger.warning("Timeout waiting for target groups %s", groups)
 
-        if players := self.mass.players.all(provider_filter=_provider.instance_id):
+        if players := self.mass.players.all_players(provider_filter=_provider.instance_id):
             any_speaker = cast("SonosPlayer", players[0])
             any_speaker.soco.zone_group_state.clear_cache()
 
index 37011b59b76ca65de8361f914650ce2401915152..12a827253aff9908b1feb96e32ec5ed8d4833449 100644 (file)
@@ -57,7 +57,7 @@ class SonosPlayerProvider(PlayerProvider):
         while self._discovery_running:
             await asyncio.sleep(0.5)
         # Clean up subscriptions and connections
-        for sonos_player in self.mass.players.all(provider_filter=self.instance_id):
+        for sonos_player in self.mass.players.all_players(provider_filter=self.instance_id):
             sonos_player = cast("SonosPlayer", sonos_player)
             await sonos_player.offline()
         # Stop the async event listener
@@ -136,7 +136,7 @@ class SonosPlayerProvider(PlayerProvider):
         """Set up a discovered Sonos player."""
         player_id = soco.uid
 
-        if existing := cast("SonosPlayer", self.mass.players.get(player_id=player_id)):
+        if existing := cast("SonosPlayer", self.mass.players.get_player(player_id=player_id)):
             if existing.soco.ip_address != soco.ip_address:
                 existing.update_ip(soco.ip_address)
             return
index 03d11b2cc53704a5e0cd2ae2843b4a1a75e89d16..2de7eb48f759c4988553f666ad6e2cf773746d4d 100644 (file)
@@ -401,7 +401,7 @@ Inherits from `PluginProvider`
 ## Related Documentation
 
 - **PluginSource Model**: See `music_assistant/models/plugin.py`
-- **Player Controller**: See `music_assistant/controllers/players/player_controller.py`
+- **Player Controller**: See `music_assistant/controllers/players/`
 - **Spotify Provider**: See `music_assistant/providers/spotify/`
 - **librespot**: https://github.com/librespot-org/librespot
 
index 6dcb54ff55fd1424d2127a1a95027242eed0d0ee..4f5749cd0440a8ae68671880defdab8e6b0ee30e 100644 (file)
@@ -98,7 +98,7 @@ async def get_config_entries(
                 *(
                     ConfigValueOption(x.display_name, x.player_id)
                     for x in sorted(
-                        mass.players.all(False, False), key=lambda p: p.display_name.lower()
+                        mass.players.all_players(False, False), key=lambda p: p.display_name.lower()
                     )
                 ),
             ],
@@ -263,14 +263,14 @@ class SpotifyConnectProvider(PluginProvider):
         # If there's an active player (source was selected on a player), use it
         if self._active_player_id:
             # Validate that the active player still exists
-            if self.mass.players.get(self._active_player_id):
+            if self.mass.players.get_player(self._active_player_id):
                 return self._active_player_id
             # Active player no longer exists, clear it
             self._active_player_id = None
 
         # Handle auto selection
         if self._default_player_id == PLAYER_ID_AUTO:
-            all_players = list(self.mass.players.all(False, False))
+            all_players = list(self.mass.players.all_players(False, False))
             # First, try to find a playing player
             for player in all_players:
                 if player.state.playback_state == PlaybackState.PLAYING:
@@ -287,7 +287,7 @@ class SpotifyConnectProvider(PluginProvider):
             return None
 
         # Use the specific default player if configured and it still exists
-        if self.mass.players.get(self._default_player_id):
+        if self.mass.players.get_player(self._default_player_id):
             return self._default_player_id
         self.logger.warning(
             "Configured default player '%s' no longer exists", self._default_player_id
@@ -647,7 +647,7 @@ class SpotifyConnectProvider(PluginProvider):
             # Get initial volume from default player if available, or use 20 as fallback
             initial_volume = 20
             if self._default_player_id and self._default_player_id != PLAYER_ID_AUTO:
-                if _player := self.mass.players.get(self._default_player_id):
+                if _player := self.mass.players.get_player(self._default_player_id):
                     if _player.volume_level:
                         initial_volume = _player.volume_level
             args: list[str] = [
index 6bf2892016b863915288b460393eeb55777fc71a..2a052a67d890a4c36af5c247fd17863907023b3a 100644 (file)
@@ -18,6 +18,7 @@ from aioslimproto.models import VisualisationType as SlimVisualisationType
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
 from music_assistant_models.enums import (
     ConfigEntryType,
+    IdentifierType,
     MediaType,
     PlaybackState,
     PlayerFeature,
@@ -60,12 +61,20 @@ if TYPE_CHECKING:
 
 CACHE_CATEGORY_PREV_STATE = 0  # category for caching previous player state
 
+PLAYER_DEVICE_TYPES = {
+    # list of device types that are considered real hardware players
+    "squeezebox",
+    "squeezebox2",
+    "transporter",
+    "receiver",
+    "controller",
+    "boom",
+}
+
 
 class SqueezelitePlayer(Player):
     """Squeezelite Player implementation."""
 
-    _attr_type = PlayerType.PLAYER
-
     def __init__(
         self,
         provider: SqueezelitePlayerProvider,
@@ -78,6 +87,7 @@ class SqueezelitePlayer(Player):
         self._provider: SqueezelitePlayerProvider = provider
         # Set static player attributes
         self._attr_supported_features = {
+            PlayerFeature.PLAY_MEDIA,
             PlayerFeature.POWER,
             PlayerFeature.SET_MEMBERS,
             PlayerFeature.MULTI_DEVICE_DSP,
@@ -233,9 +243,10 @@ class SqueezelitePlayer(Player):
 
         if not self.group_members:
             # Simple, single-player playback
+            stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
             await self._handle_play_url_for_slimplayer(
                 self.client,
-                url=media.uri,
+                url=stream_url,
                 media=media,
                 send_flush=True,
                 auto_play=False,
@@ -325,7 +336,7 @@ class SqueezelitePlayer(Player):
             if player_id == self.player_id or player_id in self.group_members:
                 # nothing to do: player is already part of the group
                 continue
-            child_player = cast("SqueezelitePlayer | None", self.mass.players.get(player_id))
+            child_player = cast("SqueezelitePlayer | None", self.mass.players.get_player(player_id))
             if not child_player:
                 # should not happen, but guard against it
                 continue
@@ -340,8 +351,8 @@ class SqueezelitePlayer(Player):
 
         if (
             (players_added or player_ids_to_remove)
-            and self.current_media
-            and self.playback_state == PlaybackState.PLAYING
+            and self.state.current_media
+            and self._attr_playback_state == PlaybackState.PLAYING
         ):
             # restart stream session if it was already playing
             # for now, we dont support late joining into an existing stream
@@ -368,6 +379,11 @@ class SqueezelitePlayer(Player):
     def update_attributes(self) -> None:
         """Update player attributes from slim player."""
         # Update player state from slim player
+        self._attr_type = (
+            PlayerType.PLAYER
+            if self.client.device_type in PLAYER_DEVICE_TYPES
+            else PlayerType.PROTOCOL
+        )
         self._attr_available = self.client.connected
         self._attr_name = self.client.name
         self._attr_powered = self.client.powered
@@ -379,8 +395,9 @@ class SqueezelitePlayer(Player):
             model=self.client.device_model,
             manufacturer=self.client.device_type,
         )
-        self._attr_device_info.ip_address = self.client.device_address
-        self._attr_device_info.mac_address = self.client.player_id
+        self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.client.device_address)
+        # player_id is the MAC address in slimproto
+        self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, self.client.player_id)
         if (
             old_state != PlaybackState.PLAYING
             and self._attr_playback_state == PlaybackState.PLAYING
@@ -471,7 +488,7 @@ class SqueezelitePlayer(Player):
 
     def _handle_player_heartbeat(self) -> None:
         """Process SlimClient elapsed_time update."""
-        if self.playback_state != PlaybackState.PLAYING:
+        if self._attr_playback_state != PlaybackState.PLAYING:
             # ignore server heartbeats when not playing
             # Some players keep sending heartbeat with increasing elapsed time
             # even when paused (e.g. WiiM)
index 3e2de090ef38cd4e60e4af7faf0b2db1969dac4d..32a7b655fd6540a26801149f51dbbcfde76e4d0a 100644 (file)
@@ -144,7 +144,7 @@ class SqueezelitePlayerProvider(PlayerProvider):
             self.mass.create_task(player.setup())
             return
 
-        if not (mass_player := self.mass.players.get(event.player_id)):
+        if not (mass_player := self.mass.players.get_player(event.player_id)):
             return  # guard for unknown player
         player = cast("SqueezelitePlayer", mass_player)
 
@@ -169,11 +169,11 @@ class SqueezelitePlayerProvider(PlayerProvider):
         if not child_player_id:
             raise web.HTTPNotFound(reason="Missing child_player_id parameter")
 
-        if not (sync_parent := self.mass.players.get(player_id)):
+        if not (sync_parent := self.mass.players.get_player(player_id)):
             raise web.HTTPNotFound(reason=f"Unknown player: {player_id}")
         sync_parent = cast("SqueezelitePlayer", sync_parent)
 
-        if not (child_player := self.mass.players.get(child_player_id)):
+        if not (child_player := self.mass.players.get_player(child_player_id)):
             raise web.HTTPNotFound(reason=f"Unknown player: {child_player_id}")
 
         if not (stream := sync_parent.multi_client_stream) or stream.done:
diff --git a/music_assistant/providers/sync_group/__init__.py b/music_assistant/providers/sync_group/__init__.py
new file mode 100644 (file)
index 0000000..f478bc5
--- /dev/null
@@ -0,0 +1,56 @@
+"""
+Sync Group Player provider.
+
+Create sync groups to group compatible speakers to play audio in sync.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from music_assistant_models.enums import ProviderFeature
+
+from .player import SyncGroupPlayer
+from .provider import SyncGroupProvider
+
+if TYPE_CHECKING:
+    from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
+    from music_assistant_models.provider import ProviderManifest
+
+    from music_assistant import MusicAssistant
+    from music_assistant.models import ProviderInstanceType
+
+SUPPORTED_FEATURES = {ProviderFeature.CREATE_GROUP_PLAYER, ProviderFeature.REMOVE_GROUP_PLAYER}
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    return SyncGroupProvider(mass, manifest, config, SUPPORTED_FEATURES)
+
+
+async def get_config_entries(
+    mass: MusicAssistant,  # noqa: ARG001
+    instance_id: str | None = None,  # noqa: ARG001
+    action: str | None = None,  # noqa: ARG001
+    values: dict[str, ConfigValueType] | None = None,  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """
+    Return Config entries to setup this provider.
+
+    :param mass: MusicAssistant instance.
+    :param instance_id: id of an existing provider instance (None if new instance setup).
+    :param action: [optional] action key called from config entries UI.
+    :param values: the (intermediate) raw values for config entries sent with the action.
+    """
+    # nothing to configure (for now)
+    return ()
+
+
+__all__ = (
+    "SyncGroupPlayer",
+    "SyncGroupProvider",
+    "get_config_entries",
+    "setup",
+)
diff --git a/music_assistant/providers/sync_group/constants.py b/music_assistant/providers/sync_group/constants.py
new file mode 100644 (file)
index 0000000..5d4d0d1
--- /dev/null
@@ -0,0 +1,39 @@
+"""Sync Group Player constants."""
+
+from __future__ import annotations
+
+from typing import Final
+
+from music_assistant_models.config_entries import ConfigEntry
+from music_assistant_models.enums import ConfigEntryType, PlayerFeature
+
+SGP_PREFIX: Final[str] = "syncgroup_"
+
+CONF_ENTRY_SGP_NOTE = ConfigEntry(
+    key="sgp_note",
+    type=ConfigEntryType.ALERT,
+    label="Sync groups allow you to group compatible players together to play audio in sync. "
+    "Players can only be grouped together if they support the same sync protocol",
+    required=False,
+)
+
+SUPPORT_DYNAMIC_LEADER = {
+    # providers that support dynamic leader selection in a syncgroup
+    # meaning that if you would remove the current leader from the group,
+    # the provider will automatically select a new leader from the remaining members
+    # and the music keeps playing uninterrupted.
+    "airplay",
+    "squeezelite",
+    "snapcast",
+    # TODO: Get this working with Sonos as well (need to handle range requests)
+}
+
+
+EXTRA_FEATURES_FROM_MEMBERS: Final[set[PlayerFeature]] = {
+    PlayerFeature.ENQUEUE,
+    PlayerFeature.GAPLESS_PLAYBACK,
+    PlayerFeature.PAUSE,
+    PlayerFeature.VOLUME_SET,
+    PlayerFeature.VOLUME_MUTE,
+    PlayerFeature.MULTI_DEVICE_DSP,
+}
diff --git a/music_assistant/providers/sync_group/icon.svg b/music_assistant/providers/sync_group/icon.svg
new file mode 100644 (file)
index 0000000..675147b
--- /dev/null
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   viewBox="0 0 512 512"
+   version="1.1"
+   id="svg1"
+   sodipodi:docname="icon-ugp.svg"
+   width="512"
+   height="512"
+   inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
+   xml:space="preserve"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"><defs
+     id="defs1" /><sodipodi:namedview
+     id="namedview1"
+     pagecolor="#46ffff"
+     bordercolor="#000000"
+     borderopacity="0.25"
+     inkscape:showpageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#d1d1d1"
+     inkscape:zoom="1.633343"
+     inkscape:cx="251.93728"
+     inkscape:cy="256.83521"
+     inkscape:window-width="1920"
+     inkscape:window-height="1129"
+     inkscape:window-x="1912"
+     inkscape:window-y="-8"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg1" /><image
+     width="512"
+     height="512"
+     preserveAspectRatio="none"
+     style="image-rendering:optimizeSpeed"
+     xlink:href="data:image/png;base64,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&#10;YII=&#10;"
+     id="image1"
+     x="-0.083149381"
+     y="-0.08315631" /></svg>
diff --git a/music_assistant/providers/sync_group/icon_monochrome.svg b/music_assistant/providers/sync_group/icon_monochrome.svg
new file mode 100644 (file)
index 0000000..72880e5
--- /dev/null
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   viewBox="0 0 512 512"
+   version="1.1"
+   id="svg1"
+   sodipodi:docname="icon_monochrome.svg"
+   width="512"
+   height="512"
+   inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
+   xml:space="preserve"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:xlink="http://www.w3.org/1999/xlink"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"><defs
+     id="defs1" /><sodipodi:namedview
+     id="namedview1"
+     pagecolor="#46ffff"
+     bordercolor="#000000"
+     borderopacity="0.25"
+     inkscape:showpageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#d1d1d1"
+     inkscape:zoom="1.633343"
+     inkscape:cx="251.93728"
+     inkscape:cy="256.83521"
+     inkscape:window-width="1920"
+     inkscape:window-height="1129"
+     inkscape:window-x="1912"
+     inkscape:window-y="-8"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg1" /><image
+     width="512"
+     height="512"
+     preserveAspectRatio="none"
+     style="image-rendering:optimizeSpeed"
+     xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAABHNCSVQICAgIfAhkiAAAENxJREFU&#10;eJzt3dl228gSRUHoLv3/L+s+WGpTNAfMqMwT8dx2gxgqN4qW/TGF+/r6uvoQOI6LC+P6uPwAPi4/&#10;hEt9Xn0AsCMDH+q4f16zp/EFBADVGfrQw8+zLAROIgCoyuCHnm6fbTFwIAFANQY/5LArcKD/XX0A&#10;MNPXZPhDKs/+AewAMDoPPjBNdgN2ZweAkRn+wD27gTsRAIzIAw68Y43YSAAwGg81MJf1YgMBwEg8&#10;zMBS1o2VBACj8BADa1k/VhAAjMDDC2xlHVlIAHA1Dy2wF+vJAgKAK3lYgb1ZV2YSAFzFQwocxfoy&#10;gwAAoCMR8IYA4AoeTOAM1poXBABn80ACDEAAcCbDHzibdecJAQAAgQQAZ1HhwFWsPw8IAAASiIA7&#10;AoAzePAABiMAAEjhZeSGAOBoHjiAAQkAAAgkAABIYlfymwDgSB40gEEJAADSeDmZBAAARBIAHEVh&#10;AwxMAABAIAEAQKL4XUoBAACBBABHiC9rgNEJAAAIJAAAIJAAAIBAAgAAAgkAAAgkAAAgkAAAgECf&#10;Vx8AwAMfM/87f+cErCQAgKvNHfZzf60ogBkEAHC2LQN/6e8vBuAJAQCc5ejB/+7/KQbghgAAjnTF&#10;0H/m51iEAEwCADjGSIP/nhCAyY8BAvsbefjf+pjqHCvsTgAAe6k6UCseM2wmAIA9VB+iVeMFVhMA&#10;wBbdBmenzwIvCQBgra7Dsuvngl8EALBG9yHZ/fOBAAAWSxmOKZ+TUAIAWCJtKKZ9XoIIAGCu1GGY&#10;+rlpTgAAc6QPwfTPT0MCAGAeEUArAgB4x+CDhgQA8Irh/5vzQRsCAHjGsHvMeaEFAQCwnAigPAEA&#10;PGLAQXMCAGAdkURpAgC4Z7BBAAEAsJ5YoiwBANwy0CCEAADYRjRRkgAAfhhkEEQAAEAgAQCwnd0T&#10;yhEAwDQZYBBHAABAIAEAsA+7KJQiAAAgkAAAvLlCIAEAAIEEAAAEEgAAEEgAAOzHn6egDAEAAIEE&#10;AAAEEgAAEOjz6gMYwNfVB7CA7xcB2IUdAAAIJAAAIJAAAIBAAgAAAgkAgP1U+kPFhBMAABBIAABA&#10;IAEAAIEEAOB7awgkAAD2IaQoRQAAQCABAACBBAAwTbavIY4AANhOQFGOAACAQAIA+OEtdh3njZIE&#10;AAAEEgDALW+zEEIAAKwnmChLAAD3DDUIIAAA1hFKlCYAgEcMt9ecH8oTAMAzhhw0JgAAlhFGtCAA&#10;gFcMu9+cD9oQAMA7ht4fzgOtCABgDsMPmhEAwFzJEZD82WlKAAC8ZvjTkgAAlkgbhmmflyACAFjq&#10;a8oYjAmfkWACAFir84Ds/NlgmiYBAGzTbVCm7G6AAAA26zIwu3wOmEUAAHuo/uZc+dhhFQEA7Kna&#10;IK0eLrDa59UHALTzM1A/Lj2K1wx94gkA4CgjhoDBD98EAHC0EULA4Ic7AgA4y+0QPiMGDH14QQAA&#10;V7gfznsEgYEPCwgAYATvhvfHjP8GWMCPAQIVGP6wMwEAAIEEAAAEEgAAEEgAAEAgAQAAgQQAAAQS&#10;AAAQSAAAQCABAACBBAAABBIAABBIAABAIAEAAIEEAAAEEgAAEEgAAEAgAQAAgQQAAAQSAAAQSAAA&#10;QCABAACBBAAABBIAABBIAABAIAEAAIEEAAAEEgAAEEgAAEAgAQAAgQQAAAQSAAAQSAAAQCABAACB&#10;BAAABBIAABBIAABAIAEAAIEEAAAEEgAAEEgAAEAgAQAAgQQAAAT6vPoAAL59bPz1X7scBYQQAMAZ&#10;tg73Pf4fAgFuCABgb2cM+zUeHZcoIJYAALYadeDPIQqIJQCApSoP/DluP58YoC0BAMzRfeg/c/+5&#10;BQFtCADgmdSh/8rPOREClCcAgFuG/jy+JqA8AQBMk8G/hRigJAEAuQz9/fmKgDIEAOQx+I8nBBie&#10;AIAcBv/5hADDEgDQn8F/PSHAcAQA9GXwj0cIMAz/HDD0ZPiP7WNyjbiYAIBeDJZaXCsu4ysA6MEg&#10;qcvXAlzCDgDUZ/j34DpyKjsAUJeB0Y/dAE5jBwBqMvx7c305nB0AqMVgyGE3gEPZAYA6DP9MrjuH&#10;EABQgyGQzY93sjsBAOOz8PPDvcBuBACMy1sfj7gn2IUAgDFZ5HnF/cFmAgDGY3FnDvcJmwgAGItF&#10;nSV8TcRqAgDGYSFnLfcOiwkAGIMFnK3cQywiAOB6Fm724l5iNgEA17Jgszf3FLMIALiOhZqjuLd4&#10;SwDANSzQHM09xksCAM5nYeYs7jWeEgBwLgsyZ3PP8ZAAgPNYiIFhCAA4h+HPldx//EMAwPEsvozA&#10;fcgvAgCOZdFlJO5H/iMAALKIAKZpEgBwJAstMKzPqw8AmjL85/va8fdy3uf5mPY97xQkAGB/htBz&#10;Rw+dR7+/6/GYCAgnAIAjjTBg7o9BEPwlAoIJANiX4TL+QLk9PteLWAIA9pM8TEYf+s+IAbsAsfwU&#10;AOwjdXh8TX2GR6fPslTq/RvNDgCwRudB+fPZDEVaswMA2yUNiqS35KTPOk1Z9zGTAADmSRuGt5I+&#10;uwgI4isA2Kb7gpky+Obw1QCt2AGA9boPAsP/se7npft9zTcBANxL2vJeq/s5EgEBBACs03WB7DzU&#10;juB8UZYAAH4YZut03Q3oGrl8EwCwXLeFsesAO5tzSCkCALIZWvvqdj67xS43BAAs02lB7DasRuG8&#10;UoIAgEyG1LE6nd9O0csNAQDzdVkIOw2nkTnPDE0AQBZD6VxdzneX+OWGAIB5OiyAXYZRNc47QxIA&#10;kMEQulaH898hgrkhAOC96gtfh+HTgevAUAQAwHmqR0D1GOaGAIDXqi941QdOR64JQxAA0JdBAzwl&#10;AKAnw39sla9P9V0xvgkAeM5Cx5EqRwANCADox2AB3hIA8FjVt3/Dv5aq16vq88ENAQBwraoRQHEC&#10;APowSIDZBAD8q+L2puFfW8XrV/E54YYAAIBAAgDqq/j2yL9cR04lAADGUS0CfA1QmACA36otaNUG&#10;BjAIAQAAgQQA1OXtv6dq17XarhnfBAAABBIA8Jc3GUZRbReAggQA1GRAAJsIAAAIJAAAxlRpl8fX&#10;ZwUJAPij0gJWaTAAgxIAABBIAACMy24PhxEAUIuBAOxCAACwh0p/joZJAABAJAEA3lwYm699OIQA&#10;gDoMAmA3AgAAAgkAAAgkAAAgkAAAGJ8//8HuBAAABBIAUIM3QCrwI7WFCAAACCQAACCQAACAQAIA&#10;AAIJAAAIJAAAIJAAAIBAAgAAAgkAAAgkAAAgkAAAgEACAAACCQAACCQAoAb/yhqwKwEAwF78s9WF&#10;CAAACCQAAMbnKyB2JwAAIJAAAIBAAgAAAgkAqPMnl30PDOxGAACMTfhxCAEAAIEEAAB7qPJVGt8E&#10;ANRiOxjYhQAAGJfg4zACAAACCQD4o9L3l94Kgc0EAMCYKoVepYDmmwAAgEACAGqq9HYIDEgAwF+2&#10;MRmFwONwAgDqMiQYgXAuSgAAjEXYcQoBAACBBAD8Vm0709tiL64npxEAUJ+hwVWqBTM3BADAGIQc&#10;pxIA8K+KbzWGB7CIAAC4XsWAqxjK3BAA0EfFIYLrxkUEADzm7QZoTQBAL94ma6l6vQRyAwIA+qk6&#10;VIATCQB4zlsORxJqXEoAQE+Gy9gqXx9h3IQAgNcqL3aVh0xnrgtDEADQm2HDnioHMXcEAPQnAsbh&#10;WjAMAQDvdXjrMXiu5xowFAEAOQyg63Q49x1CmBsCAOax+LFWh+FPQwIAshhG5+pyvgVwQwIA5uuy&#10;CH5MfQbTyJxjhiYAIJcBdZxO57ZL+HJHAMAy3RbDToNqFM4pJQgAwMDaR8evVroFLzcEACzXcVHs&#10;NrjO5vxRjgAAfnR8gz1D13PWMXS5IQBgnc6LY9eBtrfOwdT5/uabAID1Oi+SXQfbXpwfyvu8+gCA&#10;Yf0Muc6hs1TC4He9Q9gBgG0SFsvOW91LOAe0YgcAmCt1RyBp8Kdd22h2AGC7tEUzZUcg5XP+SLuP&#10;4wkA2Efi4tl1QHb9XPCLrwCArTp8NZA+8CtfO1YSALCfryl7kNx+9goDJfla3apwrTiAAIB9pUfA&#10;j1FjwLX5baRrw8kEAOxPBPx2fy7OHDquAzwhAICzvRrKa+LAkF/H2384AQDHsAuwjnN2DsMfPwYI&#10;B7LIMiL3JdM0CQA4msUWGJIAgOOJAEbhXuQ/AgAgg+HPLwIAzmHx5UruP/4hAOA8FmGu4L7jIQEA&#10;57IYcyb3G08JADifRZkzuM94SQDANSzOHMn9xVsCAK5jkeYI7itmEQBwLYs1e3I/MZsAgOtZtNmD&#10;+4hFBACMweLNFu4fFhMAMA6LOGu4b1hFAMBYLOYs4X5hNQEA4/maLOy85x5hEwEA47LA84x7g80E&#10;AIzNQs8tu0PsRgDA+Cz4TJP7gJ0JAKjBm182157dCQCoxSDIIvw4jACAegyEDK4zhxIAUJM3w95c&#10;Ww4nAKA2g6IXYcdpPq8+AGCzn4HxcelRsIWhz+nsAEAf3h5rcs24hACAfgyUGgQbl/IVAPTka4Fx&#10;GfoMQQBAb0JgHAY/QxEAkEEIXMfgZ0gCALIIgfMY/AxNAEAmIXAcg58SBABkux1WYmAbg59SBADw&#10;w67AcoY+ZQkA4J5dgfcMfsoTAMArYuAvQ59WBAAwV1oMGPi0JgCANe6HY4cgMPCJIgCAPVQMAgOf&#10;aAIAOMKz4XpFGBj08IAAAM60dhh/bPi1wAP+OWCgAsMfdiYAACCQAACAQAIAAAIJAAAIJAAAIJAA&#10;AIBAAgAAAgkAjlDhr4EFiCYAAEgU/6IiAAAgkAAAgEACgKPEb68BjEwAAJDGC8okAAAgkgDgSCob&#10;YFACAIAkXky+CQAACCQAOJraBkZhPbohAAAgkADgDKobuJp16I4AAIBAAoCzqG/gKtafBwQAZ/IQ&#10;AgxCAADQmRePJwQAZ/MwAmex3rwgALiChxLgYgKAq4gA4EjWmDcEAADdGP4zCACu5CEF9mZdmUkA&#10;cDUPK7AX68kCAoAReGiBrawjCwkARuHhBdayfqwgABjJx+RBBpaxZqwkABiRBxp4xwvDRgKAUXmw&#10;gWesDzsQAIxM4QP3rAk7EQBU4IEHvBDs7PPqA4CZfh78r0uPAjiboX8QAUA1QgAyGPwHEwBUdbs4&#10;iAHowdA/kQCgAzEAdRn6FxEAdHO/mAgCGIuBPwgBQHcWG4AH/BggAAQSAAAQSAAAQCABAACBBAAA&#10;BBIAABBIAABAIAEAAIEEAAAEEgAAEEgAAEAgAQAAgQQAAAQSAAAQSAAAQCABAACBBAAABBIAABBI&#10;AABAIAEAAIEEAAAEEgAAEEgAAEAgAQAAgQQAAAQSAAAQSAAAQCABAACBBAAABBIAABBIAABAIAEA&#10;AIEEAAAEEgAAEEgAAEAgAQAAgQQAAAQSAAAQSAAAQCABAACBBAAABPq8+gBY5OvqAwAu83H1AdCL&#10;HQAACCQAACCQAACAQAIAAAIJAAAIJAAAIJAAAIBAAgAAAgkAAAgkAPztWgAEEgAAEEgAAEAgAQAA&#10;gQTAH/4cAABRBAAABBIAf9kFACCGAACAQAIAAAIJgN98DQBABAEAAIEEwL/sAgDQngB4TAQA0JoA&#10;AIBAAuA5uwAAtCUAACCQAHjNLgAALQmA90QAAO0IgHlEAACtCID5RAAAbQiAZUQAAC0IgOVEAADl&#10;CYB1RAAApQmA9UQAAGUJgG1EAAAlCYDtRAAA5QiAfXxMQgCAQgTAvoQAACUIgGOIAACGJgCOYzcA&#10;gGF9Xn0AAW4j4OuyowCAG3YAzmVXAIAh2AG4xn0E2BkA4FT/Bw4od7hfOO5hAAAAAElFTkSuQmCC&#10;&#10;"
+     id="image1-0"
+     x="-0.083145902"
+     y="1.1413358" /></svg>
diff --git a/music_assistant/providers/sync_group/manifest.json b/music_assistant/providers/sync_group/manifest.json
new file mode 100644 (file)
index 0000000..328912e
--- /dev/null
@@ -0,0 +1,13 @@
+{
+  "type": "player",
+  "domain": "sync_group",
+  "stage": "stable",
+  "name": "Sync Group Player",
+  "description": "Create (permanent) sync groups to group speakers of compatible protocols/ecosystems to play audio in sync.",
+  "codeowners": ["@music-assistant"],
+  "requirements": [],
+  "documentation": "https://music-assistant.io/faq/groups/",
+  "multi_instance": false,
+  "builtin": true,
+  "allow_disable": false
+}
diff --git a/music_assistant/providers/sync_group/player.py b/music_assistant/providers/sync_group/player.py
new file mode 100644 (file)
index 0000000..766e109
--- /dev/null
@@ -0,0 +1,376 @@
+"""Sync Group Player implementation."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, cast
+
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
+from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.errors import UnsupportedFeaturedException
+from propcache import under_cached_property as cached_property
+
+from music_assistant.constants import (
+    APPLICATION_NAME,
+    CONF_DYNAMIC_GROUP_MEMBERS,
+    CONF_GROUP_MEMBERS,
+)
+from music_assistant.models.player import DeviceInfo, GroupPlayer, Player, PlayerMedia
+
+from .constants import CONF_ENTRY_SGP_NOTE, EXTRA_FEATURES_FROM_MEMBERS, SUPPORT_DYNAMIC_LEADER
+
+if TYPE_CHECKING:
+    from .provider import SyncGroupProvider
+
+
+class SyncGroupPlayer(GroupPlayer):
+    """Sync Group Player implementation."""
+
+    _attr_type: PlayerType = PlayerType.GROUP
+    sync_leader: Player | None = None
+    """The active sync leader player for this syncgroup."""
+
+    def __init__(
+        self,
+        provider: SyncGroupProvider,
+        player_id: str,
+    ) -> None:
+        """Initialize SyncGroupPlayer instance."""
+        super().__init__(provider, player_id)
+        self._attr_name = self.config.name or self.config.default_name or f"SyncGroup {player_id}"
+        self._attr_available = True
+        self._attr_device_info = DeviceInfo(model=provider.name, manufacturer=APPLICATION_NAME)
+        # Allow grouping with any player that supports syncing
+        # The actual compatibility is checked via can_group_with on each player
+        self._attr_can_group_with = set()
+
+    @cached_property
+    def is_dynamic(self) -> bool:
+        """Return if the player is a dynamic group player."""
+        return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False))
+
+    async def on_config_updated(self) -> None:
+        """Handle logic when the player is loaded or updated."""
+        # Config is only available after the player was registered
+        self._cache.clear()  # clear to prevent loading old is_dynamic
+        default_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
+        if self.is_dynamic:
+            self._attr_static_group_members = []
+            self._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        else:
+            self._attr_static_group_members = default_members.copy()
+            self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS)
+        self._attr_group_members = default_members.copy()
+
+    @cached_property
+    def supported_features(self) -> set[PlayerFeature]:
+        """Return the supported features of the player."""
+        # by default we don't have any features, except play_media
+        # but we can gain some features based on the capabilities of the sync leader
+        # set_members is only supported if it's a dynamic group
+        base_features: set[PlayerFeature] = {PlayerFeature.PLAY_MEDIA}
+        if self.is_dynamic:
+            base_features.add(PlayerFeature.SET_MEMBERS)
+        if not self.sync_leader:
+            return base_features
+        # add features supported by the sync leader
+        for feature in EXTRA_FEATURES_FROM_MEMBERS:
+            if feature in self.sync_leader.state.supported_features:
+                base_features.add(feature)
+        return base_features
+
+    @property
+    def playback_state(self) -> PlaybackState:
+        """Return the current playback state of the player."""
+        return self.sync_leader.state.playback_state if self.sync_leader else PlaybackState.IDLE
+
+    @property
+    def requires_flow_mode(self) -> bool:
+        """Return if the player needs flow mode."""
+        if leader := self.sync_leader:
+            return leader.flow_mode
+        return False
+
+    @property
+    def elapsed_time(self) -> float | None:
+        """Return the elapsed time in (fractional) seconds of the current track (if any)."""
+        return self.sync_leader.state.elapsed_time if self.sync_leader else None
+
+    @property
+    def elapsed_time_last_updated(self) -> float | None:
+        """Return when the elapsed time was last updated."""
+        return self.sync_leader.state.elapsed_time_last_updated if self.sync_leader else None
+
+    @property
+    def current_media(self) -> PlayerMedia | None:
+        """Return the current media item (if any) loaded in the player."""
+        return self.sync_leader.state.current_media if self.sync_leader else None
+
+    @property
+    def active_source(self) -> str | None:
+        """Return the active source id (if any) of the player."""
+        return self.sync_leader.active_source if self.sync_leader else None
+
+    @property
+    def can_group_with(self) -> set[str]:
+        """Return the id's of players this player can group with."""
+        if not self.is_dynamic:
+            # in case of static members,
+            # we can only group with the players defined in the config, so we return those directly
+            return set(self._attr_static_group_members)
+        # if we already have a sync leader, we use its can_group_with as reference
+        if self.sync_leader:
+            return {self.sync_leader.player_id, *self.sync_leader.state.can_group_with}
+        # If we have no members, but we do have default members in the config,
+        # we can group with players that are compatible with those
+        default_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
+        for member_id in default_members:
+            member_player = self.mass.players.get_player(member_id)
+            if member_player and member_player.state.available:
+                return {*default_members, *member_player.state.can_group_with}
+        # Dynamic groups can potentially group with any compatible players
+        # Actual compatibility is validated when adding members
+        temp_can_group_with = set()
+        for player in self.mass.players.all_players(return_unavailable=False):
+            if not player.available or player.type == PlayerType.GROUP:
+                # let's avoid showing group players as options to group with
+                continue
+            if (
+                PlayerFeature.SET_MEMBERS in player.state.supported_features
+                and player.state.can_group_with
+            ):
+                temp_can_group_with.add(player.player_id)
+        return temp_can_group_with
+
+    async def get_config_entries(
+        self,
+        action: str | None = None,
+        values: dict[str, ConfigValueType] | None = None,
+    ) -> list[ConfigEntry]:
+        """Return all (provider/player specific) Config Entries for the given player (if any)."""
+        entries: list[ConfigEntry] = [
+            # syncgroup specific entries
+            CONF_ENTRY_SGP_NOTE,
+            ConfigEntry(
+                key=CONF_GROUP_MEMBERS,
+                type=ConfigEntryType.STRING,
+                multi_value=True,
+                label="Group members",
+                default_value=[],
+                description="Select all players you want to be part of this sync group. "
+                "Only compatible players (based on their sync protocol) can be grouped together.",
+                required=False,  # needed for dynamic members (which allows empty members list)
+                options=[
+                    ConfigValueOption(x.display_name, x.player_id)
+                    for x in self.mass.players.all_players(True, False)
+                    if x.type != PlayerType.GROUP
+                ],
+            ),
+            ConfigEntry(
+                key=CONF_DYNAMIC_GROUP_MEMBERS,
+                type=ConfigEntryType.BOOLEAN,
+                label="Enable dynamic members",
+                description="Allow (un)joining members dynamically, so the group more or less "
+                "behaves the same like manually syncing players together, "
+                "with the main difference being that the group player will hold the queue.",
+                default_value=False,
+                required=False,
+            ),
+        ]
+        return entries
+
+    async def stop(self) -> None:
+        """Send STOP command to given player."""
+        if sync_leader := self.sync_leader:
+            # Use internal handler to bypass group redirect logic and avoid infinite loop
+            # (sync_leader is part of this group, so redirect would loop back here)
+            await self.mass.players._handle_cmd_stop(sync_leader.player_id)
+        # dissolve the sync group since we stopped playback
+        await self._dissolve_syncgroup()
+
+    async def play(self) -> None:
+        """Send PLAY (unpause) command to given player."""
+        if sync_leader := self.sync_leader:
+            # Use internal handler to bypass group redirect logic and avoid infinite loop
+            await self.mass.players._handle_cmd_play(sync_leader.player_id)
+
+    async def pause(self) -> None:
+        """Send PAUSE command to given player."""
+        if sync_leader := self.sync_leader:
+            # Use internal handler to bypass group redirect logic and avoid infinite loop
+            await self.mass.players._handle_cmd_pause(sync_leader.player_id)
+
+    async def play_media(self, media: PlayerMedia) -> None:
+        """Handle PLAY MEDIA on given player."""
+        if not self.sync_leader:
+            await self._form_syncgroup()
+        # simply forward the command to the sync leader
+        if sync_leader := self.sync_leader:
+            # Use internal handler to bypass group redirect logic and preserve protocol selection
+            await self.mass.players._handle_play_media(sync_leader.player_id, media)
+            self.update_state()
+        else:
+            raise RuntimeError("An empty group cannot play media, consider adding members first")
+
+    async def enqueue_next_media(self, media: PlayerMedia) -> None:
+        """Handle enqueuing of a next media item on the player."""
+        if sync_leader := self.sync_leader:
+            # Use internal handler to bypass group redirect logic and avoid infinite loop
+            await self.mass.players._handle_enqueue_next_media(sync_leader.player_id, media)
+
+    async def set_members(
+        self,
+        player_ids_to_add: list[str] | None = None,
+        player_ids_to_remove: list[str] | None = None,
+    ) -> None:
+        """Handle SET_MEMBERS command on the player."""
+        if not self.is_dynamic:
+            raise UnsupportedFeaturedException(
+                f"Group {self.display_name} does not allow dynamically adding/removing members!"
+            )
+        prev_leader = self.sync_leader
+        cur_leader = self._select_sync_leader(new_members=player_ids_to_add)
+        # handle additions
+        final_players_to_add: list[str] = []
+        can_group_with = cur_leader.state.can_group_with.copy() if cur_leader else set()
+        for member_id in player_ids_to_add or []:
+            if member_id == self.player_id:
+                continue  # can not add self as member
+            member = self.mass.players.get_player(member_id)
+            if member is None or not member.available:
+                continue
+            if member_id not in self._attr_group_members:
+                self._attr_group_members.append(member_id)
+            if not cur_leader:
+                continue
+            if member_id != cur_leader.player_id and member_id not in can_group_with:
+                self.logger.debug(
+                    f"Cannot add {member.display_name} to group {self.display_name} since it's "
+                    f"not compatible with the current sync leader"
+                )
+                continue
+            if member_id != cur_leader.player_id:
+                final_players_to_add.append(member_id)
+
+        # handle removals
+        final_players_to_remove: list[str] = []
+        for member_id in player_ids_to_remove or []:
+            if member_id not in self._attr_group_members:
+                continue
+            if member_id == self.player_id:
+                raise UnsupportedFeaturedException(
+                    f"Cannot remove {self.display_name} from itself as a member!"
+                )
+            self._attr_group_members.remove(member_id)
+            final_players_to_remove.append(member_id)
+        self.update_state()
+        if self.playback_state != PlaybackState.PLAYING:
+            # Don't need to do anything else if the group is not active
+            # The syncing will be done once playback starts
+            return
+        if prev_leader and cur_leader is None:
+            # Edge case: we no longer have any members in the group (and thus no leader)
+            await self._handle_leader_transition(None)
+        elif prev_leader and prev_leader != cur_leader:
+            # Edge case: we had changed the leader (or just got one)
+            await self._handle_leader_transition(cur_leader)
+        elif cur_leader and (player_ids_to_add or player_ids_to_remove):
+            # if the group still has the same leader, we just need to (re)sync the members
+            await self.mass.players.cmd_set_members(
+                cur_leader.player_id,
+                player_ids_to_add=final_players_to_add,
+                player_ids_to_remove=final_players_to_remove,
+            )
+
+    async def _form_syncgroup(self) -> None:
+        """Form syncgroup by syncing all (possible) members."""
+        if not self.sync_leader:
+            self.sync_leader = self._select_sync_leader()
+
+        if not self.sync_leader:
+            # we have no members in the group, so we can't form a syncgroup
+            return
+
+        # ensure the sync leader is first in the list
+        self._attr_group_members = [
+            self.sync_leader.player_id,
+            *[x for x in self._attr_group_members if x != self.sync_leader.player_id],
+        ]
+        members_to_sync = [
+            x
+            for x in self._attr_group_members
+            if x != self.sync_leader.player_id and x not in self.sync_leader.state.group_members
+        ]
+        if members_to_sync:
+            await self.mass.players.cmd_set_members(self.sync_leader.player_id, members_to_sync)
+
+    async def _dissolve_syncgroup(self) -> None:
+        """Dissolve the current syncgroup by ungrouping all members."""
+        if sync_leader := self.sync_leader:
+            # dissolve the temporary syncgroup from the sync leader
+            sync_children = [
+                x for x in sync_leader.state.group_members if x != sync_leader.player_id
+            ]
+            if sync_children:
+                await self.mass.players.cmd_set_members(sync_leader.player_id, [], sync_children)
+        self.sync_leader = None
+        self.update_state()
+
+    async def _handle_leader_transition(self, new_leader: Player | None) -> None:
+        """Handle transition from current leader to new leader."""
+        prev_leader = self.sync_leader
+        was_playing = False
+        if prev_leader and new_leader and prev_leader != new_leader:
+            # Check if the provider(protocol) supports dynamic leader selection
+            # For cross-provider sync groups, we need to check the provider domain
+            provider_protocol = None
+            if prev_leader.active_output_protocol and (
+                proto_prov := self.mass.get_provider(prev_leader.active_output_protocol)
+            ):
+                provider_protocol = proto_prov.domain
+            else:
+                provider_protocol = prev_leader.provider.domain
+
+            if provider_protocol and provider_protocol in SUPPORT_DYNAMIC_LEADER:
+                # TODO: figure out how to handle dynamic leader transition without
+                # stopping playback, which has become complicated due
+                # to a player can support multiple protocols
+                pass
+
+        if prev_leader:
+            # Save current media and playback state for potential restart
+            was_playing = self.playback_state == PlaybackState.PLAYING
+            # Stop current playback (which also dissolves the existing syncgroup)
+            await self.stop()
+            # allow some time to propagate the changes before resyncing
+            await asyncio.sleep(2)
+
+        # Set new leader
+        self.sync_leader = new_leader
+
+        if new_leader:
+            # form a syncgroup with the new leader
+            await self._form_syncgroup()
+            # Restart playback if requested and we have media to play
+            if was_playing:
+                await self.mass.players._handle_cmd_resume(self.player_id)
+        else:
+            # We have no leader anymore, send update since we stopped playback
+            self.update_state()
+
+    def _select_sync_leader(self, new_members: list[str] | None = None) -> Player | None:
+        """Select a (new) sync leader."""
+        if self.group_members and self.sync_leader and self.sync_leader.state.available:
+            # current leader is still available, no need to select a new one
+            return self.sync_leader
+        default_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
+        group_members = self.group_members or default_members or new_members or []
+        for member_id in group_members:
+            member_player = self.mass.players.get_player(member_id)
+            if member_player and member_player.state.available:
+                self.logger.debug(
+                    f"Auto-selected {member_player.display_name} as sync leader for "
+                    f"group {self.display_name}"
+                )
+                return member_player
+        return None
diff --git a/music_assistant/providers/sync_group/provider.py b/music_assistant/providers/sync_group/provider.py
new file mode 100644 (file)
index 0000000..7199559
--- /dev/null
@@ -0,0 +1,82 @@
+"""Sync Group Player Provider implementation."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import shortuuid
+from music_assistant_models.enums import PlayerType
+
+from music_assistant.constants import CONF_DYNAMIC_GROUP_MEMBERS, CONF_GROUP_MEMBERS
+from music_assistant.models.player_provider import PlayerProvider
+
+from .constants import SGP_PREFIX
+from .player import SyncGroupPlayer
+
+if TYPE_CHECKING:
+    from music_assistant.models.player import Player
+
+
+class SyncGroupProvider(PlayerProvider):
+    """Sync Group Player Provider."""
+
+    async def create_group_player(
+        self, name: str, members: list[str], dynamic: bool = True
+    ) -> Player:
+        """
+        Create new Sync Group Player.
+
+        :param name: Name of the group player.
+        :param members: List of player ids to add to the group.
+        :param dynamic: Whether the group is dynamic (members can change).
+        """
+        # validation to ensure all members are compatible (can_group_with check)
+        members = [x for x in members if x in [y.player_id for y in self.mass.players]]
+        final_members: list[str] = []
+        can_group_with: set[str] = set()
+        for member_id in members:
+            member = self.mass.players.get_player(member_id)
+            if member is None or not member.available:
+                continue
+            if not can_group_with:
+                # first member, add all its compatible players to the can_group_with set
+                can_group_with = set(member.state.can_group_with)
+            if member_id not in can_group_with:
+                # member is not compatible with the current group, skip it
+                continue
+            final_members.append(member_id)
+        # generate a new player_id for the group player
+        player_id = f"{SGP_PREFIX}{shortuuid.random(8).lower()}"
+        self.mass.config.create_default_player_config(
+            player_id=player_id,
+            provider=self.instance_id,
+            player_type=PlayerType.GROUP,
+            name=name,
+            enabled=True,
+            values={
+                CONF_GROUP_MEMBERS: final_members,
+                CONF_DYNAMIC_GROUP_MEMBERS: dynamic,
+            },
+        )
+        return await self._register_player(player_id)
+
+    async def remove_group_player(self, player_id: str) -> None:
+        """
+        Remove a group player.
+
+        :param player_id: ID of the group player to remove.
+        """
+        # we simply permanently unregister the player and wipe its config
+        await self.mass.players.unregister(player_id, True)
+
+    async def discover_players(self) -> None:
+        """Discover players."""
+        for player_conf in await self.mass.config.get_player_configs(self.instance_id):
+            if player_conf.player_id.startswith(SGP_PREFIX):
+                await self._register_player(player_conf.player_id)
+
+    async def _register_player(self, player_id: str) -> Player:
+        """Register a sync group player."""
+        group = SyncGroupPlayer(self, player_id)
+        await self.mass.players.register_or_update(group)
+        return group
index de9d370cb4511c5c1ab4cdb7b354fa9930be24e6..ba04fe62bec2cb588dc7325d1605aeccfd323e91 100644 (file)
@@ -39,7 +39,12 @@ from .ugp_stream import UGPStream
 if TYPE_CHECKING:
     from .provider import UniversalGroupProvider
 
-BASE_FEATURES = {PlayerFeature.POWER, PlayerFeature.VOLUME_SET, PlayerFeature.MULTI_DEVICE_DSP}
+BASE_FEATURES = {
+    PlayerFeature.PLAY_MEDIA,
+    PlayerFeature.POWER,
+    PlayerFeature.VOLUME_SET,
+    PlayerFeature.MULTI_DEVICE_DSP,
+}
 
 
 class UniversalGroupPlayer(GroupPlayer):
@@ -76,12 +81,6 @@ class UniversalGroupPlayer(GroupPlayer):
                 f"/ugp/{self.player_id}.aac", self._serve_ugp_stream
             )
         )
-        # allow grouping with all providers, except the ugp provider itself
-        self._attr_can_group_with = {
-            x.instance_id
-            for x in self.mass.players.providers
-            if x.instance_id != self.provider.instance_id
-        }
         self._set_attributes()
 
     @property
@@ -89,6 +88,20 @@ class UniversalGroupPlayer(GroupPlayer):
         """Return if the player requires flow mode."""
         return True
 
+    @property
+    def can_group_with(self) -> set[str]:
+        """Return the id's of players this player can group with."""
+        if not self.is_dynamic:
+            # in case of static members,
+            # we can only group with the players defined in the config, so we return those directly
+            return set(self._attr_static_group_members)
+        # allow grouping with all providers, except the ugp provider itself
+        return {
+            x.instance_id
+            for x in self.mass.players.providers
+            if x.instance_id != self.provider.instance_id
+        }
+
     async def on_config_updated(self) -> None:
         """Handle logic when the player is loaded or updated."""
         static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
@@ -124,7 +137,7 @@ class UniversalGroupPlayer(GroupPlayer):
                 required=False,  # needed for dynamic members (which allows empty members list)
                 options=[
                     ConfigValueOption(x.display_name, x.player_id)
-                    for x in self.mass.players.all(True, False)
+                    for x in self.mass.players.all_players(True, False)
                     if x.type != PlayerType.GROUP
                 ],
             ),
@@ -143,7 +156,8 @@ class UniversalGroupPlayer(GroupPlayer):
         """Handle STOP command."""
         async with TaskManager(self.mass) as tg:
             for member in self.mass.players.iter_group_members(self, active_only=True):
-                tg.create_task(member.stop())
+                # Use internal handler to get protocol selection and avoid redirect
+                tg.create_task(self.mass.players._handle_cmd_stop(member.player_id))
         # abort the stream session
         if self.stream and not self.stream.done:
             await self.stream.stop()
@@ -153,7 +167,10 @@ class UniversalGroupPlayer(GroupPlayer):
     async def power(self, powered: bool) -> None:
         """Handle POWER command to group player."""
         # always stop at power off
-        if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
+        if not powered and self._attr_playback_state in (
+            PlaybackState.PLAYING,
+            PlaybackState.PAUSED,
+        ):
             await self.stop()
 
         # optimistically set the group state
@@ -165,7 +182,7 @@ class UniversalGroupPlayer(GroupPlayer):
             self._attr_group_members = []
             for static_group_member in self._attr_static_group_members:
                 if (
-                    (member_player := self.mass.players.get(static_group_member))
+                    (member_player := self.mass.players.get_player(static_group_member))
                     and member_player.available
                     and member_player.enabled
                 ):
@@ -179,12 +196,16 @@ class UniversalGroupPlayer(GroupPlayer):
                     and member.active_source != self.active_source
                 ):
                     # stop playing existing content on member if we start the group player
-                    await member.stop()
-                if member.active_group is not None and member.active_group != self.player_id:
+                    # Use internal handler to get protocol selection and avoid redirect
+                    await self.mass.players._handle_cmd_stop(member.player_id)
+                if (
+                    member.state.active_group is not None
+                    and member.state.active_group != self.player_id
+                ):
                     # collision: child player is part of multiple groups
                     # and another group already active !
                     # solve this by trying to leave the group first
-                    if other_group := self.mass.players.get(member.active_group):
+                    if other_group := self.mass.players.get_player(member.state.active_group):
                         if (
                             other_group.supports_feature(PlayerFeature.SET_MEMBERS)
                             and member.player_id not in other_group.static_group_members
@@ -248,14 +269,16 @@ class UniversalGroupPlayer(GroupPlayer):
             for member in self.mass.players.iter_group_members(
                 self, only_powered=True, active_only=True
             ):
+                # Use internal handler to get protocol selection and avoid redirect
                 tg.create_task(
-                    member.play_media(
+                    self.mass.players._handle_play_media(
+                        member.player_id,
                         PlayerMedia(
                             uri=f"{base_url}?player_id={member.player_id}",
                             media_type=MediaType.FLOW_STREAM,
                             title=self.display_name,
                             source_id=self.player_id,
-                        )
+                        ),
                     )
                 )
 
@@ -277,7 +300,7 @@ class UniversalGroupPlayer(GroupPlayer):
                 raise UnsupportedFeaturedException(
                     f"Cannot add {self.display_name} to itself as a member!"
                 )
-            child_player = self.mass.players.get(player_id, True)
+            child_player = self.mass.players.get_player(player_id, True)
             assert child_player  # for type checking
             if child_player.synced_to:
                 # This is player is part of a syncgroup - ungroup it first
@@ -286,12 +309,14 @@ class UniversalGroupPlayer(GroupPlayer):
             # let the newly add member join the stream if we're playing
             if self.stream and not self.stream.done and self.powered:
                 base_url = f"{self.mass.streams.base_url}/ugp/{self.player_id}.flac"
-                await child_player.play_media(
-                    media=PlayerMedia(
+                # Use internal handler to get protocol selection and avoid redirect
+                await self.mass.players._handle_play_media(
+                    player_id,
+                    PlayerMedia(
                         uri=f"{base_url}?player_id={player_id}",
                         media_type=MediaType.FLOW_STREAM,
                         title=self.display_name,
-                        source_id=child_player.player_id,
+                        source_id=player_id,
                     ),
                 )
         # handle removals
@@ -303,14 +328,15 @@ class UniversalGroupPlayer(GroupPlayer):
                     f"Cannot remove {self.display_name} from itself as a member!"
                 )
             self._attr_group_members.remove(player_id)
-            child_player = self.mass.players.get(player_id, True)
+            child_player = self.mass.players.get_player(player_id, True)
             assert child_player is not None  # for type checking
             if child_player.playback_state in (
                 PlaybackState.PLAYING,
                 PlaybackState.PAUSED,
             ):
                 # if the child player is playing the group stream, stop it
-                await child_player.stop()
+                # Use internal handler to get protocol selection and avoid redirect
+                await self.mass.players._handle_cmd_stop(player_id)
         self.update_state()
 
     async def poll(self) -> None:
@@ -350,7 +376,7 @@ class UniversalGroupPlayer(GroupPlayer):
         child_player_id = request.query.get("player_id")  # optional!
         output_format_str = request.path.rsplit(".")[-1]
 
-        if child_player_id and (child_player := self.mass.players.get(child_player_id)):
+        if child_player_id and (child_player := self.mass.players.get_player(child_player_id)):
             # Use the preferred output format of the child player
             output_format = await self.mass.streams.get_output_format(
                 output_format_str=output_format_str,
@@ -367,7 +393,7 @@ class UniversalGroupPlayer(GroupPlayer):
             output_format = AudioFormat(content_type=ContentType.MP3)
             http_profile = "chunked"
 
-        if not (ugp_player := self.mass.players.get(ugp_player_id)):
+        if not (ugp_player := self.mass.players.get_player(ugp_player_id)):
             raise web.HTTPNotFound(reason=f"Unknown UGP player: {ugp_player_id}")
 
         if not self.stream or self.stream.done:
diff --git a/music_assistant/providers/universal_player/README.md b/music_assistant/providers/universal_player/README.md
new file mode 100644 (file)
index 0000000..d1edb86
--- /dev/null
@@ -0,0 +1,96 @@
+# Universal Player Provider
+
+## Overview
+
+The Universal Player provider creates virtual players that merge multiple protocol players (AirPlay, Chromecast, DLNA, Squeezelite, SendSpin) for the same physical device into a single unified player.
+
+## When is a Universal Player Created?
+
+A Universal Player is automatically created by the PlayerController when:
+
+1. **Multiple protocol players are detected for the same device** - Based on MAC address or IP matching
+2. **No native player provider exists** - e.g., a Denon AVR with Chromecast, AirPlay, and DLNA but no native Denon integration
+
+## Example Scenario
+
+Consider a Denon AVR receiver that supports:
+- Chromecast built-in
+- AirPlay 2
+- DLNA
+
+Without a native Denon provider in Music Assistant, the system would normally show three separate players:
+- "Living Room (Chromecast)"
+- "Living Room (AirPlay)"
+- "Living Room (DLNA)"
+
+With the Universal Player provider, these are merged into a single:
+- "Living Room" (Universal Player)
+  - Output protocols: Chromecast, AirPlay, DLNA
+
+## How It Works
+
+### Device Matching
+
+Protocol players are matched to the same device using:
+1. **MAC address** - Most reliable, extracted from device info
+2. **IP address** - Fallback when MAC is not available
+
+### Player Creation Flow
+
+```
+1. Chromecast player registers → No native parent, no other protocols → Stays as regular player
+2. AirPlay player registers → Matches Chromecast by MAC → PlayerController creates UniversalPlayer
+3. DLNA player registers → Matches existing UniversalPlayer → Added as linked protocol
+```
+
+### Feature Aggregation
+
+The Universal Player aggregates features from all linked protocols:
+- Volume control from the protocol that supports it best
+- Power control from any protocol that supports it
+- Pause/Play from active protocol
+
+### Playback Routing
+
+The Universal Player does NOT have `PLAY_MEDIA` capability. Instead:
+1. User selects "Living Room" and starts playback
+2. PlayerController uses `_select_best_output_protocol()` to choose best protocol
+3. Playback is routed to the selected protocol player (e.g., Chromecast)
+4. User can switch to different protocol in player settings
+
+## Configuration
+
+Universal Players are auto-created and require no user configuration. However, users can:
+- Rename the player
+- Choose preferred output protocol
+- Disable/enable the player
+
+## Cleanup
+
+When all protocol players for a device are removed (e.g., provider unloaded), the Universal Player is automatically cleaned up.
+
+If a native provider is later installed (e.g., Denon integration), the Universal Player is replaced by the native player, with all protocols linked to it instead.
+
+## Technical Details
+
+### Player ID Format
+
+Universal players use the format: `up{device_key}`
+
+Where `device_key` is typically the normalized MAC address.
+
+### File Structure
+
+```
+universal_player/
+├── __init__.py      # Provider setup
+├── provider.py      # UniversalPlayerProvider class
+├── player.py        # UniversalPlayer class
+├── constants.py     # Constants (prefix, etc.)
+├── manifest.json    # Provider manifest (builtin)
+└── README.md        # This file
+```
+
+### Provider Features
+
+The Universal Player provider has no special provider features - it doesn't support manual player creation via the UI. Players are only created automatically by the PlayerController.
diff --git a/music_assistant/providers/universal_player/__init__.py b/music_assistant/providers/universal_player/__init__.py
new file mode 100644 (file)
index 0000000..7e29f14
--- /dev/null
@@ -0,0 +1,56 @@
+"""
+Universal Player provider.
+
+Auto-creates virtual players that merge multiple protocol players
+(AirPlay, Chromecast, DLNA, Squeezelite, SendSpin) for the same device
+into a single unified player when no native provider exists.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from .player import UniversalPlayer
+from .provider import UniversalPlayerProvider
+
+if TYPE_CHECKING:
+    from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
+    from music_assistant_models.enums import ProviderFeature
+    from music_assistant_models.provider import ProviderManifest
+
+    from music_assistant import MusicAssistant
+    from music_assistant.models import ProviderInstanceType
+
+SUPPORTED_FEATURES: set[ProviderFeature] = set()
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    return UniversalPlayerProvider(mass, manifest, config, SUPPORTED_FEATURES)
+
+
+async def get_config_entries(
+    mass: MusicAssistant,  # noqa: ARG001
+    instance_id: str | None = None,  # noqa: ARG001
+    action: str | None = None,  # noqa: ARG001
+    values: dict[str, ConfigValueType] | None = None,  # noqa: ARG001
+) -> 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.
+    """
+    # Nothing to configure - universal players are auto-created
+    return ()
+
+
+__all__ = (
+    "UniversalPlayer",
+    "UniversalPlayerProvider",
+    "get_config_entries",
+    "setup",
+)
diff --git a/music_assistant/providers/universal_player/constants.py b/music_assistant/providers/universal_player/constants.py
new file mode 100644 (file)
index 0000000..d408d12
--- /dev/null
@@ -0,0 +1,16 @@
+"""Universal Player constants."""
+
+from __future__ import annotations
+
+from typing import Final
+
+UNIVERSAL_PLAYER_PREFIX: Final[str] = "up"
+
+# Config key for storing linked protocol player IDs (hidden config entry)
+CONF_LINKED_PROTOCOL_IDS: Final[str] = "linked_protocol_ids"
+
+# Config key for storing device identifiers (MAC, UUID, etc.)
+CONF_DEVICE_IDENTIFIERS: Final[str] = "device_identifiers"
+
+# Config key for storing device info (model, manufacturer)
+CONF_DEVICE_INFO: Final[str] = "device_info"
diff --git a/music_assistant/providers/universal_player/manifest.json b/music_assistant/providers/universal_player/manifest.json
new file mode 100644 (file)
index 0000000..d3ce496
--- /dev/null
@@ -0,0 +1,14 @@
+{
+  "type": "player",
+  "domain": "universal_player",
+  "stage": "stable",
+  "name": "Universal Player",
+  "description": "Provides support for players that have no native (vendor-specific) provider in Music Assistant but support one or more generic streaming protocols such as AirPlay, Chromecast, or DLNA. Automatically created for protocol-only devices.",
+  "codeowners": ["@music-assistant"],
+  "requirements": [],
+  "documentation": "https://music-assistant.io/",
+  "multi_instance": false,
+  "builtin": true,
+  "allow_disable": false,
+  "icon": "speaker"
+}
diff --git a/music_assistant/providers/universal_player/player.py b/music_assistant/providers/universal_player/player.py
new file mode 100644 (file)
index 0000000..1e131ef
--- /dev/null
@@ -0,0 +1,160 @@
+"""
+Universal Player implementation.
+
+A virtual player for devices that have no native (vendor-specific) provider in
+Music Assistant but support one or more generic streaming protocols such as
+AirPlay, Sendspin, Chromecast, or DLNA.
+
+The Universal Player is automatically created when a protocol player with
+PlayerType.PROTOCOL is registered, providing a unified interface while delegating
+actual playback to the underlying protocol player(s).
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from music_assistant_models.enums import PlayerFeature
+
+from music_assistant.constants import CONF_PREFERRED_OUTPUT_PROTOCOL
+from music_assistant.models.player import DeviceInfo, Player
+
+if TYPE_CHECKING:
+    from .provider import UniversalPlayerProvider
+
+
+class UniversalPlayer(Player):
+    """
+    Universal Player implementation.
+
+    A virtual player for devices without native Music Assistant support that use
+    generic streaming protocols. It does NOT have PLAY_MEDIA capability on its own.
+    Playback is always delegated to one of the linked protocol players via the protocol
+    linking system.
+    """
+
+    def __init__(
+        self,
+        provider: UniversalPlayerProvider,
+        player_id: str,
+        name: str,
+        device_info: DeviceInfo,
+        protocol_player_ids: list[str],
+    ) -> None:
+        """
+        Initialize UniversalPlayer instance.
+
+        :param provider: The UniversalPlayerProvider instance.
+        :param player_id: Unique player ID (typically based on MAC address).
+        :param name: Display name for the player.
+        :param device_info: Device information aggregated from protocol players.
+        :param protocol_player_ids: List of protocol player IDs to link.
+        """
+        super().__init__(provider, player_id)
+        self._protocol_player_ids = protocol_player_ids
+        # Set player attributes
+        self._attr_name = name
+        self._attr_device_info = device_info
+        # Start as unavailable - will be updated when protocol players are linked
+        self._attr_available = False
+        # a universal player does not have any features on its own,
+        # it delegates to protocol players
+        self._attr_supported_features = set()
+
+    def _get_control_target(
+        self, required_feature: PlayerFeature, require_active: bool = False
+    ) -> Player | None:
+        """Get the best player to send control commands to.
+
+        Prefers the active output protocol, otherwise uses the first available
+        protocol player that supports the needed feature.
+        """
+        # If we have an active protocol, use that
+        if (
+            self.active_output_protocol
+            and self.active_output_protocol != "native"
+            and (protocol_player := self.mass.players.get_player(self.active_output_protocol))
+            and required_feature in protocol_player.supported_features
+        ):
+            return protocol_player
+
+        # If require_active is set, and no active protocol found, return None
+        if require_active:
+            return None
+
+        # Otherwise, use the first available linked protocol
+        for protocol_player_id in self._protocol_player_ids:
+            if (
+                (protocol_player := self.mass.players.get_player(protocol_player_id))
+                and protocol_player.available
+                and required_feature in protocol_player.supported_features
+            ):
+                return protocol_player
+
+        return None
+
+    def update_from_protocol_players(self) -> None:
+        """
+        Update state from linked protocol players.
+
+        Called to sync state like volume, availability from protocol players.
+        """
+        # Aggregate availability - available if any protocol is available
+        self._attr_available = any(
+            (p := self.mass.players.get_player(pid)) and p.available
+            for pid in self._protocol_player_ids
+        )
+        # Get volume from best control target
+        if target := self._get_control_target(PlayerFeature.VOLUME_SET):
+            if target.volume_level is not None:
+                self._attr_volume_level = target.volume_level
+        if target := self._get_control_target(PlayerFeature.VOLUME_MUTE):
+            if target.volume_muted is not None:
+                self._attr_volume_muted = target.volume_muted
+
+        self.update_state()
+
+    def add_protocol_player(self, protocol_player_id: str) -> None:
+        """Add a protocol player to this universal player."""
+        if protocol_player_id not in self._protocol_player_ids:
+            self._protocol_player_ids.append(protocol_player_id)
+
+    def remove_protocol_player(self, protocol_player_id: str) -> None:
+        """Remove a protocol player from this universal player."""
+        if protocol_player_id in self._protocol_player_ids:
+            self._protocol_player_ids.remove(protocol_player_id)
+
+    def _get_preferred_protocol_player(self) -> Player | None:
+        """
+        Get the preferred protocol player for this universal player.
+
+        Selection priority:
+        1. Active output protocol (if set and available)
+        2. User's preferred output protocol (from settings), fallback to highest
+           priority if preferred is not available
+        """
+        # 1. Active output protocol takes precedence
+        if (
+            self.active_output_protocol
+            and self.active_output_protocol != "native"
+            and (protocol_player := self.mass.players.get_player(self.active_output_protocol))
+            and protocol_player.available
+        ):
+            return protocol_player
+
+        # 2. User's preferred output protocol (with fallback to highest priority)
+        preferred = self.mass.config.get_raw_player_config_value(
+            self.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
+        )
+        if preferred and (protocol_player := self.mass.players.get_player(str(preferred))):
+            if protocol_player.available:
+                return protocol_player
+
+        # Fallback: if user's preferred protocol is not available,
+        # use the highest priority available protocol
+        for protocol in sorted(self.linked_output_protocols, key=lambda x: x.priority):
+            if protocol_player := self.mass.players.get_player(protocol.output_protocol_id):
+                if protocol_player.available:
+                    return protocol_player
+
+        return None
diff --git a/music_assistant/providers/universal_player/provider.py b/music_assistant/providers/universal_player/provider.py
new file mode 100644 (file)
index 0000000..618b76b
--- /dev/null
@@ -0,0 +1,446 @@
+"""Universal Player Provider implementation.
+
+This provider manages UniversalPlayer instances that are auto-created for devices
+that have no native (vendor-specific) provider in Music Assistant but support one
+or more generic streaming protocols such as AirPlay, Chromecast, or DLNA.
+
+The Universal Player acts as a virtual player wrapper that provides a unified
+interface while delegating actual playback to the underlying protocol player(s).
+"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING
+
+from music_assistant_models.enums import IdentifierType, PlayerType
+
+from music_assistant.constants import CONF_PLAYERS
+from music_assistant.models.player import DeviceInfo
+from music_assistant.models.player_provider import PlayerProvider
+
+from .constants import (
+    CONF_DEVICE_IDENTIFIERS,
+    CONF_DEVICE_INFO,
+    CONF_LINKED_PROTOCOL_IDS,
+    UNIVERSAL_PLAYER_PREFIX,
+)
+from .player import UniversalPlayer
+
+if TYPE_CHECKING:
+    from music_assistant.models.player import Player
+
+
+class UniversalPlayerProvider(PlayerProvider):
+    """
+    Universal Player Provider.
+
+    Manages virtual players for devices that have no native (vendor-specific) provider
+    but support generic streaming protocols like AirPlay, Chromecast, or DLNA.
+    These players are automatically created when protocol players with PlayerType.PROTOCOL
+    are registered, providing a unified interface while delegating playback to the
+    underlying protocol player(s).
+    """
+
+    async def handle_async_init(self) -> None:
+        """Handle async initialization of the provider."""
+        # Lock to prevent race conditions during universal player creation
+        self._universal_player_locks: dict[str, asyncio.Lock] = {}
+
+    async def discover_players(self) -> None:
+        """
+        Discover players.
+
+        Universal players are created dynamically by the PlayerController,
+        not through discovery. However, we restore previously created
+        universal players from config.
+        """
+        for player_conf in await self.mass.config.get_player_configs(self.instance_id):
+            if player_conf.player_id.startswith(UNIVERSAL_PLAYER_PREFIX):
+                # Restore universal player from config
+                # The stored protocol IDs enable fast matching when protocols register
+                await self._restore_player(player_conf.player_id)
+
+    async def _restore_player(self, player_id: str) -> None:
+        """
+        Restore a universal player from config.
+
+        The stored protocol_player_ids enable fast matching when protocol players
+        register - they can be linked immediately without waiting for identifier matching.
+        Device identifiers are also restored to enable matching new protocol players.
+        """
+        # Get stored config values
+        config = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}")
+        if not config:
+            return
+
+        # Get stored values
+        values = config.get("values", {})
+        stored_protocol_ids = values.get(CONF_LINKED_PROTOCOL_IDS, [])
+        stored_identifiers = values.get(CONF_DEVICE_IDENTIFIERS, {})
+        stored_device_info = values.get(CONF_DEVICE_INFO, {})
+
+        # Check if protocols have been linked to a native player (stale universal player)
+        for protocol_id in stored_protocol_ids:
+            protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
+            if protocol_config:
+                protocol_values = protocol_config.get("values", {})
+                protocol_parent_id = protocol_values.get("protocol_parent_id")
+                if protocol_parent_id and protocol_parent_id != player_id:
+                    self.logger.info(
+                        "Deleting stale universal player %s - protocol %s has moved to parent %s",
+                        player_id,
+                        protocol_id,
+                        protocol_parent_id,
+                    )
+                    await self.mass.config.remove_player_config(player_id)
+                    return
+
+            # Check if native player has this protocol in linked_protocol_player_ids
+            all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
+            for other_player_id, other_config in all_player_configs.items():
+                if other_player_id == player_id:
+                    continue
+                if other_config.get("provider") == "universal_player":
+                    continue
+                other_values = other_config.get("values", {})
+                linked_protocols = other_values.get("linked_protocol_player_ids", [])
+                if protocol_id in linked_protocols:
+                    self.logger.info(
+                        "Deleting stale universal player %s - "
+                        "protocol %s is linked to native player %s",
+                        player_id,
+                        protocol_id,
+                        other_player_id,
+                    )
+                    await self.mass.config.remove_player_config(player_id)
+                    return
+
+        # Restore device info with stored values or defaults
+        device_info = DeviceInfo(
+            model=stored_device_info.get("model", "Universal Player"),
+            manufacturer=stored_device_info.get("manufacturer", "Music Assistant"),
+        )
+
+        # Restore identifiers (convert string keys back to IdentifierType enum)
+        for id_type_str, value in stored_identifiers.items():
+            try:
+                id_type = IdentifierType(id_type_str)
+                device_info.add_identifier(id_type, value)
+            except ValueError:
+                self.logger.warning(
+                    "Unknown identifier type %s for player %s", id_type_str, player_id
+                )
+
+        name = config.get("name", f"Universal Player {player_id}")
+
+        self.logger.debug(
+            "Restoring universal player %s with %d protocol IDs and %d identifiers",
+            player_id,
+            len(stored_protocol_ids),
+            len(stored_identifiers),
+        )
+
+        player = UniversalPlayer(
+            provider=self,
+            player_id=player_id,
+            name=name,
+            device_info=device_info,
+            protocol_player_ids=list(stored_protocol_ids),
+        )
+        await self.mass.players.register_or_update(player)
+
+    async def create_universal_player(
+        self,
+        device_key: str,
+        name: str,
+        device_info: DeviceInfo,
+        protocol_player_ids: list[str],
+    ) -> Player:
+        """
+        Create a new UniversalPlayer.
+
+        Called by the PlayerController when multiple protocol players are
+        detected for a device without a native player.
+
+        :param device_key: Unique device key (typically MAC address).
+        :param name: Display name for the player.
+        :param device_info: Aggregated device information.
+        :param protocol_player_ids: List of protocol player IDs to link.
+        :return: The created UniversalPlayer instance.
+        """
+        # Generate player_id from device_key
+        player_id = f"{UNIVERSAL_PLAYER_PREFIX}{device_key}"
+
+        # Check if player already exists
+        if existing := self.mass.players.get_player(player_id):
+            # Update existing player with new protocol players
+            if isinstance(existing, UniversalPlayer):
+                for pid in protocol_player_ids:
+                    existing.add_protocol_player(pid)
+                # Merge identifiers from new device_info
+                for id_type, value in device_info.identifiers.items():
+                    existing.device_info.add_identifier(id_type, value)
+                # Persist updated data to config
+                await self._save_player_data(player_id, existing)
+                existing.update_state()
+            return existing
+
+        # Create config for the new player (complex values saved separately after)
+        self.mass.config.create_default_player_config(
+            player_id=player_id,
+            provider=self.instance_id,
+            player_type=PlayerType.GROUP,
+            name=name,
+            enabled=True,
+            values={
+                CONF_LINKED_PROTOCOL_IDS: protocol_player_ids,
+            },
+        )
+
+        # Save device identifiers and info to config (these are nested dicts,
+        # not supported by ConfigValueType, so we save them directly)
+        base_key = f"{CONF_PLAYERS}/{player_id}/values"
+        self.mass.config.set(
+            f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
+            {k.value: v for k, v in device_info.identifiers.items()},
+        )
+        self.mass.config.set(
+            f"{base_key}/{CONF_DEVICE_INFO}",
+            {"model": device_info.model, "manufacturer": device_info.manufacturer},
+        )
+
+        self.logger.info(
+            "Creating universal player %s with protocol players: %s",
+            player_id,
+            protocol_player_ids,
+        )
+
+        # Create the player instance
+        player = UniversalPlayer(
+            provider=self,
+            player_id=player_id,
+            name=name,
+            device_info=device_info,
+            protocol_player_ids=protocol_player_ids,
+        )
+
+        await self.mass.players.register_or_update(player)
+        return player
+
+    async def _save_protocol_ids(self, player_id: str, protocol_player_ids: list[str]) -> None:
+        """Save protocol player IDs to config for persistence across restarts."""
+        conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
+        self.mass.config.set(conf_key, protocol_player_ids)
+        self.logger.debug(
+            "Saved protocol IDs for %s: %s",
+            player_id,
+            protocol_player_ids,
+        )
+
+    async def _save_player_data(self, player_id: str, player: UniversalPlayer) -> None:
+        """Save all player data to config for persistence across restarts."""
+        base_key = f"{CONF_PLAYERS}/{player_id}/values"
+
+        # Save protocol IDs
+        self.mass.config.set(
+            f"{base_key}/{CONF_LINKED_PROTOCOL_IDS}",
+            player._protocol_player_ids,
+        )
+
+        # Save identifiers (convert IdentifierType enum keys to strings)
+        self.mass.config.set(
+            f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
+            {k.value: v for k, v in player.device_info.identifiers.items()},
+        )
+
+        # Save device info (model, manufacturer)
+        self.mass.config.set(
+            f"{base_key}/{CONF_DEVICE_INFO}",
+            {
+                "model": player.device_info.model,
+                "manufacturer": player.device_info.manufacturer,
+            },
+        )
+
+        self.logger.debug(
+            "Saved player data for %s: %d protocols, %d identifiers",
+            player_id,
+            len(player._protocol_player_ids),
+            len(player.device_info.identifiers),
+        )
+
+    async def add_protocol_to_universal_player(
+        self, player_id: str, protocol_player_id: str
+    ) -> None:
+        """
+        Add a protocol player to an existing universal player.
+
+        Called when a new protocol player is discovered that matches an existing
+        universal player.
+
+        :param player_id: ID of the universal player.
+        :param protocol_player_id: ID of the protocol player to add.
+        """
+        if player := self.get_universal_player(player_id):
+            player.add_protocol_player(protocol_player_id)
+            # Save all player data (protocol IDs, identifiers, device info)
+            await self._save_player_data(player_id, player)
+            player.update_state()
+
+    async def remove_universal_player(self, player_id: str) -> None:
+        """
+        Remove a universal player.
+
+        Called when all protocol players for a device are removed.
+
+        :param player_id: ID of the universal player to remove.
+        """
+        await self.mass.players.unregister(player_id, permanent=True)
+
+    async def ensure_universal_player_for_protocols(
+        self, protocol_players: list[Player]
+    ) -> Player | None:
+        """
+        Ensure a universal player exists for a set of protocol players.
+
+        This method handles the orchestration of creating or updating a universal player
+        for the given protocol players. It uses per-device locking to prevent race
+        conditions when multiple protocols for the same device register simultaneously.
+
+        :param protocol_players: List of protocol players for the same device.
+        :return: The created or updated universal player, or None if operation failed.
+        """
+        device_key = self._get_device_key_from_players(protocol_players)
+        if not device_key:
+            return None
+
+        universal_player_id = f"{UNIVERSAL_PLAYER_PREFIX}{device_key}"
+
+        # Use a per-device lock to prevent race conditions
+        if device_key not in self._universal_player_locks:
+            self._universal_player_locks[device_key] = asyncio.Lock()
+
+        async with self._universal_player_locks[device_key]:
+            # Re-check - another task may have already handled these players
+            # Filter out players that are already linked to a parent
+            protocol_players = [p for p in protocol_players if not p.protocol_parent_id]
+            if not protocol_players:
+                return None
+
+            # Check if universal player already exists
+            if existing := self.mass.players.get_player(universal_player_id):
+                # Update existing universal player with new protocol players
+                protocol_player_ids = [p.player_id for p in protocol_players]
+                for player_id in protocol_player_ids:
+                    if isinstance(existing, UniversalPlayer):
+                        await self.add_protocol_to_universal_player(universal_player_id, player_id)
+                return existing
+
+            # Create new universal player
+            device_info = self._aggregate_device_info(protocol_players)
+            name = self._get_clean_player_name(protocol_players)
+            protocol_player_ids = [p.player_id for p in protocol_players]
+
+            return await self.create_universal_player(
+                device_key=device_key,
+                name=name,
+                device_info=device_info,
+                protocol_player_ids=protocol_player_ids,
+            )
+
+    def get_universal_player(self, player_id: str) -> UniversalPlayer | None:
+        """Get a UniversalPlayer by ID if it exists and is managed by this provider."""
+        if player := self.mass.players.get_player(player_id):
+            if isinstance(player, UniversalPlayer):
+                return player
+        return None
+
+    def _get_device_key_from_players(self, protocol_players: list[Player]) -> str | None:
+        """
+        Generate a device key from protocol players' identifiers.
+
+        Prefers MAC address (most stable), falls back to UUID, then player_id.
+        IP address is not used as it can change with DHCP and cause incorrect matches.
+        """
+        uuid_key: str | None = None
+        for player in protocol_players:
+            identifiers = player.device_info.identifiers
+            # Prefer MAC address (most reliable)
+            if mac := identifiers.get(IdentifierType.MAC_ADDRESS):
+                return mac.replace(":", "").replace("-", "").lower()
+            # Fall back to UUID (reliable for DLNA, Chromecast)
+            if not uuid_key and (uuid := identifiers.get(IdentifierType.UUID)):
+                # Normalize UUID: remove special characters, lowercase
+                uuid_key = uuid.replace("-", "").replace(":", "").replace("_", "").lower()
+        if uuid_key:
+            return uuid_key
+        # Last resort: use player_id as device key for protocol players without identifiers
+        # (e.g., Sendspin players that don't expose IP/MAC)
+        if protocol_players:
+            return protocol_players[0].player_id.replace(":", "").replace("-", "").lower()
+        return None
+
+    def _aggregate_device_info(self, protocol_players: list[Player]) -> DeviceInfo:
+        """Aggregate device info from protocol players."""
+        first_player = protocol_players[0]
+        device_info = DeviceInfo(
+            model=first_player.device_info.model,
+            manufacturer=first_player.device_info.manufacturer,
+        )
+        # Merge identifiers from all protocol players
+        for player in protocol_players:
+            for conn_type, value in player.device_info.identifiers.items():
+                device_info.add_identifier(conn_type, value)
+        return device_info
+
+    def _get_clean_player_name(self, protocol_players: list[Player]) -> str:
+        """
+        Get the best display name from protocol players.
+
+        Prefers names from protocols that typically provide user-friendly names
+        (Chromecast, DLNA, AirPlay) over those that may use technical identifiers
+        (Squeezelite, SendSpin). Filters out names that look like MAC addresses,
+        UUIDs, or player IDs.
+        """
+        # Protocol priority for name selection (higher priority = better names typically)
+        # Chromecast and DLNA usually have good user-configured names
+        # AirPlay also provides sensible names
+        # Squeezelite and SendSpin may use MAC addresses or technical IDs
+        name_priority = {
+            "chromecast": 1,
+            "airplay": 2,
+            "dlna": 3,
+            "squeezelite": 4,
+            "sendspin": 5,
+        }
+
+        def is_valid_name(name: str) -> bool:
+            """Check if a name looks like a real user-friendly name, not a technical ID."""
+            if not name or len(name) < 2:
+                return False
+            name_lower = name.lower().replace(":", "").replace("-", "").replace("_", "")
+            # Filter out names that look like MAC addresses (12 hex chars)
+            if len(name_lower) == 12 and all(c in "0123456789abcdef" for c in name_lower):
+                return False
+            # Filter out names that look like UUIDs
+            if len(name_lower) >= 32 and all(c in "0123456789abcdef" for c in name_lower[:32]):
+                return False
+            # Filter out names that start with common player ID prefixes
+            return not name_lower.startswith(
+                ("ap_", "cc_", "dlna_", "sq_", "sendspin_", "universal_")
+            )
+
+        # Sort players by protocol priority, then find the first valid name
+        sorted_players = sorted(
+            protocol_players,
+            key=lambda p: name_priority.get(p.provider.domain, 10),
+        )
+
+        for player in sorted_players:
+            player_name = player.state.name
+            if is_valid_name(player_name):
+                return player_name
+
+        # Fallback to first player's name if no valid name found
+        return protocol_players[0].display_name
index a322b9ab3d06ba80365e3ee156788a6116e3e512..4ab942c507c11d6eea6d1c0d0c2a24921e1d7e6b 100644 (file)
@@ -10,9 +10,7 @@ classifiers = [
   "Programming Language :: Python :: 3.13",
 ]
 dependencies = [
-  "aiodns>=3.2.0",
-  # Pin pycares to 4.11.0 until aiodns is updated to support pycares 5.0 API changes
-  # pycares 5.0.0 (released 2025-12-10) has breaking changes incompatible with current aiodns
+  "aiodns>=3.2.0", # Pin pycares to 4.11.0 until aiodns is updated to support pycares 5.0 API changes  # pycares 5.0.0 (released 2025-12-10) has breaking changes incompatible with current aiodns
   "pycares==4.11.0",
   "aiohttp_asyncmdnsresolver==0.1.1",
   "Brotli>=1.0.9",
@@ -200,8 +198,7 @@ ignore = [
   "COM812", # Conflicts with the Ruff formatter
   "ASYNC109", # Not relevant, since we use helpers with configurable timeouts
   "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop
-  "N818", # Just annoying, not really useful
-  # TEMPORARY DISABLED rules  # The below rules must be enabled later one-by-one !
+  "N818", # Just annoying, not really useful  # TEMPORARY DISABLED rules  # The below rules must be enabled later one-by-one !
   "BLE001",
   "FBT001",
   "FBT002",
index 52536b9722e1819d4adbee2e9e620bc85263d2f9..5dcc6b97b44bc47d92a697a73d2b7a3a37ec011e 100644 (file)
@@ -2,14 +2,18 @@
 
 import asyncio
 import contextlib
+import logging
 import pathlib
 from collections.abc import AsyncGenerator
+from unittest.mock import MagicMock
 
 import aiofiles.os
-from music_assistant_models.enums import EventType
+from music_assistant_models.enums import EventType, IdentifierType, PlayerFeature, PlayerType
 from music_assistant_models.event import MassEvent
+from music_assistant_models.player import DeviceInfo
 
 from music_assistant.mass import MusicAssistant
+from music_assistant.models.player import Player
 
 
 def _get_fixture_folder(provider: str | None = None) -> pathlib.Path:
@@ -45,3 +49,102 @@ async def wait_for_sync_completion(mass: MusicAssistant) -> AsyncGenerator[None,
     finally:
         await flag.wait()
         release_cb()
+
+
+# Mock classes for testing
+
+
+def create_mock_config(name: str) -> MagicMock:
+    """Create a mock player config with the given name."""
+    config = MagicMock()
+    config.name = None  # No custom name, use default
+    config.default_name = name
+    config.get_value = MagicMock(return_value="none")  # Default to no power control
+    return config
+
+
+class MockProvider:
+    """Mock player provider for testing."""
+
+    def __init__(
+        self, domain: str, instance_id: str = "test_instance", mass: MagicMock | None = None
+    ) -> None:
+        """Initialize the mock provider."""
+        self.domain = domain
+        self.instance_id = instance_id
+        self.name = f"Mock {domain.title()}"
+        self.manifest = MagicMock()
+        self.manifest.name = f"Mock {domain} Provider"
+        self.mass = mass or MagicMock()
+        self.logger = logging.getLogger(f"test.{domain}")
+
+
+class MockPlayer(Player):
+    """Mock player for testing."""
+
+    def __init__(
+        self,
+        provider: MockProvider,
+        player_id: str,
+        name: str,
+        player_type: PlayerType = PlayerType.PLAYER,
+        identifiers: dict[IdentifierType, str] | None = None,
+    ) -> None:
+        """Initialize the mock player."""
+        # Set up the mock config before calling super().__init__
+        # because the parent __init__ accesses config
+        provider.mass.config.get_base_player_config.return_value = create_mock_config(name)
+
+        super().__init__(provider, player_id)  # type: ignore[arg-type]
+        self._attr_name = name
+        # Set type as instance attribute (overrides class attribute)
+        self._attr_type = player_type
+        self._attr_available = True
+        self._attr_powered = True
+        self._attr_supported_features = {PlayerFeature.VOLUME_SET}
+        self._attr_can_group_with = set()
+        self._attr_group_members = []
+
+        # Set up device info with identifiers
+        self._attr_device_info = DeviceInfo(
+            model="Test Model",
+            manufacturer="Test Manufacturer",
+        )
+        if identifiers:
+            for conn_type, value in identifiers.items():
+                self._attr_device_info.add_identifier(conn_type, value)
+
+        # Clear cached properties after modifying attributes
+        self._cache.clear()
+
+    async def set_members(
+        self,
+        player_ids_to_add: list[str] | None = None,
+        player_ids_to_remove: list[str] | None = None,
+    ) -> None:
+        """Mock implementation of set_members."""
+        current_members = set(self._attr_group_members)
+
+        if player_ids_to_add:
+            current_members.update(player_ids_to_add)
+
+        if player_ids_to_remove:
+            current_members.difference_update(player_ids_to_remove)
+
+        # Always include self as first member if there are members
+        if current_members:
+            self._attr_group_members = [self.player_id] + [
+                pid for pid in current_members if pid != self.player_id
+            ]
+        else:
+            self._attr_group_members = []
+
+        # Clear cache to reflect changes
+        self._cache.clear()
+
+    async def stop(self) -> None:
+        """Stop playback - required abstract method."""
+
+
+class MockMass:
+    """Type hint for mocked MusicAssistant instance."""
diff --git a/tests/core/test_player_controller.py b/tests/core/test_player_controller.py
new file mode 100644 (file)
index 0000000..e529a1c
--- /dev/null
@@ -0,0 +1,231 @@
+"""Tests for PlayerController high-level operations.
+
+This module tests:
+- cmd_set_members validation and execution
+- Group/ungroup commands
+- Player state management
+- Cache invalidation after grouping operations
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+from unittest.mock import MagicMock
+
+import pytest
+from music_assistant_models.enums import PlayerFeature
+from music_assistant_models.errors import UnsupportedFeaturedException
+
+from music_assistant.controllers.players import PlayerController
+from music_assistant.helpers.throttle_retry import Throttler
+from tests.common import MockPlayer, MockProvider
+
+
+@pytest.fixture
+def mock_mass() -> MagicMock:
+    """Create a mock MusicAssistant instance."""
+    mass = MagicMock()
+    mass.closing = False
+    mass.loop = None
+    mass.config = MagicMock()
+    mass.config.get = MagicMock(return_value=[])
+    mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
+    # Return "GLOBAL" for log level config (standard default)
+    mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
+    mass.config.set = MagicMock()
+    mass.signal_event = MagicMock()
+    mass.get_providers = MagicMock(return_value=[])
+    return mass
+
+
+@pytest.fixture
+def controller(mock_mass: MagicMock) -> PlayerController:
+    """Create a PlayerController instance."""
+    return PlayerController(mock_mass)
+
+
+class TestSetMembersValidation:
+    """Test cmd_set_members validation logic."""
+
+    def test_set_members_requires_feature(self, mock_mass: MagicMock) -> None:
+        """Test that set_members requires SET_MEMBERS feature."""
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        leader = MockPlayer(provider, "leader", "Leader")
+        # Note: NOT adding SET_MEMBERS feature
+
+        member = MockPlayer(provider, "member", "Member")
+
+        controller._players = {"leader": leader, "member": member}
+        controller._player_throttlers = {
+            "leader": Throttler(1, 0.05),
+            "member": Throttler(1, 0.05),
+        }
+        mock_mass.players = controller
+
+        # Should raise exception because leader doesn't support SET_MEMBERS
+        with pytest.raises(UnsupportedFeaturedException):
+            asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
+
+    def test_cannot_group_incompatible_players(self, mock_mass: MagicMock) -> None:
+        """Test that incompatible players cannot be grouped."""
+        controller = PlayerController(mock_mass)
+        provider_a = MockProvider("provider_a", instance_id="provider_a", mass=mock_mass)
+        provider_b = MockProvider("provider_b", instance_id="provider_b", mass=mock_mass)
+
+        player_a = MockPlayer(provider_a, "player_a", "Player A")
+        player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        player_a._attr_can_group_with = {"provider_a"}  # Only same provider
+
+        player_b = MockPlayer(provider_b, "player_b", "Player B")
+
+        controller._players = {"player_a": player_a, "player_b": player_b}
+        controller._player_throttlers = {
+            "player_a": Throttler(1, 0.05),
+            "player_b": Throttler(1, 0.05),
+        }
+        mock_mass.players = controller
+
+        # Should raise exception because players are incompatible
+        with pytest.raises(UnsupportedFeaturedException):
+            asyncio.run(controller.cmd_set_members("player_a", player_ids_to_add=["player_b"]))
+
+
+class TestCacheInvalidationAfterGrouping:
+    """Test that caches are invalidated after grouping operations."""
+
+    async def test_all_players_cache_cleared_after_set_members(self, mock_mass: MagicMock) -> None:
+        """
+        Test that all players' caches are cleared after set_members.
+
+        Regression test for: Stale can_group_with cache after grouping changes.
+        """
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        leader = MockPlayer(provider, "leader", "Leader")
+        leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        leader._attr_can_group_with = {"test"}
+        leader._attr_group_members = []
+
+        member = MockPlayer(provider, "member", "Member")
+
+        other = MockPlayer(provider, "other", "Other")
+        other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        other._attr_can_group_with = {"test"}
+
+        controller._players = {"leader": leader, "member": member, "other": other}
+        controller._player_throttlers = {
+            "leader": Throttler(1, 0.05),
+            "member": Throttler(1, 0.05),
+            "other": Throttler(1, 0.05),
+        }
+        mock_mass.players = controller
+
+        # Populate caches
+        _ = leader.state.can_group_with
+        _ = other.state.can_group_with
+
+        # Simulate grouping (normally done by provider's set_members implementation)
+        leader._attr_group_members = ["leader", "member"]
+
+        # Call set_members to trigger cache invalidation
+        await controller._handle_set_members_with_protocols(
+            leader, player_ids_to_add=["member"], player_ids_to_remove=[]
+        )
+
+        # Note: The actual cache clearing happens via trigger_player_update
+        # which schedules update_state to be called later
+        # In a real scenario, this would clear all players' caches
+
+
+class TestGroupUngroup:
+    """Test group and ungroup commands."""
+
+    async def test_group_command(self, mock_mass: MagicMock) -> None:
+        """Test the group command (cmd_group)."""
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        leader = MockPlayer(provider, "leader", "Leader")
+        leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        leader._attr_can_group_with = {"member"}  # Leader can group with member
+
+        member = MockPlayer(provider, "member", "Member")
+        # Make sure member is already powered on to skip power handling
+        member._attr_powered = True
+
+        controller._players = {"leader": leader, "member": member}
+        controller._player_throttlers = {
+            "leader": Throttler(1, 0.05),
+            "member": Throttler(1, 0.05),
+        }
+        mock_mass.players = controller
+
+        # Update state after modifying attributes and registering with controller
+        leader.update_state(signal_event=False)
+        member.update_state(signal_event=False)
+
+        # Track if set_members was called
+        set_members_called = False
+        original_set_members = leader.set_members
+
+        async def mock_set_members(
+            player_ids_to_add: list[str] | None = None,
+            player_ids_to_remove: list[str] | None = None,
+        ) -> None:
+            nonlocal set_members_called
+            set_members_called = True
+            # Call the original to update group_members
+            await original_set_members(player_ids_to_add, player_ids_to_remove)
+
+        leader.set_members = mock_set_members  # type: ignore[method-assign]
+
+        # Mock power handling to skip power control (focus is on grouping logic)
+        async def mock_handle_cmd_power(player_id: str, powered: bool) -> None:
+            pass
+
+        controller._handle_cmd_power = mock_handle_cmd_power  # type: ignore[method-assign]
+
+        # Execute group command
+        await controller.cmd_group("member", "leader")
+
+        # Verify set_members was called
+        assert set_members_called
+        # Verify member was added to leader's group
+        assert "member" in leader._attr_group_members
+
+
+class TestPlayerAvailability:
+    """Test player availability checks in grouping."""
+
+    def test_unavailable_player_rejected(self, mock_mass: MagicMock) -> None:
+        """Test that unavailable players are rejected when grouping."""
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        leader = MockPlayer(provider, "leader", "Leader")
+        leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        leader._attr_can_group_with = {"test"}
+
+        member = MockPlayer(provider, "member", "Member")
+        member._attr_available = False  # Mark as unavailable
+
+        controller._players = {"leader": leader, "member": member}
+        controller._player_throttlers = {
+            "leader": Throttler(1, 0.05),
+            "member": Throttler(1, 0.05),
+        }
+        mock_mass.players = controller
+
+        # Attempting to group with unavailable player should be handled
+        # (either silently ignored or raise exception depending on implementation)
+        # This should either skip the unavailable player or raise an exception
+        with contextlib.suppress(Exception):
+            asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
+
+
+if __name__ == "__main__":
+    pytest.main([__file__, "-v"])
diff --git a/tests/core/test_player_grouping.py b/tests/core/test_player_grouping.py
new file mode 100644 (file)
index 0000000..59e0d5c
--- /dev/null
@@ -0,0 +1,359 @@
+"""Tests for player grouping logic (independent of protocols).
+
+This module tests the core grouping behavior including:
+- can_group_with filtering logic
+- Group member inclusion/exclusion
+- Sync leader behavior
+- Group state transitions
+- Cache invalidation
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+from music_assistant_models.enums import PlaybackState, PlayerFeature
+
+from music_assistant.controllers.players import PlayerController
+from tests.common import MockPlayer, MockProvider
+
+
+@pytest.fixture
+def mock_mass() -> MagicMock:
+    """Create a mock MusicAssistant instance."""
+    mass = MagicMock()
+    mass.closing = False
+    mass.config = MagicMock()
+    mass.config.get = MagicMock(return_value=[])
+    mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
+    # Return "GLOBAL" for log level config (standard default)
+    mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
+    mass.config.set = MagicMock()
+    mass.signal_event = MagicMock()
+    mass.get_providers = MagicMock(return_value=[])
+    return mass
+
+
+@pytest.fixture
+def controller(mock_mass: MagicMock) -> PlayerController:
+    """Create a PlayerController instance."""
+    return PlayerController(mock_mass)
+
+
+class TestCanGroupWithBasics:
+    """Test basic can_group_with filtering logic."""
+
+    def test_ungrouped_players_can_group(self, mock_mass: MagicMock) -> None:
+        """Test that two ungrouped players can group with each other."""
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        player_a = MockPlayer(provider, "player_a", "Player A")
+        player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        # Use explicit player IDs instead of provider instance ID for simpler test
+        player_a._attr_can_group_with = {"player_b"}
+
+        player_b = MockPlayer(provider, "player_b", "Player B")
+        player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        player_b._attr_can_group_with = {"player_a"}
+
+        controller._players = {"player_a": player_a, "player_b": player_b}
+        mock_mass.players = controller
+
+        # Trigger state calculation
+        player_a.update_state(signal_event=False)
+        player_b.update_state(signal_event=False)
+
+        # Both players should be able to group with each other
+        assert "player_b" in player_a.state.can_group_with
+        assert "player_a" in player_b.state.can_group_with
+
+    def test_unavailable_players_excluded(self, mock_mass: MagicMock) -> None:
+        """Test that unavailable players are excluded from can_group_with."""
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        player_a = MockPlayer(provider, "player_a", "Player A")
+        player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        player_a._attr_can_group_with = {"player_b"}
+
+        player_b = MockPlayer(provider, "player_b", "Player B")
+        player_b._attr_available = False  # Mark as unavailable
+
+        controller._players = {"player_a": player_a, "player_b": player_b}
+        mock_mass.players = controller
+
+        # Trigger state calculation
+        player_a.update_state(signal_event=False)
+        player_b.update_state(signal_event=False)
+
+        # Unavailable player should be excluded
+        assert "player_b" not in player_a.state.can_group_with
+
+    def test_playing_players_with_different_source_excluded(self, mock_mass: MagicMock) -> None:
+        """Test that players playing different sources are NOT excluded (behavior changed).
+
+        Note: Previously, players with different active sources were excluded from grouping,
+        but this was removed as it was difficult to track reliably.
+        """
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        player_a = MockPlayer(provider, "player_a", "Player A")
+        player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        player_a._attr_can_group_with = {"player_b"}
+        player_a._attr_playback_state = PlaybackState.PLAYING
+        player_a._attr_active_source = "player_a"
+
+        player_b = MockPlayer(provider, "player_b", "Player B")
+        player_b._attr_playback_state = PlaybackState.PLAYING
+        player_b._attr_active_source = "player_b"  # Different source
+
+        controller._players = {"player_a": player_a, "player_b": player_b}
+        mock_mass.players = controller
+
+        # Trigger state calculation
+        player_a.update_state(signal_event=False)
+        player_b.update_state(signal_event=False)
+
+        # Player with different active source is now ALLOWED (behavior changed)
+        assert "player_b" in player_a.state.can_group_with
+
+
+class TestSyncedPlayers:
+    """Test behavior with synced/grouped players."""
+
+    def test_synced_player_excluded_from_others(self, mock_mass: MagicMock) -> None:
+        """
+        Test that a player synced to another is excluded from other players' can_group_with.
+
+        Regression test for: Player synced to another showing up in third player's can_group_with.
+        """
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        # Sync leader
+        leader = MockPlayer(provider, "leader", "Leader")
+        leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        leader._attr_can_group_with = {"synced", "other"}
+        leader._attr_group_members = ["leader", "synced"]
+        leader._attr_playback_state = PlaybackState.PLAYING  # Make it playing so it gets excluded
+
+        # Synced player
+        synced = MockPlayer(provider, "synced", "Synced")
+
+        # Third player
+        other = MockPlayer(provider, "other", "Other")
+        other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        other._attr_can_group_with = {"leader", "synced"}
+
+        controller._players = {"leader": leader, "synced": synced, "other": other}
+        mock_mass.players = controller
+
+        # Trigger synced_to calculation
+        leader.update_state(signal_event=False)
+        synced.update_state(signal_event=False)
+        other.update_state(signal_event=False)
+
+        # The synced player should NOT appear in other's can_group_with
+        assert "synced" not in other.state.can_group_with
+        # The leader should also NOT appear (has group members)
+        assert "leader" not in other.state.can_group_with
+        # Other should only see itself as ungrouped
+        assert other.state.can_group_with == set()
+
+    def test_sync_leader_excludes_itself_from_members_can_group_with(
+        self, mock_mass: MagicMock
+    ) -> None:
+        """Test that sync leader doesn't appear in its members' can_group_with."""
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        leader = MockPlayer(provider, "leader", "Leader")
+        leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        leader._attr_can_group_with = {"member"}
+        leader._attr_group_members = ["leader", "member"]
+
+        member = MockPlayer(provider, "member", "Member")
+
+        controller._players = {"leader": leader, "member": member}
+        mock_mass.players = controller
+
+        # Trigger synced_to calculation
+        leader.update_state(signal_event=False)
+        member.update_state(signal_event=False)
+
+        # Member is synced, so can_group_with should be empty
+        assert member.state.can_group_with == set()
+
+    def test_group_members_included_in_leader_can_group_with(self, mock_mass: MagicMock) -> None:
+        """
+        Test that group members appear in sync leader's can_group_with.
+
+        This allows ungrouping members from the leader.
+        """
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        leader = MockPlayer(provider, "leader", "Leader")
+        leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        leader._attr_can_group_with = {"member_a", "member_b"}
+        leader._attr_group_members = ["leader", "member_a", "member_b"]
+
+        member_a = MockPlayer(provider, "member_a", "Member A")
+        member_b = MockPlayer(provider, "member_b", "Member B")
+
+        controller._players = {
+            "leader": leader,
+            "member_a": member_a,
+            "member_b": member_b,
+        }
+        mock_mass.players = controller
+
+        # Trigger synced_to calculation
+        leader.update_state(signal_event=False)
+        member_a.update_state(signal_event=False)
+        member_b.update_state(signal_event=False)
+
+        # Leader should be able to see its own members (for ungrouping)
+        assert "member_a" in leader.state.can_group_with
+        assert "member_b" in leader.state.can_group_with
+
+
+class TestSyncLeaderBehavior:
+    """Test sync leader specific behavior."""
+
+    def test_sync_leader_excluded_from_can_group_with(self, mock_mass: MagicMock) -> None:
+        """Test that players with group members (sync leaders) are excluded."""
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        leader = MockPlayer(provider, "leader", "Leader")
+        leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        leader._attr_can_group_with = {"member", "other"}
+        leader._attr_group_members = ["leader", "member"]
+        leader._attr_playback_state = PlaybackState.PLAYING  # Make it playing so it gets excluded
+
+        member = MockPlayer(provider, "member", "Member")
+
+        other = MockPlayer(provider, "other", "Other")
+        other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        other._attr_can_group_with = {"leader", "member"}
+
+        controller._players = {"leader": leader, "member": member, "other": other}
+        mock_mass.players = controller
+
+        # Trigger synced_to calculation
+        leader.update_state(signal_event=False)
+        member.update_state(signal_event=False)
+        other.update_state(signal_event=False)
+
+        # Leader should NOT appear in other's can_group_with (has group members)
+        assert "leader" not in other.state.can_group_with
+
+
+class TestCircularDependency:
+    """Test that circular dependencies are avoided."""
+
+    def test_no_circular_dependency_in_synced_to(self, mock_mass: MagicMock) -> None:
+        """
+        Test that synced_to calculation doesn't cause circular dependency.
+
+        Regression test for: synced_to calling group_members causing infinite recursion.
+        """
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        leader = MockPlayer(provider, "leader", "Leader")
+        leader._attr_group_members = ["leader", "member"]
+
+        member = MockPlayer(provider, "member", "Member")
+
+        controller._players = {"leader": leader, "member": member}
+        mock_mass.players = controller
+
+        # Trigger synced_to calculation via update_state
+        leader.update_state(signal_event=False)
+        member.update_state(signal_event=False)
+
+        # This should not cause infinite recursion
+        assert member.state.synced_to == "leader"
+        assert leader.state.synced_to is None
+
+
+class TestCacheInvalidation:
+    """Test that caches are invalidated correctly."""
+
+    def test_can_group_with_cache_cleared_on_update_state(self, mock_mass: MagicMock) -> None:
+        """Test that can_group_with cache is cleared when update_state is called."""
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        player_a = MockPlayer(provider, "player_a", "Player A")
+        player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        player_a._attr_can_group_with = {"player_b"}
+
+        player_b = MockPlayer(provider, "player_b", "Player B")
+
+        controller._players = {"player_a": player_a, "player_b": player_b}
+        mock_mass.players = controller
+
+        # Update state after setting attributes and registering with controller
+        player_a.update_state(signal_event=False)
+        player_b.update_state(signal_event=False)
+
+        # Get can_group_with to populate cache
+        initial = player_a.state.can_group_with
+        assert "player_b" in initial
+
+        # Modify underlying data
+        player_a._attr_can_group_with = set()
+
+        # Cache should still have old value
+        assert player_a.state.can_group_with == initial
+
+        # Clear cache via update_state
+        player_a.update_state(signal_event=False)
+
+        # Cache should be cleared, new value should be returned
+        assert player_a.state.can_group_with == set()
+
+
+class TestProviderInstanceIdExpansion:
+    """Test expansion of provider instance IDs in can_group_with."""
+
+    def test_provider_instance_id_expands_to_all_players(self, mock_mass: MagicMock) -> None:
+        """Test that provider instance IDs expand to all available players from that provider."""
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+        player_a = MockPlayer(provider, "player_a", "Player A")
+        player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        player_a._attr_can_group_with = {"test"}  # Provider instance ID
+
+        player_b = MockPlayer(provider, "player_b", "Player B")
+        player_c = MockPlayer(provider, "player_c", "Player C")
+
+        controller._players = {
+            "player_a": player_a,
+            "player_b": player_b,
+            "player_c": player_c,
+        }
+        mock_mass.players = controller
+        # Set up get_provider to return the provider for instance ID
+        mock_mass.get_provider = MagicMock(return_value=provider)
+
+        # Trigger state calculation
+        player_a.update_state(signal_event=False)
+        player_b.update_state(signal_event=False)
+        player_c.update_state(signal_event=False)
+
+        # Provider instance ID should expand to include all players from that provider
+        can_group = player_a.state.can_group_with
+        assert "player_b" in can_group
+        assert "player_c" in can_group
+
+
+if __name__ == "__main__":
+    pytest.main([__file__, "-v"])
diff --git a/tests/core/test_protocol_linking.py b/tests/core/test_protocol_linking.py
new file mode 100644 (file)
index 0000000..6227096
--- /dev/null
@@ -0,0 +1,1661 @@
+"""Tests for protocol player linking and universal player creation."""
+
+import logging
+from unittest.mock import MagicMock
+
+import pytest
+from music_assistant_models.enums import (
+    IdentifierType,
+    PlayerFeature,
+    PlayerType,
+)
+from music_assistant_models.player import OutputProtocol
+
+from music_assistant.controllers.players import PlayerController
+from music_assistant.helpers.throttle_retry import Throttler
+from music_assistant.models.player import DeviceInfo, Player
+from music_assistant.providers.universal_player.provider import UniversalPlayerProvider
+
+
+def create_mock_config(name: str) -> MagicMock:
+    """Create a mock player config with the given name."""
+    config = MagicMock()
+    config.name = None  # No custom name, use default
+    config.default_name = name
+    return config
+
+
+def create_mock_universal_provider(mock_mass: MagicMock) -> UniversalPlayerProvider:
+    """Create a mock UniversalPlayerProvider for testing."""
+    # Create a mock manifest
+    manifest = MagicMock()
+    manifest.domain = "universal_player"
+    manifest.name = "Universal Player"
+
+    # Create provider with the mock manifest
+    provider = UniversalPlayerProvider.__new__(UniversalPlayerProvider)
+    provider.mass = mock_mass
+    provider.manifest = manifest
+    provider.logger = logging.getLogger("test.universal_player")
+    return provider
+
+
+class MockProvider:
+    """Mock player provider for testing."""
+
+    def __init__(
+        self, domain: str, instance_id: str = "test_instance", mass: MagicMock | None = None
+    ) -> None:
+        """Initialize the mock provider."""
+        self.domain = domain
+        self.instance_id = instance_id
+        self.name = f"Mock {domain.title()}"
+        self.manifest = MagicMock()
+        self.manifest.name = f"Mock {domain} Provider"
+        self.mass = mass or MagicMock()
+        self.logger = logging.getLogger(f"test.{domain}")
+
+
+class MockPlayer(Player):
+    """Mock player for testing."""
+
+    def __init__(
+        self,
+        provider: MockProvider,
+        player_id: str,
+        name: str,
+        player_type: PlayerType = PlayerType.PLAYER,
+        identifiers: dict[IdentifierType, str] | None = None,
+    ) -> None:
+        """Initialize the mock player."""
+        # Set up the mock config before calling super().__init__
+        # because the parent __init__ accesses config
+        provider.mass.config.get_base_player_config.return_value = create_mock_config(name)
+
+        super().__init__(provider, player_id)  # type: ignore[arg-type]
+        self._attr_name = name
+        # Set type as instance attribute (overrides class attribute)
+        self._attr_type = player_type
+        self._attr_available = True
+        self._attr_powered = True
+        self._attr_supported_features = {PlayerFeature.VOLUME_SET}
+        self._attr_can_group_with = set()
+
+        # Set up device info with identifiers
+        self._attr_device_info = DeviceInfo(
+            model="Test Model",
+            manufacturer="Test Manufacturer",
+        )
+        if identifiers:
+            for conn_type, value in identifiers.items():
+                self._attr_device_info.add_identifier(conn_type, value)
+
+        # Clear cached properties after modifying attributes
+        self._cache.clear()
+
+        # Update state to reflect the modified attributes
+        self.update_state(signal_event=False)
+
+    async def stop(self) -> None:
+        """Stop playback - required abstract method."""
+
+
+@pytest.fixture
+def mock_mass() -> MagicMock:
+    """Create a mock MusicAssistant instance."""
+    mass = MagicMock()
+    mass.closing = False
+    mass.config = MagicMock()
+    mass.config.get = MagicMock(return_value=[])
+    mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
+    # Return "GLOBAL" for log level config (standard default)
+    mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
+    mass.config.set = MagicMock()
+    mass.signal_event = MagicMock()
+    mass.get_providers = MagicMock(return_value=[])
+    return mass
+
+
+class TestIdentifiersMatch:
+    """Tests for identifier matching logic."""
+
+    def test_mac_address_match(self, mock_mass: MagicMock) -> None:
+        """Test that MAC addresses match correctly."""
+        controller = PlayerController(mock_mass)
+
+        provider = MockProvider("test")
+        player_a = MockPlayer(
+            provider,
+            "player_a",
+            "Player A",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+        player_b = MockPlayer(
+            provider,
+            "player_b",
+            "Player B",
+            identifiers={IdentifierType.MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"},  # lowercase
+        )
+
+        assert controller._identifiers_match(player_a, player_b) is True
+
+    def test_mac_address_no_match(self, mock_mass: MagicMock) -> None:
+        """Test that different MAC addresses don't match."""
+        controller = PlayerController(mock_mass)
+
+        provider = MockProvider("test")
+        player_a = MockPlayer(
+            provider,
+            "player_a",
+            "Player A",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+        player_b = MockPlayer(
+            provider,
+            "player_b",
+            "Player B",
+            identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
+        )
+
+        assert controller._identifiers_match(player_a, player_b) is False
+
+    def test_ip_address_no_match(self, mock_mass: MagicMock) -> None:
+        """Test that IP addresses don't match (IP is excluded as it's not stable)."""
+        controller = PlayerController(mock_mass)
+
+        provider = MockProvider("test")
+        player_a = MockPlayer(
+            provider,
+            "player_a",
+            "Player A",
+            identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
+        )
+        player_b = MockPlayer(
+            provider,
+            "player_b",
+            "Player B",
+            identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
+        )
+
+        # IP address matching is intentionally disabled to prevent false matches
+        assert controller._identifiers_match(player_a, player_b) is False
+
+    def test_sonos_uuid_dlna_suffix_match(self, mock_mass: MagicMock) -> None:
+        """Test Sonos UUID matching with DLNA _MR suffix."""
+        controller = PlayerController(mock_mass)
+
+        provider = MockProvider("test")
+        # Sonos native player
+        player_a = MockPlayer(
+            provider,
+            "player_a",
+            "Sonos Player",
+            identifiers={IdentifierType.UUID: "RINCON_000E58123456"},
+        )
+        # DLNA player with _MR suffix
+        player_b = MockPlayer(
+            provider,
+            "player_b",
+            "DLNA Player",
+            identifiers={IdentifierType.UUID: "RINCON_000E58123456_MR"},
+        )
+
+        assert controller._identifiers_match(player_a, player_b) is True
+
+    def test_no_identifiers_no_match(self, mock_mass: MagicMock) -> None:
+        """Test that players without identifiers don't match."""
+        controller = PlayerController(mock_mass)
+
+        provider = MockProvider("test")
+        player_a = MockPlayer(provider, "player_a", "Player A")
+        player_b = MockPlayer(provider, "player_b", "Player B")
+
+        assert controller._identifiers_match(player_a, player_b) is False
+
+
+class TestProtocolPlayerDetection:
+    """Tests for protocol player type detection."""
+
+    def test_is_protocol_player_true(self, mock_mass: MagicMock) -> None:
+        """Test that PlayerType.PROTOCOL is correctly detected."""
+        controller = PlayerController(mock_mass)
+
+        provider = MockProvider("airplay")
+        player = MockPlayer(
+            provider,
+            "ap_123456",
+            "Samsung TV (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+        )
+
+        assert controller._is_protocol_player(player) is True
+
+    def test_is_protocol_player_false(self, mock_mass: MagicMock) -> None:
+        """Test that PlayerType.PLAYER is not detected as protocol."""
+        controller = PlayerController(mock_mass)
+
+        provider = MockProvider("airplay")
+        player = MockPlayer(
+            provider,
+            "ap_123456",
+            "HomePod",
+            player_type=PlayerType.PLAYER,  # Apple device with native support
+        )
+
+        assert controller._is_protocol_player(player) is False
+
+
+class TestFindMatchingProtocolPlayers:
+    """Tests for finding matching protocol players."""
+
+    def test_find_matching_by_mac(self, mock_mass: MagicMock) -> None:
+        """Test finding matching protocol players by MAC address."""
+        controller = PlayerController(mock_mass)
+
+        # Set up providers
+        airplay_provider = MockProvider("airplay")
+        chromecast_provider = MockProvider("chromecast")
+
+        # Create matching protocol players (same device, different protocols)
+        airplay_player = MockPlayer(
+            airplay_provider,
+            "ap_aabbccddee",
+            "Samsung TV (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+        chromecast_player = MockPlayer(
+            chromecast_provider,
+            "cc_aabbccddee",
+            "Samsung TV (Chromecast)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+
+        # Register players
+        controller._players = {
+            "ap_aabbccddee": airplay_player,
+            "cc_aabbccddee": chromecast_player,
+        }
+        controller._player_throttlers = {
+            "ap_aabbccddee": Throttler(1, 0.05),
+            "cc_aabbccddee": Throttler(1, 0.05),
+        }
+
+        # Find matching players for AirPlay player
+        matches = controller._find_matching_protocol_players(airplay_player)
+
+        assert len(matches) == 2
+        assert airplay_player in matches
+        assert chromecast_player in matches
+
+
+class TestGetDeviceKeyFromPlayers:
+    """Tests for device key generation."""
+
+    def test_device_key_from_mac(self, mock_mass: MagicMock) -> None:
+        """Test device key generation from MAC address."""
+        universal_provider = create_mock_universal_provider(mock_mass)
+
+        provider = MockProvider("airplay")
+        player = MockPlayer(
+            provider,
+            "ap_123456",
+            "Test Player",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+
+        device_key = universal_provider._get_device_key_from_players([player])
+
+        assert device_key == "aabbccddeeff"
+
+    def test_device_key_from_uuid_fallback(self, mock_mass: MagicMock) -> None:
+        """Test device key generation falls back to UUID when no MAC available."""
+        universal_provider = create_mock_universal_provider(mock_mass)
+
+        provider = MockProvider("dlna")
+        player = MockPlayer(
+            provider,
+            "dlna_123456",
+            "Test Player",
+            identifiers={IdentifierType.UUID: "uuid:12345678-1234-1234-1234-123456789abc"},
+        )
+
+        device_key = universal_provider._get_device_key_from_players([player])
+
+        assert device_key == "uuid12345678123412341234123456789abc"
+
+    def test_device_key_from_ip_falls_back_to_player_id(self, mock_mass: MagicMock) -> None:
+        """Test that device key falls back to player_id for IP-only players (IP not used)."""
+        universal_provider = create_mock_universal_provider(mock_mass)
+
+        provider = MockProvider("airplay")
+        player = MockPlayer(
+            provider,
+            "ap_123456",
+            "Test Player",
+            identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
+        )
+
+        device_key = universal_provider._get_device_key_from_players([player])
+
+        # IP address is not used for device key - falls back to player_id
+        # This allows protocol players without MAC/UUID to still get a UniversalPlayer
+        assert device_key == "ap_123456"
+
+    def test_device_key_from_no_identifiers_falls_back_to_player_id(
+        self, mock_mass: MagicMock
+    ) -> None:
+        """Test that device key falls back to player_id when no identifiers at all."""
+        universal_provider = create_mock_universal_provider(mock_mass)
+
+        provider = MockProvider("sendspin")
+        player = MockPlayer(
+            provider,
+            "sendspin-device-abc",
+            "Test Player",
+            # No identifiers at all (like Sendspin protocol players)
+        )
+
+        device_key = universal_provider._get_device_key_from_players([player])
+
+        # Falls back to player_id when no MAC/UUID identifiers
+        assert device_key == "sendspindeviceabc"
+
+
+class TestGetCleanPlayerName:
+    """Tests for player name selection."""
+
+    def test_prefers_chromecast_name(self, mock_mass: MagicMock) -> None:
+        """Test that Chromecast names are preferred over other protocols."""
+        universal_provider = create_mock_universal_provider(mock_mass)
+
+        airplay_provider = MockProvider("airplay")
+        chromecast_provider = MockProvider("chromecast")
+
+        airplay_player = MockPlayer(
+            airplay_provider,
+            "ap_123456",
+            "Samsung TV",
+            player_type=PlayerType.PROTOCOL,
+        )
+        chromecast_player = MockPlayer(
+            chromecast_provider,
+            "cc_123456",
+            "Living Room Speaker",
+            player_type=PlayerType.PROTOCOL,
+        )
+
+        # Chromecast should be preferred (priority 1)
+        clean_name = universal_provider._get_clean_player_name([airplay_player, chromecast_player])
+        assert clean_name == "Living Room Speaker"
+
+    def test_filters_mac_address_names(self, mock_mass: MagicMock) -> None:
+        """Test that MAC address-like names are filtered out."""
+        universal_provider = create_mock_universal_provider(mock_mass)
+
+        squeezelite_provider = MockProvider("squeezelite")
+        airplay_provider = MockProvider("airplay")
+
+        # Squeezelite with MAC address as name
+        sq_player = MockPlayer(
+            squeezelite_provider,
+            "sq_123456",
+            "AA:BB:CC:DD:EE:FF",
+            player_type=PlayerType.PROTOCOL,
+        )
+        # AirPlay with proper name
+        ap_player = MockPlayer(
+            airplay_provider,
+            "ap_123456",
+            "Kitchen Speaker",
+            player_type=PlayerType.PROTOCOL,
+        )
+
+        # Should prefer Kitchen Speaker over MAC address
+        clean_name = universal_provider._get_clean_player_name([sq_player, ap_player])
+        assert clean_name == "Kitchen Speaker"
+
+    def test_filters_player_id_names(self, mock_mass: MagicMock) -> None:
+        """Test that player ID-like names are filtered out."""
+        universal_provider = create_mock_universal_provider(mock_mass)
+
+        sendspin_provider = MockProvider("sendspin")
+        dlna_provider = MockProvider("dlna")
+
+        # SendSpin with player ID as name
+        ss_player = MockPlayer(
+            sendspin_provider,
+            "sendspin_123456",
+            "sendspin_device_abc",
+            player_type=PlayerType.PROTOCOL,
+        )
+        # DLNA with proper name
+        dlna_player = MockPlayer(
+            dlna_provider,
+            "dlna_123456",
+            "Bedroom TV",
+            player_type=PlayerType.PROTOCOL,
+        )
+
+        # Should prefer Bedroom TV over player ID
+        clean_name = universal_provider._get_clean_player_name([ss_player, dlna_player])
+        assert clean_name == "Bedroom TV"
+
+    def test_valid_name_unchanged(self, mock_mass: MagicMock) -> None:
+        """Test that valid names are returned unchanged."""
+        universal_provider = create_mock_universal_provider(mock_mass)
+
+        provider = MockProvider("airplay")
+        player = MockPlayer(
+            provider,
+            "ap_123456",
+            "HomePod Mini",
+            player_type=PlayerType.PLAYER,
+        )
+
+        clean_name = universal_provider._get_clean_player_name([player])
+        assert clean_name == "HomePod Mini"
+
+
+class TestCachedProtocolParentRestore:
+    """Tests for restoring cached protocol parent links."""
+
+    def test_protocol_parent_id_restored_from_config(self, mock_mass: MagicMock) -> None:
+        """Test that cached protocol_parent_id is loaded and used for immediate linking."""
+        controller = PlayerController(mock_mass)
+
+        # Mock config to return cached parent_id when queried
+        def mock_config_get(key: str, default: str | None = None) -> str | None:
+            if "protocol_parent_id" in str(key):
+                return "native_player_id"
+            return default
+
+        mock_mass.config.get.side_effect = mock_config_get
+
+        # Create native player
+        native_provider = MockProvider("sonos", mass=mock_mass)
+        native_player = MockPlayer(
+            native_provider,
+            "native_player_id",
+            "Sonos Speaker",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+
+        # Create protocol player
+        dlna_provider = MockProvider("dlna", mass=mock_mass)
+        protocol_player = MockPlayer(
+            dlna_provider,
+            "uuid:RINCON_AABBCCDDEEFF_MR",
+            "Sonos DLNA",
+            player_type=PlayerType.PROTOCOL,
+        )
+
+        # Register native player
+        controller._players = {"native_player_id": native_player}
+        controller._player_throttlers = {"native_player_id": Throttler(1, 0.05)}
+
+        # Try to link protocol to native - should load cached parent_id
+        controller._try_link_protocol_to_native(protocol_player)
+
+        # Verify protocol_parent_id was set
+        assert protocol_player.protocol_parent_id == "native_player_id"
+
+        # Verify protocol was linked to native player
+        assert any(
+            link.output_protocol_id == protocol_player.player_id
+            for link in native_player.linked_output_protocols
+        )
+
+    def test_protocol_parent_id_prevents_universal_player_creation(
+        self, mock_mass: MagicMock
+    ) -> None:
+        """Test that cached protocol_parent_id prevents creating universal player."""
+        controller = PlayerController(mock_mass)
+
+        # Mock config to return cached parent_id (parent not yet registered)
+        def mock_config_get(key: str, default: str | None = None) -> str | None:
+            if "protocol_parent_id" in str(key):
+                return "native_player_id"
+            return default
+
+        mock_mass.config.get.side_effect = mock_config_get
+
+        # Create protocol player
+        dlna_provider = MockProvider("dlna", mass=mock_mass)
+        protocol_player = MockPlayer(
+            dlna_provider,
+            "uuid:RINCON_AABBCCDDEEFF_MR",
+            "Sonos DLNA",
+            player_type=PlayerType.PROTOCOL,
+        )
+
+        # No native player registered yet
+        controller._players = {}
+
+        # Try to link protocol - should set parent_id and skip evaluation
+        controller._try_link_protocol_to_native(protocol_player)
+
+        # Verify protocol_parent_id was set
+        assert protocol_player.protocol_parent_id == "native_player_id"
+
+        # Since parent_id is set, delayed evaluation won't create a universal player
+
+
+class TestSelectBestOutputProtocol:
+    """Tests for output protocol selection logic."""
+
+    def test_select_native_when_preferred_is_native(self, mock_mass: MagicMock) -> None:
+        """Test that native protocol is selected when user prefers native."""
+        # Mock config to return "native" as preferred
+        mock_mass.config.get_raw_player_config_value = MagicMock(return_value="native")
+
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("sonos", mass=mock_mass)
+
+        # Create native player with PLAY_MEDIA support
+        native_player = MockPlayer(
+            provider,
+            "sonos_123",
+            "Kantoor",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+        native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+
+        # Wire up mock_mass.players to controller
+        mock_mass.players = controller
+
+        # Register players
+        controller._players = {"sonos_123": native_player}
+        controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
+
+        # Select protocol
+        selected_player, output_protocol = controller._select_best_output_protocol(native_player)
+
+        # Should select native player
+        assert selected_player == native_player
+        assert output_protocol is None  # None means native playback
+
+    def test_select_dlna_when_preferred_is_dlna(self, mock_mass: MagicMock) -> None:
+        """Test that DLNA protocol is selected when user prefers DLNA."""
+        # Mock config to return the full player ID as preferred
+        mock_mass.config.get_raw_player_config_value = MagicMock(return_value="dlna_AABBCCDDEEFF")
+
+        controller = PlayerController(mock_mass)
+
+        # Create native player with linked protocols
+        sonos_provider = MockProvider("sonos", mass=mock_mass)
+        native_player = MockPlayer(
+            sonos_provider,
+            "sonos_123",
+            "Kantoor",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+        native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+
+        # Create DLNA protocol player
+        dlna_provider = MockProvider("dlna", mass=mock_mass)
+        dlna_player = MockPlayer(
+            dlna_provider,
+            "dlna_AABBCCDDEEFF",
+            "Kantoor DLNA",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+
+        # Register players
+        controller._players = {
+            "sonos_123": native_player,
+            "dlna_AABBCCDDEEFF": dlna_player,
+        }
+        controller._player_throttlers = {
+            "sonos_123": Throttler(1, 0.05),
+            "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
+        }
+
+        # Link DLNA protocol to native player
+        native_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="dlna_AABBCCDDEEFF",
+                    name="DLNA",
+                    protocol_domain="dlna",
+                    priority=30,
+                )
+            ]
+        )
+
+        # Select protocol
+        selected_player, output_protocol = controller._select_best_output_protocol(native_player)
+
+        # Should select DLNA player, not native
+        assert selected_player == dlna_player
+        assert output_protocol is not None
+        assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
+
+    def test_select_airplay_when_preferred_is_airplay(self, mock_mass: MagicMock) -> None:
+        """Test that AirPlay protocol is selected when user prefers AirPlay."""
+        # Mock config to return the full player ID as preferred
+        mock_mass.config.get_raw_player_config_value = MagicMock(
+            return_value="airplay_AABBCCDDEEFF"
+        )
+
+        controller = PlayerController(mock_mass)
+
+        # Create native player
+        sonos_provider = MockProvider("sonos", mass=mock_mass)
+        native_player = MockPlayer(
+            sonos_provider,
+            "sonos_123",
+            "Kantoor",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+        native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+
+        # Create AirPlay and DLNA protocol players
+        airplay_provider = MockProvider("airplay", mass=mock_mass)
+        airplay_player = MockPlayer(
+            airplay_provider,
+            "airplay_AABBCCDDEEFF",
+            "Kantoor AirPlay",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+
+        dlna_provider = MockProvider("dlna", mass=mock_mass)
+        dlna_player = MockPlayer(
+            dlna_provider,
+            "dlna_AABBCCDDEEFF",
+            "Kantoor DLNA",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+
+        # Register players
+        controller._players = {
+            "sonos_123": native_player,
+            "airplay_AABBCCDDEEFF": airplay_player,
+            "dlna_AABBCCDDEEFF": dlna_player,
+        }
+        controller._player_throttlers = {
+            "sonos_123": Throttler(1, 0.05),
+            "airplay_AABBCCDDEEFF": Throttler(1, 0.05),
+            "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
+        }
+
+        # Link protocols to native player
+        native_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_AABBCCDDEEFF",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                ),
+                OutputProtocol(
+                    output_protocol_id="dlna_AABBCCDDEEFF",
+                    name="DLNA",
+                    protocol_domain="dlna",
+                    priority=30,
+                ),
+            ]
+        )
+
+        # Select protocol
+        selected_player, output_protocol = controller._select_best_output_protocol(native_player)
+
+        # Should select AirPlay player (even though DLNA has lower priority value),
+        # because user preference overrides priority
+        assert selected_player == airplay_player
+        assert output_protocol is not None
+        assert output_protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
+
+    def test_fallback_to_native_when_auto(self, mock_mass: MagicMock) -> None:
+        """Test that native playback is used when preference is auto."""
+        # Mock config to return "auto" as preferred
+        mock_mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
+
+        controller = PlayerController(mock_mass)
+        provider = MockProvider("sonos", mass=mock_mass)
+
+        native_player = MockPlayer(
+            provider,
+            "sonos_123",
+            "Kantoor",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+        )
+        native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+
+        controller._players = {"sonos_123": native_player}
+        controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
+
+        # Select protocol with auto preference
+        selected_player, output_protocol = controller._select_best_output_protocol(native_player)
+
+        # Should select native player
+        assert selected_player == native_player
+        assert output_protocol is None  # None means native playback
+
+
+class TestPlayerGrouping:
+    """Tests for player grouping scenarios."""
+
+    def test_native_to_native_grouping(self, mock_mass: MagicMock) -> None:
+        """Test that native players from same provider can group together."""
+        controller = PlayerController(mock_mass)
+
+        sonos_provider = MockProvider("sonos", mass=mock_mass)
+
+        # Create two Sonos players
+        player_a = MockPlayer(
+            sonos_provider,
+            "sonos_123",
+            "Living Room",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        player_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        player_a._attr_can_group_with = {"sonos_456"}
+        player_a._cache.clear()  # Clear cached properties after modifying attributes
+
+        player_b = MockPlayer(
+            sonos_provider,
+            "sonos_456",
+            "Kitchen",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+        )
+        player_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        player_b._cache.clear()
+
+        controller._players = {
+            "sonos_123": player_a,
+            "sonos_456": player_b,
+        }
+        controller._player_throttlers = {
+            "sonos_123": Throttler(1, 0.05),
+            "sonos_456": Throttler(1, 0.05),
+        }
+
+        # Translate members for native grouping
+        protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
+            parent_player=player_a,
+            player_ids=["sonos_456"],
+            parent_protocol_player=None,
+            parent_protocol_domain=None,
+        )
+
+        # Should use native grouping (same provider)
+        assert len(native_members) == 1
+        assert "sonos_456" in native_members
+        assert len(protocol_members) == 0
+
+    def test_protocol_to_protocol_grouping(self, mock_mass: MagicMock) -> None:
+        """Test that protocol players can group via shared protocol."""
+        controller = PlayerController(mock_mass)
+
+        # Create two players with AirPlay protocol support
+        sonos_provider = MockProvider("sonos", mass=mock_mass)
+        wiim_provider = MockProvider("wiim", mass=mock_mass)
+        airplay_provider = MockProvider("airplay", mass=mock_mass)
+
+        # Sonos player
+        sonos_player = MockPlayer(
+            sonos_provider,
+            "sonos_123",
+            "Living Room",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        sonos_player._cache.clear()
+
+        # WiiM player
+        wiim_player = MockPlayer(
+            wiim_provider,
+            "wiim_456",
+            "Bedroom",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+        )
+        wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        wiim_player._cache.clear()
+
+        # AirPlay protocol players
+        sonos_airplay = MockPlayer(
+            airplay_provider,
+            "airplay_sonos",
+            "Living Room (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_airplay._attr_can_group_with = {"airplay_wiim"}
+        sonos_airplay._cache.clear()
+        sonos_airplay.update_state(signal_event=False)
+
+        wiim_airplay = MockPlayer(
+            airplay_provider,
+            "airplay_wiim",
+            "Bedroom (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+        )
+
+        # Link protocol players to native players
+        sonos_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_sonos",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                )
+            ]
+        )
+        wiim_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_wiim",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                )
+            ]
+        )
+
+        controller._players = {
+            "sonos_123": sonos_player,
+            "wiim_456": wiim_player,
+            "airplay_sonos": sonos_airplay,
+            "airplay_wiim": wiim_airplay,
+        }
+        controller._player_throttlers = {
+            "sonos_123": Throttler(1, 0.05),
+            "wiim_456": Throttler(1, 0.05),
+            "airplay_sonos": Throttler(1, 0.05),
+            "airplay_wiim": Throttler(1, 0.05),
+        }
+
+        # Translate members for protocol grouping (via AirPlay)
+        protocol_members, native_members, protocol_player, _ = (
+            controller._translate_members_for_protocols(
+                parent_player=sonos_player,
+                player_ids=["wiim_456"],
+                parent_protocol_player=sonos_airplay,
+                parent_protocol_domain="airplay",
+            )
+        )
+
+        # Should use protocol grouping (AirPlay)
+        assert len(protocol_members) == 1
+        assert "airplay_wiim" in protocol_members
+        assert len(native_members) == 0
+        assert protocol_player == sonos_airplay
+
+    def test_hybrid_grouping(self, mock_mass: MagicMock) -> None:
+        """Test hybrid grouping: native + protocol players in same group."""
+        controller = PlayerController(mock_mass)
+
+        # Create Sonos players (native grouping capability)
+        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+        sonos_a = MockPlayer(
+            sonos_provider,
+            "sonos_123",
+            "Living Room",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        sonos_a._attr_can_group_with = {"sonos_456"}
+        sonos_a._cache.clear()
+
+        sonos_b = MockPlayer(
+            sonos_provider,
+            "sonos_456",
+            "Kitchen",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+        )
+        sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        sonos_b._cache.clear()
+
+        # Create WiiM player with AirPlay protocol
+        wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
+        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+
+        wiim_player = MockPlayer(
+            wiim_provider,
+            "wiim_789",
+            "Bedroom",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+        )
+        wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        wiim_player._cache.clear()
+
+        # AirPlay protocol players
+        sonos_airplay = MockPlayer(
+            airplay_provider,
+            "airplay_sonos",
+            "Living Room (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_airplay._attr_can_group_with = {"airplay_wiim"}
+        sonos_airplay._cache.clear()
+        sonos_airplay.update_state(signal_event=False)
+
+        wiim_airplay = MockPlayer(
+            airplay_provider,
+            "airplay_wiim",
+            "Bedroom (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+        )
+
+        # Link AirPlay to Sonos A
+        sonos_a.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_sonos",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                )
+            ]
+        )
+        wiim_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_wiim",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                )
+            ]
+        )
+        wiim_player.set_active_output_protocol("airplay_wiim")
+        wiim_player.set_protocol_parent_id("airplay_wiim")
+
+        # Wire up mock_mass.players to controller so get_linked_protocol works
+        mock_mass.players = controller
+
+        controller._players = {
+            "sonos_123": sonos_a,
+            "sonos_456": sonos_b,
+            "wiim_789": wiim_player,
+            "airplay_sonos": sonos_airplay,
+            "airplay_wiim": wiim_airplay,
+        }
+        controller._player_throttlers = {
+            "sonos_123": Throttler(1, 0.05),
+            "sonos_456": Throttler(1, 0.05),
+            "wiim_789": Throttler(1, 0.05),
+            "airplay_sonos": Throttler(1, 0.05),
+            "airplay_wiim": Throttler(1, 0.05),
+        }
+
+        # Group Sonos B (native) + WiiM (via AirPlay) to Sonos A
+        protocol_members, native_members, _protocol_player, _ = (
+            controller._translate_members_for_protocols(
+                parent_player=sonos_a,
+                player_ids=["sonos_456", "wiim_789"],
+                parent_protocol_player=sonos_airplay,
+                parent_protocol_domain="airplay",
+            )
+        )
+
+        # Should have hybrid group: native Sonos B + protocol WiiM
+        assert len(native_members) == 1
+        assert "sonos_456" in native_members
+        assert len(protocol_members) == 1
+        assert "airplay_wiim" in protocol_members
+
+    def test_protocol_selection_requires_set_members(self, mock_mass: MagicMock) -> None:
+        """Test that only protocols with SET_MEMBERS support are selected for grouping."""
+        controller = PlayerController(mock_mass)
+
+        sonos_provider = MockProvider("sonos", mass=mock_mass)
+        wiim_provider = MockProvider("wiim", mass=mock_mass)
+        dlna_provider = MockProvider("dlna", mass=mock_mass)
+        airplay_provider = MockProvider("airplay", mass=mock_mass)
+
+        # Sonos player
+        sonos_player = MockPlayer(
+            sonos_provider,
+            "sonos_123",
+            "Living Room",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        sonos_player._cache.clear()
+
+        # WiiM player
+        wiim_player = MockPlayer(
+            wiim_provider,
+            "wiim_456",
+            "Bedroom",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+        )
+        wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        wiim_player._cache.clear()
+
+        # DLNA protocol (does NOT support SET_MEMBERS)
+        sonos_dlna = MockPlayer(
+            dlna_provider,
+            "dlna_sonos",
+            "Living Room (DLNA)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        # Note: NO SET_MEMBERS feature
+
+        wiim_dlna = MockPlayer(
+            dlna_provider,
+            "dlna_wiim",
+            "Bedroom (DLNA)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+        )
+
+        # AirPlay protocol (DOES support SET_MEMBERS)
+        sonos_airplay = MockPlayer(
+            airplay_provider,
+            "airplay_sonos",
+            "Living Room (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_airplay._attr_can_group_with = {"airplay_wiim"}
+        sonos_airplay._cache.clear()
+
+        wiim_airplay = MockPlayer(
+            airplay_provider,
+            "airplay_wiim",
+            "Bedroom (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+        )
+        wiim_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        wiim_airplay._attr_can_group_with = {"airplay_sonos"}
+        wiim_airplay._cache.clear()
+
+        # Link protocols (DLNA has lower priority than AirPlay)
+        sonos_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="dlna_sonos",
+                    name="DLNA",
+                    protocol_domain="dlna",
+                    priority=30,  # Lower priority (higher number)
+                    available=True,
+                ),
+                OutputProtocol(
+                    output_protocol_id="airplay_sonos",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,  # Higher priority (lower number)
+                    available=True,
+                ),
+            ]
+        )
+        wiim_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="dlna_wiim",
+                    name="DLNA",
+                    protocol_domain="dlna",
+                    priority=30,
+                    available=True,
+                ),
+                OutputProtocol(
+                    output_protocol_id="airplay_wiim",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                ),
+            ]
+        )
+
+        controller._players = {
+            "sonos_123": sonos_player,
+            "wiim_456": wiim_player,
+            "dlna_sonos": sonos_dlna,
+            "dlna_wiim": wiim_dlna,
+            "airplay_sonos": sonos_airplay,
+            "airplay_wiim": wiim_airplay,
+        }
+        controller._player_throttlers = {
+            "sonos_123": Throttler(1, 0.05),
+            "wiim_456": Throttler(1, 0.05),
+            "dlna_sonos": Throttler(1, 0.05),
+            "dlna_wiim": Throttler(1, 0.05),
+            "airplay_sonos": Throttler(1, 0.05),
+            "airplay_wiim": Throttler(1, 0.05),
+        }
+
+        # Update state after modifying attributes
+        sonos_airplay.update_state(signal_event=False)
+        wiim_airplay.update_state(signal_event=False)
+
+        # Translate members - should skip DLNA (no SET_MEMBERS) and select AirPlay
+        protocol_members, _native_members, protocol_player, protocol_domain = (
+            controller._translate_members_for_protocols(
+                parent_player=sonos_player,
+                player_ids=["wiim_456"],
+                parent_protocol_player=None,
+                parent_protocol_domain=None,
+            )
+        )
+
+        # Should select AirPlay (supports SET_MEMBERS) not DLNA
+        assert len(protocol_members) == 1
+        assert "airplay_wiim" in protocol_members
+        assert protocol_domain == "airplay"
+        assert protocol_player == sonos_airplay
+
+
+class TestCanGroupWith:
+    """Tests for can_group_with property with three scenarios."""
+
+    def test_scenario_1_native_active_only_native_players(self, mock_mass: MagicMock) -> None:
+        """Test Scenario 1: Native playback active -> all protocols shown (new behavior)."""
+        controller = PlayerController(mock_mass)
+
+        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+
+        # Create Sonos player with native and AirPlay support
+        sonos_player = MockPlayer(
+            sonos_provider,
+            "sonos_123",
+            "Living Room",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_player._attr_can_group_with = {"sonos_456"}
+        sonos_player._cache.clear()
+        sonos_player.set_active_output_protocol("native")
+
+        # Create another Sonos player
+        sonos_player_b = MockPlayer(
+            sonos_provider,
+            "sonos_456",
+            "Kitchen",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+        )
+
+        # Create AirPlay protocol player
+        sonos_airplay = MockPlayer(
+            airplay_provider,
+            "airplay_sonos",
+            "Living Room (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_airplay._attr_can_group_with = {"airplay_other"}
+        sonos_airplay._cache.clear()
+        sonos_airplay.set_protocol_parent_id("sonos_123")
+
+        sonos_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_sonos",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                )
+            ]
+        )
+
+        # Wire up mock_mass.players to controller so get_linked_protocol works
+        mock_mass.players = controller
+
+        controller._players = {
+            "sonos_123": sonos_player,
+            "sonos_456": sonos_player_b,
+            "airplay_sonos": sonos_airplay,
+        }
+        controller._player_throttlers = {
+            "sonos_123": Throttler(1, 0.05),
+            "sonos_456": Throttler(1, 0.05),
+            "airplay_sonos": Throttler(1, 0.05),
+        }
+
+        # Update state after modifying attributes and registering with controller
+        sonos_player.update_state(signal_event=False)
+        sonos_player_b.update_state(signal_event=False)
+        sonos_airplay.update_state(signal_event=False)
+
+        # Get can_group_with while native is active
+        groupable = sonos_player.state.can_group_with
+
+        # NEW BEHAVIOR: Should show both native AND protocol players
+        # even when native protocol is active
+        assert "sonos_456" in groupable  # Native Sonos player
+        # Note: airplay_other is not registered in controller._players, so it won't appear
+        # But the logic should still allow showing AirPlay options if they were registered
+
+    def test_scenario_2_protocol_active_hybrid_groups(self, mock_mass: MagicMock) -> None:
+        """Test Scenario 2: Protocol active -> show all protocols (new behavior)."""
+        controller = PlayerController(mock_mass)
+
+        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+
+        # Create Sonos player with AirPlay active
+        sonos_player = MockPlayer(
+            sonos_provider,
+            "sonos_123",
+            "Living Room",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_player._attr_can_group_with = {"sonos_456"}
+        sonos_player._cache.clear()
+
+        # Create another Sonos player
+        sonos_player_b = MockPlayer(
+            sonos_provider,
+            "sonos_456",
+            "Kitchen",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+        )
+
+        # Create AirPlay protocol player
+        sonos_airplay = MockPlayer(
+            airplay_provider,
+            "airplay_sonos",
+            "Living Room (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_airplay._attr_can_group_with = {"airplay_other"}
+        sonos_airplay._cache.clear()
+        sonos_airplay.set_protocol_parent_id("sonos_123")
+
+        # Create another device with AirPlay
+        wiim_player = MockPlayer(
+            sonos_provider,
+            "wiim_789",
+            "Bedroom",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+        )
+
+        airplay_other = MockPlayer(
+            airplay_provider,
+            "airplay_other",
+            "Bedroom (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+        )
+        airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        airplay_other._attr_can_group_with = {"airplay_sonos"}
+        airplay_other._cache.clear()
+        airplay_other.set_protocol_parent_id("wiim_789")
+
+        wiim_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_other",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                ),
+            ]
+        )
+
+        sonos_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_sonos",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                )
+            ]
+        )
+        sonos_player.set_active_output_protocol("airplay_sonos")
+
+        # Wire up mock_mass.players to controller so get_linked_protocol works
+        mock_mass.players = controller
+
+        controller._players = {
+            "sonos_123": sonos_player,
+            "sonos_456": sonos_player_b,
+            "wiim_789": wiim_player,
+            "airplay_sonos": sonos_airplay,
+            "airplay_other": airplay_other,
+        }
+        controller._player_throttlers = {
+            "sonos_123": Throttler(1, 0.05),
+            "sonos_456": Throttler(1, 0.05),
+            "wiim_789": Throttler(1, 0.05),
+            "airplay_sonos": Throttler(1, 0.05),
+            "airplay_other": Throttler(1, 0.05),
+        }
+
+        # Clear cache after setting linked protocols
+        sonos_player._cache.clear()
+        wiim_player._cache.clear()
+
+        # Update state after modifying attributes and registering with controller
+        # IMPORTANT: Update protocol players FIRST, then parent players
+        sonos_airplay.update_state(signal_event=False)
+        airplay_other.update_state(signal_event=False)
+        sonos_player.update_state(signal_event=False)
+        sonos_player_b.update_state(signal_event=False)
+        wiim_player.update_state(signal_event=False)
+
+        # Get can_group_with while AirPlay is active
+        groupable = sonos_player.state.can_group_with
+
+        # NEW BEHAVIOR: Should show ALL protocols + native players
+        # regardless of which protocol is active
+        assert "sonos_456" in groupable  # Native Sonos player
+        assert "wiim_789" in groupable  # Via airplay_other protocol
+
+    def test_scenario_3_no_active_output_all_protocols_shown(self, mock_mass: MagicMock) -> None:
+        """Test Scenario 3: No active output -> show all compatible protocols + native."""
+        controller = PlayerController(mock_mass)
+
+        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+        dlna_provider = MockProvider("dlna", instance_id="dlna_instance", mass=mock_mass)
+
+        # Create Sonos player (no active protocol)
+        sonos_player = MockPlayer(
+            sonos_provider,
+            "sonos_123",
+            "Living Room",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_player._attr_can_group_with = {"sonos_456"}
+        sonos_player._cache.clear()
+        # No active output protocol set
+
+        # Create another Sonos player
+        sonos_player_b = MockPlayer(
+            sonos_provider,
+            "sonos_456",
+            "Kitchen",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+        )
+
+        # Create AirPlay protocol player (supports SET_MEMBERS)
+        sonos_airplay = MockPlayer(
+            airplay_provider,
+            "airplay_sonos",
+            "Living Room (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_airplay._attr_can_group_with = {"airplay_other"}
+        sonos_airplay._cache.clear()
+        sonos_airplay.set_protocol_parent_id("sonos_123")
+
+        # Create DLNA protocol player (does NOT support SET_MEMBERS)
+        sonos_dlna = MockPlayer(
+            dlna_provider,
+            "dlna_sonos",
+            "Living Room (DLNA)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        # No SET_MEMBERS support
+        sonos_dlna._attr_can_group_with = {"dlna_other"}
+        sonos_dlna.set_protocol_parent_id("sonos_123")
+
+        # Another device
+        wiim_player = MockPlayer(
+            sonos_provider,
+            "wiim_789",
+            "Bedroom",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+        )
+
+        airplay_other = MockPlayer(
+            airplay_provider,
+            "airplay_other",
+            "Bedroom (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+        )
+        airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        airplay_other._attr_can_group_with = {"airplay_sonos"}
+        airplay_other._cache.clear()
+        airplay_other.set_protocol_parent_id("wiim_789")
+
+        sonos_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_sonos",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                ),
+                OutputProtocol(
+                    output_protocol_id="dlna_sonos",
+                    name="DLNA",
+                    protocol_domain="dlna",
+                    priority=30,
+                    available=True,
+                ),
+            ]
+        )
+
+        wiim_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_other",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                ),
+            ]
+        )
+
+        # Clear cache after setting linked protocols (output_protocols is cached)
+        sonos_player._cache.clear()
+        wiim_player._cache.clear()
+
+        # Wire up mock_mass.players to controller so get_linked_protocol works
+        mock_mass.players = controller
+
+        controller._players = {
+            "sonos_123": sonos_player,
+            "sonos_456": sonos_player_b,
+            "wiim_789": wiim_player,
+            "airplay_sonos": sonos_airplay,
+            "airplay_other": airplay_other,
+            "dlna_sonos": sonos_dlna,
+        }
+        controller._player_throttlers = {
+            "sonos_123": Throttler(1, 0.05),
+            "sonos_456": Throttler(1, 0.05),
+            "wiim_789": Throttler(1, 0.05),
+            "airplay_sonos": Throttler(1, 0.05),
+            "airplay_other": Throttler(1, 0.05),
+            "dlna_sonos": Throttler(1, 0.05),
+        }
+
+        # Update state after modifying attributes and registering with controller
+        # Note: set_linked_output_protocols calls trigger_player_update, but since mass.players
+        # is a MagicMock, we need to manually call update_state
+        # IMPORTANT: Update protocol players FIRST, then parent players, because parent players
+        # access protocol_player.state.can_group_with during their update_state()
+        sonos_airplay.update_state(signal_event=False)
+        airplay_other.update_state(signal_event=False)
+        sonos_dlna.update_state(signal_event=False)
+        sonos_player.update_state(signal_event=False)
+        sonos_player_b.update_state(signal_event=False)
+        wiim_player.update_state(signal_event=False)
+
+        # Get can_group_with with no active protocol
+        groupable = sonos_player.state.can_group_with
+
+        # Should show native players + AirPlay players (supports SET_MEMBERS)
+        # but NOT DLNA players (no SET_MEMBERS support)
+        assert "sonos_456" in groupable
+        assert "wiim_789" in groupable  # Via AirPlay protocol
+        # DLNA players should not be shown since DLNA doesn't support SET_MEMBERS
+
+
+class TestProtocolSwitchingDuringPlayback:
+    """Tests for dynamic protocol switching when group members change during playback."""
+
+    async def test_no_protocol_set_during_grouping_without_playback(
+        self, mock_mass: MagicMock
+    ) -> None:
+        """Test that no protocol is set when grouping players without active playback."""
+        controller = PlayerController(mock_mass)
+
+        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+
+        # Create Sonos player with AirPlay support
+        sonos_player = MockPlayer(
+            sonos_provider,
+            "sonos_123",
+            "Living Room",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_player._attr_can_group_with = {"sonos_456"}
+
+        # Create another Sonos player
+        sonos_player_b = MockPlayer(
+            sonos_provider,
+            "sonos_456",
+            "Kitchen",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+        )
+        sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+
+        # Create AirPlay protocol player
+        sonos_airplay = MockPlayer(
+            airplay_provider,
+            "airplay_sonos",
+            "Living Room (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_airplay.set_protocol_parent_id("sonos_123")
+
+        sonos_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_sonos",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                )
+            ]
+        )
+
+        mock_mass.players = controller
+        controller._players = {
+            "sonos_123": sonos_player,
+            "sonos_456": sonos_player_b,
+            "airplay_sonos": sonos_airplay,
+        }
+        controller._player_throttlers = {
+            "sonos_123": Throttler(1, 0.05),
+            "sonos_456": Throttler(1, 0.05),
+            "airplay_sonos": Throttler(1, 0.05),
+        }
+
+        # Group players via protocol (simulate grouping through AirPlay)
+        # This should NOT set active_output_protocol anymore
+        await controller._forward_protocol_set_members(
+            parent_player=sonos_player,
+            parent_protocol_player=sonos_airplay,
+            protocol_members_to_add=["airplay_other"],  # Add a protocol member
+            protocol_members_to_remove=[],
+        )
+
+        # NEW BEHAVIOR: Protocol should NOT be set during grouping without playback
+        # After grouping, protocol should not be activated until playback starts
+        assert sonos_player.active_output_protocol is None
+
+    async def test_protocol_selected_at_playback_time(self, mock_mass: MagicMock) -> None:
+        """Test that protocol is selected when playback starts, not during grouping."""
+        controller = PlayerController(mock_mass)
+
+        sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+        airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+
+        # Create Sonos player with AirPlay support
+        sonos_player = MockPlayer(
+            sonos_provider,
+            "sonos_123",
+            "Living Room",
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+
+        # Create AirPlay protocol player with group members
+        sonos_airplay = MockPlayer(
+            airplay_provider,
+            "airplay_sonos",
+            "Living Room (AirPlay)",
+            player_type=PlayerType.PROTOCOL,
+            identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+        )
+        sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+        sonos_airplay._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+        sonos_airplay.set_protocol_parent_id("sonos_123")
+        # Simulate that AirPlay protocol has group members (needs >1 for grouping check)
+        sonos_airplay._attr_group_members = ["airplay_sonos", "airplay_other"]
+
+        sonos_player.set_linked_output_protocols(
+            [
+                OutputProtocol(
+                    output_protocol_id="airplay_sonos",
+                    name="AirPlay",
+                    protocol_domain="airplay",
+                    priority=10,
+                    available=True,
+                )
+            ]
+        )
+
+        mock_mass.players = controller
+        controller._players = {
+            "sonos_123": sonos_player,
+            "airplay_sonos": sonos_airplay,
+        }
+
+        # Update state to apply group members to state
+        sonos_airplay.update_state(signal_event=False)
+        sonos_player.update_state(signal_event=False)
+
+        # Protocol should not be set yet
+        assert sonos_player.active_output_protocol is None
+
+        # Select protocol for playback
+        selected_player, output_protocol = controller._select_best_output_protocol(sonos_player)
+
+        # Should select AirPlay protocol because it has group members (Priority 1)
+        assert selected_player == sonos_airplay
+        assert output_protocol is not None
+        assert output_protocol.output_protocol_id == "airplay_sonos"