From: Marvin Schenkel Date: Thu, 19 Feb 2026 19:35:40 +0000 (+0100) Subject: Fix grouping for for players whos native protocol is a protocol of other players... X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=44149dd2b9109b0055baf00887b4310233908c14;p=music-assistant-server.git Fix grouping for for players whos native protocol is a protocol of other players (#3192) --- diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index a03d92ad..e4c31a29 100644 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -1721,9 +1721,18 @@ class Player(ABC): # 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) + if player.type == PlayerType.PROTOCOL: + # Protocol player is hidden - translate to its visible parent player + if not player.protocol_parent_id: + continue + visible_parent = self.mass.players.get_player(player.protocol_parent_id) + if not visible_parent or not _should_include_player(visible_parent): + continue + result.add(visible_parent.player_id) + else: + 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: @@ -1777,7 +1786,7 @@ class Player(ABC): (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). + :param player_ids: Set of player IDs. :return: Set of visible players. """ result: set[Player] = set() @@ -1785,7 +1794,11 @@ class Player(ABC): 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: + if not target_player: + continue + if target_player.type != PlayerType.PROTOCOL: + # Non-protocol player is already visible - include directly + result.add(target_player) continue # This is a protocol player - find its visible parent if not target_player.protocol_parent_id: diff --git a/tests/core/test_protocol_linking.py b/tests/core/test_protocol_linking.py index 2ec7a812..8d088905 100644 --- a/tests/core/test_protocol_linking.py +++ b/tests/core/test_protocol_linking.py @@ -1663,6 +1663,147 @@ class TestCanGroupWith: # DLNA players should not be shown since DLNA doesn't support SET_MEMBERS +class TestNativePlayerProtocolGrouping: + """Tests for grouping between native PLAYER type and PROTOCOL type AirPlay players.""" + + def test_native_airplay_player_sees_protocol_players_as_visible_parents( + self, mock_mass: MagicMock + ) -> None: + """Test that a native PLAYER type translates protocol players to visible parents.""" + controller = PlayerController(mock_mass) + + airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass) + sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass) + + # HomePod: native AirPlay PLAYER (not PROTOCOL) + homepod = MockPlayer(airplay_provider, "homepod_1", "Office") + homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS) + homepod._attr_can_group_with = {"airplay"} # Provider instance ID + homepod._cache.clear() + + # Sonos native player (visible to the user) + sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen") + sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS) + sonos_player._cache.clear() + + # AirPlay protocol player for the Sonos (hidden, linked to sonos_player) + sonos_airplay = MockPlayer( + airplay_provider, + "airplay_sonos_1", + "Kitchen (AirPlay)", + player_type=PlayerType.PROTOCOL, + ) + sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS) + sonos_airplay._attr_can_group_with = {"airplay"} + sonos_airplay._cache.clear() + sonos_airplay.set_protocol_parent_id("sonos_1") + + sonos_player.set_linked_output_protocols( + [ + OutputProtocol( + output_protocol_id="airplay_sonos_1", + name="AirPlay", + protocol_domain="airplay", + priority=10, + available=True, + ) + ] + ) + + mock_mass.players = controller + mock_mass.get_provider = MagicMock(return_value=airplay_provider) + + controller._players = { + "homepod_1": homepod, + "sonos_1": sonos_player, + "airplay_sonos_1": sonos_airplay, + } + controller._player_throttlers = { + "homepod_1": Throttler(1, 0.05), + "sonos_1": Throttler(1, 0.05), + "airplay_sonos_1": Throttler(1, 0.05), + } + + # Update protocol players first, then parents + sonos_airplay.update_state(signal_event=False) + sonos_player.update_state(signal_event=False) + homepod.update_state(signal_event=False) + + groupable = homepod.state.can_group_with + + # HomePod should see Sonos's VISIBLE player, not the hidden protocol player + assert "sonos_1" in groupable + assert "airplay_sonos_1" not in groupable # Hidden protocol ID must NOT appear + + def test_protocol_linked_player_sees_native_airplay_player(self, mock_mass: MagicMock) -> None: + """Test that a player with linked AirPlay protocol sees native PLAYER type players.""" + controller = PlayerController(mock_mass) + + airplay_provider = MockProvider("airplay", instance_id="airplay", mass=mock_mass) + sonos_provider = MockProvider("sonos", instance_id="sonos", mass=mock_mass) + + # HomePod: native AirPlay PLAYER + homepod = MockPlayer(airplay_provider, "homepod_1", "Office") + homepod._attr_supported_features.add(PlayerFeature.SET_MEMBERS) + homepod._attr_can_group_with = {"airplay"} + homepod._cache.clear() + + # Sonos native player (visible to the user) + sonos_player = MockPlayer(sonos_provider, "sonos_1", "Kitchen") + sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA) + sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS) + sonos_player._attr_can_group_with = set() # No native Sonos grouping peers + sonos_player._cache.clear() + + # AirPlay protocol player for the Sonos (hidden, linked to sonos_player) + sonos_airplay = MockPlayer( + airplay_provider, + "airplay_sonos_1", + "Kitchen (AirPlay)", + player_type=PlayerType.PROTOCOL, + ) + sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS) + sonos_airplay._attr_can_group_with = {"airplay"} # Provider instance ID + sonos_airplay._cache.clear() + sonos_airplay.set_protocol_parent_id("sonos_1") + + sonos_player.set_linked_output_protocols( + [ + OutputProtocol( + output_protocol_id="airplay_sonos_1", + name="AirPlay", + protocol_domain="airplay", + priority=10, + available=True, + ) + ] + ) + + mock_mass.players = controller + mock_mass.get_provider = MagicMock(return_value=airplay_provider) + + controller._players = { + "homepod_1": homepod, + "sonos_1": sonos_player, + "airplay_sonos_1": sonos_airplay, + } + controller._player_throttlers = { + "homepod_1": Throttler(1, 0.05), + "sonos_1": Throttler(1, 0.05), + "airplay_sonos_1": Throttler(1, 0.05), + } + + # Update protocol players first, then parents + sonos_airplay.update_state(signal_event=False) + homepod.update_state(signal_event=False) + sonos_player.update_state(signal_event=False) + + groupable = sonos_player.state.can_group_with + + # Sonos should see HomePod via its linked AirPlay protocol's can_group_with + assert "homepod_1" in groupable + + class TestProtocolSwitchingDuringPlayback: """Tests for dynamic protocol switching when group members change during playback."""