From 7c9dfe45bda921356ac4eba45076de9ac4c13f8f Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 18 Feb 2026 12:16:02 +0100 Subject: [PATCH] Fix: Don't merge player protocols for multiple software players on same host --- music_assistant/controllers/players/README.md | 2 + .../controllers/players/protocol_linking.py | 5 ++ tests/core/test_protocol_linking.py | 48 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/music_assistant/controllers/players/README.md b/music_assistant/controllers/players/README.md index dbd81767..6458e875 100644 --- a/music_assistant/controllers/players/README.md +++ b/music_assistant/controllers/players/README.md @@ -165,6 +165,8 @@ Protocol players are matched to the same physical device using identifiers in or **Note:** IP_ADDRESS is intentionally NOT used for matching as it can change with DHCP and cause incorrect matches between different devices. +**Important:** Protocol players from the **same protocol domain** (same provider.domain) will NOT be matched together, even if they share the same MAC/IP address. This is intentional to handle multiple software player instances (e.g., multiple Snapcast clients, multiple SendSpin web players) running on the same host. These are separate logical players, not multiple protocols of the same physical device. + **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 diff --git a/music_assistant/controllers/players/protocol_linking.py b/music_assistant/controllers/players/protocol_linking.py index a4c8a694..5b0c1ed3 100644 --- a/music_assistant/controllers/players/protocol_linking.py +++ b/music_assistant/controllers/players/protocol_linking.py @@ -332,6 +332,7 @@ class ProtocolLinkingMixin: the same physical device. """ matching = [protocol_player] + protocol_domain = protocol_player.provider.domain for other_player in self.all_players(return_protocol_players=True): if other_player.player_id == protocol_player.player_id: @@ -340,6 +341,10 @@ class ProtocolLinkingMixin: continue if other_player.protocol_parent_id: continue + # Skip players from the same protocol domain + # Multiple instances of the same protocol on one host are separate players + if other_player.provider.domain == protocol_domain: + continue if self._identifiers_match(protocol_player, other_player): matching.append(other_player) diff --git a/tests/core/test_protocol_linking.py b/tests/core/test_protocol_linking.py index 62270962..6fd7e2f6 100644 --- a/tests/core/test_protocol_linking.py +++ b/tests/core/test_protocol_linking.py @@ -289,6 +289,54 @@ class TestFindMatchingProtocolPlayers: assert airplay_player in matches assert chromecast_player in matches + def test_same_protocol_not_matched(self, mock_mass: MagicMock) -> None: + """Test that multiple players of same protocol on same host are NOT matched together.""" + controller = PlayerController(mock_mass) + + # Set up provider + snapcast_provider = MockProvider("snapcast") + + # Create multiple Snapcast players on same host (same MAC/IP) + # This simulates multiple Snapcast clients running on the same server + snapcast_player_1 = MockPlayer( + snapcast_provider, + "snapcast_client_1", + "Snapcast Client 1", + player_type=PlayerType.PROTOCOL, + identifiers={ + IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", + IdentifierType.IP_ADDRESS: "192.168.1.100", + }, + ) + snapcast_player_2 = MockPlayer( + snapcast_provider, + "snapcast_client_2", + "Snapcast Client 2", + player_type=PlayerType.PROTOCOL, + identifiers={ + IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", + IdentifierType.IP_ADDRESS: "192.168.1.100", + }, + ) + + # Register players + controller._players = { + "snapcast_client_1": snapcast_player_1, + "snapcast_client_2": snapcast_player_2, + } + controller._player_throttlers = { + "snapcast_client_1": Throttler(1, 0.05), + "snapcast_client_2": Throttler(1, 0.05), + } + + # Find matching players for first Snapcast player + matches = controller._find_matching_protocol_players(snapcast_player_1) + + # Should only match itself, NOT the other Snapcast player (same protocol domain) + assert len(matches) == 1 + assert snapcast_player_1 in matches + assert snapcast_player_2 not in matches + class TestGetDeviceKeyFromPlayers: """Tests for device key generation.""" -- 2.34.1