From 9f15583bf45e743a0ae45015197047043d7673fd Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 29 Jul 2023 13:04:08 +0200 Subject: [PATCH] Some fixes for dlna based players (#800) * some small fixes * some finishing touches * increase default icy interval * adjust helper for chunksize * rework didl lite generator * allow run multicast scan for dlna and sonos * some more fixes for dlna players * fix small typo --- music_assistant/server/controllers/streams.py | 12 +-- music_assistant/server/helpers/audio.py | 22 +++--- music_assistant/server/helpers/didl_lite.py | 17 ++--- .../server/providers/dlna/__init__.py | 45 +++++------ .../server/providers/filesystem_local/base.py | 7 +- .../server/providers/sonos/__init__.py | 75 ++++++++++++------- 6 files changed, 104 insertions(+), 74 deletions(-) diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index a8d68619..989bb4aa 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -60,6 +60,7 @@ DEFAULT_STREAM_HEADERS = { "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", } @@ -565,7 +566,7 @@ class StreamsController(CoreController): ) # 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}", @@ -637,11 +638,10 @@ class StreamsController(CoreController): 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 ): @@ -651,6 +651,8 @@ class StreamsController(CoreController): 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) diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py index 96c8d250..56d67030 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -716,22 +716,22 @@ async def get_silence( 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( diff --git a/music_assistant/server/helpers/didl_lite.py b/music_assistant/server/helpers/didl_lite.py index 7a8f0edd..f7cd3cda 100644 --- a/music_assistant/server/helpers/didl_lite.py +++ b/music_assistant/server/helpers/didl_lite.py @@ -27,7 +27,7 @@ def create_didl_metadata( f"{escape_string(MASS_LOGO_ONLINE)}" "object.item.audioItem.audioBroadcast" f"audio/{ext}" - f'{escape_string(url)}' + f'{escape_string(url)}' "" "" ) @@ -37,13 +37,13 @@ def create_didl_metadata( # radio or other non-track item return ( '' - f'' + '' f"{escape_string(queue_item.name)}" f"{escape_string(image_url)}" f"{queue_item.queue_item_id}" "object.item.audioItem.audioBroadcast" f"audio/{ext}" - f'{escape_string(url)}' + f'{escape_string(url)}' "" "" ) @@ -56,22 +56,21 @@ def create_didl_metadata( 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 ( '' - f'' + '' f"{title}" f"{artist}" f"{album}" f"{artist}" - f"{queue_item.duration}" + f"{int(queue_item.duration)}" "Music Assistant" f"{queue_item.queue_item_id}" f"{escape_string(image_url)}" - f"{item_class}" + "object.item.audioItem.audioBroadcast" f"audio/{ext}" - f'{escape_string(url)}' + f'{escape_string(url)}' "" "" ) diff --git a/music_assistant/server/providers/dlna/__init__.py b/music_assistant/server/providers/dlna/__init__.py index d9fedf51..14da2528 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -13,6 +13,7 @@ import time 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 @@ -130,6 +131,7 @@ class DLNAPlayer: 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.""" @@ -143,11 +145,13 @@ class DLNAPlayer: 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 ( @@ -290,7 +294,13 @@ class DLNAPlayerProvider(PlayerProvider): 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) @@ -362,7 +372,7 @@ class DLNAPlayerProvider(PlayerProvider): 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 @@ -394,13 +404,17 @@ class DLNAPlayerProvider(PlayerProvider): 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) @@ -607,23 +621,12 @@ class DLNAPlayerProvider(PlayerProvider): # 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 diff --git a/music_assistant/server/providers/filesystem_local/base.py b/music_assistant/server/providers/filesystem_local/base.py index fa6f9cbc..292f8557 100644 --- a/music_assistant/server/providers/filesystem_local/base.py +++ b/music_assistant/server/providers/filesystem_local/base.py @@ -370,8 +370,11 @@ class FileSystemProviderBase(MusicProvider): 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 diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 97da3d80..200be689 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -78,7 +78,8 @@ class SonosPlayer: 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) @@ -112,8 +113,13 @@ class SonosPlayer: # 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: @@ -131,6 +137,7 @@ class SonosPlayer: 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"]) @@ -138,20 +145,16 @@ class SonosPlayer: # 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 @@ -208,8 +211,9 @@ class SonosPlayer: 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.""" @@ -222,9 +226,19 @@ class SonosPlayerProvider(PlayerProvider): 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 @@ -244,6 +258,7 @@ class SonosPlayerProvider(PlayerProvider): 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.""" @@ -287,14 +302,17 @@ class SonosPlayerProvider(PlayerProvider): 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.""" @@ -305,6 +323,10 @@ class SonosPlayerProvider(PlayerProvider): 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: @@ -371,7 +393,7 @@ class SonosPlayerProvider(PlayerProvider): 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 @@ -379,7 +401,7 @@ class SonosPlayerProvider(PlayerProvider): 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() @@ -404,10 +426,11 @@ class SonosPlayerProvider(PlayerProvider): 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.""" @@ -582,8 +605,8 @@ class SonosPlayerProvider(PlayerProvider): 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 -- 2.34.1