From 012867cb249646846588c43372f4b88273c77da9 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 25 Feb 2026 14:58:36 +0100 Subject: [PATCH] Fix PlayerQueue debounce logic for next/previous command --- music_assistant/controllers/player_queues.py | 184 ++++++++++--------- 1 file changed, 96 insertions(+), 88 deletions(-) diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index b0209d6d..1bfab895 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -484,6 +484,8 @@ class PlayerQueuesController(CoreController): to account for playback history per user when the play_media is called from a shared context (like a web hook or automation). """ + # cancel any pending play_index calls for this queue to prevent conflicts + self.mass.cancel_timer(f"queue_play_index_{queue_id}") # ruff: noqa: PLR0915 # we use a contextvar to bypass the throttler for this asyncio task/context # this makes sure that playback has priority over other requests that may be @@ -802,6 +804,8 @@ class PlayerQueuesController(CoreController): - queue_id: queue_id of the playerqueue to handle the command. """ + # cancel any pending play_index calls for this queue to prevent conflicts + self.mass.cancel_timer(f"queue_play_index_{queue_id}") queue_player = self.mass.players.get_player(queue_id, True) if queue_player is None: raise PlayerUnavailableError(f"Player {queue_id} is not available") @@ -843,6 +847,8 @@ class PlayerQueuesController(CoreController): - queue_id: queue_id of the playerqueue to handle the command. """ + # cancel any pending play_index calls for this queue to prevent conflicts + self.mass.cancel_timer(f"queue_play_index_{queue_id}") if not (queue := self._queues.get(queue_id)): return queue_active = queue.active @@ -900,23 +906,33 @@ class PlayerQueuesController(CoreController): """ if (queue := self.get(queue_id)) is None or not queue.active: raise InvalidCommand(f"Queue {queue_id} is not active") + # we set a flag to notify the update logic that we're transitioning to a new track + # NOTE that this flag is reset in the play_index method + self._transitioning_players.add(queue_id) idx = self._queues[queue_id].current_index if idx is None: self.logger.warning("Queue %s has no current index", queue.display_name) return - attempts = 5 - while attempts: - try: - if (next_index := self._get_next_index(queue_id, idx, True)) is not None: - await self.play_index(queue_id, next_index, debounce=True) - break - except MediaNotFoundError: - self.logger.warning( - "Failed to fetch next track for queue %s - trying next item", - queue.display_name, - ) - idx += 1 - attempts -= 1 + next_index = self._get_next_index(queue_id, idx, True) + if next_index is None: + return + + # immediately update current item so UI shows the new track right away + queue.current_index = next_index + queue.current_item = self.get_item(queue_id, next_index) + queue.elapsed_time = 0 + queue.elapsed_time_last_updated = time.time() + self.signal_update(queue_id) + if queue_player := self.mass.players.get_player(queue_id, True): + # also update player so it can update its 'current_media' + queue_player.update_state() + + # debounce rapid next button presses using call_later + self.mass.call_later( + 1, + self.play_index(queue_id, next_index), + task_id=f"queue_play_index_{queue_id}", + ) @api_command("player_queues/previous") async def previous(self, queue_id: str) -> None: @@ -926,15 +942,34 @@ class PlayerQueuesController(CoreController): """ if (queue := self.get(queue_id)) is None or not queue.active: raise InvalidCommand(f"Queue {queue_id} is not active") + # we set a flag to notify the update logic that we're transitioning to a new track + # NOTE that this flag is reset in the play_index method + self._transitioning_players.add(queue_id) current_index = self._queues[queue_id].current_index if current_index is None: return - next_index = int(current_index) + prev_index = int(current_index) # restart current track if current track has played longer than 4 # otherwise skip to previous track if self._queues[queue_id].elapsed_time < 5: - next_index = max(current_index - 1, 0) - await self.play_index(queue_id, next_index, debounce=True) + prev_index = max(current_index - 1, 0) + + # immediately update current item so UI shows the new track right away + queue.current_index = prev_index + queue.current_item = self.get_item(queue_id, prev_index) + queue.elapsed_time = 0 + queue.elapsed_time_last_updated = time.time() + self.signal_update(queue_id) + if queue_player := self.mass.players.get_player(queue_id, True): + # also update player so it can update its 'current_media' + queue_player.update_state() + + # debounce rapid previous button presses using call_later + self.mass.call_later( + 1, + self.play_index(queue_id, prev_index), + task_id=f"queue_play_index_{queue_id}", + ) @api_command("player_queues/skip") async def skip(self, queue_id: str, seconds: int = 10) -> None: @@ -1027,9 +1062,12 @@ class PlayerQueuesController(CoreController): index: int | str, seek_position: int = 0, fade_in: bool = False, - debounce: bool = False, ) -> None: """Play item at index (or item_id) X in queue.""" + # cancel any pending play_index calls for this queue to prevent conflicts + self.mass.cancel_timer(f"queue_play_index_{queue_id}") + # we set a flag to notify the update logic that we're transitioning to a new track + self._transitioning_players.add(queue_id) queue = self._queues[queue_id] queue.resume_pos = 0 if isinstance(index, str): @@ -1038,13 +1076,6 @@ class PlayerQueuesController(CoreController): raise InvalidDataError(f"Item {index} not found in queue") index = temp_index # At this point index is guaranteed to be int - queue.current_index = index - # update current item and elapsed time and signal update - # this way the UI knows immediately that a new item is loading - queue.current_item = self.get_item(queue_id, index) - queue.elapsed_time = seek_position - queue.elapsed_time_last_updated = time.time() - self.signal_update(queue_id) queue.index_in_buffer = index queue.flow_mode_stream_log = [] target_player = self.mass.players.get_player(queue_id) @@ -1061,72 +1092,49 @@ class PlayerQueuesController(CoreController): ): seek_position = max(0, int((resume_position_ms - 500) / 1000)) - # send play_media request to player - # NOTE that we debounce this a bit to account for someone hitting the next button - # like a madman. This will prevent the player from being overloaded with requests. - async def _play_index(index: int, debounce: bool) -> None: - for attempt in range(5): - try: - queue_item = self.get_item(queue_id, index) - if not queue_item: - continue # guard - await self._load_item( - queue_item, - self._get_next_index(queue_id, index), - is_start=True, - seek_position=seek_position if attempt == 0 else 0, - fade_in=fade_in if attempt == 0 else False, + # try to load the item, retry with next item if it fails + for attempt in range(5): + try: + queue_item = self.get_item(queue_id, index) + if not queue_item: + continue # guard + await self._load_item( + queue_item, + self._get_next_index(queue_id, index), + is_start=True, + seek_position=seek_position if attempt == 0 else 0, + fade_in=fade_in if attempt == 0 else False, + ) + # if we reach this point, loading the item succeeded, break the loop + queue.current_index = index + queue.current_item = queue_item + break + except (MediaNotFoundError, AudioError): + # the requested index can not be played. + if queue_item: + self.logger.warning( + "Skipping unplayable item %s (%s)", + queue_item.name, + queue_item.uri, ) - # if we reach this point, loading the item succeeded, break the loop - queue.current_index = index - queue.current_item = queue_item - break - except (MediaNotFoundError, AudioError): - # the requested index can not be played. - if queue_item: - self.logger.warning( - "Skipping unplayable item %s (%s)", - queue_item.name, - queue_item.uri, - ) - queue_item.available = False - next_index = self._get_next_index(queue_id, index, allow_repeat=False) - if next_index is None: - raise MediaNotFoundError("No next item available") - index = next_index - else: - # all attempts to find a playable item failed - raise MediaNotFoundError("No playable item found to start playback") - - # Reset flow_mode - the streams controller will set it if flow mode is used. - queue.flow_mode = False - await asyncio.sleep(0.5 if debounce else 0.1) - await self.mass.players.play_media( - player_id=queue_id, - media=await self.player_media_from_queue_item(queue_item), - ) - queue.current_index = index - queue.current_item = queue_item - await asyncio.sleep(2) - self._transitioning_players.discard(queue_id) - - # we set a flag to notify the update logic that we're transitioning to a new track - self._transitioning_players.add(queue_id) - - # we debounce the play_index command to handle the case where someone - # is spamming next/previous on the player - task_id = f"play_index_{queue_id}" - if existing_task := self.mass.get_task(task_id): - existing_task.cancel() - with suppress(asyncio.CancelledError): - await existing_task - task = self.mass.create_task( - _play_index, - index, - debounce, - task_id=task_id, + queue_item.available = False + next_index = self._get_next_index(queue_id, index, allow_repeat=False) + if next_index is None: + raise MediaNotFoundError("No next item available") + index = next_index + else: + # all attempts to find a playable item failed + raise MediaNotFoundError("No playable item found to start playback") + + # Reset flow_mode - the streams controller will set it if flow mode is used. + queue.flow_mode = False + await self.mass.players.play_media( + player_id=queue_id, + media=await self.player_media_from_queue_item(queue_item), ) - await task + queue.current_index = index + queue.current_item = queue_item + self._transitioning_players.discard(queue_id) self.signal_update(queue_id) @api_command("player_queues/transfer") -- 2.34.1