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
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 (
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 = [
)
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:
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
# 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."""
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:
# 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}")