A few small bugfixes and enhancements (#2078)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 30 Mar 2025 13:35:35 +0000 (15:35 +0200)
committerGitHub <noreply@github.com>
Sun, 30 Mar 2025 13:35:35 +0000 (15:35 +0200)
* Prevent duplicates when searching library tracks or albums

* Fix preload and enqueue of next queue item

* Chore: some tweaks to DIDL metadata

music_assistant/controllers/media/albums.py
music_assistant/controllers/media/tracks.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/streams.py
music_assistant/helpers/didl_lite.py

index 97acf1602832cd67da1e22d788a442cebd9e3f85..94f8379d290b18985e9ceb7ede777ba042ae493d 100644 (file)
@@ -166,7 +166,8 @@ class AlbumsController(MediaControllerBase[Album]):
                 else "AND artists.search_name LIKE :search_artist"
             )
             extra_query_params["search_artist"] = f"%{search}%"
-            return result + await self._get_library_items_by_query(
+            existing_uris = {item.uri for item in result}
+            for _album in await self._get_library_items_by_query(
                 favorite=favorite,
                 search=None,
                 limit=limit,
@@ -175,7 +176,10 @@ class AlbumsController(MediaControllerBase[Album]):
                 extra_query_parts=extra_query_parts,
                 extra_query_params=extra_query_params,
                 extra_join_parts=extra_join_parts,
-            )
+            ):
+                # prevent duplicates (when artist is also in the title)
+                if _album.uri not in existing_uris:
+                    result.append(_album)
         return result
 
     async def library_count(
index 19cf7dc8cd6f74e5a5cf9d40ccfaab86a60c0656..5ad4d61533243b30750f1489ce3316c4516c86fe 100644 (file)
@@ -203,7 +203,8 @@ class TracksController(MediaControllerBase[Track]):
                 "AND artists.search_name LIKE :search_artist"
             )
             extra_query_params["search_artist"] = f"%{artist_search_str}%"
-            return result + await self._get_library_items_by_query(
+            existing_uris = {item.uri for item in result}
+            for _track in await self._get_library_items_by_query(
                 favorite=favorite,
                 search=None,
                 limit=limit,
@@ -212,7 +213,10 @@ class TracksController(MediaControllerBase[Track]):
                 extra_query_parts=extra_query_parts,
                 extra_query_params=extra_query_params,
                 extra_join_parts=extra_join_parts,
-            )
+            ):
+                # prevent duplicates (when artist is also in the title)
+                if _track.uri not in existing_uris:
+                    result.append(_track)
         return result
 
     async def versions(
index d7d1ecce9cc4939ef506ba9840b8dd4a060de034..c507a12440b829aea91dbe0942c7ab8c1a2e7049 100644 (file)
@@ -952,7 +952,7 @@ class PlayerQueuesController(CoreController):
         self._queues.pop(player_id, None)
         self._queue_items.pop(player_id, None)
 
-    async def get_next_queue_item(
+    async def preload_next_queue_item(
         self,
         queue_id: str,
         current_item_id: str,
@@ -967,11 +967,12 @@ class PlayerQueuesController(CoreController):
             msg = f"PlayerQueue {queue_id} is not available"
             raise PlayerUnavailableError(msg)
         cur_index = self.index_by_id(queue_id, current_item_id)
+        if cur_index is None:
+            # this is just a guard for bad data
+            raise QueueEmpty("Invalid item id for queue given.")
         next_item: QueueItem | None = None
         idx = 0
         while True:
-            if cur_index is None:
-                break
             next_index = self._get_next_index(queue_id, cur_index + idx)
             if next_index is None:
                 raise QueueEmpty("No more tracks left in the queue.")
@@ -1085,8 +1086,10 @@ class PlayerQueuesController(CoreController):
         queue.index_in_buffer = self.index_by_id(queue_id, item_id)
         self.logger.debug("PlayerQueue %s loaded item %s in buffer", queue.display_name, item_id)
         self.signal_update(queue_id)
-        # enqueue/precache next track on the player
-        self.enqueue_next_item(queue_id, item_id)
+        # enqueue next track on the player
+        self._enqueue_next_item(queue_id, self._get_next_item(queue_id, item_id))
+        # preload next streamdetails
+        self._preload_next_item(queue_id, queue.index_in_buffer)
 
     # Main queue manipulation methods
 
@@ -1404,8 +1407,10 @@ class PlayerQueuesController(CoreController):
         # all other: just the next index
         return cur_index + 1
 
-    def _get_next_item(self, queue_id: str, cur_index: int | None = None) -> QueueItem | None:
+    def _get_next_item(self, queue_id: str, cur_index: int | str | None = None) -> QueueItem | None:
         """Return next QueueItem for given queue."""
+        if isinstance(cur_index, str):
+            cur_index = self.index_by_id(queue_id, cur_index)
         for _retries in range(3):
             if (next_index := self._get_next_index(queue_id, cur_index)) is None:
                 break
@@ -1431,39 +1436,77 @@ class PlayerQueuesController(CoreController):
             insert_at_index=len(self._queue_items[queue_id]) + 1,
         )
 
-    def enqueue_next_item(self, queue_id: str, current_item_id: str) -> None:
+    def _enqueue_next_item(self, queue_id: str, next_item: QueueItem | None) -> None:
         """Enqueue/precache the next item on the player."""
+        if not next_item:
+            # no next item, nothing to do...
+            return
+
         queue = self._queues[queue_id]
-        task_id = f"enqueue_next_item_{queue_id}"
+        if queue.flow_mode:
+            # ignore this for flow mode
+            return
 
-        async def _enqueue_next_item(queue_id: str, current_item_id: str) -> None:
-            if not (current_item := self.get_item(queue_id, current_item_id)):
-                return
-            try:
-                next_item = await self.get_next_queue_item(queue_id, current_item_id)
-            except QueueEmpty:
-                return
-            if not self._queues[queue_id].flow_mode and current_item.media_type != MediaType.RADIO:
-                await self.mass.players.enqueue_next_media(
-                    player_id=queue_id,
-                    media=await self.player_media_from_queue_item(next_item, False),
-                )
+        async def _enqueue_next_item_on_player(next_item: QueueItem) -> None:
+            await self.mass.players.enqueue_next_media(
+                player_id=queue_id,
+                media=await self.player_media_from_queue_item(next_item, False),
+            )
             self.logger.debug(
                 "Enqueued next track %s on queue %s",
                 next_item.name,
                 self._queues[queue_id].display_name,
             )
 
-        if not (current_item := self.get_item(queue_id, current_item_id)):
+        # Enqueue the next item immediately once the player started
+        # buffering/playing an item (with a small debounce delay).
+        task_id = f"enqueue_next_item_{queue_id}"
+        self.mass.call_later(1, _enqueue_next_item_on_player, next_item, task_id=task_id)
+
+    def _preload_next_item(self, queue_id: str, item_id_in_buffer: str) -> None:
+        """
+        Preload the next item in the queue.
+
+        This basically ensures the item is playable and fetches the stream details.
+        If caching is enabled, this will also start filling the stream cache.
+        If an error occurs, the item will be skipped and the next item will be loaded.
+        """
+        queue = self._queues[queue_id]
+
+        async def _preload_streamdetails() -> None:
+            try:
+                new_next_item = await self.preload_next_queue_item(queue_id, item_id_in_buffer)
+            except QueueEmpty:
+                return
+            if (
+                queue.current_item.queue_item_id == item_id_in_buffer
+                and queue.next_item != new_next_item
+            ):
+                # the next item has changed, so we need to enqueue the new one
+                # this can happen when fetching the streamdetails failed so the
+                # track was skipped.
+                queue.next_item = new_next_item
+                await self._enqueue_next_item(queue_id, next_item)
+                return
+
+        if not (current_item := self.get_item(queue_id, item_id_in_buffer)):
             # this should not happen, but guard anyways
             return
+        if current_item.media_type == MediaType.RADIO or not current_item.duration:
+            # radio items or no duration, nothing to do
+            return
+        if not (next_item := self._get_next_item(queue_id, item_id_in_buffer)):
+            return  # nothing to do
+        if next_item.available and next_item.streamdetails:
+            # streamdetails already loaded, nothing to do
+            return
 
-        if not current_item.duration:
-            delay = 5
-        else:
-            delay = max(int((current_item.duration / 2) - queue.elapsed_time), 0)
-
-        self.mass.call_later(delay, _enqueue_next_item, queue_id, current_item_id, task_id=task_id)
+        # preload the streamdetails for the next item 60 seconds before the current item ends
+        # this should be enough time to load the stream details and start buffering
+        # NOTE: we use the duration of the current item, not the next item
+        delay = max(0, current_item.duration - 60)
+        task_id = f"preload_next_item_{queue_id}"
+        self.mass.call_later(delay, _preload_streamdetails, task_id=task_id)
 
     async def _resolve_media_items(
         self, media_item: MediaItemTypeOrItemMapping, start_item: str | None = None
index 915076845ada383f3317464014aaaaa521df080d..2c79c640c761a70b704208a997168407a131c608 100644 (file)
@@ -767,7 +767,7 @@ class StreamsController(CoreController):
                 queue_track = start_queue_item
             else:
                 try:
-                    queue_track = await self.mass.player_queues.get_next_queue_item(
+                    queue_track = await self.mass.player_queues.preload_next_queue_item(
                         queue.queue_id, queue_track.queue_item_id
                     )
                 except QueueEmpty:
index 78ce38c7ed13f5cd8dfbec1454d7569b8593aed9..5b44a254c276162d51084a2518274e96fb2524cf 100644 (file)
@@ -30,22 +30,23 @@ def create_didl_metadata(media: PlayerMedia) -> str:
             f"<dc:queueItemId>{media.uri}</dc:queueItemId>"
             "<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
             f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
-            f'<res duration="23:59:59.000" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_PN={ext.upper()};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(media.uri)}</res>'
+            f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_PN={ext.upper()};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(media.uri)}</res>'
             "</item>"
             "</DIDL-Lite>"
         )
     duration_str = str(datetime.timedelta(seconds=media.duration or 0)) + ".000"
     return (
-        '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">'
-        '<item id="1" parentID="0" restricted="1">'
+        '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/">'
+        f'<item id="{media.queue_item_id or media.uri}" restricted="true">'
         f"<dc:title>{escape_string(media.title or media.uri)}</dc:title>"
         f"<dc:creator>{escape_string(media.artist or '')}</dc:creator>"
         f"<upnp:album>{escape_string(media.album or '')}</upnp:album>"
         f"<upnp:artist>{escape_string(media.artist or '')}</upnp:artist>"
         f"<upnp:duration>{int(media.duration or 0)}</upnp:duration>"
         f"<dc:queueItemId>{media.uri}</dc:queueItemId>"
+        f"<dc:description>Music Assistant</dc:description>"
         f"<upnp:albumArtURI>{escape_string(image_url)}</upnp:albumArtURI>"
-        "<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
+        "<upnp:class>object.item.audioItem.musicTrack</upnp:class>"
         f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
         f'<res duration="{duration_str}" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_PN={ext.upper()};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(media.uri)}</res>'
         "</item>"