Fix several issues when streaming to (DLNA based) players (#2551)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 26 Oct 2025 14:34:33 +0000 (15:34 +0100)
committerGitHub <noreply@github.com>
Sun, 26 Oct 2025 14:34:33 +0000 (15:34 +0100)
* Optimizations for DLNA players

* prevent deadlock in audio buffer

* Fix race condition with track_loaded_in_buffer

* Fix clear queue once queue is fully played

music_assistant/constants.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/streams.py
music_assistant/helpers/audio_buffer.py
music_assistant/helpers/upnp.py
music_assistant/providers/sonos/player.py

index 3ead2c86e0d60b9bf63e18e7e35bde074bce6355..50cf41d81dc3569b1abc4538aa326fdbdaa285e6 100644 (file)
@@ -930,9 +930,10 @@ def create_sample_rates_config_entry(
 DEFAULT_STREAM_HEADERS = {
     "Server": APPLICATION_NAME,
     "transferMode.dlna.org": "Streaming",
-    "contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000",  # noqa: E501
+    "contentFeatures.dlna.org": "DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01700000000000000000000000000000",
     "Cache-Control": "no-cache",
     "Pragma": "no-cache",
+    "icy-name": APPLICATION_NAME,
 }
 ICY_HEADERS = {
     "icy-name": APPLICATION_NAME,
index 5631090aa61a030669baaaadea7939c85270878d..04b7d2ed3f2060033072bf50a685ff3283cdd3c0 100644 (file)
@@ -2002,15 +2002,12 @@ class PlayerQueuesController(CoreController):
             and new_state["state"] == PlaybackState.IDLE
         ):
             return
-        # check if no more items in the queue
+        # check if no more items in the queue (next_item should be None at end of queue)
         if queue.next_item is not None:
             return
-        # check if we had a previous item
+        # check if we had a previous item playing
         if prev_state["current_item_id"] is None:
             return
-        # check that we have a current item
-        if queue.current_item is None:
-            return
 
         async def _clear_queue_delayed():
             for _ in range(5):
@@ -2022,14 +2019,20 @@ class PlayerQueuesController(CoreController):
             self.logger.info("End of queue reached, clearing items")
             self.clear(queue.queue_id)
 
-        # all checks passed, we stopped playback at the last (or single) of the queue
-        # now determine if the item was fully played
-        if streamdetails := queue.current_item.streamdetails:
+        # 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
-        else:
+        elif queue.current_item:
             duration = queue.current_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)
         # 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:
             self.mass.create_task(_clear_queue_delayed())
 
index ae4988ba41a5d25b95a24814f8fbd9a9da2d3a2e..89e0f25907a6960797f5e3b0a6b5e16141d590ae 100644 (file)
@@ -399,6 +399,7 @@ class StreamsController(CoreController):
         headers = {
             **DEFAULT_STREAM_HEADERS,
             "icy-name": queue_item.name,
+            "contentFeatures.dlna.org": "DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01500000000000000000000000000000",  # noqa: E501
             "Accept-Ranges": "none",
             "Content-Type": f"audio/{output_format.output_format_str}",
         }
@@ -477,6 +478,7 @@ class StreamsController(CoreController):
         # this final ffmpeg process in the chain will convert the raw, lossless PCM audio into
         # the desired output format for the player including any player specific filter params
         # such as channels mixing, DSP, resampling and, only if needed, encoding to lossy formats
+        first_chunk_received = False
         async for chunk in get_ffmpeg_stream(
             audio_input=audio_input,
             input_format=pcm_format,
@@ -487,14 +489,16 @@ class StreamsController(CoreController):
                 input_format=pcm_format,
                 output_format=output_format,
             ),
-            # we need to slowly feed the music to avoid the player stopping and later
-            # restarting (or completely failing) the audio stream by keeping the buffer short.
-            # this is reported to be an issue especially with Chromecast players.
-            # see for example: https://github.com/music-assistant/support/issues/3717
-            extra_input_args=["-readrate", "1.0", "-readrate_initial_burst", "5"],
         ):
             try:
                 await resp.write(chunk)
+                if not first_chunk_received:
+                    first_chunk_received = True
+                    # inform the queue that the track is now loaded in the buffer
+                    # so for example the next track can be enqueued
+                    self.mass.player_queues.track_loaded_in_buffer(
+                        queue_item.queue_id, queue_item.queue_item_id
+                    )
             except (BrokenPipeError, ConnectionResetError, ConnectionError):
                 break
         if queue_item.streamdetails.stream_error:
@@ -553,6 +557,7 @@ class StreamsController(CoreController):
         headers = {
             **DEFAULT_STREAM_HEADERS,
             **ICY_HEADERS,
+            "contentFeatures.dlna.org": "DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01700000000000000000000000000000",  # noqa: E501
             "Accept-Ranges": "none",
             "Content-Type": f"audio/{output_format.output_format_str}",
         }
@@ -742,6 +747,7 @@ class StreamsController(CoreController):
         )
         headers = {
             **DEFAULT_STREAM_HEADERS,
+            "contentFeatures.dlna.org": "DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01700000000000000000000000000000",  # noqa: E501
             "icy-name": plugin_source.name,
             "Accept-Ranges": "none",
             "Content-Type": f"audio/{output_format.output_format_str}",
@@ -801,7 +807,7 @@ class StreamsController(CoreController):
         # like https hosts and it also offers the pre-announce 'bell'
         return f"{self.base_url}/announcement/{player_id}.{content_type.value}"
 
-    @use_buffer(30, 5)
+    @use_buffer(30, 1)
     async def get_queue_flow_stream(
         self,
         queue: PlayerQueue,
@@ -890,11 +896,19 @@ class StreamsController(CoreController):
             bytes_written = 0
             buffer = b""
             # handle incoming audio chunks
+            first_chunk_received = False
             async for chunk in self.get_queue_item_stream(
                 queue_track,
                 pcm_format=pcm_format,
                 seek_position=queue_track.streamdetails.seek_position,
             ):
+                if not first_chunk_received:
+                    first_chunk_received = True
+                    # inform the queue that the track is now loaded in the buffer
+                    # so the next track can be preloaded
+                    self.mass.player_queues.track_loaded_in_buffer(
+                        queue.queue_id, queue_track.queue_item_id
+                    )
                 # buffer size needs to be big enough to include the crossfade part
                 req_buffer_size = (
                     pcm_sample_size
@@ -1172,11 +1186,6 @@ class StreamsController(CoreController):
                 bytes_received += len(chunk)
                 if not first_chunk_received:
                     first_chunk_received = True
-                    # inform the queue that the track is now loaded in the buffer
-                    # so for example the next track can be enqueued
-                    self.mass.player_queues.track_loaded_in_buffer(
-                        queue_item.queue_id, queue_item.queue_item_id
-                    )
                     self.logger.debug(
                         "First audio chunk received for %s (%s) after %.2f seconds",
                         queue_item.name,
@@ -1227,7 +1236,7 @@ class StreamsController(CoreController):
             loop = asyncio.get_running_loop()
             await loop.run_in_executor(None, gc.collect)
 
-    @use_buffer(30, 5)
+    @use_buffer(30, 1)
     async def get_queue_item_stream_with_smartfade(
         self,
         queue_item: QueueItem,
index 56a127f25a59ab6eb187d1273db44a730f412f9d..45777c0430b80aad8b45ada1e2205bece42dedaa 100644 (file)
@@ -249,10 +249,18 @@ class AudioBuffer:
             self._buffer_fill_task.cancel()
             with suppress(asyncio.CancelledError):
                 await self._buffer_fill_task
+        # cancel the inactivity task
         if self._inactivity_task:
-            self._inactivity_task.cancel()
-            with suppress(asyncio.CancelledError):
-                await self._inactivity_task
+            current_task = asyncio.current_task()
+            # Don't await inactivity task cancellation if we're being called from it
+            # to avoid deadlock/blocking
+            if current_task != self._inactivity_task:
+                self._inactivity_task.cancel()
+                with suppress(asyncio.CancelledError):
+                    await self._inactivity_task
+            else:
+                # Just cancel it without waiting since we're inside it
+                self._inactivity_task.cancel()
         async with self._lock:
             # Replace the deque instead of clearing it to avoid blocking
             # Clearing a large deque can take >100ms
index 0dcd6b52a30fff29e2087ee23cfbf77324111f0d..b7b43db47e5552149eefff74f7f7af700afddb23 100644 (file)
@@ -2,7 +2,6 @@
 
 from __future__ import annotations
 
-import datetime
 from typing import TYPE_CHECKING
 from xml.sax.saxutils import escape as xmlescape
 
@@ -183,6 +182,7 @@ def create_didl_metadata(media: PlayerMedia) -> str:
     image_url = media.image_url or MASS_LOGO_ONLINE
     if media.media_type in (MediaType.FLOW_STREAM, MediaType.RADIO) or not media.duration:
         # flow stream, radio or other duration-less stream
+        # Use streaming-optimized DLNA flags to prevent buffering
         title = media.title or media.uri
         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/">'
@@ -192,15 +192,22 @@ def create_didl_metadata(media: PlayerMedia) -> str:
             f"<dc:queueItemId>{escape_metadata(media.uri)}</dc:queueItemId>"
             f"<dc:description>Music Assistant</dc:description>"
             "<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
-            f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
-            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_metadata(media.uri)}</res>'
+            f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01700000000000000000000000000000">{escape_metadata(media.uri)}</res>'
             "</item>"
             "</DIDL-Lite>"
         )
-    duration_str = str(datetime.timedelta(seconds=media.duration or 0)) + ".000"
 
     assert media.queue_item_id is not None  # for type checking
 
+    # For regular tracks with duration, use flags optimized for on-demand content
+    # DLNA.ORG_FLAGS=01500000000000000000000000000000 indicates:
+    # - Streaming transfer mode (bit 24)
+    # - Background transfer mode supported (bit 22)
+    # - DLNA v1.5 (bit 20)
+    duration_str = str(int(media.duration or 0) // 3600).zfill(2) + ":"
+    duration_str += str((int(media.duration or 0) % 3600) // 60).zfill(2) + ":"
+    duration_str += str(int(media.duration or 0) % 60).zfill(2)
+
     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:r="urn:schemas-rinconnetworks-com:metadata-1-0/">'
         f'<item id="{media.queue_item_id or xmlescape(media.uri)}" restricted="true" parentID="{media.source_id or ""}">'
@@ -208,13 +215,11 @@ def create_didl_metadata(media: PlayerMedia) -> str:
         f"<dc:creator>{escape_metadata(media.artist or '')}</dc:creator>"
         f"<upnp:album>{escape_metadata(media.album or '')}</upnp:album>"
         f"<upnp:artist>{escape_metadata(media.artist or '')}</upnp:artist>"
-        f"<upnp:duration>{int(media.duration or 0)}</upnp:duration>"
         f"<dc:queueItemId>{escape_metadata(media.queue_item_id)}</dc:queueItemId>"
         f"<dc:description>Music Assistant</dc:description>"
         f"<upnp:albumArtURI>{escape_metadata(image_url)}</upnp:albumArtURI>"
         "<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_metadata(media.uri)}</res>'
+        f'<res duration="{duration_str}" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01500000000000000000000000000000">{escape_metadata(media.uri)}</res>'
         '<desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">RINCON_AssociatedZPUDN</desc>'
         "</item>"
         "</DIDL-Lite>"
index be99014b88b98697a90ae2985380689d71bed7f6..d3526971ffd0938ff828b4e7cf7cf5fbfbc73764 100644 (file)
@@ -688,6 +688,13 @@ class SonosPlayer(Player):
             # the player has nothing loaded at all (empty queue and no service active)
             self._attr_active_source = None
 
+        # special case: Sonos reports PAUSED state when MA stopped playback
+        if (
+            active_service == MusicService.MUSIC_ASSISTANT
+            and self._attr_playback_state == PlaybackState.PAUSED
+        ):
+            self._attr_playback_state = PlaybackState.IDLE
+
         # parse current media
         self._attr_elapsed_time = self.client.player.group.position
         self._attr_elapsed_time_last_updated = time.time()