From 1d6fec3cbdcd9defbb584c05abc0eaf91fe1272b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 20 Jan 2026 08:27:41 -0500 Subject: [PATCH] Bump aiosendspin to 3.0 (#2924) * Bump aiosendspin to 2.0 Adapt to breaking changes in aiosendspin: - Change event listener callbacks from async to synchronous - Change start_server advertise_host to advertise_addresses * fix: handle client disconnect during pending unregister wait Check if client still exists after waiting for pending unregister, preventing player registration attempts for disconnected clients. * chore(deps): bump aiosendspin to 3.0.0 --------- Co-authored-by: Maxim Raznatovski --- .../providers/sendspin/manifest.json | 2 +- music_assistant/providers/sendspin/player.py | 56 ++++++++------- .../providers/sendspin/provider.py | 68 +++++++++++-------- requirements_all.txt | 2 +- 4 files changed, 71 insertions(+), 57 deletions(-) diff --git a/music_assistant/providers/sendspin/manifest.json b/music_assistant/providers/sendspin/manifest.json index a74f52b5..c25c8350 100644 --- a/music_assistant/providers/sendspin/manifest.json +++ b/music_assistant/providers/sendspin/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://music-assistant.io/player-support/sendspin/", "codeowners": ["@music-assistant"], "credits": ["[Sendspin](https://sendspin-audio.com)"], - "requirements": ["aiosendspin==2.0.0", "av==16.1.0"], + "requirements": ["aiosendspin==3.0.0", "av==16.1.0"], "builtin": true, "allow_disable": false } diff --git a/music_assistant/providers/sendspin/player.py b/music_assistant/providers/sendspin/player.py index c33a242f..69687b19 100644 --- a/music_assistant/providers/sendspin/player.py +++ b/music_assistant/providers/sendspin/player.py @@ -234,7 +234,7 @@ class SendspinPlayer(Player): ) self._attr_expose_to_ha_by_default = not self.is_web_player - async def event_cb(self, client: SendspinClient, event: ClientEvent) -> None: + def event_cb(self, client: SendspinClient, event: ClientEvent) -> None: """Event callback registered to the sendspin server.""" self.logger.debug("Received PlayerEvent: %s", event) match event: @@ -289,7 +289,7 @@ class SendspinPlayer(Player): case MediaCommand.UNSHUFFLE if queue: await self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=False) - async def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None: + def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None: """Event callback registered to the sendspin group this player belongs to.""" if self.synced_to is not None: # Only handle group events as the leader, except for: @@ -302,7 +302,7 @@ class SendspinPlayer(Player): match event: case GroupCommandEvent(command=command): self.logger.debug("Group command received: %s", command) - await self._handle_group_command(command) + self.mass.create_task(self._handle_group_command(command)) case GroupStateChangedEvent(state=state): self.logger.debug("Group state changed to: %s", state) match state: @@ -322,32 +322,36 @@ class SendspinPlayer(Player): self.update_state() case GroupMemberRemovedEvent(client_id=client_id): self.logger.debug("Group member removed: %s", client_id) - if client_id == self.player_id: - if len(self._attr_group_members) > 0: - # We were just removed as a leader: - # 1. stop playback on the old group - await group.stop() - # 2. clear our members (since we are now alone) - group_members = [ - member for member in self._attr_group_members if member != client_id - ] - self._attr_group_members = [] - # 3. assign new leader if there are members left - if len(group_members) > 0 and ( - new_leader := self.mass.players.get(group_members[0]) - ): - new_leader = cast("SendspinPlayer", new_leader) - new_leader._attr_group_members = group_members[1:] - new_leader.api.disconnect_behaviour = DisconnectBehaviour.STOP - new_leader.update_state() - self.update_state() - elif client_id in self._attr_group_members: - # Someone else left our group - self._attr_group_members.remove(client_id) - self.update_state() + self.mass.create_task(self._handle_member_removed(group, client_id)) case GroupDeletedEvent(): pass + async def _handle_member_removed(self, group: SendspinGroup, client_id: str) -> None: + """Handle group member removed event asynchronously.""" + if client_id == self.player_id: + if len(self._attr_group_members) > 0: + # We were just removed as a leader: + # 1. stop playback on the old group + await group.stop() + # 2. clear our members (since we are now alone) + group_members = [ + member for member in self._attr_group_members if member != client_id + ] + self._attr_group_members = [] + # 3. assign new leader if there are members left + if len(group_members) > 0 and ( + new_leader := self.mass.players.get(group_members[0]) + ): + new_leader = cast("SendspinPlayer", new_leader) + new_leader._attr_group_members = group_members[1:] + new_leader.api.disconnect_behaviour = DisconnectBehaviour.STOP + new_leader.update_state() + self.update_state() + elif client_id in self._attr_group_members: + # Someone else left our group + self._attr_group_members.remove(client_id) + self.update_state() + async def volume_set(self, volume_level: int) -> None: """Handle VOLUME_SET command on the player.""" if player_client := self.api.player: diff --git a/music_assistant/providers/sendspin/provider.py b/music_assistant/providers/sendspin/provider.py index eaebd2bf..1866a53c 100644 --- a/music_assistant/providers/sendspin/provider.py +++ b/music_assistant/providers/sendspin/provider.py @@ -40,43 +40,53 @@ class SendspinProvider(PlayerProvider): self.server_api.add_event_listener(self.event_cb), ] - async def event_cb(self, server: SendspinServer, event: SendspinEvent) -> None: + def event_cb(self, server: SendspinServer, event: SendspinEvent) -> None: """Event callback registered to the sendspin server.""" self.logger.debug("Received SendspinEvent: %s", event) match event: case ClientAddedEvent(client_id): - # Wait for any pending unregister to complete before registering - # This prevents a race condition where a slow unregister removes - # a newly registered player after a quick reconnect - if pending_event := self._pending_unregisters.get(client_id): - self.logger.debug( - "Waiting for pending unregister of %s before registering", client_id - ) - await pending_event.wait() - player = SendspinPlayer(self, client_id) - self.logger.debug("Client %s connected", client_id) - if player.device_info.manufacturer == "ESPHome" and ( - hass := self.mass.get_provider("hass") - ): - # Try to get device name from Home Assistant for ESPHome devices - hass = cast("HomeAssistantProvider", hass) - if hass_device := await hass.get_device_by_connection(client_id): - player._attr_name = ( - hass_device["name_by_user"] or hass_device["name"] or player.name - ) - await self.mass.players.register(player) + self.mass.create_task(self._handle_client_added(client_id)) case ClientRemovedEvent(client_id): - self.logger.debug("Client %s disconnected", client_id) - unregister_event = asyncio.Event() - self._pending_unregisters[client_id] = unregister_event - try: - await self.mass.players.unregister(client_id) - finally: - self._pending_unregisters.pop(client_id, None) - unregister_event.set() + self.mass.create_task(self._handle_client_removed(client_id)) case _: self.logger.error("Unknown sendspin event: %s", event) + async def _handle_client_added(self, client_id: str) -> None: + """Handle client added event asynchronously.""" + # Wait for any pending unregister to complete before registering + # This prevents a race condition where a slow unregister removes + # a newly registered player after a quick reconnect + if pending_event := self._pending_unregisters.get(client_id): + 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: + self.logger.debug("Client %s gone after waiting for pending unregister", client_id) + return + player = SendspinPlayer(self, client_id) + self.logger.debug("Client %s connected", client_id) + if player.device_info.manufacturer == "ESPHome" and ( + hass := self.mass.get_provider("hass") + ): + # Try to get device name from Home Assistant for ESPHome devices + hass = cast("HomeAssistantProvider", hass) + if hass_device := await hass.get_device_by_connection(client_id): + player._attr_name = ( + hass_device["name_by_user"] or hass_device["name"] or player.name + ) + await self.mass.players.register(player) + + async def _handle_client_removed(self, client_id: str) -> None: + """Handle client removed event asynchronously.""" + self.logger.debug("Client %s disconnected", client_id) + unregister_event = asyncio.Event() + self._pending_unregisters[client_id] = unregister_event + try: + await self.mass.players.unregister(client_id) + finally: + self._pending_unregisters.pop(client_id, None) + unregister_event.set() + @property def supported_features(self) -> set[ProviderFeature]: """Return the features supported by this Provider.""" diff --git a/requirements_all.txt b/requirements_all.txt index 4cf53715..5a29efe4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -11,7 +11,7 @@ aiojellyfin==0.14.1 aiomusiccast==0.15.0 aiortc>=1.6.0 aiorun==2025.1.1 -aiosendspin==2.0.0 +aiosendspin==3.0.0 aioslimproto==3.1.4 aiosonos==0.1.9 aiosqlite==0.22.1 -- 2.34.1