Various small (bug)fixes (#2846)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 19 Dec 2025 00:23:02 +0000 (01:23 +0100)
committerGitHub <noreply@github.com>
Fri, 19 Dec 2025 00:23:02 +0000 (01:23 +0100)
* 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

17 files changed:
music_assistant/controllers/metadata.py
music_assistant/controllers/music.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/players/player_controller.py
music_assistant/controllers/webserver/helpers/auth_providers.py
music_assistant/helpers/images.py
music_assistant/helpers/util.py
music_assistant/models/player.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/genius_lyrics/__init__.py
music_assistant/providers/hass/__init__.py
music_assistant/providers/lrclib/__init__.py
music_assistant/providers/plex_connect/player_remote.py
music_assistant/providers/sendspin/player.py
music_assistant/providers/sendspin/provider.py
music_assistant/providers/squeezelite/player.py
music_assistant/providers/tidal/provider.py

index 920a2b3e013fb99f86503b836c694321776f4d25..c071c0a3c5a66bcc5293c808ec11ba142c22277d 100644 (file)
@@ -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
index d5c186019c20575144824c6fadef132193fe811e..6dab3f9b62490276fb79a607838882dd383f1ee4 100644 (file)
@@ -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)
 
index 67b407b7f71575ee9abcad9350a7640c341608b3..0fe55ed0b2c27c73eb6bca31608b0dce70cb78d0 100644 (file)
@@ -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 "<artist(s)> - <title>"
-    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
index fdd71989d3a30290421b01bcc153e229497ebbfc..40a6da6a25c4a8cfa40caef0807587c789cb3aac 100644 (file)
@@ -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
index 139f07d84b012007cec6531f556f73d858ac6975..84f07933467331047a3ec68e4f0458d0026badc8 100644 (file)
@@ -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:
index 9b7926a9cf2c3f87785fb06b0e4af6cd9d1ec2b5..97639ffa44e28c6279ae7af0d15367ad5e69a83a 100644 (file)
@@ -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:
index 1b0f4d65ddbdfa4b459a25ca45cb544165e8e674..bc14912b22f255c38293608775490d315923eb77 100644 (file)
@@ -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:
index e9e945ce27a15db02ea0fc9867a3d8c4c348ff79..3bebe24ae90616c1fa8f58734ad80b996053f936 100644 (file)
@@ -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(
index e0ea6199aa338cca63efd71a69a04a7123906096..375dffc7cee9ded7de782881f94fd0aa12312a08 100644 (file)
@@ -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"):
index b5ebb744dec7af370ed814a6297d2763e2c0b0a9..ffdaaf2ce35ca43eef50c38e0f5e987565f6976d 100644 (file)
@@ -28,6 +28,7 @@ from .helpers import clean_song_title, cleanup_lyrics
 
 SUPPORTED_FEATURES = {
     ProviderFeature.TRACK_METADATA,
+    ProviderFeature.LYRICS,
 }
 
 
index 9e6d4a02f6bc120d1acd285451ccc1398d0eaeb3..5f4b71568b94315db345d8705df5a55a0e5e59ee 100644 (file)
@@ -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:
index c07e7679b3cbf85bfb6b512832bbeead30843475..1c96c8fb4ce7b11fe6548ed26a3e1a29ac276f14 100644 (file)
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
 
 SUPPORTED_FEATURES = {
     ProviderFeature.TRACK_METADATA,
+    ProviderFeature.LYRICS,
 }
 
 CONF_API_URL = "api_url"
index 3f0637610c90d81790d04aba0f8a24980c69cba0..a775a0d0ffa5b3f2d294d281048dc9d7dba82f91 100644 (file)
@@ -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
index 2ba68770f206b6bfc9c9c38f7cca9eecd8b4258f..cf168cb0265d95c6a29f53079b1cc932328e30df 100644 (file)
@@ -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."""
index 8ed7aa583f727a95c31dabbe114676cf23afff8b..9c5028bd91c82c188e358d36c615676af495a5d8 100644 (file)
@@ -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)
index 7da712e6cd4c4c240972d24ebfded96bdc9c3e08..b68ada644af7667bcca125eb7ca661ee20538a54 100644 (file)
@@ -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"):
index 3185d8e6b0d7d3a7832b060d288a328d5c66cc67..d263c47832074196be3ed6a81c0b1e4fbfe72d44 100644 (file)
@@ -64,6 +64,7 @@ SUPPORTED_FEATURES = {
     ProviderFeature.BROWSE,
     ProviderFeature.PLAYLIST_TRACKS_EDIT,
     ProviderFeature.RECOMMENDATIONS,
+    ProviderFeature.LYRICS,
 }