Fixes for active source and current media with linked protocols
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 20 Feb 2026 23:19:00 +0000 (00:19 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 20 Feb 2026 23:19:00 +0000 (00:19 +0100)
music_assistant/controllers/players/controller.py
music_assistant/models/player.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/bluesound/player.py
music_assistant/providers/chromecast/player.py
music_assistant/providers/hass_players/player.py
music_assistant/providers/sonos/player.py
music_assistant/providers/squeezelite/player.py
music_assistant/providers/sync_group/constants.py
music_assistant/providers/sync_group/player.py
music_assistant/providers/sync_group/provider.py

index 67102a94d08aee9aac7a4701e12c011ef6b306e1..54776c06fbe525b8d7b2a17f51f73c53607e61c0 100644 (file)
@@ -1563,8 +1563,15 @@ class PlayerController(ProtocolLinkingMixin, CoreController):
         # something external while we have a grouped protocol active
         if ATTR_ACTIVE_SOURCE in changed_values:
             prev_source, new_source = changed_values[ATTR_ACTIVE_SOURCE]
-            self._handle_external_source_takeover(player, prev_source, new_source)
-
+            task_id = f"external_source_takeover_{player_id}"
+            self.mass.call_later(
+                3,
+                self._handle_external_source_takeover,
+                player,
+                prev_source,
+                new_source,
+                task_id=task_id,
+            )
         became_inactive = False
         if ATTR_AVAILABLE in changed_values:
             became_inactive = changed_values[ATTR_AVAILABLE][1] is False
@@ -2295,6 +2302,10 @@ class PlayerController(ProtocolLinkingMixin, CoreController):
         if player.type == PlayerType.PROTOCOL:
             return
 
+        # Not a takeover if the player is not actively playing
+        if player.playback_state != PlaybackState.PLAYING:
+            return
+
         # Only relevant if we have an active output protocol (not native)
         if not player.active_output_protocol or player.active_output_protocol == "native":
             return
@@ -2308,11 +2319,12 @@ class PlayerController(ProtocolLinkingMixin, CoreController):
         if not protocol_player:
             return
 
-        # Only relevant if the protocol is grouped
-        if not self._is_protocol_grouped(protocol_player):
+        # If the source matches the active protocol's domain, it's expected - not a takeover
+        # e.g., source "airplay" when using AirPlay protocol is normal
+        if new_source and new_source.lower() == protocol_player.provider.domain.lower():
             return
 
-        # External source took over while protocol was grouped - unbond
+        # Confirmed external source takeover
         self.logger.info(
             "External source '%s' took over on %s while grouped via protocol %s - "
             "clearing active output protocol and ungrouping",
@@ -2332,7 +2344,7 @@ class PlayerController(ProtocolLinkingMixin, CoreController):
         Check if a source is managed by Music Assistant.
 
         MA-managed sources include:
-        - None (no source active)
+        - None (=autodetect, no source explicitly set by player)
         - The player's own ID (MA queue)
         - Any active queue ID
         - Any plugin source ID
index a4f25edb7fdabcda5c96b529023e309cf3334f91..2221d592abe560d45b4a245e9b8a56e4a40a1b07 100644 (file)
@@ -62,6 +62,7 @@ from music_assistant.helpers.util import get_changed_dataclass_values
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, PlayerConfig
+    from music_assistant_models.player_queue import PlayerQueue
 
     from .player_provider import PlayerProvider
 
@@ -127,7 +128,7 @@ class Player(ABC):
         self._extra_data: dict[str, Any] = {}
         self._extra_attributes: dict[str, Any] = {}
         self._on_unload_callbacks: list[Callable[[], None]] = []
-        self.__active_mass_source = player_id
+        self.__active_mass_source: str | None = None
         # The PlayerState is the (snapshotted) final state of the player
         # after applying any config overrides and other transformations,
         # such as the display name and player controls.
@@ -1334,7 +1335,13 @@ class Player(ABC):
             and self._state.playback_state == PlaybackState.IDLE
         ):
             self.__stop_called = True
-            self.__active_mass_source = None
+            # when we're going to idle,
+            # we want to reset the active mass source after a short delay
+            # this is done using a timer which gets reset if the player starts playing again
+            # before the timer is up, using the task_id
+            self.mass.call_later(
+                5, self.set_active_mass_source, None, task_id=f"set_mass_source_{self.player_id}"
+            )
 
         return get_changed_dataclass_values(
             prev_state,
@@ -1357,14 +1364,17 @@ class Player(ABC):
             and (
                 protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol)
             )
+            and protocol_player.playback_state != PlaybackState.IDLE
         ):
             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 we're synced, use the syncleader state for playback state and elapsed time
+        # NOTE: Don't do this for the active group player,
+        # because the group player relies on the sync leader for state info.
+        parent_id = self.__final_synced_to
         if parent_id and (parent_player := self.mass.players.get_player(parent_id)):
             return (
                 parent_player.state.playback_state,
@@ -1471,6 +1481,7 @@ class Player(ABC):
                 parent_player := self.mass.players.get_player(parent_player_id)
             ):
                 return parent_player.state.current_media
+            return None  # if parent player not found, return None for 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):
@@ -1495,14 +1506,11 @@ class Player(ABC):
                 elapsed_time_last_updated=source.metadata.elapsed_time_last_updated,
             )
         # 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)
+        active_queue: PlayerQueue | None = None
         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):
             item_image_url = (
                 # the image format needs to be 500x500 jpeg for maximum compatibility with players
@@ -1796,19 +1804,35 @@ class Player(ABC):
         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
+            return None  # should not happen but just in case
+        if self.type == PlayerType.PROTOCOL:
+            if self.protocol_parent_id and (
+                parent_player := self.mass.players.get_player(self.protocol_parent_id)
+            ):
+                # if this is a protocol player, use the active source of the parent player
+                return parent_player.state.active_source
+            # fallback to None here if parent player not found,
+            # protocol players should not have an active source themselves
+            return None
         # 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
+        output_protocol_domain: str | None = None
+        if self.active_output_protocol and self.active_output_protocol != "native":
+            if protocol_player := self.mass.players.get_player(self.active_output_protocol):
+                output_protocol_domain = protocol_player.provider.domain
         # active source as reported by the player itself, but only if playing/paused
-        if self.playback_state != PlaybackState.IDLE and self.active_source:
+        if (
+            self.playback_state != PlaybackState.IDLE
+            and self.active_source
+            # try to catch cases where player reports an active source
+            # that is actually from an active output protocol (e.g. AirPlay)
+            and self.active_source.lower() != output_protocol_domain
+        ):
             return self.active_source
         # return the (last) known MA source
-        return self.__last_active_mass_source
+        return self.__active_mass_source
 
     @final
     def _translate_protocol_ids_to_visible(self, player_ids: set[str]) -> set[Player]:
@@ -1900,18 +1924,17 @@ class Player(ABC):
     # 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 | None = None
-    __last_active_mass_source: str | None = None
 
     @final
-    def set_active_mass_source(self, value: str) -> None:
+    def set_active_mass_source(self, value: str | None) -> None:
         """
-        Set the id of the active mass source.
+        Set 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).
         """
+        self.mass.cancel_timer(f"set_mass_source_{self.player_id}")
         self.__active_mass_source = value
-        self.__last_active_mass_source = value
         self.update_state()
 
     __stop_called: bool = False
index 8e9d5e7c2d5dc69e350203bf3db676aa219ff9c5..bccdae2233a2df9745ed894acbe1760b3cb9935f 100644 (file)
@@ -256,43 +256,35 @@ 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.players.get_active_queue(player)
-            if not active_queue:
-                self.logger.warning(
-                    "DACP request for %s (%s) but no active queue found, ignoring request",
-                    player.display_name,
-                    player_id,
-                )
-                return
             if path == "/ctrl-int/1/nextitem":
-                self.mass.create_task(self.mass.player_queues.next(active_queue.queue_id))
+                self.mass.create_task(self.mass.players.cmd_next_track(player_id))
             elif path == "/ctrl-int/1/previtem":
-                self.mass.create_task(self.mass.player_queues.previous(active_queue.queue_id))
+                self.mass.create_task(self.mass.players.cmd_previous_track(player_id))
             elif path == "/ctrl-int/1/play":
                 # sometimes this request is sent by a device as confirmation of a play command
                 # we ignore this if the player is already playing
                 if player.playback_state != PlaybackState.PLAYING:
-                    self.mass.create_task(self.mass.player_queues.play(active_queue.queue_id))
+                    self.mass.create_task(self.mass.players.cmd_play(player_id))
             elif path == "/ctrl-int/1/playpause":
-                self.mass.create_task(self.mass.player_queues.play_pause(active_queue.queue_id))
+                self.mass.create_task(self.mass.players.cmd_play_pause(player_id))
             elif path == "/ctrl-int/1/stop":
-                self.mass.create_task(self.mass.player_queues.stop(active_queue.queue_id))
+                self.mass.create_task(self.mass.players.cmd_stop(player_id))
             elif path == "/ctrl-int/1/volumeup":
                 self.mass.create_task(self.mass.players.cmd_volume_up(player_id))
             elif path == "/ctrl-int/1/volumedown":
                 self.mass.create_task(self.mass.players.cmd_volume_down(player_id))
             elif path == "/ctrl-int/1/shuffle_songs":
-                queue = self.mass.player_queues.get(player_id)
-                if not queue:
+                active_queue = self.mass.players.get_active_queue(player)
+                if not active_queue:
                     return
                 await self.mass.player_queues.set_shuffle(
-                    active_queue.queue_id, not queue.shuffle_enabled
+                    active_queue.queue_id, not active_queue.shuffle_enabled
                 )
             elif path in ("/ctrl-int/1/pause", "/ctrl-int/1/discrete-pause"):
                 # sometimes this request is sent by a device as confirmation of a play command
                 # we ignore this if the player is already playing
                 if player.playback_state == PlaybackState.PLAYING:
-                    self.mass.create_task(self.mass.player_queues.pause(active_queue.queue_id))
+                    self.mass.create_task(self.mass.players.cmd_pause(player_id))
             elif "dmcp.device-volume=" in path and not ignore_volume_report:
                 # This is a bit annoying as this can be either the device confirming a new volume
                 # we've sent or the device requesting a new volume itself.
index a962547888d9032fc1eed45fe0b41771964962a4..81bdabcf88aee7d70a3eeae829bd61b1a84430be 100644 (file)
@@ -276,9 +276,9 @@ class BluesoundPlayer(Player):
             )
 
         self.logger.debug(self.status)
-        mass_active = self.mass.streams.base_url
-        if self.status.stream_url and mass_active in self.status.stream_url:
-            self._attr_active_source = self.player_id
+        mass_url = self.mass.streams.base_url
+        if self.status.stream_url and mass_url in self.status.stream_url:
+            self._attr_active_source = None
         elif player_source := PLAYER_SOURCE_MAP.get(self.status.input_id):
             self._attr_active_source = self.status.input_id
             self._attr_source_list.append(player_source)
index b650d347dbbdc058e97b9a3dbe99f4fb81cd97f9..1a68f8b77497d371c6d1396097a90b8926aaf51b 100644 (file)
@@ -425,7 +425,7 @@ class ChromecastPlayer(Player):
                     raise PlayerUnavailableError("Failed to launch Sendspin Cast App")
             else:
                 await self._launch_app()
-            self._attr_active_source = self.player_id
+            self._attr_active_source = None
         else:
             self._attr_active_source = None
             await asyncio.to_thread(self.cc.quit_app)
@@ -725,7 +725,7 @@ class ChromecastPlayer(Player):
         if group_player:
             self._attr_active_source = group_player.active_source or group_player.player_id
         elif self.cc.app_id in (MASS_APP_ID, APP_MEDIA_RECEIVER):
-            self._attr_active_source = self.player_id
+            self._attr_active_source = None
         else:
             app_name = self.cc.app_display_name or "Unknown App"
             app_id = app_name.lower().replace(" ", "_")
@@ -770,7 +770,7 @@ class ChromecastPlayer(Player):
                     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
+                    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)
 
index 33658d12a0c899c06024c369cb78aa0d27b5b048..d0c472b808cff3bcb0045f643cc06e2e4d7b17d1 100644 (file)
@@ -455,13 +455,12 @@ class HomeAssistantPlayer(Player):
         # so we later only react to state updates that include it.
         media_content_id = self._hass_attributes.get("media_content_id", "")
         is_ma_playback = media_content_id.startswith(self.mass.streams.base_url)
-
         media_title = self._hass_attributes.get("media_title")
 
         if media_content_id and is_ma_playback:
             # MA playback - ensure active_source points to player_id for queue lookup.
             # The actual current_media will be set by MA's queue controller.
-            self._attr_active_source = self.player_id
+            self._attr_active_source = None
         elif (
             media_content_id
             and media_title
index 074890b1dddd095f97b4354e725bed95d10925a8..04c1074554ce027fcffb18688c66fe6c263f5985 100644 (file)
@@ -228,7 +228,7 @@ class SonosPlayer(Player):
         if self.client.player.is_passive:
             self.logger.debug("Ignore PAUSE command: Player is synced to another player.")
             return
-        active_source = self._attr_active_source
+        active_source = self.state.active_source
         if self.mass.player_queues.get(active_source):
             # Sonos seems to be bugged when playing our queue tracks and we send pause,
             # it can't resume the current track and simply aborts/skips it
@@ -526,12 +526,8 @@ class SonosPlayer(Player):
             if SOURCE_SPOTIFY not in [x.id for x in self._attr_source_list]:
                 self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_SPOTIFY])
         elif active_service == MusicService.MUSIC_ASSISTANT:
-            if (object_id := container.get("id", {}).get("objectId")) and object_id.startswith(
-                "mass:"
-            ):
-                self._attr_active_source = object_id.split(":")[1]
-            else:
-                self._attr_active_source = None
+            # setting active source to None is fine
+            self._attr_active_source = None
         # its playing some service we did not yet map
         elif container and container.get("service", {}).get("name"):
             self._attr_active_source = container["service"]["name"]
@@ -621,11 +617,11 @@ class SonosPlayer(Player):
 
         # 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.
+        # We capture the group state here and restore it after a delay.
 
         self.logger.debug(
             "AirPlay playback starting on %s with native group members %s - "
-            "scheduling restoration to avoid Sonos ungrouping bug",
+            "scheduling restoration to work around Sonos ungrouping bug",
             self.name,
             current_members,
         )
@@ -633,11 +629,14 @@ class SonosPlayer(Player):
 
         async def _restore_airplay_group() -> None:
             try:
+                self.logger.info(
+                    "Restoring AirPlay group for %s with members %s",
+                    self.name,
+                    members_to_restore,
+                )
                 # 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
-                )
+                await self.set_members(player_ids_to_add=members_to_restore)
             except Exception as err:
                 self.logger.warning("Failed to restore AirPlay group: %s", err)
 
index 2a052a67d890a4c36af5c247fd17863907023b3a..bef9acb76b97f47aaa69d663748546a2553da5b2 100644 (file)
@@ -417,11 +417,8 @@ class SqueezelitePlayer(Player):
                 source_id=metadata.get("source_id"),
                 queue_item_id=metadata.get("queue_item_id"),
             )
-            # Set active source from metadata if available, otherwise use player_id
-            self._attr_active_source = metadata.get("source_id") or self.player_id
         else:
             self._attr_current_media = None
-            self._attr_active_source = self.player_id
 
     async def _handle_play_url_for_slimplayer(
         self,
index 5d4d0d18e3a353f88014b8a67e32121ea4637c0c..7c24ff8f767b06a90e603483cf2285ef1f237309 100644 (file)
@@ -32,7 +32,6 @@ SUPPORT_DYNAMIC_LEADER = {
 EXTRA_FEATURES_FROM_MEMBERS: Final[set[PlayerFeature]] = {
     PlayerFeature.ENQUEUE,
     PlayerFeature.GAPLESS_PLAYBACK,
-    PlayerFeature.PAUSE,
     PlayerFeature.VOLUME_SET,
     PlayerFeature.VOLUME_MUTE,
     PlayerFeature.MULTI_DEVICE_DSP,
index 766e109f355325ef267a1a3883e7287477e20ea4..5de5e65df507b312b3819088f766cd59df3e2e72 100644 (file)
@@ -101,16 +101,6 @@ class SyncGroupPlayer(GroupPlayer):
         """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."""
@@ -181,6 +171,8 @@ class SyncGroupPlayer(GroupPlayer):
 
     async def stop(self) -> None:
         """Send STOP command to given player."""
+        self._attr_current_media = None
+        self._attr_active_source = None
         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)
@@ -194,14 +186,10 @@ class SyncGroupPlayer(GroupPlayer):
             # 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."""
+        self._attr_current_media = media
+        self._attr_active_source = media.source_id if media.source_id else None
         if not self.sync_leader:
             await self._form_syncgroup()
         # simply forward the command to the sync leader
index 7199559177b25ec7e9a891c976455260775cf650..2e3c0324eca1b260ba6a6a7630f5aa95e65c6ab3 100644 (file)
@@ -41,7 +41,7 @@ class SyncGroupProvider(PlayerProvider):
             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:
+            elif member_id not in can_group_with:
                 # member is not compatible with the current group, skip it
                 continue
             final_members.append(member_id)