From d9a60e8c4b5939393e8016fa3025107ce19397a4 Mon Sep 17 00:00:00 2001 From: OzGav Date: Sat, 23 May 2026 13:12:14 +1000 Subject: [PATCH 1/6] Resolve universal_player wrappers in UGP stream handler (#3952) Fixes https://github.com/music-assistant/support/issues/5517 --- music_assistant/providers/universal_group/player.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/music_assistant/providers/universal_group/player.py b/music_assistant/providers/universal_group/player.py index 1814bcde70..7f80221747 100644 --- a/music_assistant/providers/universal_group/player.py +++ b/music_assistant/providers/universal_group/player.py @@ -390,6 +390,15 @@ async def _serve_ugp_stream(self, request: web.Request) -> web.StreamResponse: output_format_str = request.path.rsplit(".")[-1] if child_player_id and (child_player := self.mass.players.get_player(child_player_id)): + # Resolve universal_player wrappers to their active protocol player — + # sample_rates and http_profile live on the protocol, not the wrapper. + if ( + child_player.active_output_protocol + and child_player.active_output_protocol != "native" + and (proto := self.mass.players.get_player(child_player.active_output_protocol)) + ): + child_player = proto + child_player_id = proto.player_id # Use the preferred output format of the child player output_format = await self.mass.streams.audio.get_output_format( output_format_str=output_format_str, From 9c55afd2fc649ab9fed074d94e90d1672511c707 Mon Sep 17 00:00:00 2001 From: OzGav Date: Sat, 23 May 2026 19:12:18 +1000 Subject: [PATCH 2/6] Revert "Resolve universal_player wrappers in UGP stream handler" (#3956) Reverts music-assistant/server#3952 --- music_assistant/providers/universal_group/player.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/music_assistant/providers/universal_group/player.py b/music_assistant/providers/universal_group/player.py index 7f80221747..1814bcde70 100644 --- a/music_assistant/providers/universal_group/player.py +++ b/music_assistant/providers/universal_group/player.py @@ -390,15 +390,6 @@ async def _serve_ugp_stream(self, request: web.Request) -> web.StreamResponse: output_format_str = request.path.rsplit(".")[-1] if child_player_id and (child_player := self.mass.players.get_player(child_player_id)): - # Resolve universal_player wrappers to their active protocol player — - # sample_rates and http_profile live on the protocol, not the wrapper. - if ( - child_player.active_output_protocol - and child_player.active_output_protocol != "native" - and (proto := self.mass.players.get_player(child_player.active_output_protocol)) - ): - child_player = proto - child_player_id = proto.player_id # Use the preferred output format of the child player output_format = await self.mass.streams.audio.get_output_format( output_format_str=output_format_str, From 39ee8fcafb6990ca888fd9b7ae8c9636448a452a Mon Sep 17 00:00:00 2001 From: Marvin Schenkel Date: Tue, 26 May 2026 15:02:59 +0200 Subject: [PATCH 3/6] Skip DSP-triggered playback restart when DSP was and remains disabled (#3988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What does this implement/fix? When a player's DSP settings are changed while the main DSP toggle is **disabled**, the active playback was unnecessarily restarted, causing audio stutter and a misleading `Restarting playback of Player after DSP change` log line. The fix reads the old `enabled` state in `save_dsp_config` before saving the new config. `on_player_dsp_change` is only called when DSP was or is now enabled — covering all cases that actually affect the active audio stream (e.g. toggling DSP off still triggers a restart to flush the old processing chain). If DSP was already disabled and stays disabled, the call is skipped entirely. **Related issue (if applicable):** - related issue https://github.com/music-assistant/support/issues/5532 ## Types of changes - [x] Bugfix (non-breaking change which fixes an issue) — `bugfix` - [ ] New feature (non-breaking change which adds functionality) — `new-feature` - [ ] Enhancement to an existing feature — `enhancement` - [ ] New music/player/metadata/plugin provider — `new-provider` - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — `breaking-change` - [ ] Refactor (no behaviour change) — `refactor` - [ ] Documentation only — `documentation` - [ ] Maintenance / chore — `maintenance` - [ ] CI / workflow change — `ci` - [ ] Dependencies bump — `dependencies` ## Checklist - [ ] The code change is tested and works locally. - [x] `pre-commit run --all-files` passes. - [x] `pytest` passes, and tests have been added/updated under `tests/` where applicable. - [ ] For changes to shared models, the companion PR in `music-assistant/models` is linked. - [ ] For changes affecting the UI, the companion PR in `music-assistant/frontend` is linked. - [x] I have read and complied with the project's [AI Policy](https://github.com/music-assistant/.github/blob/main/AI_POLICY.md) for any AI-assisted contributions. --------- Co-authored-by: Claude --- music_assistant/controllers/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/music_assistant/controllers/config.py b/music_assistant/controllers/config.py index 1d6b7adaa0..3fe30d661a 100644 --- a/music_assistant/controllers/config.py +++ b/music_assistant/controllers/config.py @@ -926,9 +926,11 @@ async def save_dsp_config(self, player_id: str, config: DSPConfig) -> DSPConfig: # validate the new config config.validate() + old_dsp_enabled = self.get_player_dsp_config(player_id).enabled # Save and apply the new config to the player self.set(f"{CONF_PLAYER_DSP}/{player_id}", config.to_dict()) - await self.mass.players.on_player_dsp_change(player_id) + if old_dsp_enabled or config.enabled: + await self.mass.players.on_player_dsp_change(player_id) # send the dsp config updated event self.mass.signal_event( EventType.PLAYER_DSP_CONFIG_UPDATED, From 6e0ea065a53294c19985935a755cb2b675d97a51 Mon Sep 17 00:00:00 2001 From: Marvin Schenkel Date: Mon, 1 Jun 2026 10:01:10 +0200 Subject: [PATCH 4/6] Fix Deezer playback stalling on tracks with insufficient rights (error 2002) (#4048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What does this implement/fix? When the Deezer API returns error code 2002 ("Track token has no sufficient rights on requested media."), `DeezerGWError` — a `BaseException` subclass, not `Exception` — escaped `get_stream_details` uncaught. Because `_do_prepare` in `player_queues.py` only catches `AudioError`/`MediaNotFoundError`, the unhandled exception left the player queue wedged indefinitely. The user had to manually click "next track" twice to resume playback. Fix: catch `DeezerGWError` in `get_stream_details` and re-raise as `MediaNotFoundError` so the existing skip-on-error path in `_do_prepare` handles the unavailable track gracefully and continues to the next queue item. **Related issue (if applicable):** - related issue https://github.com/music-assistant/support/issues/5562 ## Types of changes - [x] Bugfix (non-breaking change which fixes an issue) — `bugfix` ## Checklist - [x] The code change is tested and works locally. - [x] `pre-commit run --all-files` passes. - [x] `pytest` passes, and tests have been added/updated under `tests/` where applicable. - [ ] For changes to shared models, the companion PR in `music-assistant/models` is linked. - [ ] For changes affecting the UI, the companion PR in `music-assistant/frontend` is linked. - [x] I have read and complied with the project's [AI Policy](https://github.com/music-assistant/.github/blob/main/AI_POLICY.md) for any AI-assisted contributions. --------- Co-authored-by: Claude --- music_assistant/providers/deezer/__init__.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/music_assistant/providers/deezer/__init__.py b/music_assistant/providers/deezer/__init__.py index d482de6891..beeaa74a12 100644 --- a/music_assistant/providers/deezer/__init__.py +++ b/music_assistant/providers/deezer/__init__.py @@ -24,7 +24,12 @@ ProviderFeature, StreamType, ) -from music_assistant_models.errors import InvalidDataError, LoginFailed, MediaNotFoundError +from music_assistant_models.errors import ( + AudioError, + InvalidDataError, + LoginFailed, + MediaNotFoundError, +) from music_assistant_models.media_items import ( Album, Artist, @@ -52,7 +57,7 @@ from music_assistant.models import ProviderInstanceType from music_assistant.models.music_provider import MusicProvider -from .gw_client import GWClient +from .gw_client import DeezerGWError, GWClient SUPPORTED_FEATURES = { ProviderFeature.LIBRARY_ARTISTS, @@ -740,7 +745,14 @@ async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[ async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: """Return the content details for the given track when it will be streamed.""" - url_details, song_data = await self.gw_client.get_deezer_track_urls(item_id) + try: + url_details, song_data = await self.gw_client.get_deezer_track_urls(item_id) + except DeezerGWError as err: + api_errors = err.args[1] if len(err.args) > 1 else [] + if isinstance(api_errors, list) and api_errors and api_errors[0].get("code") == 2002: + # code 2002: track not available in the user's region or plan + raise MediaNotFoundError(f"Track {item_id} is not available on Deezer") from err + raise AudioError(str(err)) from err url = url_details["sources"][0]["url"] return StreamDetails( item_id=item_id, From 910af67c1547d1ffca18092cff3c9ede02e4f235 Mon Sep 17 00:00:00 2001 From: OzGav Date: Wed, 3 Jun 2026 16:51:26 +1000 Subject: [PATCH 5/6] Phishin fixes and optimisations (#4066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # What does this implement/fix? Noticed a couple of minor bugs with the Phish-in provider - Fix appears on section — Tracks now expose their show as a full Album, so the "Appears On" section populates instead of showing empty. - Fix empty Phish.in playlists and reduce redundant playlist API calls — Playlists resolved against the full list (not just page 1), so all imported playlists show their tracks; shared cached helper removes repeated fetches. - URL-encode Phish.in search terms — Search terms are percent-encoded, so queries containing / (e.g. "AC/DC Bag") no longer 404. - Fix Phish.in top-tracks last-page detection — Pagination uses the real page size (500) to detect the final page, avoiding a wasted extra request. - Consolidate Phish.in album building for tracks — Removes duplicated album-building logic so all tracks get consistent names, artwork, and details. **Related issue (if applicable):** - related issue ## Types of changes - [X] Bugfix (non-breaking change which fixes an issue) — `bugfix` - [ ] New feature (non-breaking change which adds functionality) — `new-feature` - [ ] Enhancement to an existing feature — `enhancement` - [ ] New music/player/metadata/plugin provider — `new-provider` - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — `breaking-change` - [ ] Refactor (no behaviour change) — `refactor` - [ ] Documentation only — `documentation` - [ ] Maintenance / chore — `maintenance` - [ ] CI / workflow change — `ci` - [ ] Dependencies bump — `dependencies` ## Checklist - [X] The code change is tested and works locally. - [X] `pre-commit run --all-files` passes. - [X] `pytest` passes, and tests have been added/updated under `tests/` where applicable. - [ ] For changes to shared models, the companion PR in `music-assistant/models` is linked. - [ ] For changes affecting the UI, the companion PR in `music-assistant/frontend` is linked. - [X] I have read and complied with the project's [AI Policy](https://github.com/music-assistant/.github/blob/main/AI_POLICY.md) for any AI-assisted contributions. - [ ] I have raised a PR against the documentation repository targeting the main or beta branch as appropriate. --- music_assistant/providers/phishin/helpers.py | 87 +++++------ music_assistant/providers/phishin/provider.py | 137 +++++------------- 2 files changed, 79 insertions(+), 145 deletions(-) diff --git a/music_assistant/providers/phishin/helpers.py b/music_assistant/providers/phishin/helpers.py index 138e509a5c..9dc4326f68 100644 --- a/music_assistant/providers/phishin/helpers.py +++ b/music_assistant/providers/phishin/helpers.py @@ -62,8 +62,17 @@ async def api_request( raise ProviderUnavailableError(f"Phish.in API unavailable: {err}") from err -def show_to_album(provider: MusicProvider, show_data: dict[str, Any]) -> Album: - """Convert a Phish.in show to a Music Assistant Album.""" +def show_to_album( + provider: MusicProvider, + show_data: dict[str, Any], + *, + available: bool | None = None, +) -> Album: + """ + Convert a Phish.in show to a Music Assistant Album. + + :param available: Override album availability; defaults to the show's audio_status. + """ show_date = show_data.get("date", "") venue_data = show_data.get("venue", {}) venue_name = venue_data.get("name", "Unknown Venue") @@ -103,6 +112,7 @@ def show_to_album(provider: MusicProvider, show_data: dict[str, Any]) -> Album: audio_status = show_data.get("audio_status", "missing") details_parts.append(f"audio_status:{audio_status}") + is_available = available if available is not None else audio_status in ["complete", "partial"] if show_data.get("tour_name"): details_parts.append(f"tour:{show_data.get('tour_name')}") @@ -129,7 +139,7 @@ def show_to_album(provider: MusicProvider, show_data: dict[str, Any]) -> Album: item_id=show_date, provider_domain=provider.domain, provider_instance=provider.instance_id, - available=audio_status in ["complete", "partial"], + available=is_available, audio_format=AudioFormat(content_type=ContentType.MP3), details="|".join(details_parts), ) @@ -162,7 +172,8 @@ async def get_phish_artist(provider: MusicProvider) -> Artist: def _extract_version_from_title(full_title: str) -> tuple[str, str]: - """Extract song title and version from full title with performance indicators. + """ + Extract song title and version from full title with performance indicators. Returns: Tuple of (clean_song_title, version_string) @@ -190,38 +201,6 @@ def _extract_version_from_title(full_title: str) -> tuple[str, str]: return song_title, version or "" -def _create_album_mapping( - provider: MusicProvider, - show_date: str, - show_data: dict[str, Any] | None, -) -> ItemMapping | None: - """Create album ItemMapping with image for a track.""" - if not show_date: - return None - - venue_name = show_data.get("venue", {}).get("name", "") if show_data else "" - - # Create the image for the album mapping - album_image = None - if show_data: - image_url = show_data.get("album_cover_url") or FALLBACK_ALBUM_IMAGE - album_image = MediaItemImage( - type=ImageType.THUMB, - path=image_url, - provider=provider.instance_id, - remotely_accessible=True, - ) - - return ItemMapping( - item_id=show_date, - provider=provider.instance_id, - name=f"{show_date} - {venue_name}" if venue_name else show_date, - media_type=MediaType.ALBUM, - available=True, - image=album_image, - ) - - def _build_track_details( track_data: dict[str, Any], song_data: dict[str, Any], @@ -247,6 +226,23 @@ def _build_track_details( return "|".join(details_parts) +def _flat_track_show(track_data: dict[str, Any]) -> dict[str, Any]: + """ + Build a show_data dict from a track's flat fields. + + Some endpoints (search results, playlist entries) return track objects + without a nested ``show`` object, exposing the show details as flat fields. + """ + return { + "date": track_data.get("show_date"), + "album_cover_url": track_data.get("show_album_cover_url"), + "venue": { + "name": track_data.get("venue_name"), + "location": track_data.get("venue_location"), + }, + } + + def track_to_ma_track( provider: MusicProvider, track_data: dict[str, Any], @@ -268,9 +264,9 @@ def track_to_ma_track( track_number = int(position) if position is not None else 0 set_name = track_data.get("set_name", "") - # Get show information + # Get show information; fall back to the nested show or flat track fields if show_data is None: - show_data = track_data.get("show", {}) + show_data = track_data.get("show") or _flat_track_show(track_data) show_date = show_data.get("date", "") venue_name = show_data.get("venue", {}).get("name", "") @@ -283,8 +279,9 @@ def track_to_ma_track( available=True, ) - # Create album mapping with image - album_mapping = _create_album_mapping(provider, show_date, show_data) + # Build the parent album (full Album so the track shows under "Appears On"). + # Tracks we surface always have audio, so force the album available. + track_album = show_to_album(provider, show_data, available=True) if show_date else None # Build details string details = _build_track_details(track_data, song_data, show_date, set_name, venue_name) @@ -313,7 +310,7 @@ def track_to_ma_track( name=song_title, version=version, artists=UniqueList([phish_artist]), - album=album_mapping, + album=track_album, duration=duration, track_number=track_number, metadata=metadata, @@ -519,13 +516,7 @@ def _parse_tracks( clean_title = strip_performance_indicators(full_title) if contains_search_term(clean_title): - # Extract show data from track data for image - show_data = { - "date": track_data.get("show_date"), - "album_cover_url": track_data.get("show_album_cover_url"), - "venue": {"name": track_data.get("venue_name")}, - } - tracks.append(track_to_ma_track(provider, track_data, show_data)) + tracks.append(track_to_ma_track(provider, track_data)) # Deduplicate by album - only return one track per show seen_albums = set() diff --git a/music_assistant/providers/phishin/provider.py b/music_assistant/providers/phishin/provider.py index ca62c87d38..aae98254ab 100644 --- a/music_assistant/providers/phishin/provider.py +++ b/music_assistant/providers/phishin/provider.py @@ -5,10 +5,10 @@ from collections.abc import AsyncGenerator from datetime import datetime from typing import TYPE_CHECKING, Any +from urllib.parse import quote from music_assistant_models.enums import ( ContentType, - ImageType, MediaType, StreamType, ) @@ -19,22 +19,17 @@ AudioFormat, BrowseFolder, ItemMapping, - MediaItemImage, - MediaItemMetadata, Playlist, - ProviderMapping, SearchResults, Track, ) from music_assistant_models.streamdetails import StreamDetails -from music_assistant_models.unique_list import UniqueList from music_assistant.controllers.cache import use_cache from music_assistant.models.music_provider import MusicProvider from .constants import ( ENDPOINTS, - FALLBACK_ALBUM_IMAGE, MAX_SEARCH_RESULTS, PHISH_ARTIST_ID, ) @@ -42,6 +37,7 @@ api_request, get_phish_artist, parse_search_results, + playlist_to_ma_playlist, show_to_album, track_to_ma_track, ) @@ -77,7 +73,8 @@ async def search( return SearchResults() try: - endpoint = ENDPOINTS["search"].format(term=search_query) + # encode the term so values with "/" etc. don't break the URL path + endpoint = ENDPOINTS["search"].format(term=quote(search_query, safe="")) search_data = await api_request( self, endpoint, params={"audio_status": "complete_or_partial"} ) @@ -205,15 +202,16 @@ async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: try: all_tracks: list[Track] = [] page = 1 + per_page = 500 max_pages = 5 # 2500 tracks max for UI performance - while len(all_tracks) < (max_pages * 500) and page <= max_pages: + while page <= max_pages: tracks_data = await api_request( self, ENDPOINTS["tracks"], params={ "page": page, - "per_page": 500, + "per_page": per_page, "sort": "likes_count:desc", "audio_status": "complete_or_partial", }, @@ -224,15 +222,10 @@ async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]: break for track_data in tracks_on_page: - show_data = { - "date": track_data.get("show_date"), - "album_cover_url": track_data.get("show_album_cover_url"), - "venue": {"name": track_data.get("venue_name")}, - } - track = track_to_ma_track(self, track_data, show_data) - all_tracks.append(track) - - if len(tracks_on_page) < 50: + all_tracks.append(track_to_ma_track(self, track_data)) + + # a short page means we've reached the end + if len(tracks_on_page) < per_page: break page += 1 @@ -363,43 +356,9 @@ async def _get_recent_shows(self) -> Any: async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: """Retrieve library playlists from the provider.""" try: - playlists_data = await api_request( - self, ENDPOINTS["playlists"], params={"per_page": 100, "sort": "likes_count:desc"} - ) - - for playlist_data in playlists_data.get("playlists", []): - track_count = playlist_data.get("tracks_count", 0) - if track_count > 0: - playlist_id = str(playlist_data.get("id")) - - metadata = MediaItemMetadata( - images=UniqueList( - [ - MediaItemImage( - type=ImageType.THUMB, - path=FALLBACK_ALBUM_IMAGE, - provider=self.instance_id, - remotely_accessible=True, - ) - ] - ) - ) - yield Playlist( - item_id=playlist_id, - provider=self.instance_id, - name=playlist_data.get("name", ""), - owner=playlist_data.get("username", ""), - is_editable=False, - metadata=metadata, - provider_mappings={ - ProviderMapping( - item_id=playlist_id, - provider_domain=self.domain, - provider_instance=self.instance_id, - available=True, - ) - }, - ) + for playlist_data in await self._get_playlists(): + if playlist_data.get("tracks_count", 0) > 0: + yield playlist_to_ma_playlist(self, playlist_data) except (MediaNotFoundError, ProviderUnavailableError): raise except Exception as err: @@ -410,35 +369,10 @@ async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: async def get_playlist(self, prov_playlist_id: str) -> Playlist: """Get full playlist details by id.""" try: - playlists_data = await api_request(self, ENDPOINTS["playlists"]) - playlist_slug = None - playlist_info = None - - for playlist in playlists_data.get("playlists", []): - if str(playlist.get("id")) == prov_playlist_id: - playlist_slug = playlist.get("slug") - playlist_info = playlist - break - - if not playlist_slug or not playlist_info: + playlist_info = await self._resolve_playlist(prov_playlist_id) + if not playlist_info: raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") - - return Playlist( - item_id=prov_playlist_id, - provider=self.instance_id, - name=playlist_info.get("name", ""), - owner=playlist_info.get("username", ""), - is_editable=False, - provider_mappings={ - ProviderMapping( - item_id=prov_playlist_id, - provider_domain=self.domain, - provider_instance=self.instance_id, - available=True, - ) - }, - ) - + return playlist_to_ma_playlist(self, playlist_info) except MediaNotFoundError: raise except Exception as err: @@ -450,29 +384,21 @@ async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> lis if page > 0: return [] try: - playlists_data = await api_request(self, ENDPOINTS["playlists"]) - playlist_slug = None - - for playlist in playlists_data.get("playlists", []): - if str(playlist.get("id")) == prov_playlist_id: - playlist_slug = playlist.get("slug") - break - - if not playlist_slug: + playlist_info = await self._resolve_playlist(prov_playlist_id) + if not playlist_info: return [] playlist_data = await api_request( - self, ENDPOINTS["playlist_by_slug"].format(slug=playlist_slug) + self, ENDPOINTS["playlist_by_slug"].format(slug=playlist_info["slug"]) ) - all_tracks = [] + tracks = [] for entry in playlist_data.get("entries", []): track_data = entry.get("track") if track_data and track_data.get("mp3_url"): - track = track_to_ma_track(self, track_data) - all_tracks.append(track) + tracks.append(track_to_ma_track(self, track_data)) - return all_tracks + return tracks except (MediaNotFoundError, ProviderUnavailableError): raise @@ -957,3 +883,20 @@ async def _get_shows_for_tag(self, tag_slug: str) -> list[BrowseFolder | Album | except Exception as err: self.logger.error("Failed to get shows for tag %s: %s", tag_slug, err) raise ProviderUnavailableError(f"Tag shows error: {err}") from err + + @use_cache(expiration=86400) # 24 hours - the playlist list changes rarely + async def _get_playlists(self) -> list[dict[str, Any]]: + """Fetch the full list of Phish.in playlists (single cached request).""" + # cached and shared by all playlist methods to avoid repeated list fetches + playlists_data = await api_request( + self, ENDPOINTS["playlists"], params={"per_page": 100, "sort": "likes_count:desc"} + ) + playlists: list[dict[str, Any]] = playlists_data.get("playlists", []) + return playlists + + async def _resolve_playlist(self, prov_playlist_id: str) -> dict[str, Any] | None: + """Return the raw playlist data for the given id from the cached list.""" + for playlist in await self._get_playlists(): + if str(playlist.get("id")) == prov_playlist_id: + return playlist + return None From 0b219fa75d85cc6b9ced509518ca15c406d09534 Mon Sep 17 00:00:00 2001 From: OzGav Date: Wed, 3 Jun 2026 23:03:01 +1000 Subject: [PATCH 6/6] Fix Bluesound ungroup crashing on non-existent pyblu client attributes (#4072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # What does this implement/fix? While looking at typing fixes for the Bluesound provider I came across a bug in the code. `BluesoundPlayer.ungroup()` reads `self.client.leader` and `self.client.provider.player_map(...)`. But `self.client` is the pyblu Player, which has no `leader `and no `provider `attribute so the first line throws `AttributeError`. The fix gets the group leader from `self.sync_status.leader` and looks up its player id via the provider's player_map, mirroring how the existing `synced_to` property already does it. This hasn't been encountered before because the normal `ungroup `command routes through `set_members` (which works) and never calls this method. The only path that calls `ungroup()` directly is universal group teardown, so it only triggers when a Bluesound player that's part of a native sync group is a member of a universal group. I can't test this as I don't have a Bluesound device but it just aligns `ungroup()` with the working pattern already used in `synced_to` and `update_attributes`, so it's low risk. **Related issue (if applicable):** - related issue ## Types of changes - [X] Bugfix (non-breaking change which fixes an issue) — `bugfix` - [ ] New feature (non-breaking change which adds functionality) — `new-feature` - [ ] Enhancement to an existing feature — `enhancement` - [ ] New music/player/metadata/plugin provider — `new-provider` - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — `breaking-change` - [ ] Refactor (no behaviour change) — `refactor` - [ ] Documentation only — `documentation` - [ ] Maintenance / chore — `maintenance` - [ ] CI / workflow change — `ci` - [ ] Dependencies bump — `dependencies` ## Checklist - [ ] The code change is tested and works locally. - [X] `pre-commit run --all-files` passes. - [X] `pytest` passes, and tests have been added/updated under `tests/` where applicable. - [ ] For changes to shared models, the companion PR in `music-assistant/models` is linked. - [ ] For changes affecting the UI, the companion PR in `music-assistant/frontend` is linked. - [X] I have read and complied with the project's [AI Policy](https://github.com/music-assistant/.github/blob/main/AI_POLICY.md) for any AI-assisted contributions. - [ ] I have raised a PR against the documentation repository targeting the main or beta branch as appropriate. --- music_assistant/providers/bluesound/player.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/music_assistant/providers/bluesound/player.py b/music_assistant/providers/bluesound/player.py index 4029463aa4..d5b7cc10ad 100644 --- a/music_assistant/providers/bluesound/player.py +++ b/music_assistant/providers/bluesound/player.py @@ -253,9 +253,11 @@ def player_id_to_paired_player(player_id: str) -> PairedPlayer: async def ungroup(self) -> None: """Handle UNGROUP command for BluOS player.""" - leader = self.client.leader - leader_player_id = self.client.provider.player_map((leader.ip, leader.port)) - await self.mass.players.get_player(leader_player_id).set_members(None, [self.player_id]) + if not (leader := self.sync_status.leader): + return + leader_player_id = self.provider.player_map.get((leader.ip, leader.port)) + if leader_player_id and (leader_player := self.mass.players.get_player(leader_player_id)): + await leader_player.set_members(None, [self.player_id]) async def poll(self) -> None: """Poll player for state updates."""