From c94714b0a5eda922f826b168d8ea51510f354a1d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 19 Feb 2026 22:05:11 +0100 Subject: [PATCH] Fixes for sendspin web player --- .../controllers/players/protocol_linking.py | 14 ++++++- music_assistant/providers/sendspin/player.py | 14 +++---- .../providers/sendspin/provider.py | 12 +++++- .../providers/universal_player/player.py | 16 -------- .../providers/universal_player/provider.py | 39 ++++++++++++++++++- 5 files changed, 67 insertions(+), 28 deletions(-) diff --git a/music_assistant/controllers/players/protocol_linking.py b/music_assistant/controllers/players/protocol_linking.py index 04ff213c..37020bbb 100644 --- a/music_assistant/controllers/players/protocol_linking.py +++ b/music_assistant/controllers/players/protocol_linking.py @@ -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 diff --git a/music_assistant/providers/sendspin/player.py b/music_assistant/providers/sendspin/player.py index 4fdaffd5..02b2ab16 100644 --- a/music_assistant/providers/sendspin/player.py +++ b/music_assistant/providers/sendspin/player.py @@ -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: diff --git a/music_assistant/providers/sendspin/provider.py b/music_assistant/providers/sendspin/provider.py index 392cc46f..b2338584 100644 --- a/music_assistant/providers/sendspin/provider.py +++ b/music_assistant/providers/sendspin/provider.py @@ -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 diff --git a/music_assistant/providers/universal_player/player.py b/music_assistant/providers/universal_player/player.py index 691bb08d..70a4e2e0 100644 --- a/music_assistant/providers/universal_player/player.py +++ b/music_assistant/providers/universal_player/player.py @@ -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: diff --git a/music_assistant/providers/universal_player/provider.py b/music_assistant/providers/universal_player/provider.py index dccc6de0..775f6f15 100644 --- a/music_assistant/providers/universal_player/provider.py +++ b/music_assistant/providers/universal_player/provider.py @@ -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}") -- 2.34.1