From: Marcel van der Veldt Date: Sun, 8 Sep 2024 12:31:44 +0000 (+0200) Subject: Fix several issues with enqueueing of next track (#1653) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=9b30c40a3d6ea74b7482dd254b696f59a14cc815;p=music-assistant-server.git Fix several issues with enqueueing of next track (#1653) --- diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index 064ca3be..4c2a404b 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -359,6 +359,7 @@ CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED = ConfigEntry.from_dict( {**CONF_ENTRY_FLOW_MODE.to_dict(), "default_value": False, "value": False, "hidden": True} ) + CONF_ENTRY_AUTO_PLAY = ConfigEntry( key=CONF_AUTO_PLAY, type=ConfigEntryType.BOOLEAN, diff --git a/music_assistant/common/models/enums.py b/music_assistant/common/models/enums.py index 7229c57f..8374afa9 100644 --- a/music_assistant/common/models/enums.py +++ b/music_assistant/common/models/enums.py @@ -292,7 +292,6 @@ class PlayerFeature(StrEnum): PAUSE = "pause" SYNC = "sync" SEEK = "seek" - ENQUEUE_NEXT = "enqueue_next" PLAY_ANNOUNCEMENT = "play_announcement" UNKNOWN = "unknown" diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index 9fdc0407..b373cbf2 100644 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -19,7 +19,6 @@ from music_assistant.common.models.enums import ( ConfigEntryType, EventType, MediaType, - PlayerFeature, PlayerState, QueueOption, RepeatMode, @@ -276,7 +275,20 @@ class PlayerQueuesController(CoreController): if queue.repeat_mode == repeat_mode: return # no change queue.repeat_mode = repeat_mode + # ensure that we restart playback or trigger enqueue next if repeat mode changed self.signal_update(queue_id) + if ( + repeat_mode == RepeatMode.ONE + and queue.flow_mode + and queue.state == PlayerState.PLAYING + and queue.current_index != queue.index_in_buffer + ): + # edge case; repeat one enabled in flow mode but the + # flow stream had already loaded a new item in the buffer, + # we need to restart playback + self.mass.create_task(self.resume(queue_id)) + else: + self.mass.create_task(self._enqueue_next(queue, queue.current_index)) @api_command("player_queues/play_media") async def play_media( @@ -955,9 +967,18 @@ class PlayerQueuesController(CoreController): elif prev_state["current_index"] != new_state["current_index"]: queue.end_of_track_reached = False - # handle enqueuing of next item to play - if not queue.flow_mode or queue.stream_finished: - self._check_enqueue_next(player, queue, prev_state, new_state) + # handle auto restart of queue in flow mode when repeat is enabled + if ( + queue.flow_mode + and queue.repeat_mode != RepeatMode.OFF + and queue.stream_finished + and prev_state["state"] == PlayerState.PLAYING + and new_state["state"] == PlayerState.IDLE + ): + # flow mode and repeat mode is on, restart the queue + next_index = self._get_next_index(queue_id, queue.current_index, allow_repeat=True) + if next_index is not None: + self.mass.create_task(self.play_index(queue_id, next_index)) # do not send full updates if only time was updated if changed_keys == {"elapsed_time"}: @@ -1064,6 +1085,20 @@ class PlayerQueuesController(CoreController): raise QueueEmpty("No more (playable) tracks left in the queue.") return next_item + def track_loaded_in_buffer(self, queue_id: str, item_id: str) -> None: + """Call when a player has (started) loading a track in the buffer.""" + queue = self.get(queue_id) + if not queue: + msg = f"PlayerQueue {queue_id} is not available" + raise PlayerUnavailableError(msg) + queue.index_in_buffer = self.index_by_id(queue_id, item_id) + if queue.flow_mode: + return # nothing to do when flow mode is active + self.signal_update(queue_id) + # enqueue the next track as soon as the player reports + # it has started buffering the given queue item + self.mass.create_task(self._enqueue_next(queue, item_id)) + # Main queue manipulation methods def load( @@ -1131,6 +1166,13 @@ class PlayerQueuesController(CoreController): base_key=queue_id, ) ) + # signal preload of next item (to ensure the player loads the correct next item) + if queue.index_in_buffer is not None: + task_id = f"enqueue_next_{queue.queue_id}" + self.mass.call_later( + 1, self._enqueue_next(queue, queue.index_in_buffer), task_id=task_id + ) + # always send the base event self.mass.signal_event(EventType.QUEUE_UPDATED, object_id=queue_id, data=queue) # save state @@ -1221,88 +1263,19 @@ class PlayerQueuesController(CoreController): await asyncio.sleep(5) setattr(self, debounce_key, None) - def _check_enqueue_next( - self, - player: Player, - queue: PlayerQueue, - prev_state: CompareState, - new_state: CompareState, - ) -> None: - """Check if we need to enqueue the next item to the player itself.""" - if not queue.active: - return - if prev_state["state"] != PlayerState.PLAYING: - return + async def _enqueue_next(self, queue: PlayerQueue, current_index: int | str) -> None: + """Enqueue the next item in the queue.""" if (player := self.mass.players.get(queue.queue_id)) and player.announcement_in_progress: self.logger.warning("Ignore queue command: An announcement is in progress") return - current_item = self.get_item(queue.queue_id, queue.current_index) - if not current_item: - return # guard, just in case something bad happened - if not current_item.duration: - return - # NOTE: 'seconds_streamed' can actually be 0 if there was a stream error! - if current_item.streamdetails and current_item.streamdetails.seconds_streamed is not None: - duration = current_item.streamdetails.seconds_streamed - else: - duration = current_item.duration - seconds_remaining = int(duration - player.corrected_elapsed_time) - - async def _enqueue_next(current_index: int, supports_enqueue: bool = False) -> None: - if ( - player := self.mass.players.get(queue.queue_id) - ) and player.announcement_in_progress: - self.logger.warning("Ignore queue command: An announcement is in progress") - return - with suppress(QueueEmpty): - next_item = await self.preload_next_item(queue.queue_id, current_index) - if supports_enqueue: - await self.mass.players.enqueue_next_media( - player_id=player.player_id, - media=self.player_media_from_queue_item(next_item, queue.flow_mode), - ) - return - await self.play_index(queue.queue_id, next_item.queue_item_id) - - # handle queue fully played - clear it completely once the player stopped - if ( - queue.stream_finished - and queue.state == PlayerState.IDLE - and self._get_next_index(queue.queue_id, queue.current_index) is None - ): - self.logger.debug("End of queue reached for %s", queue.display_name) - self.clear(queue.queue_id) - return - - # handle native enqueue next support of player - if PlayerFeature.ENQUEUE_NEXT in player.supported_features: - # we enqueue the next track after a new track - # has started playing and (repeat) before the current track ends - new_track_started = ( - new_state["state"] == PlayerState.PLAYING - and prev_state["current_index"] != new_state["current_index"] + if isinstance(current_index, str): + current_index = self.index_by_id(queue.queue_id, current_index) + with suppress(QueueEmpty): + next_item = await self.preload_next_item(queue.queue_id, current_index) + await self.mass.players.enqueue_next_media( + player_id=player.player_id, + media=self.player_media_from_queue_item(next_item, queue.flow_mode), ) - if ( - new_track_started - or seconds_remaining == 15 - or int(player.corrected_elapsed_time) == 1 - ): - self.mass.create_task(_enqueue_next(queue.current_index, True)) - return - - # player does not support enqueue next feature. - # we wait for the player to stop after it reaches the end of the track - if ( - (not queue.flow_mode or queue.repeat_mode in (RepeatMode.ALL, RepeatMode.ONE)) - # we have a couple of guards here to prevent the player starting - # playback again when its stopped outside of MA's control - and queue.stream_finished - and queue.end_of_track_reached - and queue.state == PlayerState.IDLE - ): - queue.stream_finished = None - self.mass.create_task(_enqueue_next(queue.current_index, False)) - return async def _get_radio_tracks(self, queue_id: str) -> list[MediaItemType]: """Call the registered music providers for dynamic tracks.""" diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index c505ee14..6aed2e99 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -338,7 +338,7 @@ class StreamsController(CoreController): queue_item.uri, queue.display_name, ) - queue.index_in_buffer = self.mass.player_queues.index_by_id(queue_id, queue_item_id) + self.mass.player_queues.track_loaded_in_buffer(queue_id, queue_item_id) pcm_format = AudioFormat( content_type=ContentType.from_bit_depth(output_format.bit_depth), sample_rate=queue_item.streamdetails.audio_format.sample_rate, @@ -617,7 +617,7 @@ class StreamsController(CoreController): queue_track.name, queue.display_name, ) - queue.index_in_buffer = self.mass.player_queues.index_by_id( + self.mass.player_queues.track_loaded_in_buffer( queue.queue_id, queue_track.queue_item_id ) diff --git a/music_assistant/server/models/player_provider.py b/music_assistant/server/models/player_provider.py index f1646752..534673eb 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -167,9 +167,8 @@ class PlayerProvider(Provider): """ Handle enqueuing of the next (queue) item on the player. - Only called if the player supports PlayerFeature.ENQUE_NEXT. - Called about 1 second after a new track started playing. - Called about 15 seconds before the end of the current track. + Called when player reports it started buffering a queue item + and when the queue items updated. A PlayerProvider implementation is in itself responsible for handling this so that the queue items keep playing until its empty or the player stopped. diff --git a/music_assistant/server/providers/_template_player_provider/__init__.py b/music_assistant/server/providers/_template_player_provider/__init__.py index 268247bf..7d2cfaa9 100644 --- a/music_assistant/server/providers/_template_player_provider/__init__.py +++ b/music_assistant/server/providers/_template_player_provider/__init__.py @@ -215,7 +215,6 @@ class MyDemoPlayerprovider(PlayerProvider): PlayerFeature.VOLUME_SET, PlayerFeature.VOLUME_MUTE, PlayerFeature.PLAY_ANNOUNCEMENT, # see play_announcement method - PlayerFeature.ENQUEUE_NEXT, # see play_media/enqueue_next_media methods ), ) # register the player with the player manager @@ -333,9 +332,8 @@ class MyDemoPlayerprovider(PlayerProvider): """ Handle enqueuing of the next (queue) item on the player. - Only called if the player supports PlayerFeature.ENQUE_NEXT. - Called about 1 second after a new track started playing. - Called about 15 seconds before the end of the current track. + Called when player reports it started buffering a queue item + and when the queue items updated. A PlayerProvider implementation is in itself responsible for handling this so that the queue items keep playing until its empty or the player stopped. @@ -343,7 +341,6 @@ class MyDemoPlayerprovider(PlayerProvider): This will NOT be called if the end of the queue is reached (and repeat disabled). This will NOT be called if the player is using flow mode to playback the queue. """ - # OPTIONAL - required only if you specified PlayerFeature.ENQUEUE_NEXT # this method should handle the enqueuing of the next queue item on the player. async def cmd_sync(self, player_id: str, target_player: str) -> None: diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index ca040528..9c54500e 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -372,7 +372,6 @@ class ChromecastProvider(PlayerProvider): PlayerFeature.POWER, PlayerFeature.VOLUME_MUTE, PlayerFeature.VOLUME_SET, - PlayerFeature.ENQUEUE_NEXT, PlayerFeature.PAUSE, ), enabled_by_default=enabled_by_default, @@ -431,7 +430,6 @@ class ChromecastProvider(PlayerProvider): castplayer.player.supported_features = ( PlayerFeature.POWER, PlayerFeature.VOLUME_SET, - PlayerFeature.ENQUEUE_NEXT, PlayerFeature.PAUSE, ) diff --git a/music_assistant/server/providers/dlna/__init__.py b/music_assistant/server/providers/dlna/__init__.py index 94901754..7566825e 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -64,20 +64,8 @@ BASE_PLAYER_FEATURES = ( PlayerFeature.VOLUME_SET, ) -CONF_ENQUEUE_NEXT = "enqueue_next" - PLAYER_CONFIG_ENTRIES = ( - ConfigEntry( - key=CONF_ENQUEUE_NEXT, - type=ConfigEntryType.BOOLEAN, - label="Player supports enqueue next/gapless", - default_value=False, - description="If the player supports enqueuing the next item for fluid/gapless playback. " - "\n\nUnfortunately this feature is missing or broken on many DLNA players. \n" - "Enable it with care. If music stops after one song, " - "disable this setting (and use flow-mode instead).", - ), CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED, CONF_ENTRY_CROSSFADE_DURATION, CONF_ENTRY_ENFORCE_MP3, @@ -627,9 +615,3 @@ class DLNAPlayerProvider(PlayerProvider): def _set_player_features(self, dlna_player: DLNAPlayer) -> None: """Set Player Features based on config values and capabilities.""" dlna_player.player.supported_features = BASE_PLAYER_FEATURES - player_id = dlna_player.player.player_id - if self.mass.config.get_raw_player_config_value(player_id, CONF_ENQUEUE_NEXT, False): - dlna_player.player.supported_features = ( - *dlna_player.player.supported_features, - PlayerFeature.ENQUEUE_NEXT, - ) diff --git a/music_assistant/server/providers/hass_players/__init__.py b/music_assistant/server/providers/hass_players/__init__.py index 708bb711..15454d20 100644 --- a/music_assistant/server/providers/hass_players/__init__.py +++ b/music_assistant/server/providers/hass_players/__init__.py @@ -17,7 +17,7 @@ from music_assistant.common.models.config_entries import ( CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED, CONF_ENTRY_ENABLE_ICY_METADATA, CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED, - CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED, + CONF_ENTRY_FLOW_MODE_ENFORCED, CONF_ENTRY_HTTP_PROFILE, ConfigEntry, ConfigValueOption, @@ -120,6 +120,16 @@ async def _get_hass_media_players( yield state +async def _get_hass_media_player( + hass_prov: HomeAssistantProvider, entity_id: str +) -> HassState | None: + """Return Hass state object for a single media_player entity.""" + for state in await hass_prov.hass.get_states(): + if state["entity_id"] == entity_id: + return state + return None + + async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: @@ -198,9 +208,13 @@ class HomeAssistantPlayers(PlayerProvider): """Return all (provider/player specific) Config Entries for the given player (if any).""" entries = await super().get_player_config_entries(player_id) entries = entries + PLAYER_CONFIG_ENTRIES - if player := self.mass.players.get(player_id): - if PlayerFeature.ENQUEUE_NEXT not in player.supported_features: - entries += (CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED,) + if hass_state := await _get_hass_media_player(self.hass_prov, player_id): + hass_supported_features = MediaPlayerEntityFeature( + hass_state["attributes"]["supported_features"] + ) + if MediaPlayerEntityFeature.MEDIA_ENQUEUE not in hass_supported_features: + entries += (CONF_ENTRY_FLOW_MODE_ENFORCED,) + return entries async def cmd_stop(self, player_id: str) -> None: @@ -371,8 +385,6 @@ class HomeAssistantPlayers(PlayerProvider): supported_features.append(PlayerFeature.SYNC) if MediaPlayerEntityFeature.PAUSE in hass_supported_features: supported_features.append(PlayerFeature.PAUSE) - if MediaPlayerEntityFeature.MEDIA_ENQUEUE in hass_supported_features: - supported_features.append(PlayerFeature.ENQUEUE_NEXT) if MediaPlayerEntityFeature.VOLUME_SET in hass_supported_features: supported_features.append(PlayerFeature.VOLUME_SET) if MediaPlayerEntityFeature.VOLUME_MUTE in hass_supported_features: diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index f9085291..7dcbfd53 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -465,12 +465,14 @@ class SlimprotoProvider(PlayerProvider): transition_duration = 0 metadata = { - "item_id": media.queue_item_id or media.uri, + "item_id": media.uri, "title": media.title, "album": media.album, "artist": media.artist, "image_url": media.image_url, "duration": media.duration, + "queue_id": media.queue_id, + "queue_item_id": media.queue_item_id, } queue = self.mass.player_queues.get(media.queue_id or player_id) slimplayer.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode] @@ -643,7 +645,6 @@ class SlimprotoProvider(PlayerProvider): PlayerFeature.VOLUME_SET, PlayerFeature.PAUSE, PlayerFeature.VOLUME_MUTE, - PlayerFeature.ENQUEUE_NEXT, ), can_sync_with=tuple( x.player_id for x in self.slimproto.players if x.player_id != player_id @@ -653,11 +654,19 @@ class SlimprotoProvider(PlayerProvider): # update player state on player events player.available = True - player.current_item_id = ( - slimplayer.current_media.metadata.get("item_id") - if slimplayer.current_media and slimplayer.current_media.metadata - else slimplayer.current_url - ) + if slimplayer.current_media and (metadata := slimplayer.current_media.metadata): + player.current_media = PlayerMedia( + uri=metadata.get("item_id"), + title=metadata.get("title"), + album=metadata.get("album"), + artist=metadata.get("artist"), + image_url=metadata.get("image_url"), + duration=metadata.get("duration"), + queue_id=metadata.get("queue_id"), + queue_item_id=metadata.get("queue_item_id"), + ) + else: + player.current_media = None player.active_source = player.player_id player.name = slimplayer.name player.powered = slimplayer.powered diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index ab8d8334..1e8b03fe 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -65,7 +65,6 @@ PLAYBACK_STATE_MAP = { PLAYER_FEATURES_BASE = { PlayerFeature.SYNC, PlayerFeature.VOLUME_MUTE, - PlayerFeature.ENQUEUE_NEXT, PlayerFeature.PAUSE, } diff --git a/music_assistant/server/providers/sonos_s1/__init__.py b/music_assistant/server/providers/sonos_s1/__init__.py index b4ffeef0..7f388f3f 100644 --- a/music_assistant/server/providers/sonos_s1/__init__.py +++ b/music_assistant/server/providers/sonos_s1/__init__.py @@ -55,7 +55,6 @@ if TYPE_CHECKING: PLAYER_FEATURES = ( PlayerFeature.SYNC, PlayerFeature.VOLUME_MUTE, - PlayerFeature.ENQUEUE_NEXT, PlayerFeature.PAUSE, ) @@ -179,7 +178,12 @@ class SonosPlayerProvider(PlayerProvider): base_entries = await super().get_player_config_entries(player_id) if not (self.sonosplayers.get(player_id)): # most probably a syncgroup - return (*base_entries, CONF_ENTRY_CROSSFADE, CONF_ENTRY_ENFORCE_MP3) + return ( + *base_entries, + CONF_ENTRY_CROSSFADE, + CONF_ENTRY_ENFORCE_MP3, + CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED, + ) return ( *base_entries, CONF_ENTRY_CROSSFADE, diff --git a/music_assistant/server/providers/sonos_s1/player.py b/music_assistant/server/providers/sonos_s1/player.py index 6732a3ab..92d5016a 100644 --- a/music_assistant/server/providers/sonos_s1/player.py +++ b/music_assistant/server/providers/sonos_s1/player.py @@ -47,7 +47,6 @@ PLAYER_FEATURES = ( PlayerFeature.SYNC, PlayerFeature.VOLUME_MUTE, PlayerFeature.VOLUME_SET, - PlayerFeature.ENQUEUE_NEXT, ) DURATION_SECONDS = "duration_in_s" POSITION_SECONDS = "position_in_s"