Bump aiosendspin to 3.0 (#2924)
authorPaulus Schoutsen <balloob@gmail.com>
Tue, 20 Jan 2026 13:27:41 +0000 (08:27 -0500)
committerGitHub <noreply@github.com>
Tue, 20 Jan 2026 13:27:41 +0000 (08:27 -0500)
* 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 <nda.mr43@gmail.com>
music_assistant/providers/sendspin/manifest.json
music_assistant/providers/sendspin/player.py
music_assistant/providers/sendspin/provider.py
requirements_all.txt

index a74f52b500a26b8459a85ae28fb30b79bd81dd97..c25c835005333d451940ab09b6b81421f5e6ca4f 100644 (file)
@@ -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
 }
index c33a242f9ba56b45a9f4e77664ecba898671baff..69687b1906e5832f6a9ae6a787bc69d2fa3af7a7 100644 (file)
@@ -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:
index eaebd2bfddd99e96e3f90dc66a83d1f63fbbb810..1866a53c46139b599de56494d19e94d144152eab 100644 (file)
@@ -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."""
index 4cf53715b664b5a840323880d1bdd82d2dd750f9..5a29efe459cde133500604c1b3ecb01f8c22e4bd 100644 (file)
@@ -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