Fix: Don't merge player protocols for multiple software players on same host
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 18 Feb 2026 11:16:02 +0000 (12:16 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 18 Feb 2026 11:16:02 +0000 (12:16 +0100)
music_assistant/controllers/players/README.md
music_assistant/controllers/players/protocol_linking.py
tests/core/test_protocol_linking.py

index dbd817677e754f2e8c9901a606b8423825bdbae3..6458e87590acab550aac6e44510eb1f34dec338b 100644 (file)
@@ -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
index a4c8a694b8c3fa5cd1e5dfeca894f597ad0af79d..5b0c1ed376c22443001e2e2012afddedb6d90434 100644 (file)
@@ -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)
 
index 62270962a6b68106c2bd2c2591ad0d3542ca728d..6fd7e2f651bb75c8c6f5fd1f3720c8f5afc35bed 100644 (file)
@@ -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."""