From 1d7e8b62f1df79c9a923b86f01d97a15177649ca Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 19 Dec 2025 01:23:02 +0100 Subject: [PATCH] Various small (bug)fixes (#2846) * Fix random shuffle performance hog * Fix transparent PNG image thumbs * Fix load order of new players * Small fix for player controls with ha entities * Fix port in use errros on slimproto * Wait for Home Assistant provider to be ready * Fix end of queue detection, should clear * Fix lyrics retrieval for the frontend * updated device name for web player * get name of esphome sendspin client from HA * fix LAST_PROVIDER_INSTANCE_SCAN --- music_assistant/controllers/metadata.py | 49 ++++- music_assistant/controllers/music.py | 5 +- music_assistant/controllers/player_queues.py | 177 ++++++++---------- .../controllers/players/player_controller.py | 2 + .../webserver/helpers/auth_providers.py | 21 ++- music_assistant/helpers/images.py | 4 + music_assistant/helpers/util.py | 10 +- music_assistant/models/player.py | 9 +- music_assistant/providers/airplay/provider.py | 2 +- .../providers/genius_lyrics/__init__.py | 1 + music_assistant/providers/hass/__init__.py | 32 +++- music_assistant/providers/lrclib/__init__.py | 1 + .../providers/plex_connect/player_remote.py | 10 +- music_assistant/providers/sendspin/player.py | 8 +- .../providers/sendspin/provider.py | 11 ++ .../providers/squeezelite/player.py | 2 +- music_assistant/providers/tidal/provider.py | 1 + 17 files changed, 214 insertions(+), 131 deletions(-) diff --git a/music_assistant/controllers/metadata.py b/music_assistant/controllers/metadata.py index 920a2b3e..c071c0a3 100644 --- a/music_assistant/controllers/metadata.py +++ b/music_assistant/controllers/metadata.py @@ -394,10 +394,12 @@ class MetaDataController(CoreController): image: MediaItemImage, size: int = 0, prefer_proxy: bool = False, - image_format: str = "png", + image_format: str | None = None, prefer_stream_server: bool = False, ) -> str: """Get (proxied) URL for MediaItemImage.""" + if image_format is None: + image_format = "png" if image.path.lower().endswith(".png") else "jpg" if not image.remotely_accessible or prefer_proxy or size: # return imageproxy url for images that need to be resolved # the original path is double encoded @@ -417,11 +419,13 @@ class MetaDataController(CoreController): provider: str, size: int | None = None, base64: bool = False, - image_format: str = "png", + image_format: str | None = None, ) -> bytes | str: """Get/create thumbnail image for path (image url or local path).""" if not self.mass.get_provider(provider) and not path.startswith("http"): raise ProviderUnavailableError + if image_format is None: + image_format = "png" if path.lower().endswith(".png") else "jpg" if provider == "builtin" and path.startswith("/collage/"): # special case for collage images path = os.path.join(self._collage_images_dir, path.split("/collage/")[-1]) @@ -441,7 +445,9 @@ class MetaDataController(CoreController): # temporary for backwards compatibility provider = "builtin" size = int(request.query.get("size", "0")) - image_format = request.query.get("fmt", "png") + image_format = request.query.get("fmt", None) + if image_format is None: + image_format = "png" if path.lower().endswith(".png") else "jpg" if not self.mass.get_provider(provider) and not path.startswith("http"): return web.Response(status=404) if "%" in path: @@ -510,6 +516,43 @@ class MetaDataController(CoreController): ) return None + @api_command("metadata/get_track_lyrics") + async def get_track_lyrics( + self, + track: Track, + ) -> tuple[str | None, str | None]: + """ + Get lyrics for given track from metadata providers. + + Returns a tuple of (lyrics, lrc_lyrics) if found. + """ + if track.metadata and track.metadata.lyrics: + return track.metadata.lyrics, track.metadata.lrc_lyrics + + if track.provider == "library": + # try to update metadata first + await self._update_track_metadata(track, force_refresh=False) + return track.metadata.lyrics, track.metadata.lrc_lyrics + + # prefer lyrics from the track's own provider + track_provider = self.mass.get_provider(track.provider, provider_type=MusicProvider) + if track_provider and ProviderFeature.LYRICS in track_provider.supported_features: + full_track = await self.mass.music.tracks.get_provider_item( + track.item_id, track.provider + ) + if full_track.metadata and full_track.metadata.lyrics: + return full_track.metadata.lyrics, full_track.metadata.lrc_lyrics + + # fallback to other metadata providers + for provider in self.providers: + if ProviderFeature.LYRICS not in provider.supported_features: + continue + if (metadata := await provider.get_track_metadata(track)) and ( + metadata.lyrics or metadata.lrc_lyrics + ): + return metadata.lyrics, metadata.lrc_lyrics + return None, None + async def _update_artist_metadata(self, artist: Artist, force_refresh: bool = False) -> None: """Get/update rich metadata for an artist.""" # collect metadata from all (online) music + metadata providers diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index d5c18601..6dab3f9b 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -181,7 +181,10 @@ class MusicController(CoreController): ): await self.cleanup_provider(removed_provider) # schedule cleanup task for matching provider instances - last_scan = cast("int", self.config.get_value(LAST_PROVIDER_INSTANCE_SCAN, 0)) + last_scan = cast( + "int", + self.mass.config.get_raw_core_config_value(self.domain, LAST_PROVIDER_INSTANCE_SCAN, 0), + ) if time.time() - last_scan > PROVIDER_INSTANCE_SCAN_INTERVAL: self.mass.call_later(60, self.correct_multi_instance_provider_mappings) diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 67b407b7..0fe55ed0 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -119,6 +119,9 @@ class CompareState(TypedDict): next_item_id: str | None current_item: QueueItem | None elapsed_time: int + # last_playing_elapsed_time: elapsed time from the last PLAYING state update + # used to determine if a track was fully played when transitioning to idle + last_playing_elapsed_time: int stream_title: str | None codec_type: ContentType | None output_formats: list[str] | None @@ -303,7 +306,7 @@ class PlayerQueuesController(CoreController): # Queue commands @api_command("player_queues/shuffle") - def set_shuffle(self, queue_id: str, shuffle_enabled: bool) -> None: + async def set_shuffle(self, queue_id: str, shuffle_enabled: bool) -> None: """Configure shuffle setting on the the queue.""" queue = self._queues[queue_id] if queue.shuffle_enabled == shuffle_enabled: @@ -320,7 +323,7 @@ class PlayerQueuesController(CoreController): if not shuffle_enabled: # shuffle disabled, try to restore original sort order of the remaining items next_items.sort(key=lambda x: x.sort_index, reverse=False) - self.load( + await self.load( queue_id=queue_id, queue_items=next_items, insert_at_index=next_index, @@ -517,7 +520,7 @@ class PlayerQueuesController(CoreController): # handle replace: clear all items and replace with the new items if option == QueueOption.REPLACE: - self.load( + await self.load( queue_id, queue_items=queue_items, keep_remaining=False, @@ -528,7 +531,7 @@ class PlayerQueuesController(CoreController): return # handle next: add item(s) in the index next to the playing/loaded/buffered index if option == QueueOption.NEXT: - self.load( + await self.load( queue_id, queue_items=queue_items, insert_at_index=insert_at_index, @@ -536,7 +539,7 @@ class PlayerQueuesController(CoreController): ) return if option == QueueOption.REPLACE_NEXT: - self.load( + await self.load( queue_id, queue_items=queue_items, insert_at_index=insert_at_index, @@ -546,7 +549,7 @@ class PlayerQueuesController(CoreController): return # handle play: replace current loaded/playing index with new item(s) if option == QueueOption.PLAY: - self.load( + await self.load( queue_id, queue_items=queue_items, insert_at_index=insert_at_index, @@ -557,7 +560,7 @@ class PlayerQueuesController(CoreController): return # handle add: add/append item(s) to the remaining queue items if option == QueueOption.ADD: - self.load( + await self.load( queue_id=queue_id, queue_items=queue_items, insert_at_index=insert_at_index @@ -1014,7 +1017,7 @@ class PlayerQueuesController(CoreController): target_queue.current_item.queue_id = target_queue_id self.clear(source_queue_id) - self.load(target_queue_id, source_items, keep_remaining=False, keep_played=False) + await self.load(target_queue_id, source_items, keep_remaining=False, keep_played=False) for item in source_items: item.queue_id = target_queue_id self.update_items(target_queue_id, source_items) @@ -1302,7 +1305,7 @@ class PlayerQueuesController(CoreController): # Main queue manipulation methods - def load( + async def load( self, queue_id: str, queue_items: list[QueueItem], @@ -1331,7 +1334,7 @@ class PlayerQueuesController(CoreController): item.sort_index += insert_at_index + index # (re)shuffle the final batch if needed if shuffle: - next_items = _smart_shuffle(next_items) + next_items = await _smart_shuffle(next_items) self.update_items(queue_id, prev_items + next_items) def update_items(self, queue_id: str, queue_items: list[QueueItem]) -> None: @@ -1385,6 +1388,8 @@ class PlayerQueuesController(CoreController): ) # always send the base event self.mass.signal_event(EventType.QUEUE_UPDATED, object_id=queue_id, data=queue) + # also signal update to the player itself so it can update its current_media + self.mass.players.trigger_player_update(queue_id) # save state self.mass.create_task( self.mass.cache.set( @@ -1693,7 +1698,7 @@ class PlayerQueuesController(CoreController): tracks = await self._get_radio_tracks(queue_id=queue_id, is_initial_radio_mode=False) # fill queue - filter out unavailable items queue_items = [QueueItem.from_media_item(queue_id, x) for x in tracks if x.available] - self.load( + await self.load( queue_id, queue_items, insert_at_index=len(self._queue_items[queue_id]) + 1, @@ -2102,11 +2107,28 @@ class PlayerQueuesController(CoreController): next_item_id=None, current_item=None, elapsed_time=0, + last_playing_elapsed_time=0, stream_title=None, codec_type=None, output_formats=None, ), ) + # update last_playing_elapsed_time only when the player is actively playing + # use corrected_elapsed_time which accounts for time since last update + # this preserves the last known elapsed time when transitioning to idle/paused + prev_playing_elapsed = prev_state["last_playing_elapsed_time"] + prev_item_id = prev_state["current_item_id"] + current_item_id = queue.current_item.queue_item_id if queue.current_item else None + if queue.state == PlaybackState.PLAYING: + current_elapsed = int(queue.corrected_elapsed_time) + if current_item_id != prev_item_id: + # new track started, reset the elapsed time tracker + last_playing_elapsed_time = current_elapsed + else: + # same track, use the max of current and previous to handle timing issues + last_playing_elapsed_time = max(current_elapsed, prev_playing_elapsed) + else: + last_playing_elapsed_time = prev_playing_elapsed new_state = CompareState( queue_id=queue_id, state=queue.state, @@ -2114,6 +2136,7 @@ class PlayerQueuesController(CoreController): next_item_id=queue.next_item.queue_item_id if queue.next_item else None, current_item=queue.current_item, elapsed_time=int(queue.elapsed_time), + last_playing_elapsed_time=last_playing_elapsed_time, stream_title=( queue.current_item.streamdetails.stream_title if queue.current_item and queue.current_item.streamdetails @@ -2129,6 +2152,8 @@ class PlayerQueuesController(CoreController): changed_keys = get_changed_keys(dict(prev_state), dict(new_state)) with suppress(KeyError): changed_keys.remove("next_item_id") + with suppress(KeyError): + changed_keys.remove("last_playing_elapsed_time") # store the new state if queue.active: @@ -2159,8 +2184,6 @@ class PlayerQueuesController(CoreController): if send_update: self.signal_update(queue_id) - # also signal update to the player itself so it can update its current_media - self.mass.players.trigger_player_update(queue_id) if "output_formats" in changed_keys: # refresh DSP details since they may have changed @@ -2338,16 +2361,30 @@ class PlayerQueuesController(CoreController): # all checks passed, we stopped playback at the last (or single) track of the queue # now determine if the item was fully played before clearing - if queue.current_item and (streamdetails := queue.current_item.streamdetails): - duration = streamdetails.duration or queue.current_item.duration or 24 * 3600 - elif queue.current_item: - duration = queue.current_item.duration or 24 * 3600 + + # For flow mode, check if the last track was fully streamed using the stream log + # This is more reliable than elapsed_time which can be reset/incorrect + if queue.flow_mode and queue.flow_mode_stream_log: + last_log_entry = queue.flow_mode_stream_log[-1] + if last_log_entry.seconds_streamed is not None: + # The last track finished streaming, safe to clear queue + self.mass.create_task(_clear_queue_delayed()) + return + + # For non-flow mode, use prev_state values since queue state may have been updated/reset + prev_item = prev_state["current_item"] + if prev_item and (streamdetails := prev_item.streamdetails): + duration = streamdetails.duration or prev_item.duration or 24 * 3600 + elif prev_item: + duration = prev_item.duration or 24 * 3600 else: # No current item means player has already cleared it, safe to clear queue self.mass.create_task(_clear_queue_delayed()) return - seconds_played = int(queue.elapsed_time) + # use last_playing_elapsed_time which preserves the elapsed time from when the player + # was still playing (before transitioning to idle where elapsed_time may be reset to 0) + seconds_played = int(prev_state["last_playing_elapsed_time"]) # debounce this a bit to make sure we're not clearing the queue by accident # only clear if the last track was played to near completion (within 5 seconds of end) if seconds_played >= (duration or 3600) - 5: @@ -2476,96 +2513,36 @@ class PlayerQueuesController(CoreController): ) -def _smart_shuffle(items: list[QueueItem]) -> list[QueueItem]: - """Shuffle queue items with smart spacing rules. +async def _smart_shuffle(items: list[QueueItem]) -> list[QueueItem]: + """Shuffle queue items, avoiding identical tracks next to each other. - This shuffle tries to prevent the same track and artist from appearing - too close together. Spacing requirements scale with playlist size: - - >1000 items: track spacing 15, artist spacing 10 - - >500 items: track spacing 10, artist spacing 6 - - >100 items: track spacing 5, artist spacing 3 - - <=100 items: track spacing 2, no artist spacing - - This is a best-effort approach - when playing an album where all tracks - are from the same artist, artist spacing won't be possible. + Best-effort approach to prevent the same track from appearing adjacent. + Does a random shuffle first, then makes a limited number of passes to + swap adjacent duplicates with a random item further in the list. :param items: List of queue items to shuffle. """ - if len(items) <= 1: - return items - - # Determine spacing based on playlist size - num_items = len(items) - if num_items > 1000: - track_spacing, artist_spacing = 15, 10 - elif num_items > 500: - track_spacing, artist_spacing = 10, 6 - elif num_items > 100: - track_spacing, artist_spacing = 5, 3 - else: - track_spacing, artist_spacing = 2, 0 - - # Extract artist from name format " - " - def get_artist(name: str) -> str | None: - return name.split(" - ", 1)[0] if " - " in name else None + if len(items) <= 2: + return random.sample(items, len(items)) if len(items) == 2 else items # Start with a random shuffle shuffled = random.sample(items, len(items)) - # Iteratively fix violations - max_attempts = len(items) * 3 - for _ in range(max_attempts): - violation_found = False - - for i in range(1, len(shuffled)): - current = shuffled[i] - current_artist = get_artist(current.name) - - # Check for track collision - has_violation = any( - shuffled[j].name == current.name for j in range(max(0, i - track_spacing), i) - ) - - # Check for artist collision (only if artist_spacing > 0) - if not has_violation and artist_spacing and current_artist: - has_violation = any( - get_artist(shuffled[j].name) == current_artist - for j in range(max(0, i - artist_spacing), i) - ) - - if has_violation: - violation_found = True - # Find best position after current by scoring distance from conflicts - best_pos, best_score = i, -1 - for pos in range(i + 1, len(shuffled)): - track_dist = min( - ( - pos - j - for j in range(max(0, pos - track_spacing), pos) - if shuffled[j].name == current.name - ), - default=track_spacing, - ) - artist_dist = artist_spacing - if artist_spacing and current_artist: - artist_dist = min( - ( - pos - j - for j in range(max(0, pos - artist_spacing), pos) - if get_artist(shuffled[j].name) == current_artist - ), - default=artist_spacing, - ) - score = track_dist * 2 + artist_dist - if score > best_score: - best_score, best_pos = score, pos - - if best_pos != i: - item = shuffled.pop(i) - shuffled.insert(best_pos, item) - break - - if not violation_found: + # Make a few passes to fix adjacent duplicates + max_passes = 3 + for _ in range(max_passes): + swapped = False + for i in range(len(shuffled) - 1): + if shuffled[i].name == shuffled[i + 1].name: + # Found adjacent duplicate - swap with random position at least 2 away + swap_candidates = [j for j in range(len(shuffled)) if abs(j - i - 1) >= 2] + if swap_candidates: + swap_pos = random.choice(swap_candidates) + shuffled[i + 1], shuffled[swap_pos] = shuffled[swap_pos], shuffled[i + 1] + swapped = True + if not swapped: break + # Yield to event loop between passes + await asyncio.sleep(0) return shuffled diff --git a/music_assistant/controllers/players/player_controller.py b/music_assistant/controllers/players/player_controller.py index fdd71989..40a6da6a 100644 --- a/music_assistant/controllers/players/player_controller.py +++ b/music_assistant/controllers/players/player_controller.py @@ -1361,6 +1361,8 @@ class PlayerController(CoreController): player.display_name, ) # signal event that a player was added + # update state without signaling event first (to ensure all attributes are set correctly) + player.update_state(signal_event=False) self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player) # register playerqueue for this player diff --git a/music_assistant/controllers/webserver/helpers/auth_providers.py b/music_assistant/controllers/webserver/helpers/auth_providers.py index 139f07d8..84f07933 100644 --- a/music_assistant/controllers/webserver/helpers/auth_providers.py +++ b/music_assistant/controllers/webserver/helpers/auth_providers.py @@ -40,19 +40,34 @@ def normalize_username(username: str) -> str: LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.auth") -async def get_ha_user_role(mass: MusicAssistant, ha_user_id: str) -> UserRole: +async def get_ha_user_role( + mass: MusicAssistant, ha_user_id: str, wait_timeout: float = 30.0 +) -> UserRole: """ Get user role based on Home Assistant admin status. :param mass: MusicAssistant instance. :param ha_user_id: The Home Assistant user ID to check. + :param wait_timeout: Maximum time to wait for HA provider to become available (default 30s). """ try: - hass_prov = mass.get_provider("hass") + # Wait for the HA provider to become available (handles race condition at startup) + hass_prov = None + wait_interval = 0.5 + elapsed = 0.0 + while elapsed < wait_timeout: + hass_prov = mass.get_provider("hass") + if hass_prov is not None and hass_prov.available: + break + await asyncio.sleep(wait_interval) + elapsed += wait_interval + hass_prov = None # Reset to None for the final check + if hass_prov is None or not hass_prov.available: raise RuntimeError("Home Assistant provider not available") - hass_prov = cast("HomeAssistantProvider", hass_prov) + if TYPE_CHECKING: + hass_prov = cast("HomeAssistantProvider", hass_prov) # Query HA for user list to check admin status result = await hass_prov.hass.send_command("config/auth/list") if not result: diff --git a/music_assistant/helpers/images.py b/music_assistant/helpers/images.py index 9b7926a9..97639ffa 100644 --- a/music_assistant/helpers/images.py +++ b/music_assistant/helpers/images.py @@ -74,6 +74,10 @@ async def get_image_thumb( if not size and image_format.encode() in img_data: return img_data + image_format = image_format.upper() + if image_format == "JPG": + image_format = "JPEG" + def _create_image() -> bytes: data = BytesIO() try: diff --git a/music_assistant/helpers/util.py b/music_assistant/helpers/util.py index 1b0f4d65..bc14912b 100644 --- a/music_assistant/helpers/util.py +++ b/music_assistant/helpers/util.py @@ -308,18 +308,16 @@ async def is_port_in_use(port: int) -> bool: def _is_port_in_use() -> bool: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _sock: + # Set SO_REUSEADDR to match asyncio.start_server behavior + # This allows binding to ports in TIME_WAIT state + _sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: _sock.bind(("0.0.0.0", port)) except OSError: return True return False - try: - if await check_output(f"lsof -i :{port}"): - return True - except Exception: - # lsof not available (or some other error), fallback to socket check - return await asyncio.to_thread(_is_port_in_use) + return await asyncio.to_thread(_is_port_in_use) async def select_free_port(range_start: int, range_end: int) -> int: diff --git a/music_assistant/models/player.py b/music_assistant/models/player.py index e9e945ce..3bebe24a 100644 --- a/music_assistant/models/player.py +++ b/music_assistant/models/player.py @@ -1021,7 +1021,7 @@ class Player(ABC): return self._state @final - def update_state(self, force_update: bool = False) -> None: + def update_state(self, force_update: bool = False, signal_event: bool = True) -> None: """ Update the PlayerState with the current state of the player. @@ -1030,6 +1030,7 @@ class Player(ABC): :param force_update: If True, a state update event will be pushed even if the state has not actually changed. + :param signal_event: If True, signal the state update event to the PlayerController. """ self.mass.verify_event_loop_thread("player.update_state") # clear the dict for the cached properties @@ -1052,7 +1053,8 @@ class Player(ABC): if len(changed_values) == 0 and not force_update: return # signal the state update to the PlayerController - self.mass.players.signal_player_state_update(self, changed_values) + if signal_event: + self.mass.players.signal_player_state_update(self, changed_values) @final def set_current_media( # noqa: PLR0913 @@ -1423,6 +1425,9 @@ class Player(ABC): elapsed_time=int(active_queue.elapsed_time), elapsed_time_last_updated=active_queue.elapsed_time_last_updated, ) + elif active_queue: + # queue is active but no current item + return None # return native current media if no group/queue is active if self._current_media: return PlayerMedia( diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index e0ea6199..375dffc7 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -294,7 +294,7 @@ class AirPlayProvider(PlayerProvider): queue = self.mass.player_queues.get(player_id) if not queue: return - self.mass.player_queues.set_shuffle( + await self.mass.player_queues.set_shuffle( active_queue.queue_id, not queue.shuffle_enabled ) elif path in ("/ctrl-int/1/pause", "/ctrl-int/1/discrete-pause"): diff --git a/music_assistant/providers/genius_lyrics/__init__.py b/music_assistant/providers/genius_lyrics/__init__.py index b5ebb744..ffdaaf2c 100644 --- a/music_assistant/providers/genius_lyrics/__init__.py +++ b/music_assistant/providers/genius_lyrics/__init__.py @@ -28,6 +28,7 @@ from .helpers import clean_song_title, cleanup_lyrics SUPPORTED_FEATURES = { ProviderFeature.TRACK_METADATA, + ProviderFeature.LYRICS, } diff --git a/music_assistant/providers/hass/__init__.py b/music_assistant/providers/hass/__init__.py index 9e6d4a02..5f4b7156 100644 --- a/music_assistant/providers/hass/__init__.py +++ b/music_assistant/providers/hass/__init__.py @@ -37,7 +37,7 @@ from music_assistant.models.plugin import PluginProvider from .constants import OFF_STATES, MediaPlayerEntityFeature if TYPE_CHECKING: - from hass_client.models import CompressedState, EntityStateEvent + from hass_client.models import CompressedState, Device, EntityStateEvent from music_assistant_models.config_entries import ProviderConfig from music_assistant_models.provider import ProviderManifest @@ -220,11 +220,11 @@ async def _get_player_control_config_entries(hass: HomeAssistantClient) -> tuple if not hass.connected: return () for state in await hass.get_states(): - if "friendly_name" not in state["attributes"]: - # filter out invalid/unavailable players - continue entity_platform = state["entity_id"].split(".")[0] - name = f"{state['attributes']['friendly_name']} ({state['entity_id']})" + if "friendly_name" not in state["attributes"]: + name = state["entity_id"] + else: + name = f"{state['attributes']['friendly_name']} ({state['entity_id']})" if entity_platform in ("switch", "input_boolean"): # simple on/off controls are suitable as power and mute controls @@ -472,6 +472,28 @@ class HomeAssistantProvider(PluginProvider): service_data={"value": volume_level}, ) + async def get_device_by_connection( + self, + connection_value: str, + connection_type: str = "mac", + ) -> Device | None: + """ + Get device details from Home Assistant by connection type and value. + + :param connection_value: The connection value (e.g. MAC address). + :param connection_type: The connection type (default: 'mac'). + """ + devices = await self.hass.get_device_registry() + for device in devices: + for connection in device.get("connections", []): + if ( + len(connection) == 2 + and connection[0] == connection_type + and connection[1].lower() == connection_value.lower() + ): + return device + return None + def _update_control_from_state_msg(self, entity_id: str, state: CompressedState) -> None: """Update PlayerControl from state(update) message.""" if self._player_controls is None: diff --git a/music_assistant/providers/lrclib/__init__.py b/music_assistant/providers/lrclib/__init__.py index c07e7679..1c96c8fb 100644 --- a/music_assistant/providers/lrclib/__init__.py +++ b/music_assistant/providers/lrclib/__init__.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: SUPPORTED_FEATURES = { ProviderFeature.TRACK_METADATA, + ProviderFeature.LYRICS, } CONF_API_URL = "api_url" diff --git a/music_assistant/providers/plex_connect/player_remote.py b/music_assistant/providers/plex_connect/player_remote.py index 3f063761..a775a0d0 100644 --- a/music_assistant/providers/plex_connect/player_remote.py +++ b/music_assistant/providers/plex_connect/player_remote.py @@ -429,7 +429,7 @@ class PlexRemoteControlServer: # Set shuffle if requested if shuffle: - self.provider.mass.player_queues.set_shuffle(player_id, shuffle) + await self.provider.mass.player_queues.set_shuffle(player_id, shuffle) # Seek to offset if specified if offset > 0: @@ -591,7 +591,7 @@ class PlexRemoteControlServer: # Apply shuffle if requested if shuffle: - self.provider.mass.player_queues.set_shuffle(player_id, shuffle) + await self.provider.mass.player_queues.set_shuffle(player_id, shuffle) # Seek to offset if specified if offset > 0: @@ -771,7 +771,7 @@ class PlexRemoteControlServer: return web.Response(status=500, text="No player assigned") # disable shuffle to avoid infinite loop - self.provider.mass.player_queues.set_shuffle(player_id, False) + await self.provider.mass.player_queues.set_shuffle(player_id, False) ma_queue = self.provider.mass.player_queues.get(player_id) if not ma_queue: LOGGER.error(f"MA queue not found for player {player_id}") @@ -904,7 +904,7 @@ class PlexRemoteControlServer: # Apply shuffle if requested (Plex may have already shuffled server-side) if shuffle: - self.provider.mass.player_queues.set_shuffle(player_id, shuffle) + await self.provider.mass.player_queues.set_shuffle(player_id, shuffle) else: LOGGER.error("No valid tracks in created play queue") return web.Response(status=500, text="Failed to load tracks from play queue") @@ -1150,7 +1150,7 @@ class PlexRemoteControlServer: if "shuffle" in request.query: # Plex sends shuffle as "0" or "1" shuffle = request.query["shuffle"] == "1" - self.provider.mass.player_queues.set_shuffle(self._ma_player_id, shuffle) + await self.provider.mass.player_queues.set_shuffle(self._ma_player_id, shuffle) if "repeat" in request.query: # Plex repeat: 0=off, 1=repeat one, 2=repeat all diff --git a/music_assistant/providers/sendspin/player.py b/music_assistant/providers/sendspin/player.py index 2ba68770..cf168cb0 100644 --- a/music_assistant/providers/sendspin/player.py +++ b/music_assistant/providers/sendspin/player.py @@ -228,9 +228,9 @@ class SendspinPlayer(Player): self._attr_volume_muted = player_client.muted self._attr_available = True self.is_web_player = sendspin_client.name.startswith( - "Music Assistant Web (" # The regular Web Interface + "Web (" # The regular Web Interface ) or sendspin_client.name.startswith( - "Music Assistant (" # The PWA App + "PWA (" # The PWA App ) self._attr_expose_to_ha_by_default = not self.is_web_player @@ -285,9 +285,9 @@ class SendspinPlayer(Player): case MediaCommand.REPEAT_ALL if queue: self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.ALL) case MediaCommand.SHUFFLE if queue: - self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=True) + await self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=True) case MediaCommand.UNSHUFFLE if queue: - self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=False) + await self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=False) async def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None: """Event callback registered to the sendspin group this player belongs to.""" diff --git a/music_assistant/providers/sendspin/provider.py b/music_assistant/providers/sendspin/provider.py index 8ed7aa58..9c5028bd 100644 --- a/music_assistant/providers/sendspin/provider.py +++ b/music_assistant/providers/sendspin/provider.py @@ -17,6 +17,8 @@ if TYPE_CHECKING: from music_assistant_models.config_entries import ProviderConfig from music_assistant_models.provider import ProviderManifest + from music_assistant.providers.hass import HomeAssistantProvider + class SendspinProvider(PlayerProvider): """Player Provider for Sendspin.""" @@ -53,6 +55,15 @@ class SendspinProvider(PlayerProvider): await pending_event.wait() player = SendspinPlayer(self, client_id) self.logger.debug("Client %s connected", client_id) + if player.device_info.manufacturer == "ESPHome" and ( + hass := self.mass.get_provider("hass") + ): + # Try to get device name from Home Assistant for ESPHome devices + hass = cast("HomeAssistantProvider", hass) + if hass_device := await hass.get_device_by_connection(client_id): + player._attr_name = ( + hass_device["name_by_user"] or hass_device["name"] or player.name + ) await self.mass.players.register(player) case ClientRemovedEvent(client_id): self.logger.debug("Client %s disconnected", client_id) diff --git a/music_assistant/providers/squeezelite/player.py b/music_assistant/providers/squeezelite/player.py index 7da712e6..b68ada64 100644 --- a/music_assistant/providers/squeezelite/player.py +++ b/music_assistant/providers/squeezelite/player.py @@ -528,7 +528,7 @@ class SqueezelitePlayer(Player): self.client.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode] self.client.signal_update() elif event.data == "button shuffle": - self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled) + await self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled) self.client.extra_data["playlist shuffle"] = int(queue.shuffle_enabled) self.client.signal_update() elif event_data in ("button jump_fwd", "button fwd"): diff --git a/music_assistant/providers/tidal/provider.py b/music_assistant/providers/tidal/provider.py index 3185d8e6..d263c478 100644 --- a/music_assistant/providers/tidal/provider.py +++ b/music_assistant/providers/tidal/provider.py @@ -64,6 +64,7 @@ SUPPORTED_FEATURES = { ProviderFeature.BROWSE, ProviderFeature.PLAYLIST_TRACKS_EDIT, ProviderFeature.RECOMMENDATIONS, + ProviderFeature.LYRICS, } -- 2.34.1