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/music_assistant/providers/sendspin/playback.py b/music_assistant/providers/sendspin/playback.py index 25084e7a766..d6d9412c835 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 ------------------------------------------------------------ @@ -369,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) @@ -741,6 +745,13 @@ 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: + with suppress(Exception): + await self.player.api.group.stop() # -- Join injection -------------------------------------------------------- @@ -915,6 +926,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.""" @@ -1078,13 +1091,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 diff --git a/music_assistant/providers/sendspin/player.py b/music_assistant/providers/sendspin/player.py index 8b15fedd593..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 @@ -405,9 +413,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() 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 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