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,
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):
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())
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}",
}
# 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,
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:
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}",
}
)
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}",
# 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,
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
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,
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,
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
from __future__ import annotations
-import datetime
from typing import TYPE_CHECKING
from xml.sax.saxutils import escape as xmlescape
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/">'
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 ""}">'
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>"
# 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()