Fixes for sendspin web player
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 19 Feb 2026 21:05:11 +0000 (22:05 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 19 Feb 2026 21:05:11 +0000 (22:05 +0100)
music_assistant/controllers/players/protocol_linking.py
music_assistant/providers/sendspin/player.py
music_assistant/providers/sendspin/provider.py
music_assistant/providers/universal_player/player.py
music_assistant/providers/universal_player/provider.py

index 04ff213cf380d03dea2df8fac5c8677b9284c881..37020bbb78240ae8ec7a8ee90188882e75f1ccb0 100644 (file)
@@ -501,7 +501,19 @@ class ProtocolLinkingMixin:
         for player in list(self._players.values()):
             if player.provider.domain != "universal_player":
                 continue
-            if not self._identifiers_match(native_player, player, ""):
+
+            # Check by identifiers first
+            identifiers_match = self._identifiers_match(native_player, player, "")
+
+            # Also check if native player's ID is in the universal player's stored protocol list
+            # This handles players that changed type (e.g., sendspin web players changed from
+            # PROTOCOL to PLAYER type) and have no identifiers to match against
+            player_id_in_protocols = (
+                isinstance(player, UniversalPlayer)
+                and native_player.player_id in player._protocol_player_ids
+            )
+
+            if not identifiers_match and not player_id_in_protocols:
                 continue
 
             # Transfer all protocol links from universal player to native player
index 4fdaffd554947a7e5d31f438f1981b093e5b3abe..02b2ab16777743229d54ecb7b1b78b6112e20dcf 100644 (file)
@@ -11,12 +11,7 @@ from typing import TYPE_CHECKING, cast
 from aiosendspin.models import AudioCodec, MediaCommand
 from aiosendspin.models.types import PlaybackStateType
 from aiosendspin.models.types import RepeatMode as SendspinRepeatMode
-from aiosendspin.server import (
-    ClientEvent,
-    GroupEvent,
-    SendspinGroup,
-    VolumeChangedEvent,
-)
+from aiosendspin.server import ClientEvent, GroupEvent, SendspinGroup, VolumeChangedEvent
 from aiosendspin.server.audio import AudioFormat as SendspinAudioFormat
 from aiosendspin.server.client import DisconnectBehaviour
 from aiosendspin.server.events import (
@@ -60,9 +55,7 @@ from music_assistant.constants import (
     CONF_ENTRY_SAMPLE_RATES,
 )
 from music_assistant.models.player import Player, PlayerMedia
-from music_assistant.providers.sendspin.playback import (
-    SendspinPlaybackSession,
-)
+from music_assistant.providers.sendspin.playback import SendspinPlaybackSession
 
 # Supported group commands for Sendspin players
 SUPPORTED_GROUP_COMMANDS = [
@@ -205,6 +198,9 @@ class SendspinPlayer(Player):
         )
         self._attr_expose_to_ha_by_default = not self.is_web_player
         self._attr_hidden_by_default = self.is_web_player
+        # register web/app player as native player type because it doesn't need to be linked
+        # every web/app player is just a standalone player.
+        self._attr_type = PlayerType.PLAYER if self.is_web_player else PlayerType.PROTOCOL
 
     @property
     def _artwork_role(self) -> ArtworkGroupRole | None:
index 392cc46fce408b3377c01a89a64a4201ad01fdcc..b23385845cdcefdaa835f1e9c39ce620ba1907b3 100644 (file)
@@ -60,9 +60,19 @@ class SendspinProvider(PlayerProvider):
             self.logger.debug("Waiting for pending unregister of %s before registering", client_id)
             await pending_event.wait()
         # Check if client still exists (may have disconnected while waiting)
-        if self.server_api.get_client(client_id) is None:
+        sendspin_client = self.server_api.get_client(client_id)
+        if sendspin_client is None:
             self.logger.debug("Client %s gone after waiting for pending unregister", client_id)
             return
+        # Wait for client hello to be processed (info becomes available)
+        # ClientAddedEvent fires before the hello handshake completes
+        for _ in range(50):  # Wait up to 5 seconds
+            if sendspin_client._info is not None:
+                break
+            await asyncio.sleep(0.1)
+        else:
+            self.logger.warning("Client %s hello not received within timeout", client_id)
+            return
         if self.mass.players.get_player(client_id) is not None:
             self.logger.debug(
                 "Client %s already registered, skipping duplicate add event", client_id
index 691bb08d31354eaa44bb370d6263bee390a7ad64..70a4e2e03bfcc557a3fa528af26e9e5fae93dd87 100644 (file)
@@ -59,14 +59,6 @@ class UniversalPlayer(Player):
         # it delegates to protocol players
         self._attr_supported_features = set()
 
-    @property
-    def hidden_by_default(self) -> bool:
-        """Return if the player should be hidden in the UI by default."""
-        if self.device_info.model.lower() == "web browser":  # noqa: SIM103
-            # hide web players by default
-            return True
-        return False
-
     @property
     def available(self) -> bool:
         """Return if the player is currently available."""
@@ -76,14 +68,6 @@ class UniversalPlayer(Player):
             for pid in self._protocol_player_ids
         )
 
-    @property
-    def expose_to_ha_by_default(self) -> bool:
-        """Return if the player should be exposed to Home Assistant by default."""
-        if self.device_info.model.lower() == "web browser":  # noqa: SIM103
-            # hide web players by default
-            return False
-        return True
-
     def _get_control_target(
         self, required_feature: PlayerFeature, require_active: bool = False
     ) -> Player | None:
index dccc6de099f8ca71546b1e99c9d53d1974b6746b..775f6f15114648d4bd72ddeadba7193c736b322a 100644 (file)
@@ -79,10 +79,47 @@ class UniversalPlayerProvider(PlayerProvider):
 
         # Get stored values
         values = config.get("values", {})
-        stored_protocol_ids = values.get(CONF_LINKED_PROTOCOL_IDS, [])
+        stored_protocol_ids = list(values.get(CONF_LINKED_PROTOCOL_IDS, []))
         stored_identifiers = values.get(CONF_DEVICE_IDENTIFIERS, {})
         stored_device_info = values.get(CONF_DEVICE_INFO, {})
 
+        # Filter out protocol IDs that are no longer PROTOCOL type players
+        valid_protocol_ids = []
+        for protocol_id in stored_protocol_ids:
+            protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
+            if not protocol_config:
+                # Config doesn't exist, keep it for now (player may register later)
+                valid_protocol_ids.append(protocol_id)
+                continue
+            protocol_player_type = protocol_config.get("player_type")
+            if protocol_player_type == "protocol":
+                valid_protocol_ids.append(protocol_id)
+            else:
+                self.logger.info(
+                    "Removing %s from universal player %s - player type changed to %s",
+                    protocol_id,
+                    player_id,
+                    protocol_player_type,
+                )
+
+        # If no valid protocol IDs remain, delete this stale universal player
+        if not valid_protocol_ids:
+            self.logger.info(
+                "Deleting stale universal player %s - no valid protocol players remain",
+                player_id,
+            )
+            await self.mass.config.remove_player_config(player_id)
+            return
+
+        stored_protocol_ids = valid_protocol_ids
+
+        # Persist the filtered protocol IDs to config if they changed
+        if len(valid_protocol_ids) != len(values.get(CONF_LINKED_PROTOCOL_IDS, [])):
+            self.mass.config.set(
+                f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}",
+                valid_protocol_ids,
+            )
+
         # Check if protocols have been linked to a native player (stale universal player)
         for protocol_id in stored_protocol_ids:
             protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")