From 60cda4914cfce5e7a3f7b6c322fe6c11f5574c7d Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Feb 2026 19:13:06 +0100 Subject: [PATCH 1/7] chore: bump aiosendspin to 4.2.0 --- music_assistant/providers/sendspin/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/sendspin/manifest.json b/music_assistant/providers/sendspin/manifest.json index 1e945ecbb6f..a27e3bc0fad 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==4.0.1", "av==16.1.0"], + "requirements": ["aiosendspin==4.2.0", "av==16.1.0"], "builtin": true, "allow_disable": false } diff --git a/requirements_all.txt b/requirements_all.txt index bdb0d6649b1..d4c2ce6040f 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==4.0.1 +aiosendspin==4.2.0 aioslimproto==3.1.5 aiosonos==0.1.9 aiosqlite==0.22.1 From d6aaffbc9c124df3d2a8b1957292aa9b0ec42c8c Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Feb 2026 19:16:47 +0100 Subject: [PATCH 2/7] fix: defer client-added handling until client info is ready --- music_assistant/providers/sendspin/provider.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/music_assistant/providers/sendspin/provider.py b/music_assistant/providers/sendspin/provider.py index b23385845cd..6ee3a9ef723 100644 --- a/music_assistant/providers/sendspin/provider.py +++ b/music_assistant/providers/sendspin/provider.py @@ -53,6 +53,10 @@ def event_cb(self, server: SendspinServer, event: SendspinEvent) -> None: 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 From 3d7a1876fd0f112a94d9a100c74d841d7cb83939 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 19 Feb 2026 12:57:11 +0100 Subject: [PATCH 3/7] fix: playback fixes needed for the aiosendspin draft PR Fixes skipping tracks --- music_assistant/providers/sendspin/playback.py | 5 +++++ music_assistant/providers/sendspin/player.py | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/sendspin/playback.py b/music_assistant/providers/sendspin/playback.py index 25084e7a766..e6f5d3f8623 100644 --- a/music_assistant/providers/sendspin/playback.py +++ b/music_assistant/providers/sendspin/playback.py @@ -741,6 +741,11 @@ async def _commit_pending_chunks() -> None: self._first_commit_monotonic_us = None self._produced_audio_us = 0 self._history.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 -------------------------------------------------------- diff --git a/music_assistant/providers/sendspin/player.py b/music_assistant/providers/sendspin/player.py index 8b15fedd593..145c33d7476 100644 --- a/music_assistant/providers/sendspin/player.py +++ b/music_assistant/providers/sendspin/player.py @@ -405,9 +405,9 @@ async def play_media(self, media: PlayerMedia) -> None: 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() From 3bfd94470282ab31e290b9b1fd66cad5b9b99016 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 24 Feb 2026 16:45:06 +0100 Subject: [PATCH 4/7] fix: apply leader DSP routing --- music_assistant/providers/sendspin/playback.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/music_assistant/providers/sendspin/playback.py b/music_assistant/providers/sendspin/playback.py index e6f5d3f8623..ef7f51e1586 100644 --- a/music_assistant/providers/sendspin/playback.py +++ b/music_assistant/providers/sendspin/playback.py @@ -315,8 +315,11 @@ async def _snapshot_active_pipelines( """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 ------------------------------------------------------------ @@ -920,6 +923,8 @@ async def _refresh_member_mappings(self) -> None: 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.""" @@ -1083,13 +1088,9 @@ def _resolve_channel_for_player(self, player_id: str) -> UUID: 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 From 16125933a56816072ab63a71ee9b5c360c49bc4e Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 24 Feb 2026 15:11:38 +0100 Subject: [PATCH 5/7] fix: clear dsp config cache when playback stops Previously the leader never had DSP applied. --- music_assistant/providers/sendspin/playback.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/music_assistant/providers/sendspin/playback.py b/music_assistant/providers/sendspin/playback.py index ef7f51e1586..2179f93788f 100644 --- a/music_assistant/providers/sendspin/playback.py +++ b/music_assistant/providers/sendspin/playback.py @@ -744,6 +744,8 @@ async def _commit_pending_chunks() -> None: 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: From 5fb25ada4861275c58ee80154a0fe709acae3d4c Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Tue, 24 Feb 2026 17:02:48 +0100 Subject: [PATCH 6/7] fix: only stop group playback when leader is removed Previously any player removal would stop the group. Non-leader players should detach without disrupting the remaining group. --- music_assistant/providers/sendspin/player.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/music_assistant/providers/sendspin/player.py b/music_assistant/providers/sendspin/player.py index 145c33d7476..1ae50be4401 100644 --- a/music_assistant/providers/sendspin/player.py +++ b/music_assistant/providers/sendspin/player.py @@ -357,12 +357,20 @@ def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None: 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 From 99e6c87cac3f5fbdd124bcd52dcc88ec2b888e4e Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 26 Feb 2026 08:57:03 +0100 Subject: [PATCH 7/7] fix: preserve sendspin join channel assignment --- music_assistant/providers/sendspin/playback.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/sendspin/playback.py b/music_assistant/providers/sendspin/playback.py index 2179f93788f..d6d9412c835 100644 --- a/music_assistant/providers/sendspin/playback.py +++ b/music_assistant/providers/sendspin/playback.py @@ -372,8 +372,9 @@ async def add_member(self, player_id: str) -> None: 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)