Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion music_assistant/providers/sendspin/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
25 changes: 17 additions & 8 deletions music_assistant/providers/sendspin/playback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 --------------------------------------------------------

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
22 changes: 15 additions & 7 deletions music_assistant/providers/sendspin/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
4 changes: 4 additions & 0 deletions music_assistant/providers/sendspin/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading