From: Marcel van der Veldt Date: Sun, 26 Oct 2025 14:34:33 +0000 (+0100) Subject: Fix several issues when streaming to (DLNA based) players (#2551) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=d60ce33d39e00f0294cef85dd7e9b4eb9896acef;p=music-assistant-server.git Fix several issues when streaming to (DLNA based) players (#2551) * 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 --- diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 3ead2c86..50cf41d8 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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, diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 5631090a..04b7d2ed 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -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()) diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py index ae4988ba..89e0f259 100644 --- a/music_assistant/controllers/streams.py +++ b/music_assistant/controllers/streams.py @@ -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, diff --git a/music_assistant/helpers/audio_buffer.py b/music_assistant/helpers/audio_buffer.py index 56a127f2..45777c04 100644 --- a/music_assistant/helpers/audio_buffer.py +++ b/music_assistant/helpers/audio_buffer.py @@ -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 diff --git a/music_assistant/helpers/upnp.py b/music_assistant/helpers/upnp.py index 0dcd6b52..b7b43db4 100644 --- a/music_assistant/helpers/upnp.py +++ b/music_assistant/helpers/upnp.py @@ -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 ( '' @@ -192,15 +192,22 @@ def create_didl_metadata(media: PlayerMedia) -> str: f"{escape_metadata(media.uri)}" f"Music Assistant" "object.item.audioItem.audioBroadcast" - f"audio/{ext}" - f'{escape_metadata(media.uri)}' + f'{escape_metadata(media.uri)}' "" "" ) - 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 ( '' f'' @@ -208,13 +215,11 @@ def create_didl_metadata(media: PlayerMedia) -> str: f"{escape_metadata(media.artist or '')}" f"{escape_metadata(media.album or '')}" f"{escape_metadata(media.artist or '')}" - f"{int(media.duration or 0)}" f"{escape_metadata(media.queue_item_id)}" f"Music Assistant" f"{escape_metadata(image_url)}" "object.item.audioItem.musicTrack" - f"audio/{ext}" - f'{escape_metadata(media.uri)}' + f'{escape_metadata(media.uri)}' 'RINCON_AssociatedZPUDN' "" "" diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index be99014b..d3526971 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -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()