From: Marcel van der Veldt Date: Sat, 5 Apr 2025 13:33:48 +0000 (+0200) Subject: Several small fixes for playback and enqueuing (#2105) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=997bbac4dad88b1e8b45fce867e6bfd2fd6e1bf0;p=music-assistant-server.git Several small fixes for playback and enqueuing (#2105) --- diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 112b0a79..1df95f12 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -1022,7 +1022,7 @@ class PlayerQueuesController(CoreController): BYPASS_THROTTLER.set(True) self.logger.debug( - "loading (next) item for queue %s...", + "(pre)loading (next) item for queue %s...", queue.display_name, ) @@ -1100,9 +1100,6 @@ class PlayerQueuesController(CoreController): queue.index_in_buffer = self.index_by_id(queue_id, item_id) self.logger.debug("PlayerQueue %s loaded item %s in buffer", queue.display_name, item_id) self.signal_update(queue_id) - # enqueue the item on the player as soon as one is loaded - if next_item := self.get_next_item(queue_id, item_id): - self._enqueue_next_item(queue_id, next_item) # preload next streamdetails self._preload_next_item(queue_id, item_id) @@ -1143,12 +1140,18 @@ class PlayerQueuesController(CoreController): def update_items(self, queue_id: str, queue_items: list[QueueItem]) -> None: """Update the existing queue items, mostly caused by reordering.""" self._queue_items[queue_id] = queue_items - self._queues[queue_id].items = len(self._queue_items[queue_id]) + queue = self._queues[queue_id] + queue.items = len(self._queue_items[queue_id]) # to track if the queue items changed we set a timestamp # this is a simple way to detect changes in the list of items # without having to compare the entire list - self._queues[queue_id].items_last_updated = time.time() + queue.items_last_updated = time.time() self.signal_update(queue_id, True) + if queue.state == PlayerState.PLAYING: + # if the queue is playing, + # ensure to (re)queue the next track because it might have changed + if next_item := self.get_next_item(queue_id, queue.index_in_buffer): + self._enqueue_next_item(queue_id, next_item) # Helper methods @@ -1435,7 +1438,7 @@ class PlayerQueuesController(CoreController): if (next_index := self._get_next_index(queue_id, cur_index)) is None: break next_item = self.get_item(queue_id, next_index) - if next_item.media_item and not next_item.media_item.available: + if not next_item.available: # ensure that we skip unavailable items (set by load_next track logic) continue return next_item @@ -1457,7 +1460,7 @@ class PlayerQueuesController(CoreController): ) def _enqueue_next_item(self, queue_id: str, next_item: QueueItem | None) -> None: - """Enqueue/precache the next item on the player.""" + """Enqueue the next item on the player.""" if not next_item: # no next item, nothing to do... return @@ -1481,29 +1484,23 @@ class PlayerQueuesController(CoreController): ) task_id = f"enqueue_next_item_{queue_id}" - self.mass.create_task( - _enqueue_next_item_on_player(next_item), task_id=task_id, abort_existing=True - ) + self.mass.call_later(0.5, _enqueue_next_item_on_player, next_item, task_id=task_id) def _preload_next_item(self, queue_id: str, item_id_in_buffer: str) -> None: """ - Preload the next item in the queue. + Preload the streamdetails for the next item in the queue/buffer. This basically ensures the item is playable and fetches the stream details. If caching is enabled, this will also start filling the stream cache. If an error occurs, the item will be skipped and the next item will be loaded. """ - async def _preload_streamdetails() -> None: + async def _preload_streamdetails(item_id_in_buffer: str) -> None: try: next_item = await self.preload_next_queue_item(queue_id, item_id_in_buffer) + self._enqueue_next_item(queue_id, next_item) except QueueEmpty: return - # always send enqueue next (even though we may have already sent that) - # because it could have been changed and also because some players - # sometimes miss the enqueue_next call when its sent too short after - # the play_media call, so consider this a safety net. - self._enqueue_next_item(queue_id, next_item) if not (current_item := self.get_item(queue_id, item_id_in_buffer)): # this should not happen, but guard anyways @@ -1520,7 +1517,7 @@ class PlayerQueuesController(CoreController): return task_id = f"preload_next_item_{queue_id}" - self.mass.call_later(30, _preload_streamdetails, task_id=task_id) + self.mass.call_later(0.5, _preload_streamdetails, item_id_in_buffer, task_id=task_id) async def _resolve_media_items( self, media_item: MediaItemTypeOrItemMapping, start_item: str | None = None diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py index 0b185410..ee33fe88 100644 --- a/music_assistant/controllers/streams.py +++ b/music_assistant/controllers/streams.py @@ -13,7 +13,6 @@ import os import shutil import urllib.parse from collections.abc import AsyncGenerator -from contextlib import suppress from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -66,7 +65,7 @@ from music_assistant.helpers.audio import ( ) from music_assistant.helpers.audio import LOGGER as AUDIO_LOGGER from music_assistant.helpers.ffmpeg import LOGGER as FFMPEG_LOGGER -from music_assistant.helpers.ffmpeg import FFMpeg, check_ffmpeg_version, get_ffmpeg_stream +from music_assistant.helpers.ffmpeg import check_ffmpeg_version, get_ffmpeg_stream from music_assistant.helpers.util import ( get_folder_size, get_free_space, @@ -431,23 +430,29 @@ class StreamsController(CoreController): channels=2, ) - # 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_id, queue_item_id) - - # work out crossfade details - self._crossfade_data.setdefault(queue_id, CrossfadeData()) - crossfade_data = self._crossfade_data[queue_id] - enable_crossfade = self._get_crossfade_config(queue_item, flow_mode=False) + crossfade = await self.mass.config.get_player_config_value(queue.queue_id, CONF_CROSSFADE) + if crossfade and PlayerFeature.GAPLESS_PLAYBACK not in queue_player.supported_features: + # crossfade is not supported on this player due to missing gapless playback + self.logger.warning("Crossfade disabled: gapless playback not supported on player") + return False - async for chunk in get_ffmpeg_stream( - audio_input=self.get_queue_item_stream( + if crossfade: + # crossfade is enabled, use special crossfaded single item stream + # where the crossfade of the next track is present in the stream of + # a single track. This only works if the player supports gapless playback. + audio_input = self.get_queue_item_stream_with_crossfade( queue_item=queue_item, pcm_format=pcm_format, - enable_crossfade=enable_crossfade, - crossfade_data=crossfade_data, session_id=session_id, - ), + ) + else: + audio_input = self.get_queue_item_stream( + queue_item=queue_item, + pcm_format=pcm_format, + ) + + async for chunk in get_ffmpeg_stream( + audio_input=audio_input, input_format=pcm_format, output_format=output_format, filter_params=get_player_filter_params( @@ -473,36 +478,6 @@ class StreamsController(CoreController): await resp.write(chunk) except (BrokenPipeError, ConnectionResetError, ConnectionError): break - return resp - # lookup next item in queue to determine additional actions - next_item = self.mass.player_queues.get_next_item(queue_id, queue_item_id) - if not next_item: - # end of queue reached: make sure we yield the last_fadeout_part - if crossfade_data and crossfade_data.fadeout_part: - await resp.write(crossfade_data.fadeout_part) - crossfade_data.fadeout_part = b"" - if ( - crossfade_data.fadeout_part - and next_item - and next_item.streamdetails - and next_item.streamdetails.audio_format.sample_rate - != crossfade_data.pcm_format.sample_rate - and PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE not in queue_player.supported_features - ): - # next track's sample rate differs from current track - # most players do not properly support gapless playback between different sample rates - # so let's just output the fadeout data - crossfade_data.session_id = "" - self.logger.debug("Skipping crossfade: sample rate mismatch") - async with FFMpeg( - audio_input="-", - input_format=crossfade_data.pcm_format, - output_format=output_format, - ) as ffmpeg: - res = await ffmpeg.communicate(crossfade_data.fadeout_part) - with suppress(BrokenPipeError, ConnectionResetError, ConnectionError): - await resp.write(res[0]) - return resp async def serve_queue_flow_stream(self, request: web.Request) -> web.Response: @@ -792,69 +767,155 @@ class StreamsController(CoreController): """Get a flow stream of all tracks in the queue as raw PCM audio.""" # ruff: noqa: PLR0915 assert pcm_format.content_type.is_pcm() - queue_item: QueueItem | None = None - crossfade_data = CrossfadeData(b"", pcm_format) + queue_track = None + last_fadeout_part = b"" queue.flow_mode = True - if not start_queue_item: # this can happen in some (edge case) race conditions return - pcm_sample_size = int( pcm_format.sample_rate * (pcm_format.bit_depth / 8) * pcm_format.channels ) + crossfade_enabled = await self.mass.config.get_player_config_value( + queue.queue_id, CONF_CROSSFADE + ) + if start_queue_item.media_type != MediaType.TRACK: + # we only support crossfade for tracks, not for radio items + crossfade_enabled = False + crossfade_duration = self.mass.config.get_raw_player_config_value( + queue.queue_id, CONF_CROSSFADE_DURATION, 10 + ) self.logger.info( - "Start Queue Flow stream for Queue %s", + "Start Queue Flow stream for Queue %s - crossfade: %s", queue.display_name, + f"{crossfade_duration}s" if crossfade_enabled else "disabled", ) + total_bytes_sent = 0 + while True: # get (next) queue item to stream - if queue_item is None: - queue_item = start_queue_item + if queue_track is None: + queue_track = start_queue_item else: try: - queue_item = await self.mass.player_queues.preload_next_queue_item( - queue.queue_id, queue_item.queue_item_id + queue_track = await self.mass.player_queues.preload_next_queue_item( + queue.queue_id, queue_track.queue_item_id ) except QueueEmpty: break - if queue_item.streamdetails is None: + if queue_track.streamdetails is None: raise RuntimeError( "No Streamdetails known for queue item %s", - queue_item.queue_item_id, + queue_track.queue_item_id, ) - self.mass.player_queues.track_loaded_in_buffer(queue.queue_id, queue_item.queue_item_id) + self.logger.debug( + "Start Streaming queue track: %s (%s) for queue %s", + queue_track.streamdetails.uri, + queue_track.name, + queue.display_name, + ) # append to play log so the queue controller can work out which track is playing - play_log_entry = PlayLogEntry(queue_item.queue_item_id) + play_log_entry = PlayLogEntry(queue_track.queue_item_id) queue.flow_mode_stream_log.append(play_log_entry) - # work out crossfade details - enable_crossfade = self._get_crossfade_config(queue_item, flow_mode=True) - + # set some basic vars + pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2) + crossfade_size = int(pcm_sample_size * crossfade_duration) + bytes_written = 0 + buffer = b"" # handle incoming audio chunks async for chunk in self.get_queue_item_stream( - queue_item, + queue_track, pcm_format=pcm_format, - enable_crossfade=enable_crossfade, - crossfade_data=crossfade_data, ): - yield chunk + # buffer size needs to be big enough to include the crossfade part + req_buffer_size = pcm_sample_size if not crossfade_enabled else crossfade_size + + # ALWAYS APPEND CHUNK TO BUFFER + buffer += chunk + del chunk + if len(buffer) < req_buffer_size: + # buffer is not full enough, move on + continue - #### HANDLE END OF TRACK - play_log_entry.seconds_streamed = queue_item.streamdetails.seconds_streamed - play_log_entry.duration = queue_item.streamdetails.duration + #### HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK + if last_fadeout_part: + # perform crossfade + fadein_part = buffer[:crossfade_size] + remaining_bytes = buffer[crossfade_size:] + crossfade_part = await crossfade_pcm_parts( + fadein_part, + last_fadeout_part, + pcm_format=pcm_format, + ) + # send crossfade_part (as one big chunk) + bytes_written += len(crossfade_part) + yield crossfade_part + + # also write the leftover bytes from the crossfade action + if remaining_bytes: + yield remaining_bytes + bytes_written += len(remaining_bytes) + del remaining_bytes + # clear vars + last_fadeout_part = b"" + buffer = b"" + + #### OTHER: enough data in buffer, feed to output + while len(buffer) > req_buffer_size: + yield buffer[:pcm_sample_size] + bytes_written += pcm_sample_size + buffer = buffer[pcm_sample_size:] + #### HANDLE END OF TRACK + if last_fadeout_part: + # edge case: we did not get enough data to make the crossfade + yield last_fadeout_part + bytes_written += len(last_fadeout_part) + last_fadeout_part = b"" + if self._crossfade_allowed(queue_track, flow_mode=True): + # if crossfade is enabled, save fadeout part to pickup for next track + last_fadeout_part = buffer[-crossfade_size:] + remaining_bytes = buffer[:-crossfade_size] + if remaining_bytes: + yield remaining_bytes + bytes_written += len(remaining_bytes) + del remaining_bytes + elif buffer: + # no crossfade enabled, just yield the buffer last part + bytes_written += len(buffer) + yield buffer + # make sure the buffer gets cleaned up + del buffer + + # update duration details based on the actual pcm data we sent + # this also accounts for crossfade and silence stripping + seconds_streamed = bytes_written / pcm_sample_size + queue_track.streamdetails.seconds_streamed = seconds_streamed + queue_track.streamdetails.duration = ( + queue_track.streamdetails.seek_position + seconds_streamed + ) + play_log_entry.seconds_streamed = seconds_streamed + play_log_entry.duration = queue_track.streamdetails.duration + total_bytes_sent += bytes_written + self.logger.debug( + "Finished Streaming queue track: %s (%s) on queue %s", + queue_track.streamdetails.uri, + queue_track.name, + queue.display_name, + ) #### HANDLE END OF QUEUE FLOW STREAM # end of queue flow: make sure we yield the last_fadeout_part - if crossfade_data and crossfade_data.fadeout_part: - yield crossfade_data.fadeout_part + if last_fadeout_part: + yield last_fadeout_part # correct seconds streamed/duration - last_part_seconds = len(crossfade_data.fadeout_part) / pcm_sample_size - queue_item.streamdetails.seconds_streamed += last_part_seconds - queue_item.streamdetails.duration += last_part_seconds - del crossfade_data + last_part_seconds = len(last_fadeout_part) / pcm_sample_size + queue_track.streamdetails.seconds_streamed += last_part_seconds + queue_track.streamdetails.duration += last_part_seconds + del last_fadeout_part + total_bytes_sent += bytes_written self.logger.info("Finished Queue Flow stream for Queue %s", queue.display_name) async def get_announcement_stream( @@ -931,27 +992,12 @@ class StreamsController(CoreController): self, queue_item: QueueItem, pcm_format: AudioFormat, - enable_crossfade: bool = False, - crossfade_data: CrossfadeData | None = None, - session_id: str | None = None, ) -> AsyncGenerator[bytes, None]: """Get the audio stream for a single queue item as raw PCM audio.""" # collect all arguments for ffmpeg streamdetails = queue_item.streamdetails assert streamdetails filter_params = [] - crossfade_duration = self.mass.config.get_raw_player_config_value( - queue_item.queue_id, CONF_CROSSFADE_DURATION, 10 - ) - - queue = self.mass.player_queues.get(queue_item.queue_id) - self.logger.debug( - "Start Streaming queue track: %s (%s) for queue %s - crossfade: %s", - queue_item.streamdetails.uri, - queue_item.name, - queue.display_name, - f"{crossfade_duration}s" if enable_crossfade else "disabled", - ) # handle volume normalization gain_correct: float | None = None @@ -980,49 +1026,72 @@ class StreamsController(CoreController): filter_params.append(f"volume={gain_correct}dB") streamdetails.volume_normalization_gain_correct = gain_correct - pad_silence_seconds = 0 if streamdetails.media_type == MediaType.RADIO or not streamdetails.duration: # pad some silence before the radio/live stream starts to create some headroom # for radio stations (or other live streams) that do not provide any look ahead buffer # without this, some radio streams jitter a lot, especially with dynamic normalization, # if the stream does not provide a look ahead buffer - pad_silence_seconds = 4 + async for silence in get_silence(4, pcm_format): + yield silence + del silence + + first_chunk_received = False + async for chunk in get_media_stream( + self.mass, + streamdetails=streamdetails, + pcm_format=pcm_format, + filter_params=filter_params, + ): + 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 + ) + yield chunk + del chunk + + async def get_queue_item_stream_with_crossfade( + self, + queue_item: QueueItem, + pcm_format: AudioFormat, + session_id: str | None = None, + ) -> AsyncGenerator[bytes, None]: + """Get the audio stream for a single queue item with crossfade to the next item.""" + queue = self.mass.player_queues.get(queue_item.queue_id) + streamdetails = queue_item.streamdetails + assert streamdetails + crossfade_duration = self.mass.config.get_raw_player_config_value( + queue_item.queue_id, CONF_CROSSFADE_DURATION, 10 + ) + self._crossfade_data.setdefault(queue.queue_id, CrossfadeData()) + crossfade_data = self._crossfade_data[queue.queue_id] + + self.logger.debug( + "Start Streaming queue track: %s (%s) for queue %s - crossfade: %s", + queue_item.streamdetails.uri, + queue_item.name, + queue.display_name, + f"{crossfade_duration} seconds", + ) if crossfade_data.session_id != session_id: # invalidate expired crossfade data crossfade_data.fadeout_part = b"" - first_chunk_received = False buffer = b"" bytes_written = 0 pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2) # buffer size needs to be big enough to include the crossfade part crossfade_size = int(pcm_sample_size * crossfade_duration) - req_buffer_size = pcm_sample_size - if enable_crossfade or (crossfade_data and crossfade_data.fadeout_part): - # crossfade is enabled, so we need to make sure we have enough data in the buffer - # to perform the crossfade - req_buffer_size += crossfade_size - - async for chunk in get_media_stream( - self.mass, - streamdetails=streamdetails, - pcm_format=pcm_format, - filter_params=filter_params, - ): - # yield silence when the chunk has been received from source but not yet sent to player - # so we have a bit of backpressure to prevent jittering - if not first_chunk_received and pad_silence_seconds: - first_chunk_received = True - async for silence in get_silence(pad_silence_seconds, pcm_format): - yield silence - del silence + async for chunk in self.get_queue_item_stream(queue_item, pcm_format): # ALWAYS APPEND CHUNK TO BUFFER buffer += chunk del chunk - if len(buffer) < req_buffer_size: + if len(buffer) < crossfade_size: # buffer is not full enough, move on continue @@ -1052,7 +1121,7 @@ class StreamsController(CoreController): del fade_in_part #### OTHER: enough data in buffer, feed to output - while len(buffer) > req_buffer_size: + while len(buffer) > crossfade_size: yield buffer[:pcm_sample_size] bytes_written += pcm_sample_size buffer = buffer[pcm_sample_size:] @@ -1063,8 +1132,9 @@ class StreamsController(CoreController): if crossfade_data.pcm_format == pcm_format: yield crossfade_data.fadeout_part bytes_written += len(crossfade_data.fadeout_part) - crossfade_data.fadeout_part = b"" - if enable_crossfade: + # always reset fadeout part at this point + crossfade_data.fadeout_part = b"" + if self._crossfade_allowed(queue_item, flow_mode=False): # if crossfade is enabled, save fadeout part to pickup for next track crossfade_data.fadeout_part = buffer[-crossfade_size:] crossfade_data.pcm_format = pcm_format @@ -1076,7 +1146,7 @@ class StreamsController(CoreController): bytes_written += len(remaining_bytes) del remaining_bytes elif buffer: - # no crossfade enabled, just yield the buffer last part + # no crossfade enabled/allowed, just yield the buffer last part bytes_written += len(buffer) yield buffer # make sure the buffer gets cleaned up @@ -1201,20 +1271,12 @@ class StreamsController(CoreController): # reschedule self self.mass.call_later(3600, self._clean_audio_cache) - def _get_crossfade_config(self, queue_item: QueueItem, flow_mode: bool = False) -> bool: + def _crossfade_allowed(self, queue_item: QueueItem, flow_mode: bool = False) -> bool: """Get the crossfade config for a queue item.""" if not (queue_player := self.mass.players.get(queue_item.queue_id)): return False # just a guard - use_crossfade = self.mass.config.get_raw_player_config_value( - queue_item.queue_id, CONF_CROSSFADE, False - ) - if not use_crossfade: - return False - if not flow_mode and PlayerFeature.GAPLESS_PLAYBACK not in queue_player.supported_features: - # crossfade is not supported on this player due to missing gapless playback - self.logger.debug("Skipping crossfade: gapless playback not supported on player") - return False if queue_item.media_type != MediaType.TRACK: + self.logger.debug("Skipping crossfade: current item is not a track") return False # check if the next item is part of the same album next_item = self.mass.player_queues.get_next_item( diff --git a/music_assistant/providers/audible/audible_helper.py b/music_assistant/providers/audible/audible_helper.py index 3054dabe..3eb37da0 100644 --- a/music_assistant/providers/audible/audible_helper.py +++ b/music_assistant/providers/audible/audible_helper.py @@ -10,6 +10,8 @@ import logging import os import re from collections.abc import AsyncGenerator +from contextlib import suppress +from datetime import datetime from os import PathLike from typing import Any from urllib.parse import parse_qs, urlparse @@ -527,7 +529,9 @@ class AudibleHelper: str(audiobook_data.get("extended_product_description", "")) ) book.metadata.languages = UniqueList([audiobook_data.get("language") or ""]) - book.metadata.release_date = audiobook_data.get("release_date") + if release_date := audiobook_data.get("release_date"): + with suppress(ValueError): + book.metadata.release_date = datetime.fromisoformat(release_date) # Set review if available reviews = audiobook_data.get("editorial_reviews", []) diff --git a/music_assistant/providers/deezer/__init__.py b/music_assistant/providers/deezer/__init__.py index 37dd768a..26434278 100644 --- a/music_assistant/providers/deezer/__init__.py +++ b/music_assistant/providers/deezer/__init__.py @@ -519,8 +519,6 @@ class DeezerProvider(MusicProvider): metadata.duration = track.duration if hasattr(track, "rank"): metadata.popularity = track.rank - if hasattr(track, "release_date"): - metadata.release_date = track.release_date if hasattr(track, "album") and hasattr(track.album, "cover_big"): metadata.images = [ MediaItemImage( diff --git a/music_assistant/providers/filesystem_local/__init__.py b/music_assistant/providers/filesystem_local/__init__.py index 3fe868de..d0ca6ef7 100644 --- a/music_assistant/providers/filesystem_local/__init__.py +++ b/music_assistant/providers/filesystem_local/__init__.py @@ -57,6 +57,7 @@ from music_assistant.constants import ( DB_TABLE_TRACK_ARTISTS, VARIOUS_ARTISTS_MBID, VARIOUS_ARTISTS_NAME, + VERBOSE_LOG_LEVEL, ) from music_assistant.helpers.compare import compare_strings, create_safe_string from music_assistant.helpers.json import json_loads @@ -384,7 +385,7 @@ class LocalFileSystemProvider(MusicProvider): def _process_item(self, item: FileSystemItem, prev_checksum: str | None) -> bool: """Process a single item. NOT async friendly.""" try: - self.logger.debug("Processing: %s", item.relative_path) + self.logger.log(VERBOSE_LOG_LEVEL, "Processing: %s", item.relative_path) # ignore playlists that are in album directories # we need to run this check early because the setting may have changed diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index 0c03fd17..e1c33dc5 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -29,8 +29,6 @@ from music_assistant_models.enums import ( ) from music_assistant_models.player import DeviceInfo, Player, PlayerMedia -from music_assistant.constants import CONF_CROSSFADE - from .const import ( CONF_AIRPLAY_MODE, PLAYBACK_STATE_MAP, @@ -520,22 +518,17 @@ class SonosPlayer: queue = self.mass.player_queues.get(queue_id) if not queue or queue.state not in (PlayerState.PLAYING, PlayerState.PAUSED): return - crossfade = await self.mass.config.get_player_config_value(queue.queue_id, CONF_CROSSFADE) repeat_single_enabled = queue.repeat_mode == RepeatMode.ONE repeat_all_enabled = queue.repeat_mode == RepeatMode.ALL play_modes = self.client.player.group.play_modes if ( - play_modes.crossfade != crossfade - or play_modes.repeat != repeat_all_enabled + play_modes.repeat != repeat_all_enabled or play_modes.repeat_one != repeat_single_enabled - or play_modes.shuffle != queue.shuffle_enabled ): try: await self.client.player.group.set_play_modes( - crossfade=crossfade, repeat=repeat_all_enabled, repeat_one=repeat_single_enabled, - shuffle=queue.shuffle_enabled, ) except FailedCommand as err: if "groupCoordinatorChanged" not in str(err): diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py index ef000149..51234e97 100644 --- a/music_assistant/providers/sonos/provider.py +++ b/music_assistant/providers/sonos/provider.py @@ -145,8 +145,8 @@ class SonosPlayerProvider(PlayerProvider): """Return Config Entries for the given player.""" base_entries = ( *await super().get_player_config_entries(player_id), - CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, CONF_ENTRY_HTTP_PROFILE_DEFAULT_2, create_sample_rates_config_entry( max_sample_rate=48000, max_bit_depth=24, safe_max_bit_depth=24, hidden=True @@ -325,15 +325,16 @@ class SonosPlayerProvider(PlayerProvider): await sonos_player.client.player.group.set_group_members(group_childs) return - if ( - media.queue_id - and media.media_type - not in ( - MediaType.PLUGIN_SOURCE, - MediaType.FLOW_STREAM, - ) - and not media.queue_id.startswith("ugp_") - ): + if media.media_type in ( + MediaType.PLUGIN_SOURCE, + MediaType.FLOW_STREAM, + ) or media.queue_id.startswith("ugp_"): + # flow stream or plugin source playback + # use the legacy playback method for this as it also + await self._play_media_legacy(sonos_player, media) + return + + if media.queue_id: # Regular Queue item playback # create a sonos cloud queue and load it cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/" @@ -525,7 +526,7 @@ class SonosPlayerProvider(PlayerProvider): "canRepeat": True, "canRepeatOne": True, "canCrossfade": False, # crossfading is handled by our streams controller - "canShuffle": True, + "canShuffle": False, # handled by our streams controller }, } return web.json_response(result) diff --git a/music_assistant/providers/tidal/__init__.py b/music_assistant/providers/tidal/__init__.py index 59ac7bdb..4d409467 100644 --- a/music_assistant/providers/tidal/__init__.py +++ b/music_assistant/providers/tidal/__init__.py @@ -7,6 +7,7 @@ import functools import json from collections.abc import Awaitable, Callable from contextlib import suppress +from datetime import datetime from enum import StrEnum from typing import TYPE_CHECKING, Any, TypeVar, cast @@ -17,11 +18,7 @@ from aiohttp.client_exceptions import ( ClientPayloadError, ClientResponseError, ) -from music_assistant_models.config_entries import ( - ConfigEntry, - ConfigValueOption, - ConfigValueType, -) +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType from music_assistant_models.enums import ( AlbumType, ConfigEntryType, @@ -53,14 +50,8 @@ from music_assistant_models.media_items import ( ) from music_assistant_models.streamdetails import StreamDetails -from music_assistant.constants import ( - CACHE_CATEGORY_DEFAULT, - CACHE_CATEGORY_RECOMMENDATIONS, -) -from music_assistant.helpers.throttle_retry import ( - ThrottlerManager, - throttle_with_retries, -) +from music_assistant.constants import CACHE_CATEGORY_DEFAULT, CACHE_CATEGORY_RECOMMENDATIONS +from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries from music_assistant.models.music_provider import MusicProvider from .auth_manager import ManualAuthenticationHelper, TidalAuthManager @@ -1601,12 +1592,13 @@ class TidalProvider(MusicProvider): album.album_type = AlbumType.SINGLE # Safely parse year - release_date = album_obj.get("releaseDate", "") - if release_date: + if release_date := album_obj.get("releaseDate", ""): try: album.year = int(release_date.split("-")[0]) except (ValueError, IndexError): self.logger.debug("Invalid release date format: %s", release_date) + with suppress(ValueError): + album.metadata.release_date = datetime.fromisoformat(release_date) # Safely set metadata upc = album_obj.get("upc") diff --git a/music_assistant/providers/ytmusic/__init__.py b/music_assistant/providers/ytmusic/__init__.py index 170bdad2..9f9b1686 100644 --- a/music_assistant/providers/ytmusic/__init__.py +++ b/music_assistant/providers/ytmusic/__init__.py @@ -5,6 +5,8 @@ from __future__ import annotations import asyncio import logging from collections.abc import AsyncGenerator +from contextlib import suppress +from datetime import datetime from io import StringIO from typing import TYPE_CHECKING, Any from urllib.parse import unquote @@ -866,7 +868,8 @@ class YoutubeMusicProvider(MusicProvider): if thumbnails := episode_obj.get("thumbnails"): episode.metadata.images = self._parse_thumbnails(thumbnails) if release_date := episode_obj.get("date"): - episode.metadata.release_date = release_date + with suppress(ValueError): + episode.metadata.release_date = datetime.fromisoformat(release_date) return episode async def _get_stream_format(self, item_id: str) -> dict[str, Any]: