Update to `aiosendspin` 4.2.0 and fix a couple of issues (#3249)
authorMaxim Raznatovski <nda.mr43@gmail.com>
Thu, 26 Feb 2026 09:32:50 +0000 (10:32 +0100)
committerGitHub <noreply@github.com>
Thu, 26 Feb 2026 09:32:50 +0000 (10:32 +0100)
music_assistant/providers/sendspin/manifest.json
music_assistant/providers/sendspin/playback.py
music_assistant/providers/sendspin/player.py
music_assistant/providers/sendspin/provider.py
requirements_all.txt

index 1e945ecbb6f9127baccd908768ec11441196f13c..a27e3bc0fadfcffec90d7ab06e2574532480a1b5 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==4.0.1", "av==16.1.0"],
+  "requirements": ["aiosendspin==4.2.0", "av==16.1.0"],
   "builtin": true,
   "allow_disable": false
 }
index 25084e7a766ea3b5181b097cbc3885432e0939d1..d6d9412c83552d3a01f272402a4ab145638b75ef 100644 (file)
@@ -315,8 +315,11 @@ class SendspinPlaybackSession:
         """Return (join_pending_ids, active_pipelines) under lock."""
         async with self._state_lock:
             members = self._members
+            leader_id = self.player.player_id
             return set(self._join_catchup), tuple(
-                (mid, p) for mid, p in self._member_pipelines.items() if mid in members
+                (mid, p)
+                for mid, p in self._member_pipelines.items()
+                if mid in members or mid == leader_id
             )
 
     # -- Public API ------------------------------------------------------------
@@ -369,8 +372,9 @@ class SendspinPlaybackSession:
             if player_id in self._members:
                 self.pending_join_members.discard(player_id)
                 return
-            # Force a fresh channel identity for every new join cycle.
-            self._preassigned_channels[player_id] = uuid4()
+            # Preserve any channel pre-resolved during add_client so join-time
+            # role requirements and prepared audio stay on the same channel.
+            self._preassigned_channels.setdefault(player_id, uuid4())
         self.pending_join_members.add(player_id)
         try:
             await self._start_join_catchup(player_id)
@@ -741,6 +745,13 @@ class SendspinPlaybackSession:
                 self._first_commit_monotonic_us = None
                 self._produced_audio_us = 0
                 self._history.clear()
+                # Drop cached DSP decisions so next playback reflects latest config.
+                self._pipeline_config_cache.clear()
+            # Only emit a group STOP when MA stream playback reached natural EOF.
+            # Skip this on cancellation/error paths to avoid stop-event races with transitions.
+            if producer_stopped_cleanly:
+                with suppress(Exception):
+                    await self.player.api.group.stop()
 
     # -- Join injection --------------------------------------------------------
 
@@ -915,6 +926,8 @@ class SendspinPlaybackSession:
             self._mapping_dirty = False
         for member_id in member_ids:
             await self._sync_member_pipeline(member_id)
+        # Keep leader pipeline in sync so leader DSP can be applied when required.
+        await self._sync_member_pipeline(self.player.player_id)
 
     async def _sync_member_pipeline(self, player_id: str) -> _MemberPipeline:
         """Create/update pipeline state for one member from current MA config."""
@@ -1078,13 +1091,9 @@ class SendspinPlaybackSession:
         pipeline = self._member_pipelines.get(player_id)
         if pipeline is not None:
             return pipeline.channel_id
-        # The leader always receives MAIN_CHANNEL audio directly from the
-        # commit loop; only group members get per-player DSP channels.
-        if player_id == self.player.player_id:
-            return MAIN_CHANNEL
         # Force a fresh config read for pending/unknown joiners so the very
         # first resolution (triggered by add_client) uses up-to-date DSP settings.
-        force = player_id not in self._members
+        force = player_id not in self._members and player_id != self.player.player_id
         config = self._get_pipeline_config_cached(player_id, force_refresh=force)
         if not config.requires_transform:
             return MAIN_CHANNEL
index 8b15fedd59368ebd549c6c06e2b6d8a2ab584d85..1ae50be4401e1e87f34d2c5b020574898dd826a2 100644 (file)
@@ -357,12 +357,20 @@ class SendspinPlayer(Player):
     async def _handle_group_member_removed(self, group: SendspinGroup, client_id: str) -> None:
         """Handle a group member being removed asynchronously."""
         if client_id == self.player_id:
-            if len(group.clients) > 0:
-                # We were just removed as a leader:
-                # 1. stop playback on the old group
+            was_leader = (
+                bool(self._attr_group_members) and self._attr_group_members[0] == self.player_id
+            )
+            if was_leader and len(group.clients) > 0:
+                # We were removed as the group leader:
+                # stop playback on the old group before we continue as solo.
                 await group.stop()
-                # 2. clear our members (since we are now alone in a new group)
-                self._attr_group_members = []
+            elif not was_leader:
+                self.logger.debug(
+                    "Player %s removed from group as non-leader; keeping old group playing",
+                    self.player_id,
+                )
+            # Clear members for our detached/solo state.
+            self._attr_group_members = []
             self.update_state()
         elif client_id in self._attr_group_members:
             # Someone else left our group
@@ -405,9 +413,9 @@ class SendspinPlayer(Player):
         self._attr_elapsed_time_last_updated = time.time()
         # playback_state will be set by the group state change event
 
-        # Stop previous stream in case we were already playing something
+        # Stop previous stream in case we were already playing something.
+        # Do not call group.stop() here to avoid STOPPED-event races with next-track transitions.
         await self.playback_session.cancel("new media requested")
-        await self.api.group.stop()
         await self.playback_session.start(media)
         self.update_state()
 
index b23385845cdcefdaa835f1e9c39ce620ba1907b3..6ee3a9ef723bdd28b50849e3f096ac9cb229d98a 100644 (file)
@@ -53,6 +53,10 @@ class SendspinProvider(PlayerProvider):
 
     async def _handle_client_added(self, client_id: str) -> None:
         """Handle a new client connection asynchronously."""
+        # Yield to allow any synchronous registration (like register_external_player) to complete
+        # This is needed because ClientAddedEvent fires during get_or_create_client, before
+        # preload_hello sets the client info
+        await asyncio.sleep(0)
         # 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
index bdb0d6649b1142d84e2763a9e6934159bb512adc..d4c2ce6040f85054d3bb8e7ed0f9ced47ccb7da6 100644 (file)
@@ -11,7 +11,7 @@ aiojellyfin==0.14.1
 aiomusiccast==0.15.0
 aiortc>=1.6.0
 aiorun==2025.1.1
-aiosendspin==4.0.1
+aiosendspin==4.2.0
 aioslimproto==3.1.5
 aiosonos==0.1.9
 aiosqlite==0.22.1