From: Marcel van der Veldt Date: Fri, 20 Feb 2026 23:19:00 +0000 (+0100) Subject: Fixes for active source and current media with linked protocols X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=ed7f50babaa3b15ca0c19400978c91fd99b79c16;p=music-assistant-server.git Fixes for active source and current media with linked protocols --- diff --git a/music_assistant/controllers/players/controller.py b/music_assistant/controllers/players/controller.py index 67102a94..54776c06 100644 --- a/music_assistant/controllers/players/controller.py +++ b/music_assistant/controllers/players/controller.py @@ -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 diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index a4f25edb..2221d592 100644 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -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 diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index 8e9d5e7c..bccdae22 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -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. diff --git a/music_assistant/providers/bluesound/player.py b/music_assistant/providers/bluesound/player.py index a9625478..81bdabcf 100644 --- a/music_assistant/providers/bluesound/player.py +++ b/music_assistant/providers/bluesound/player.py @@ -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) diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index b650d347..1a68f8b7 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -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) diff --git a/music_assistant/providers/hass_players/player.py b/music_assistant/providers/hass_players/player.py index 33658d12..d0c472b8 100644 --- a/music_assistant/providers/hass_players/player.py +++ b/music_assistant/providers/hass_players/player.py @@ -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 diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index 074890b1..04c10745 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -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) diff --git a/music_assistant/providers/squeezelite/player.py b/music_assistant/providers/squeezelite/player.py index 2a052a67..bef9acb7 100644 --- a/music_assistant/providers/squeezelite/player.py +++ b/music_assistant/providers/squeezelite/player.py @@ -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, diff --git a/music_assistant/providers/sync_group/constants.py b/music_assistant/providers/sync_group/constants.py index 5d4d0d18..7c24ff8f 100644 --- a/music_assistant/providers/sync_group/constants.py +++ b/music_assistant/providers/sync_group/constants.py @@ -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, diff --git a/music_assistant/providers/sync_group/player.py b/music_assistant/providers/sync_group/player.py index 766e109f..5de5e65d 100644 --- a/music_assistant/providers/sync_group/player.py +++ b/music_assistant/providers/sync_group/player.py @@ -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 diff --git a/music_assistant/providers/sync_group/provider.py b/music_assistant/providers/sync_group/provider.py index 71995591..2e3c0324 100644 --- a/music_assistant/providers/sync_group/provider.py +++ b/music_assistant/providers/sync_group/provider.py @@ -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)