"contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000", # noqa: E501
"Cache-Control": "no-cache",
"Connection": "close",
+ # "Accept-Ranges": "none",
"icy-name": "Music Assistant",
"icy-pub": "0",
}
)
# prepare request, add some DLNA/UPNP compatible headers
enable_icy = request.headers.get("Icy-MetaData", "") == "1"
- icy_meta_interval = 65536 if output_format.content_type.is_lossless() else 8192
+ icy_meta_interval = 16384 * 4 if output_format.content_type.is_lossless() else 16384
headers = {
**DEFAULT_STREAM_HEADERS,
"Content-Type": f"audio/{output_format.output_format_str}",
continue
# if icy metadata is enabled, send the icy metadata after the chunk
- current_item = self.mass.player_queues.get_item(
- queue.queue_id, queue.index_in_buffer
- )
if (
- current_item
+ # use current item here and not buffered item, otherwise
+ # the icy metadata will be too much ahead
+ (current_item := queue.current_item)
and current_item.streamdetails
and current_item.streamdetails.stream_title
):
else:
title = "Music Assistant"
metadata = f"StreamTitle='{title}';".encode()
+ if current_item and current_item.image:
+ metadata += f"StreamURL='{current_item.image.path}'".encode()
while len(metadata) % 16 != 0:
metadata += b"\x00"
length = len(metadata)
def get_chunksize(
- content_type: ContentType,
- sample_rate: int = 44100,
- bit_depth: int = 16,
+ fmt: AudioFormat,
seconds: int = 1,
) -> int:
"""Get a default chunksize for given contenttype."""
- pcm_size = int(sample_rate * (bit_depth / 8) * 2 * seconds)
- if content_type.is_pcm() or content_type == ContentType.WAV:
+ pcm_size = int(fmt.sample_rate * (fmt.bit_depth / 8) * 2 * seconds)
+ if fmt.content_type.is_pcm() or fmt.content_type == ContentType.WAV:
return pcm_size
- if content_type in (ContentType.WAV, ContentType.AIFF, ContentType.DSF):
+ if fmt.content_type in (ContentType.WAV, ContentType.AIFF, ContentType.DSF):
return pcm_size
- if content_type in (ContentType.FLAC, ContentType.WAVPACK, ContentType.ALAC):
- return int(pcm_size * 0.6)
- if content_type in (ContentType.MP3, ContentType.OGG, ContentType.M4A):
- return int(640000 * seconds)
- return 32000 * seconds
+ if fmt.content_type in (ContentType.FLAC, ContentType.WAVPACK, ContentType.ALAC):
+ return int(pcm_size * 0.5)
+ if fmt.content_type in (ContentType.MP3, ContentType.OGG):
+ return int((320000 / 8) * seconds)
+ if fmt.content_type in (ContentType.AAC, ContentType.M4A):
+ return int((256000 / 8) * seconds)
+ return int((320000 / 8) * seconds)
async def _get_ffmpeg_args(
f"<upnp:albumArtURI>{escape_string(MASS_LOGO_ONLINE)}</upnp:albumArtURI>"
"<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
- f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
+ 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(url)}</res>'
"</item>"
"</DIDL-Lite>"
)
# radio or other non-track item
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'<item id="{queue_item.queue_item_id}" parentID="0" restricted="1">'
+ '<item id="1" parentID="0" restricted="1">'
f"<dc:title>{escape_string(queue_item.name)}</dc:title>"
f"<upnp:albumArtURI>{escape_string(image_url)}</upnp:albumArtURI>"
f"<dc:queueItemId>{queue_item.queue_item_id}</dc:queueItemId>"
"<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
- f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
+ 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(url)}</res>'
"</item>"
"</DIDL-Lite>"
)
album = escape_string(queue_item.media_item.album.name)
else:
album = ""
- item_class = "object.item.audioItem.musicTrack"
- duration_str = str(datetime.timedelta(seconds=queue_item.duration))
+ duration_str = str(datetime.timedelta(seconds=queue_item.duration)) + ".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/">'
- f'<item id="{queue_item.queue_item_id}" parentID="0" restricted="1">'
+ '<item id="1" parentID="0" restricted="1">'
f"<dc:title>{title}</dc:title>"
f"<dc:creator>{artist}</dc:creator>"
f"<upnp:album>{album}</upnp:album>"
f"<upnp:artist>{artist}</upnp:artist>"
- f"<upnp:duration>{queue_item.duration}</upnp:duration>"
+ f"<upnp:duration>{int(queue_item.duration)}</upnp:duration>"
"<upnp:playlistTitle>Music Assistant</upnp:playlistTitle>"
f"<dc:queueItemId>{queue_item.queue_item_id}</dc:queueItemId>"
f"<upnp:albumArtURI>{escape_string(image_url)}</upnp:albumArtURI>"
- f"<upnp:class>{item_class}</upnp:class>"
+ "<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
- f'<res duration="{duration_str}" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{escape_string(url)}</res>'
+ 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(url)}</res>'
"</item>"
"</DIDL-Lite>"
)
from collections.abc import Awaitable, Callable, Coroutine, Sequence
from contextlib import suppress
from dataclasses import dataclass, field
+from ipaddress import IPv4Address
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
from async_upnp_client.aiohttp import AiohttpSessionRequester
supports_next_uri: bool | None = None
end_of_track_reached: float | None = None
last_command: float = field(default_factory=time.time)
+ need_elapsed_time_workaround: bool = False
def update_attributes(self):
"""Update attributes of the MA Player from DLNA state."""
self.player.state = self.get_state(self.device)
self.player.supported_features = self.get_supported_features(self.device)
self.player.current_url = self.device.current_track_uri or ""
- self.player.elapsed_time = float(self.device.media_position or 0)
- if self.device.media_position_updated_at is not None:
- self.player.elapsed_time_last_updated = (
- self.device.media_position_updated_at.timestamp()
- )
+ if self.device.media_position:
+ # only update elapsed_time if the device actually reports it
+ self.player.elapsed_time = float(self.device.media_position)
+ if self.device.media_position_updated_at is not None:
+ self.player.elapsed_time_last_updated = (
+ self.device.media_position_updated_at.timestamp()
+ )
# some dlna players get stuck at the end of the track and won't
# automatically play the next track, try to workaround that
if (
await dlna_player.device.async_set_transport_uri(url, title, didl_metadata)
# Play it
await dlna_player.device.async_wait_for_can_play(10)
+ # optimistically set this timestamp to help in case of a player
+ # that does not report the progress
+ now = time.time()
+ dlna_player.player.elapsed_time = 0
+ dlna_player.player.elapsed_time_last_updated = now
await dlna_player.device.async_play()
+
# force poll the device
for sleep in (1, 2):
await asyncio.sleep(sleep)
finally:
dlna_player.force_poll = False
- async def _run_discovery(self) -> None:
+ async def _run_discovery(self, use_multicast: bool = False) -> None:
"""Discover DLNA players on the network."""
if self._discovery_running:
return
await self._device_discovered(ssdp_udn, discovery_info["location"])
- await async_search(on_response)
+ # we iterate between using a regular and multicast search
+ if use_multicast:
+ await async_search(on_response, target=(str(IPv4Address("255.255.255.255")), 1900))
+ else:
+ await async_search(on_response)
finally:
self._discovery_running = False
def reschedule():
- self.mass.create_task(self._run_discovery())
+ self.mass.create_task(self._run_discovery(use_multicast=not use_multicast))
# reschedule self once finished
self.mass.loop.call_later(120, reschedule)
# enqueue next item if needed
if (
dlna_player.player.state == PlayerState.PLAYING
+ and dlna_player.player.player_id in current_url
and (not dlna_player.next_url or dlna_player.next_url == current_url)
# prevent race conditions at start/stop by doing this check
- and (time.time() - dlna_player.last_command) > 10
+ and (time.time() - dlna_player.last_command) > 4
):
self.mass.create_task(self._enqueue_next_track(dlna_player))
- # try to detect a player that gets stuck at the end of the track
- if (
- dlna_player.end_of_track_reached
- and dlna_player.next_url
- and dlna_player.supports_next_uri
- and time.time() - dlna_player.end_of_track_reached > 10
- ):
- self.logger.warning(
- "Detected that the player is stuck at the end of the track, "
- "enabling workaround for this player."
- )
- dlna_player.supports_next_uri = False
# if player does not support next uri, manual play it
if (
not dlna_player.supports_next_uri
file_path, self.instance_id
):
if library_item.media_type == MediaType.TRACK:
- album_ids.add(library_item.album.item_id)
- for artist in library_item.artists + library_item.album.artists:
+ if library_item.album:
+ album_ids.add(library_item.album.item_id)
+ for artist in library_item.album.artists:
+ artist_ids.add(artist.item_id)
+ for artist in library_item.artists:
artist_ids.add(artist.item_id)
await controller.remove_item_from_library(library_item.item_id)
# check if any albums need to be cleaned up
is_stereo_pair: bool = False
next_url: str | None = None
elapsed_time: int = 0
- radio_mode_started: float | None = None
+ playback_started: float | None = None
+ need_elapsed_time_workaround: bool = False
subscriptions: list[SubscriptionBase] = field(default_factory=list)
# track info
if update_track_info:
self.track_info = self.soco.get_current_track_info()
+ # sonos reports bullshit elapsed time while playing radio (or flow mode),
+ # trying to be "smart" and resetting the counter when new ICY metadata is detected
+ # we try to detect this and work around it
+ self.need_elapsed_time_workaround = self.track_info["duration"] == "0:00:00"
+ if not self.need_elapsed_time_workaround:
+ self.elapsed_time = _timespan_secs(self.track_info["position"]) or 0
self.track_info_updated = time.time()
- self.elapsed_time = _timespan_secs(self.track_info["position"]) or 0
# speaker info
if update_speaker_info:
def update_attributes(self):
"""Update attributes of the MA Player from soco.SoCo state."""
+ now = time.time()
# generic attributes (speaker_info)
self.player.name = self.speaker_info["zone_name"]
self.player.volume_level = int(self.rendering_control_info["volume"])
# transport info (playback state)
current_transport_state = self.transport_info["current_transport_state"]
- new_state = _convert_state(current_transport_state)
- self.player.state = new_state
+ self.player.state = current_state = _convert_state(current_transport_state)
+
+ if self.playback_started is not None and current_state == PlayerState.IDLE:
+ self.playback_started = None
+ elif self.playback_started is None and current_state == PlayerState.PLAYING:
+ self.playback_started = now
# media info (track info)
self.player.current_url = self.track_info["uri"]
-
- if self.radio_mode_started is not None:
- # sonos reports bullshit elapsed time while playing radio,
- # trying to be "smart" and resetting the counter when new ICY metadata is detected
- if new_state == PlayerState.PLAYING:
- now = time.time()
- self.player.elapsed_time = int(now - self.radio_mode_started + 0.5)
- self.player.elapsed_time_last_updated = now
- else:
+ if not self.need_elapsed_time_workaround:
self.player.elapsed_time = self.elapsed_time
self.player.elapsed_time_last_updated = self.track_info_updated
class SonosPlayerProvider(PlayerProvider):
"""Sonos Player provider."""
- sonosplayers: dict[str, SonosPlayer]
- _discovery_running: bool
+ sonosplayers: dict[str, SonosPlayer] | None = None
+ _discovery_running: bool = False
+ _discovery_reschedule_timer: asyncio.TimerHandle | None = None
async def handle_setup(self) -> None:
"""Handle async initialization of the provider."""
async def unload(self) -> None:
"""Handle close/cleanup of the provider."""
- if hasattr(self, "sonosplayers"):
- for player in self.sonosplayers.values():
- player.soco.end_direct_control_session
+ if self._discovery_reschedule_timer:
+ self._discovery_reschedule_timer.cancel()
+ self._discovery_reschedule_timer = None
+ # await any in-progress discovery
+ while self._discovery_running:
+ await asyncio.sleep(0.5)
+ # cleanup players
+ if self.sonosplayers:
+ for player_id in list(self.sonosplayers):
+ player = self.sonosplayers.pop(player_id)
+ player.player.available = False
+ player.soco.end_direct_control_session()
+ self.sonosplayers = None
def on_player_config_changed(
self, config: PlayerConfig, changed_keys: set[str] # noqa: ARG002
return
await asyncio.to_thread(sonos_player.soco.stop)
await asyncio.to_thread(sonos_player.soco.clear_queue)
+ sonos_player.playback_started = None
async def cmd_play(self, player_id: str) -> None:
"""Send PLAY command to given player."""
if queue_item is None:
# enforce mp3 radio mode for flow stream
url = url.replace(".flac", ".mp3").replace(".wav", ".mp3")
- sonos_player.radio_mode_started = time.time()
await asyncio.to_thread(
sonos_player.soco.play_uri, url, title="Music Assistant", force_radio=True
)
else:
- sonos_player.radio_mode_started = None
await self._enqueue_item(sonos_player, url=url, queue_item=queue_item)
await asyncio.to_thread(sonos_player.soco.play_from_queue, 0)
+ # optimistically set this timestamp to help figure out elapsed time later
+ now = time.time()
+ sonos_player.playback_started = now
+ sonos_player.player.elapsed_time = 0
+ sonos_player.player.elapsed_time_last_updated = now
async def cmd_pause(self, player_id: str) -> None:
"""Send PAUSE command to given player."""
player_id,
)
return
+ if sonos_player.need_elapsed_time_workaround:
+ # no pause allowed when radio/flow mode is active
+ await self.cmd_stop()
+ return
await asyncio.to_thread(sonos_player.soco.pause)
async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
except ConnectionResetError as err:
raise PlayerUnavailableError from err
- async def _run_discovery(self) -> None:
+ async def _run_discovery(self, allow_network_scan=False) -> None:
"""Discover Sonos players on the network."""
if self._discovery_running:
return
self._discovery_running = True
self.logger.debug("Sonos discovery started...")
discovered_devices: set[soco.SoCo] = await asyncio.to_thread(
- soco.discover, 120, allow_network_scan=True
+ soco.discover, allow_network_scan=allow_network_scan
)
if discovered_devices is None:
discovered_devices = set()
self._discovery_running = False
def reschedule():
- self.mass.create_task(self._run_discovery())
+ self._discovery_reschedule_timer = None
+ self.mass.create_task(self._run_discovery(allow_network_scan=not allow_network_scan))
# reschedule self once finished
- self.mass.loop.call_later(300, reschedule)
+ self._discovery_reschedule_timer = self.mass.loop.call_later(120, reschedule)
async def _device_discovered(self, soco_device: soco.SoCo) -> None:
"""Handle discovered Sonos player."""
if signal_update:
# send update to the player manager right away only if we are triggered from an event
- # when we're just updating from a manual poll, the player manager will
- # update will detect changes to the player object itself
+ # when we're just updating from a manual poll, the player manager
+ # will detect changes to the player object itself
self.mass.players.update(sonos_player.player_id)
# enqueue next item if needed