music-assistant-server

114.5 KBPY
player_queues.py
114.5 KB2,620 lines • python
1"""
2MusicAssistant Player Queues Controller.
3
4Handles all logic to PLAY Media Items, provided by Music Providers to supported players.
5
6It is loosely coupled to the MusicAssistant Music Controller and Player Controller.
7A Music Assistant Player always has a PlayerQueue associated with it
8which holds the queue items and state.
9
10The PlayerQueue is in that case the active source of the player,
11but it can also be something else, hence the loose coupling.
12"""
13
14from __future__ import annotations
15
16import asyncio
17import random
18import time
19from contextlib import suppress
20from types import NoneType
21from typing import TYPE_CHECKING, Any, TypedDict, cast
22
23import shortuuid
24from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
25from music_assistant_models.enums import (
26    ConfigEntryType,
27    ContentType,
28    EventType,
29    MediaType,
30    PlaybackState,
31    PlayerFeature,
32    ProviderFeature,
33    QueueOption,
34    RepeatMode,
35)
36from music_assistant_models.errors import (
37    AudioError,
38    InvalidCommand,
39    InvalidDataError,
40    MediaNotFoundError,
41    MusicAssistantError,
42    PlayerUnavailableError,
43    QueueEmpty,
44    UnsupportedFeaturedException,
45)
46from music_assistant_models.media_items import (
47    Album,
48    Artist,
49    Audiobook,
50    BrowseFolder,
51    ItemMapping,
52    MediaItemType,
53    PlayableMediaItemType,
54    Playlist,
55    Podcast,
56    PodcastEpisode,
57    Track,
58    UniqueList,
59    media_from_dict,
60)
61from music_assistant_models.playback_progress_report import MediaItemPlaybackProgressReport
62from music_assistant_models.player_queue import PlayerQueue
63from music_assistant_models.queue_item import QueueItem
64
65from music_assistant.constants import (
66    ATTR_ANNOUNCEMENT_IN_PROGRESS,
67    CONF_FLOW_MODE,
68    MASS_LOGO_ONLINE,
69    VERBOSE_LOG_LEVEL,
70)
71from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
72from music_assistant.helpers.api import api_command
73from music_assistant.helpers.audio import get_stream_details, get_stream_dsp_details
74from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER
75from music_assistant.helpers.util import get_changed_keys, percentage
76from music_assistant.models.core_controller import CoreController
77from music_assistant.models.player import Player, PlayerMedia
78
79if TYPE_CHECKING:
80    from collections.abc import Iterator
81
82    from music_assistant_models.auth import User
83    from music_assistant_models.media_items.metadata import MediaItemImage
84
85    from music_assistant import MusicAssistant
86    from music_assistant.models.player import Player
87
88
89CONF_DEFAULT_ENQUEUE_SELECT_ARTIST = "default_enqueue_select_artist"
90CONF_DEFAULT_ENQUEUE_SELECT_ALBUM = "default_enqueue_select_album"
91
92ENQUEUE_SELECT_ARTIST_DEFAULT_VALUE = "all_tracks"
93ENQUEUE_SELECT_ALBUM_DEFAULT_VALUE = "all_tracks"
94
95CONF_DEFAULT_ENQUEUE_OPTION_ARTIST = "default_enqueue_option_artist"
96CONF_DEFAULT_ENQUEUE_OPTION_ALBUM = "default_enqueue_option_album"
97CONF_DEFAULT_ENQUEUE_OPTION_TRACK = "default_enqueue_option_track"
98CONF_DEFAULT_ENQUEUE_OPTION_RADIO = "default_enqueue_option_radio"
99CONF_DEFAULT_ENQUEUE_OPTION_PLAYLIST = "default_enqueue_option_playlist"
100CONF_DEFAULT_ENQUEUE_OPTION_AUDIOBOOK = "default_enqueue_option_audiobook"
101CONF_DEFAULT_ENQUEUE_OPTION_PODCAST = "default_enqueue_option_podcast"
102CONF_DEFAULT_ENQUEUE_OPTION_PODCAST_EPISODE = "default_enqueue_option_podcast_episode"
103CONF_DEFAULT_ENQUEUE_OPTION_FOLDER = "default_enqueue_option_folder"
104CONF_DEFAULT_ENQUEUE_OPTION_UNKNOWN = "default_enqueue_option_unknown"
105RADIO_TRACK_MAX_DURATION_SECS = 20 * 60  # 20 minutes
106CACHE_CATEGORY_PLAYER_QUEUE_STATE = 0
107CACHE_CATEGORY_PLAYER_QUEUE_ITEMS = 1
108
109
110class CompareState(TypedDict):
111    """Simple object where we store the (previous) state of a queue.
112
113    Used for compare actions.
114    """
115
116    queue_id: str
117    state: PlaybackState
118    current_item_id: str | None
119    next_item_id: str | None
120    current_item: QueueItem | None
121    elapsed_time: int
122    # last_playing_elapsed_time: elapsed time from the last PLAYING state update
123    # used to determine if a track was fully played when transitioning to idle
124    last_playing_elapsed_time: int
125    stream_title: str | None
126    codec_type: ContentType | None
127    output_formats: list[str] | None
128
129
130class PlayerQueuesController(CoreController):
131    """Controller holding all logic to enqueue music for players."""
132
133    domain: str = "player_queues"
134
135    def __init__(self, mass: MusicAssistant) -> None:
136        """Initialize core controller."""
137        super().__init__(mass)
138        self._queues: dict[str, PlayerQueue] = {}
139        self._queue_items: dict[str, list[QueueItem]] = {}
140        self._prev_states: dict[str, CompareState] = {}
141        self._transitioning_players: set[str] = set()
142        self.manifest.name = "Player Queues controller"
143        self.manifest.description = (
144            "Music Assistant's core controller which manages the queues for all players."
145        )
146        self.manifest.icon = "playlist-music"
147
148    async def close(self) -> None:
149        """Cleanup on exit."""
150        # stop all playback
151        for queue in self.all():
152            if queue.state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
153                await self.stop(queue.queue_id)
154
155    async def get_config_entries(
156        self,
157        action: str | None = None,
158        values: dict[str, ConfigValueType] | None = None,
159    ) -> tuple[ConfigEntry, ...]:
160        """Return all Config Entries for this core module (if any)."""
161        enqueue_options = [ConfigValueOption(x.name, x.value) for x in QueueOption]
162        return (
163            ConfigEntry(
164                key=CONF_DEFAULT_ENQUEUE_SELECT_ARTIST,
165                type=ConfigEntryType.STRING,
166                default_value=ENQUEUE_SELECT_ARTIST_DEFAULT_VALUE,
167                label="Items to select when you play a (in-library) artist.",
168                options=[
169                    ConfigValueOption(
170                        title="Only in-library tracks",
171                        value="library_tracks",
172                    ),
173                    ConfigValueOption(
174                        title="All tracks from all albums in the library",
175                        value="library_album_tracks",
176                    ),
177                    ConfigValueOption(
178                        title="All (top) tracks from (all) streaming provider(s)",
179                        value="all_tracks",
180                    ),
181                    ConfigValueOption(
182                        title="All tracks from all albums from (all) streaming provider(s)",
183                        value="all_album_tracks",
184                    ),
185                ],
186            ),
187            ConfigEntry(
188                key=CONF_DEFAULT_ENQUEUE_SELECT_ALBUM,
189                type=ConfigEntryType.STRING,
190                default_value=ENQUEUE_SELECT_ALBUM_DEFAULT_VALUE,
191                label="Items to select when you play a (in-library) album.",
192                options=[
193                    ConfigValueOption(
194                        title="Only in-library tracks",
195                        value="library_tracks",
196                    ),
197                    ConfigValueOption(
198                        title="All tracks for album on (streaming) provider",
199                        value="all_tracks",
200                    ),
201                ],
202            ),
203            ConfigEntry(
204                key=CONF_DEFAULT_ENQUEUE_OPTION_ARTIST,
205                type=ConfigEntryType.STRING,
206                default_value=QueueOption.REPLACE.value,
207                label="Default enqueue option for Artist item(s).",
208                options=enqueue_options,
209                description="Define the default enqueue action for this mediatype.",
210            ),
211            ConfigEntry(
212                key=CONF_DEFAULT_ENQUEUE_OPTION_ALBUM,
213                type=ConfigEntryType.STRING,
214                default_value=QueueOption.REPLACE.value,
215                label="Default enqueue option for Album item(s).",
216                options=enqueue_options,
217                description="Define the default enqueue action for this mediatype.",
218            ),
219            ConfigEntry(
220                key=CONF_DEFAULT_ENQUEUE_OPTION_TRACK,
221                type=ConfigEntryType.STRING,
222                default_value=QueueOption.PLAY.value,
223                label="Default enqueue option for Track item(s).",
224                options=enqueue_options,
225                description="Define the default enqueue action for this mediatype.",
226            ),
227            ConfigEntry(
228                key=CONF_DEFAULT_ENQUEUE_OPTION_RADIO,
229                type=ConfigEntryType.STRING,
230                default_value=QueueOption.REPLACE.value,
231                label="Default enqueue option for Radio item(s).",
232                options=enqueue_options,
233                description="Define the default enqueue action for this mediatype.",
234            ),
235            ConfigEntry(
236                key=CONF_DEFAULT_ENQUEUE_OPTION_PLAYLIST,
237                type=ConfigEntryType.STRING,
238                default_value=QueueOption.REPLACE.value,
239                label="Default enqueue option for Playlist item(s).",
240                options=enqueue_options,
241                description="Define the default enqueue action for this mediatype.",
242            ),
243            ConfigEntry(
244                key=CONF_DEFAULT_ENQUEUE_OPTION_AUDIOBOOK,
245                type=ConfigEntryType.STRING,
246                default_value=QueueOption.REPLACE.value,
247                label="Default enqueue option for Audiobook item(s).",
248                options=enqueue_options,
249                hidden=True,
250            ),
251            ConfigEntry(
252                key=CONF_DEFAULT_ENQUEUE_OPTION_PODCAST,
253                type=ConfigEntryType.STRING,
254                default_value=QueueOption.REPLACE.value,
255                label="Default enqueue option for Podcast item(s).",
256                options=enqueue_options,
257                hidden=True,
258            ),
259            ConfigEntry(
260                key=CONF_DEFAULT_ENQUEUE_OPTION_PODCAST_EPISODE,
261                type=ConfigEntryType.STRING,
262                default_value=QueueOption.REPLACE.value,
263                label="Default enqueue option for Podcast-episode item(s).",
264                options=enqueue_options,
265                hidden=True,
266            ),
267            ConfigEntry(
268                key=CONF_DEFAULT_ENQUEUE_OPTION_FOLDER,
269                type=ConfigEntryType.STRING,
270                default_value=QueueOption.REPLACE.value,
271                label="Default enqueue option for Folder item(s).",
272                options=enqueue_options,
273                hidden=True,
274            ),
275        )
276
277    def __iter__(self) -> Iterator[PlayerQueue]:
278        """Iterate over (available) players."""
279        return iter(self._queues.values())
280
281    @api_command("player_queues/all")
282    def all(self) -> tuple[PlayerQueue, ...]:
283        """Return all registered PlayerQueues."""
284        return tuple(self._queues.values())
285
286    @api_command("player_queues/get")
287    def get(self, queue_id: str) -> PlayerQueue | None:
288        """Return PlayerQueue by queue_id or None if not found."""
289        return self._queues.get(queue_id)
290
291    @api_command("player_queues/items")
292    def items(self, queue_id: str, limit: int = 500, offset: int = 0) -> list[QueueItem]:
293        """Return all QueueItems for given PlayerQueue."""
294        if queue_id not in self._queue_items:
295            return []
296
297        return self._queue_items[queue_id][offset : offset + limit]
298
299    @api_command("player_queues/get_active_queue")
300    def get_active_queue(self, player_id: str) -> PlayerQueue | None:
301        """Return the current active/synced queue for a player."""
302        if player := self.mass.players.get(player_id):
303            return self.mass.players.get_active_queue(player)
304        return None
305
306    # Queue commands
307
308    @api_command("player_queues/shuffle")
309    async def set_shuffle(self, queue_id: str, shuffle_enabled: bool) -> None:
310        """Configure shuffle setting on the the queue."""
311        queue = self._queues[queue_id]
312        if queue.shuffle_enabled == shuffle_enabled:
313            return  # no change
314        queue.shuffle_enabled = shuffle_enabled
315        queue_items = self._queue_items[queue_id]
316        cur_index = queue.index_in_buffer or queue.current_index
317        if cur_index is not None:
318            next_index = cur_index + 1
319            next_items = queue_items[next_index:]
320        else:
321            next_items = []
322            next_index = 0
323        if not shuffle_enabled:
324            # shuffle disabled, try to restore original sort order of the remaining items
325            next_items.sort(key=lambda x: x.sort_index, reverse=False)
326        await self.load(
327            queue_id=queue_id,
328            queue_items=next_items,
329            insert_at_index=next_index,
330            keep_remaining=False,
331            shuffle=shuffle_enabled,
332        )
333
334    @api_command("player_queues/dont_stop_the_music")
335    def set_dont_stop_the_music(self, queue_id: str, dont_stop_the_music_enabled: bool) -> None:
336        """Configure Don't stop the music setting on the queue."""
337        providers_available_with_similar_tracks = any(
338            ProviderFeature.SIMILAR_TRACKS in provider.supported_features
339            for provider in self.mass.music.providers
340        )
341        if dont_stop_the_music_enabled and not providers_available_with_similar_tracks:
342            raise UnsupportedFeaturedException(
343                "Don't stop the music is not supported by any of the available music providers"
344            )
345        queue = self._queues[queue_id]
346        queue.dont_stop_the_music_enabled = dont_stop_the_music_enabled
347        self.signal_update(queue_id=queue_id)
348        # if this happens to be the last track in the queue, fill the radio source
349        if (
350            queue.dont_stop_the_music_enabled
351            and queue.enqueued_media_items
352            and queue.current_index is not None
353            and (queue.items - queue.current_index) <= 1
354        ):
355            queue.radio_source = queue.enqueued_media_items
356            task_id = f"fill_radio_tracks_{queue_id}"
357            self.mass.call_later(5, self._fill_radio_tracks, queue_id, task_id=task_id)
358
359    @api_command("player_queues/repeat")
360    def set_repeat(self, queue_id: str, repeat_mode: RepeatMode) -> None:
361        """Configure repeat setting on the the queue."""
362        queue = self._queues[queue_id]
363        if queue.repeat_mode == repeat_mode:
364            return  # no change
365        queue.repeat_mode = repeat_mode
366        self.signal_update(queue_id)
367        if (
368            queue.state == PlaybackState.PLAYING
369            and queue.index_in_buffer is not None
370            and queue.index_in_buffer == queue.current_index
371        ):
372            # if the queue is playing,
373            # ensure to (re)queue the next track because it might have changed
374            # note that we only do this if the player has loaded the current track
375            # if not, we wait until it has loaded to prevent conflicts
376            if next_item := self.get_next_item(queue_id, queue.index_in_buffer):
377                self._enqueue_next_item(queue_id, next_item)
378
379    @api_command("player_queues/play_media")
380    async def play_media(
381        self,
382        queue_id: str,
383        media: MediaItemType | ItemMapping | str | list[MediaItemType | ItemMapping | str],
384        option: QueueOption | None = None,
385        radio_mode: bool = False,
386        start_item: PlayableMediaItemType | str | None = None,
387        username: str | None = None,
388    ) -> None:
389        """Play media item(s) on the given queue.
390
391        :param queue_id: The queue_id of the queue to play media on.
392        :param media: Media that should be played (MediaItem(s) and/or uri's).
393        :param option: Which enqueue mode to use.
394        :param radio_mode: Enable radio mode for the given item(s).
395        :param start_item: Optional item to start the playlist or album from.
396        :param username: The username of the user requesting the playback.
397            Setting the username allows for overriding the logged-in user
398            to account for playback history per user when the play_media is
399            called from a shared context (like a web hook or automation).
400        """
401        # ruff: noqa: PLR0915
402        # we use a contextvar to bypass the throttler for this asyncio task/context
403        # this makes sure that playback has priority over other requests that may be
404        # happening in the background
405        BYPASS_THROTTLER.set(True)
406        if not (queue := self.get(queue_id)):
407            raise PlayerUnavailableError(f"Queue {queue_id} is not available")
408        # always fetch the underlying player so we can raise early if its not available
409        queue_player = self.mass.players.get(queue_id, True)
410        assert queue_player is not None  # for type checking
411        if queue_player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
412            self.logger.warning("Ignore queue command: An announcement is in progress")
413            return
414
415        # save the user requesting the playback
416        playback_user: User | None
417        if username and (user := await self.mass.webserver.auth.get_user_by_username(username)):
418            playback_user = user
419        else:
420            playback_user = get_current_user()
421        queue.userid = playback_user.user_id if playback_user else None
422
423        # a single item or list of items may be provided
424        media_list = media if isinstance(media, list) else [media]
425
426        # clear queue if needed
427        if option == QueueOption.REPLACE:
428            self.clear(queue_id)
429        # Clear the 'enqueued media item' list when a new queue is requested
430        if option not in (QueueOption.ADD, QueueOption.NEXT):
431            queue.enqueued_media_items.clear()
432
433        media_items: list[MediaItemType] = []
434        radio_source: list[MediaItemType] = []
435        # resolve all media items
436        for item in media_list:
437            try:
438                # parse provided uri into a MA MediaItem or Basic QueueItem from URL
439                media_item: MediaItemType | ItemMapping | BrowseFolder
440                if isinstance(item, str):
441                    media_item = await self.mass.music.get_item_by_uri(item)
442                elif isinstance(item, dict):  # type: ignore[unreachable]
443                    # TODO: Investigate why the API parser sometimes passes raw dicts instead of
444                    # converting them to MediaItem objects. The parse_value function in api.py
445                    # should handle dict-to-object conversion, but dicts are slipping through
446                    # in some cases. This is defensive handling for that parser bug.
447                    media_item = media_from_dict(item)  # type: ignore[unreachable]
448                    self.logger.debug("Converted to: %s", type(media_item))
449                else:
450                    # item is MediaItemType | ItemMapping at this point
451                    media_item = item
452
453                # Save requested media item to play on the queue so we can use it as a source
454                # for Don't stop the music. Use FIFO list to keep track of the last 10 played items
455                # Skip ItemMapping and BrowseFolder - only queue full MediaItemType objects
456                if not isinstance(
457                    media_item, (ItemMapping, BrowseFolder)
458                ) and media_item.media_type in (
459                    MediaType.TRACK,
460                    MediaType.ALBUM,
461                    MediaType.PLAYLIST,
462                    MediaType.ARTIST,
463                ):
464                    queue.enqueued_media_items.append(media_item)
465                    if len(queue.enqueued_media_items) > 10:
466                        queue.enqueued_media_items.pop(0)
467
468                # handle default enqueue option if needed
469                if option is None:
470                    config_value = await self.mass.config.get_core_config_value(
471                        self.domain,
472                        f"default_enqueue_option_{media_item.media_type.value}",
473                        return_type=str,
474                    )
475                    option = QueueOption(config_value)
476                    if option == QueueOption.REPLACE:
477                        self.clear(queue_id, skip_stop=True)
478
479                # collect media_items to play
480                if radio_mode:
481                    # Type guard for mypy - only add full MediaItemType to radio_source
482                    if not isinstance(media_item, (ItemMapping, BrowseFolder)):
483                        radio_source.append(media_item)
484                else:
485                    # Convert start_item to string URI if needed
486                    start_item_uri: str | None = None
487                    if isinstance(start_item, str):
488                        start_item_uri = start_item
489                    elif start_item is not None:
490                        start_item_uri = start_item.uri
491                    media_items += await self._resolve_media_items(
492                        media_item, start_item_uri, queue_id=queue_id
493                    )
494
495            except MusicAssistantError as err:
496                # invalid MA uri or item not found error
497                self.logger.warning("Skipping %s: %s", item, str(err))
498
499        # overwrite or append radio source items
500        if option not in (QueueOption.ADD, QueueOption.NEXT):
501            queue.radio_source = radio_source
502        else:
503            queue.radio_source += radio_source
504        # Use collected media items to calculate the radio if radio mode is on
505        if radio_mode:
506            radio_tracks = await self._get_radio_tracks(
507                queue_id=queue_id, is_initial_radio_mode=True
508            )
509            media_items = list(radio_tracks)
510
511        # only add valid/available items
512        queue_items: list[QueueItem] = []
513        for x in media_items:
514            if not x or not x.available:
515                continue
516            queue_items.append(
517                QueueItem.from_media_item(queue_id, cast("PlayableMediaItemType", x))
518            )
519
520        if not queue_items:
521            raise MediaNotFoundError("No playable items found")
522
523        # load the items into the queue
524        if queue.state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
525            cur_index = queue.index_in_buffer or queue.current_index or 0
526        else:
527            cur_index = queue.current_index or 0
528        insert_at_index = cur_index + 1
529        # Radio modes are already shuffled in a pattern we would like to keep.
530        shuffle = queue.shuffle_enabled and len(queue_items) > 1 and not radio_mode
531
532        # handle replace: clear all items and replace with the new items
533        if option == QueueOption.REPLACE:
534            await self.load(
535                queue_id,
536                queue_items=queue_items,
537                keep_remaining=False,
538                keep_played=False,
539                shuffle=shuffle,
540            )
541            await self.play_index(queue_id, 0)
542            return
543        # handle next: add item(s) in the index next to the playing/loaded/buffered index
544        if option == QueueOption.NEXT:
545            await self.load(
546                queue_id,
547                queue_items=queue_items,
548                insert_at_index=insert_at_index,
549                shuffle=shuffle,
550            )
551            return
552        if option == QueueOption.REPLACE_NEXT:
553            await self.load(
554                queue_id,
555                queue_items=queue_items,
556                insert_at_index=insert_at_index,
557                keep_remaining=False,
558                shuffle=shuffle,
559            )
560            return
561        # handle play: replace current loaded/playing index with new item(s)
562        if option == QueueOption.PLAY:
563            await self.load(
564                queue_id,
565                queue_items=queue_items,
566                insert_at_index=insert_at_index,
567                shuffle=shuffle,
568            )
569            next_index = min(insert_at_index, len(self._queue_items[queue_id]) - 1)
570            await self.play_index(queue_id, next_index)
571            return
572        # handle add: add/append item(s) to the remaining queue items
573        if option == QueueOption.ADD:
574            await self.load(
575                queue_id=queue_id,
576                queue_items=queue_items,
577                insert_at_index=insert_at_index
578                if queue.shuffle_enabled
579                else len(self._queue_items[queue_id]) + 1,
580                shuffle=queue.shuffle_enabled,
581            )
582            # handle edgecase, queue is empty and items are only added (not played)
583            # mark first item as new index
584            if queue.current_index is None:
585                queue.current_index = 0
586                queue.current_item = self.get_item(queue_id, 0)
587                queue.items = len(queue_items)
588                self.signal_update(queue_id)
589
590    @api_command("player_queues/move_item")
591    def move_item(self, queue_id: str, queue_item_id: str, pos_shift: int = 1) -> None:
592        """
593        Move queue item x up/down the queue.
594
595        - queue_id: id of the queue to process this request.
596        - queue_item_id: the item_id of the queueitem that needs to be moved.
597        - pos_shift: move item x positions down if positive value
598        - pos_shift: move item x positions up if negative value
599        - pos_shift:  move item to top of queue as next item if 0.
600        """
601        queue = self._queues[queue_id]
602        item_index = self.index_by_id(queue_id, queue_item_id)
603        if item_index is None:
604            raise InvalidDataError(f"Item {queue_item_id} not found in queue")
605        if queue.index_in_buffer is not None and item_index <= queue.index_in_buffer:
606            msg = f"{item_index} is already played/buffered"
607            raise IndexError(msg)
608
609        queue_items = self._queue_items[queue_id]
610        queue_items = queue_items.copy()
611
612        if pos_shift == 0 and queue.state == PlaybackState.PLAYING:
613            new_index = (queue.current_index or 0) + 1
614        elif pos_shift == 0:
615            new_index = queue.current_index or 0
616        else:
617            new_index = item_index + pos_shift
618        if (new_index < (queue.current_index or 0)) or (new_index > len(queue_items)):
619            return
620        # move the item in the list
621        queue_items.insert(new_index, queue_items.pop(item_index))
622        self.update_items(queue_id, queue_items)
623
624    @api_command("player_queues/move_item_end")
625    def move_item_end(self, queue_id: str, queue_item_id: str) -> None:
626        """
627        Move queue item to the end the queue.
628
629        - queue_id: id of the queue to process this request.
630        - queue_item_id: the item_id of the queueitem that needs to be moved.
631        """
632        queue = self._queues[queue_id]
633        item_index = self.index_by_id(queue_id, queue_item_id)
634        if item_index is None:
635            raise InvalidDataError(f"Item {queue_item_id} not found in queue")
636        if queue.index_in_buffer is not None and item_index <= queue.index_in_buffer:
637            msg = f"{item_index} is already played/buffered"
638            raise IndexError(msg)
639
640        queue_items = self._queue_items[queue_id]
641        if item_index == (len(queue_items) - 1):
642            return
643        queue_items = queue_items.copy()
644
645        new_index = len(self._queue_items[queue_id]) - 1
646
647        # move the item in the list
648        queue_items.insert(new_index, queue_items.pop(item_index))
649        self.update_items(queue_id, queue_items)
650
651    @api_command("player_queues/delete_item")
652    def delete_item(self, queue_id: str, item_id_or_index: int | str) -> None:
653        """Delete item (by id or index) from the queue."""
654        if isinstance(item_id_or_index, str):
655            item_index = self.index_by_id(queue_id, item_id_or_index)
656            if item_index is None:
657                raise InvalidDataError(f"Item {item_id_or_index} not found in queue")
658        else:
659            item_index = item_id_or_index
660        queue = self._queues[queue_id]
661        if queue.index_in_buffer is not None and item_index <= queue.index_in_buffer:
662            # ignore request if track already loaded in the buffer
663            # the frontend should guard so this is just in case
664            self.logger.warning("delete requested for item already loaded in buffer")
665            return
666        queue_items = self._queue_items[queue_id]
667        queue_items.pop(item_index)
668        self.update_items(queue_id, queue_items)
669
670    @api_command("player_queues/clear")
671    def clear(self, queue_id: str, skip_stop: bool = False) -> None:
672        """Clear all items in the queue."""
673        queue = self._queues[queue_id]
674        queue.radio_source = []
675        if queue.state != PlaybackState.IDLE and not skip_stop:
676            self.mass.create_task(self.stop(queue_id))
677        queue.current_index = None
678        queue.current_item = None
679        queue.elapsed_time = 0
680        queue.elapsed_time_last_updated = time.time()
681        queue.index_in_buffer = None
682        self.update_items(queue_id, [])
683
684    @api_command("player_queues/stop")
685    async def stop(self, queue_id: str) -> None:
686        """
687        Handle STOP command for given queue.
688
689        - queue_id: queue_id of the playerqueue to handle the command.
690        """
691        queue_player = self.mass.players.get(queue_id, True)
692        if queue_player is None:
693            raise PlayerUnavailableError(f"Player {queue_id} is not available")
694        if (queue := self.get(queue_id)) and queue.active:
695            if queue.state == PlaybackState.PLAYING:
696                queue.resume_pos = int(queue.corrected_elapsed_time)
697            # forward the actual command to the player
698            if temp_player := self.mass.players.get(queue_id):
699                await temp_player.stop()
700
701    @api_command("player_queues/play")
702    async def play(self, queue_id: str) -> None:
703        """
704        Handle PLAY command for given queue.
705
706        - queue_id: queue_id of the playerqueue to handle the command.
707        """
708        queue_player = self.mass.players.get(queue_id, True)
709        if queue_player is None:
710            raise PlayerUnavailableError(f"Player {queue_id} is not available")
711        if (
712            (queue := self._queues.get(queue_id))
713            and queue.active
714            and queue.state == PlaybackState.PAUSED
715        ):
716            # forward the actual play/unpause command to the player
717            await queue_player.play()
718            return
719        # player is not paused, perform resume instead
720        await self.resume(queue_id)
721
722    @api_command("player_queues/pause")
723    async def pause(self, queue_id: str) -> None:
724        """Handle PAUSE command for given queue.
725
726        - queue_id: queue_id of the playerqueue to handle the command.
727        """
728        if queue := self._queues.get(queue_id):
729            if queue.state == PlaybackState.PLAYING:
730                queue.resume_pos = int(queue.corrected_elapsed_time)
731        # forward the actual command to the player controller
732        queue_player = self.mass.players.get(queue_id)
733        assert queue_player is not None  # for type checking
734        if not (self.mass.players.get_player_provider(queue_id)):
735            return  # guard
736
737        if PlayerFeature.PAUSE not in queue_player.supported_features:
738            # if player does not support pause, we need to send stop
739            await queue_player.stop()
740            return
741        await queue_player.pause()
742
743        async def _watch_pause() -> None:
744            count = 0
745            # wait for pause
746            while count < 5 and queue_player.playback_state == PlaybackState.PLAYING:
747                count += 1
748                await asyncio.sleep(1)
749            # wait for unpause
750            if queue_player.playback_state != PlaybackState.PAUSED:
751                return
752            count = 0
753            while count < 30 and queue_player.playback_state == PlaybackState.PAUSED:
754                count += 1
755                await asyncio.sleep(1)
756            # if player is still paused when the limit is reached, send stop
757            if queue_player.playback_state == PlaybackState.PAUSED:
758                await queue_player.stop()
759
760        # we auto stop a player from paused when its paused for 30 seconds
761        if not queue_player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
762            self.mass.create_task(_watch_pause())
763
764    @api_command("player_queues/play_pause")
765    async def play_pause(self, queue_id: str) -> None:
766        """Toggle play/pause on given playerqueue.
767
768        - queue_id: queue_id of the queue to handle the command.
769        """
770        if (queue := self._queues.get(queue_id)) and queue.state == PlaybackState.PLAYING:
771            await self.pause(queue_id)
772            return
773        await self.play(queue_id)
774
775    @api_command("player_queues/next")
776    async def next(self, queue_id: str) -> None:
777        """Handle NEXT TRACK command for given queue.
778
779        - queue_id: queue_id of the queue to handle the command.
780        """
781        if (queue := self.get(queue_id)) is None or not queue.active:
782            # TODO: forward to underlying player if not active
783            return
784        idx = self._queues[queue_id].current_index
785        if idx is None:
786            self.logger.warning("Queue %s has no current index", queue.display_name)
787            return
788        attempts = 5
789        while attempts:
790            try:
791                if (next_index := self._get_next_index(queue_id, idx, True)) is not None:
792                    await self.play_index(queue_id, next_index, debounce=True)
793                break
794            except MediaNotFoundError:
795                self.logger.warning(
796                    "Failed to fetch next track for queue %s - trying next item",
797                    queue.display_name,
798                )
799                idx += 1
800                attempts -= 1
801
802    @api_command("player_queues/previous")
803    async def previous(self, queue_id: str) -> None:
804        """Handle PREVIOUS TRACK command for given queue.
805
806        - queue_id: queue_id of the queue to handle the command.
807        """
808        if (queue := self.get(queue_id)) is None or not queue.active:
809            # TODO: forward to underlying player if not active
810            return
811        current_index = self._queues[queue_id].current_index
812        if current_index is None:
813            return
814        next_index = int(current_index)
815        # restart current track if current track has played longer than 4
816        # otherwise skip to previous track
817        if self._queues[queue_id].elapsed_time < 5:
818            next_index = max(current_index - 1, 0)
819        await self.play_index(queue_id, next_index, debounce=True)
820
821    @api_command("player_queues/skip")
822    async def skip(self, queue_id: str, seconds: int = 10) -> None:
823        """Handle SKIP command for given queue.
824
825        - queue_id: queue_id of the queue to handle the command.
826        - seconds: number of seconds to skip in track. Use negative value to skip back.
827        """
828        if (queue := self.get(queue_id)) is None or not queue.active:
829            # TODO: forward to underlying player if not active
830            return
831        await self.seek(queue_id, int(self._queues[queue_id].elapsed_time + seconds))
832
833    @api_command("player_queues/seek")
834    async def seek(self, queue_id: str, position: int = 10) -> None:
835        """Handle SEEK command for given queue.
836
837        - queue_id: queue_id of the queue to handle the command.
838        - position: position in seconds to seek to in the current playing item.
839        """
840        if not (queue := self.get(queue_id)):
841            return
842        queue_player = self.mass.players.get(queue_id, True)
843        if queue_player is None:
844            raise PlayerUnavailableError(f"Player {queue_id} is not available")
845        if not queue.current_item:
846            raise InvalidCommand(f"Queue {queue_player.display_name} has no item(s) loaded.")
847        if not queue.current_item.duration:
848            raise InvalidCommand("Can not seek items without duration.")
849        position = max(0, int(position))
850        if position > queue.current_item.duration:
851            raise InvalidCommand("Can not seek outside of duration range.")
852        if queue.current_index is None:
853            raise InvalidCommand(f"Queue {queue_player.display_name} has no current index.")
854        await self.play_index(queue_id, queue.current_index, seek_position=position)
855
856    @api_command("player_queues/resume")
857    async def resume(self, queue_id: str, fade_in: bool | None = None) -> None:
858        """Handle RESUME command for given queue.
859
860        - queue_id: queue_id of the queue to handle the command.
861        """
862        queue = self._queues[queue_id]
863        queue_items = self._queue_items[queue_id]
864        resume_item = queue.current_item
865        if queue.state == PlaybackState.PLAYING:
866            # resume requested while already playing,
867            # use current position as resume position
868            resume_pos = queue.corrected_elapsed_time
869            fade_in = False
870        else:
871            resume_pos = queue.resume_pos or queue.elapsed_time
872
873        if not resume_item and queue.current_index is not None and len(queue_items) > 0:
874            resume_item = self.get_item(queue_id, queue.current_index)
875            resume_pos = 0
876        elif not resume_item and queue.current_index is None and len(queue_items) > 0:
877            # items available in queue but no previous track, start at 0
878            resume_item = self.get_item(queue_id, 0)
879            resume_pos = 0
880
881        if resume_item is not None:
882            queue_player = self.mass.players.get(queue_id)
883            if queue_player is None:
884                raise PlayerUnavailableError(f"Player {queue_id} is not available")
885            if (
886                fade_in is None
887                and queue_player.playback_state == PlaybackState.IDLE
888                and (time.time() - queue.elapsed_time_last_updated) > 60
889            ):
890                # enable fade in effect if the player is idle for a while
891                fade_in = resume_pos > 0
892            if resume_item.media_type == MediaType.RADIO:
893                # we're not able to skip in online radio so this is pointless
894                resume_pos = 0
895            await self.play_index(
896                queue_id, resume_item.queue_item_id, int(resume_pos), fade_in or False
897            )
898        else:
899            # Queue is empty, try to resume from playlog
900            if await self._try_resume_from_playlog(queue):
901                return
902            msg = f"Resume queue requested but queue {queue.display_name} is empty"
903            raise QueueEmpty(msg)
904
905    @api_command("player_queues/play_index")
906    async def play_index(
907        self,
908        queue_id: str,
909        index: int | str,
910        seek_position: int = 0,
911        fade_in: bool = False,
912        debounce: bool = False,
913    ) -> None:
914        """Play item at index (or item_id) X in queue."""
915        queue = self._queues[queue_id]
916        queue.resume_pos = 0
917        if isinstance(index, str):
918            temp_index = self.index_by_id(queue_id, index)
919            if temp_index is None:
920                raise InvalidDataError(f"Item {index} not found in queue")
921            index = temp_index
922        # At this point index is guaranteed to be int
923        queue.current_index = index
924        # update current item and elapsed time and signal update
925        # this way the UI knows immediately that a new item is loading
926        queue.current_item = self.get_item(queue_id, index)
927        queue.elapsed_time = seek_position
928        queue.elapsed_time_last_updated = time.time()
929        self.signal_update(queue_id)
930        queue.index_in_buffer = index
931        queue.flow_mode_stream_log = []
932        target_player = self.mass.players.get(queue_id)
933        if target_player is None:
934            raise PlayerUnavailableError(f"Player {queue_id} is not available")
935        enqueue_supported = PlayerFeature.ENQUEUE in target_player.supported_features
936        queue.next_item_id_enqueued = None
937        # always update session id when we start a new playback session
938        queue.session_id = shortuuid.random(length=8)
939        # handle resume point of audiobook(chapter) or podcast(episode)
940        if (
941            not seek_position
942            and (queue_item := self.get_item(queue_id, index))
943            and (resume_position_ms := getattr(queue_item.media_item, "resume_position_ms", 0))
944        ):
945            seek_position = max(0, int((resume_position_ms - 500) / 1000))
946
947        # send play_media request to player
948        # NOTE that we debounce this a bit to account for someone hitting the next button
949        # like a madman. This will prevent the player from being overloaded with requests.
950        async def _play_index(index: int, debounce: bool) -> None:
951            for attempt in range(5):
952                try:
953                    queue_item = self.get_item(queue_id, index)
954                    if not queue_item:
955                        continue  # guard
956                    await self._load_item(
957                        queue_item,
958                        self._get_next_index(queue_id, index),
959                        is_start=True,
960                        seek_position=seek_position if attempt == 0 else 0,
961                        fade_in=fade_in if attempt == 0 else False,
962                    )
963                    # if we reach this point, loading the item succeeded, break the loop
964                    queue.current_index = index
965                    queue.current_item = queue_item
966                    break
967                except (MediaNotFoundError, AudioError):
968                    # the requested index can not be played.
969                    if queue_item:
970                        self.logger.warning(
971                            "Skipping unplayable item %s (%s)",
972                            queue_item.name,
973                            queue_item.uri,
974                        )
975                        queue_item.available = False
976                    next_index = self._get_next_index(queue_id, index, allow_repeat=False)
977                    if next_index is None:
978                        raise MediaNotFoundError("No next item available")
979                    index = next_index
980            else:
981                # all attempts to find a playable item failed
982                raise MediaNotFoundError("No playable item found to start playback")
983
984            # work out if we need to use flow mode
985            prefer_flow_mode = await self.mass.config.get_player_config_value(
986                queue_id, CONF_FLOW_MODE, default=False
987            )
988            flow_mode = (
989                prefer_flow_mode or not enqueue_supported
990            ) and queue_item.media_type not in (
991                # don't use flow mode for duration-less streams
992                MediaType.RADIO,
993                MediaType.PLUGIN_SOURCE,
994            )
995            await asyncio.sleep(0.5 if debounce else 0.1)
996            queue.flow_mode = flow_mode
997            await self.mass.players.play_media(
998                player_id=queue_id,
999                media=await self.player_media_from_queue_item(queue_item, flow_mode),
1000            )
1001            queue.current_index = index
1002            queue.current_item = queue_item
1003            await asyncio.sleep(2)
1004            self._transitioning_players.discard(queue_id)
1005
1006        # we set a flag to notify the update logic that we're transitioning to a new track
1007        self._transitioning_players.add(queue_id)
1008
1009        # we debounce the play_index command to handle the case where someone
1010        # is spamming next/previous on the player
1011        task_id = f"play_index_{queue_id}"
1012        if existing_task := self.mass.get_task(task_id):
1013            existing_task.cancel()
1014            with suppress(asyncio.CancelledError):
1015                await existing_task
1016        task = self.mass.create_task(
1017            _play_index,
1018            index,
1019            debounce,
1020            task_id=task_id,
1021        )
1022        await task
1023        self.signal_update(queue_id)
1024
1025    @api_command("player_queues/transfer")
1026    async def transfer_queue(
1027        self,
1028        source_queue_id: str,
1029        target_queue_id: str,
1030        auto_play: bool | None = None,
1031    ) -> None:
1032        """Transfer queue to another queue."""
1033        if not (source_queue := self.get(source_queue_id)):
1034            raise PlayerUnavailableError(f"Queue {source_queue_id} is not available")
1035        if not (target_queue := self.get(target_queue_id)):
1036            raise PlayerUnavailableError(f"Queue {target_queue_id} is not available")
1037        if auto_play is None:
1038            auto_play = source_queue.state == PlaybackState.PLAYING
1039
1040        target_player = self.mass.players.get(target_queue_id)
1041        if target_player is None:
1042            raise PlayerUnavailableError(f"Player {target_queue_id} is not available")
1043        if target_player.active_group or target_player.synced_to:
1044            # edge case: the user wants to move playback from the group as a whole, to a single
1045            # player in the group or it is grouped and the command targeted at the single player.
1046            # We need to dissolve the group first.
1047            group_id = target_player.active_group or target_player.synced_to
1048            assert group_id is not None  # checked in if condition above
1049            await self.mass.players.cmd_ungroup(group_id)
1050            await asyncio.sleep(3)
1051
1052        source_items = self._queue_items[source_queue_id]
1053        target_queue.repeat_mode = source_queue.repeat_mode
1054        target_queue.shuffle_enabled = source_queue.shuffle_enabled
1055        target_queue.dont_stop_the_music_enabled = source_queue.dont_stop_the_music_enabled
1056        target_queue.radio_source = source_queue.radio_source
1057        target_queue.enqueued_media_items = source_queue.enqueued_media_items
1058        target_queue.resume_pos = int(source_queue.elapsed_time)
1059        target_queue.current_index = source_queue.current_index
1060        if source_queue.current_item:
1061            target_queue.current_item = source_queue.current_item
1062            target_queue.current_item.queue_id = target_queue_id
1063        self.clear(source_queue_id)
1064
1065        await self.load(target_queue_id, source_items, keep_remaining=False, keep_played=False)
1066        for item in source_items:
1067            item.queue_id = target_queue_id
1068        self.update_items(target_queue_id, source_items)
1069        if auto_play:
1070            await self.resume(target_queue_id)
1071
1072    # Interaction with player
1073
1074    async def on_player_register(self, player: Player) -> None:
1075        """Register PlayerQueue for given player/queue id."""
1076        queue_id = player.player_id
1077        queue: PlayerQueue | None = None
1078        queue_items: list[QueueItem] = []
1079        # try to restore previous state
1080        if prev_state := await self.mass.cache.get(
1081            key=queue_id,
1082            provider=self.domain,
1083            category=CACHE_CATEGORY_PLAYER_QUEUE_STATE,
1084        ):
1085            try:
1086                queue = PlayerQueue.from_dict(prev_state)
1087                prev_items = await self.mass.cache.get(
1088                    key=queue_id,
1089                    provider=self.domain,
1090                    category=CACHE_CATEGORY_PLAYER_QUEUE_ITEMS,
1091                    default=[],
1092                )
1093                queue_items = []
1094                for idx, item_data in enumerate(prev_items):
1095                    qi = QueueItem.from_cache(item_data)
1096                    if not qi.media_item:
1097                        # Skip items with missing media_item - this can happen if
1098                        # MA was killed during shutdown while cache was being written
1099                        self.logger.debug(
1100                            "Skipping queue item %s (index %d) restored from cache "
1101                            "without media_item",
1102                            qi.name,
1103                            idx,
1104                        )
1105                        continue
1106                    queue_items.append(qi)
1107                if queue.enqueued_media_items:
1108                    # we need to restore the MediaItem objects for the enqueued media items
1109                    # Items from cache may be dicts that need deserialization
1110                    restored_enqueued_items: list[MediaItemType] = []
1111                    cached_items: list[dict[str, Any] | MediaItemType] = cast(
1112                        "list[dict[str, Any] | MediaItemType]", queue.enqueued_media_items
1113                    )
1114                    for item in cached_items:
1115                        if isinstance(item, dict):
1116                            restored_item = media_from_dict(item)
1117                            restored_enqueued_items.append(cast("MediaItemType", restored_item))
1118                        else:
1119                            restored_enqueued_items.append(item)
1120                    queue.enqueued_media_items = restored_enqueued_items
1121            except Exception as err:
1122                self.logger.warning(
1123                    "Failed to restore the queue(items) for %s - %s",
1124                    player.display_name,
1125                    str(err),
1126                )
1127                # Reset to clean state on failure
1128                queue = None
1129                queue_items = []
1130        if queue is None:
1131            queue = PlayerQueue(
1132                queue_id=queue_id,
1133                active=False,
1134                display_name=player.display_name,
1135                available=player.available,
1136                dont_stop_the_music_enabled=False,
1137                items=0,
1138            )
1139
1140        self._queues[queue_id] = queue
1141        self._queue_items[queue_id] = queue_items
1142        # always call update to calculate state etc
1143        self.on_player_update(player, {})
1144        self.mass.signal_event(EventType.QUEUE_ADDED, object_id=queue_id, data=queue)
1145
1146    def on_player_update(
1147        self,
1148        player: Player,
1149        changed_values: dict[str, tuple[Any, Any]],
1150    ) -> None:
1151        """
1152        Call when a PlayerQueue needs to be updated (e.g. when player updates).
1153
1154        NOTE: This is called every second if the player is playing.
1155        """
1156        queue_id = player.player_id
1157        if (queue := self._queues.get(queue_id)) is None:
1158            # race condition
1159            return
1160        if player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
1161            # do nothing while the announcement is in progress
1162            return
1163        # determine if this queue is currently active for this player
1164        queue.active = player.active_source in (queue.queue_id, None)
1165        if not queue.active and queue_id not in self._prev_states:
1166            queue.state = PlaybackState.IDLE
1167            # return early if the queue is not active and we have no previous state
1168            return
1169        if queue.queue_id in self._transitioning_players:
1170            # we're currently transitioning to a new track,
1171            # ignore updates from the player during this time
1172            return
1173
1174        # queue is active and preflight checks passed, update the queue details
1175        self._update_queue_from_player(player)
1176
1177    def on_player_remove(self, player_id: str, permanent: bool) -> None:
1178        """Call when a player is removed from the registry."""
1179        if permanent:
1180            # if the player is permanently removed, we also remove the cached queue data
1181            self.mass.create_task(
1182                self.mass.cache.delete(
1183                    key=player_id,
1184                    provider=self.domain,
1185                    category=CACHE_CATEGORY_PLAYER_QUEUE_STATE,
1186                )
1187            )
1188            self.mass.create_task(
1189                self.mass.cache.delete(
1190                    key=player_id,
1191                    provider=self.domain,
1192                    category=CACHE_CATEGORY_PLAYER_QUEUE_ITEMS,
1193                )
1194            )
1195        self._queues.pop(player_id, None)
1196        self._queue_items.pop(player_id, None)
1197
1198    async def load_next_queue_item(
1199        self,
1200        queue_id: str,
1201        current_item_id: str,
1202    ) -> QueueItem:
1203        """
1204        Call when a player wants the next queue item to play.
1205
1206        Raises QueueEmpty if there are no more tracks left.
1207        """
1208        queue = self.get(queue_id)
1209        if not queue:
1210            msg = f"PlayerQueue {queue_id} is not available"
1211            raise PlayerUnavailableError(msg)
1212        cur_index = self.index_by_id(queue_id, current_item_id)
1213        if cur_index is None:
1214            # this is just a guard for bad data
1215            raise QueueEmpty("Invalid item id for queue given.")
1216        next_item: QueueItem | None = None
1217        idx = 0
1218        while True:
1219            next_index = self._get_next_index(queue_id, cur_index + idx)
1220            if next_index is None:
1221                raise QueueEmpty("No more tracks left in the queue.")
1222            queue_item = self.get_item(queue_id, next_index)
1223            if queue_item is None:
1224                raise QueueEmpty("No more tracks left in the queue.")
1225            if idx >= 10:
1226                # we only allow 10 retries to prevent infinite loops
1227                raise QueueEmpty("No more (playable) tracks left in the queue.")
1228            try:
1229                await self._load_item(queue_item, next_index)
1230                # we're all set, this is our next item
1231                next_item = queue_item
1232                break
1233            except (MediaNotFoundError, AudioError):
1234                # No stream details found, skip this QueueItem
1235                self.logger.warning(
1236                    "Skipping unplayable item %s (%s)", queue_item.name, queue_item.uri
1237                )
1238                queue_item.available = False
1239                idx += 1
1240        if idx != 0:
1241            # we skipped some items, signal a queue items update
1242            self.update_items(queue_id, self._queue_items[queue_id])
1243        if next_item is None:
1244            raise QueueEmpty("No more (playable) tracks left in the queue.")
1245
1246        return next_item
1247
1248    async def _load_item(
1249        self,
1250        queue_item: QueueItem,
1251        next_index: int | None,
1252        is_start: bool = False,
1253        seek_position: int = 0,
1254        fade_in: bool = False,
1255    ) -> None:
1256        """Try to load the stream details for the given queue item."""
1257        queue_id = queue_item.queue_id
1258        queue = self._queues[queue_id]
1259
1260        # we use a contextvar to bypass the throttler for this asyncio task/context
1261        # this makes sure that playback has priority over other requests that may be
1262        # happening in the background
1263        BYPASS_THROTTLER.set(True)
1264
1265        self.logger.debug(
1266            "(pre)loading (next) item for queue %s...",
1267            queue.display_name,
1268        )
1269
1270        if not queue_item.available:
1271            raise MediaNotFoundError(f"Item {queue_item.uri} is not available")
1272
1273        # work out if we are playing an album and if we should prefer album
1274        # loudness
1275        next_track_from_same_album = (
1276            next_index is not None
1277            and (next_item := self.get_item(queue_id, next_index))
1278            and (
1279                queue_item.media_item
1280                and hasattr(queue_item.media_item, "album")
1281                and queue_item.media_item.album
1282                and next_item.media_item
1283                and hasattr(next_item.media_item, "album")
1284                and next_item.media_item.album
1285                and queue_item.media_item.album.item_id == next_item.media_item.album.item_id
1286            )
1287        )
1288        current_index = self.index_by_id(queue_id, queue_item.queue_item_id)
1289        if current_index is None:
1290            previous_track_from_same_album = False
1291        else:
1292            previous_index = max(current_index - 1, 0)
1293            previous_track_from_same_album = (
1294                previous_index > 0
1295                and (previous_item := self.get_item(queue_id, previous_index)) is not None
1296                and previous_item.media_item is not None
1297                and hasattr(previous_item.media_item, "album")
1298                and previous_item.media_item.album is not None
1299                and queue_item.media_item is not None
1300                and hasattr(queue_item.media_item, "album")
1301                and queue_item.media_item.album is not None
1302                and queue_item.media_item.album.item_id == previous_item.media_item.album.item_id
1303            )
1304        playing_album_tracks = next_track_from_same_album or previous_track_from_same_album
1305        if queue_item.media_item and isinstance(queue_item.media_item, Track):
1306            album = queue_item.media_item.album
1307            # prefer the full library media item so we have all metadata and provider(quality) info
1308            # always request the full library item as there might be other qualities available
1309            if library_item := await self.mass.music.get_library_item_by_prov_id(
1310                queue_item.media_item.media_type,
1311                queue_item.media_item.item_id,
1312                queue_item.media_item.provider,
1313            ):
1314                queue_item.media_item = cast("Track", library_item)
1315            elif not queue_item.media_item.image or queue_item.media_item.provider.startswith(
1316                "ytmusic"
1317            ):
1318                # Youtube Music has poor thumbs by default, so we always fetch the full item
1319                # this also catches the case where they have an unavailable item in a listing
1320                fetched_item = await self.mass.music.get_item_by_uri(queue_item.uri)
1321                queue_item.media_item = cast("Track", fetched_item)
1322
1323            # ensure we got the full (original) album set
1324            if album and (
1325                library_album := await self.mass.music.get_library_item_by_prov_id(
1326                    album.media_type,
1327                    album.item_id,
1328                    album.provider,
1329                )
1330            ):
1331                queue_item.media_item.album = cast("Album", library_album)
1332            elif album:
1333                # Restore original album if we have no better alternative from the library
1334                queue_item.media_item.album = album
1335            # prefer album image over track image
1336            if queue_item.media_item.album and queue_item.media_item.album.image:
1337                org_images: list[MediaItemImage] = queue_item.media_item.metadata.images or []
1338                queue_item.media_item.metadata.images = UniqueList(
1339                    [
1340                        queue_item.media_item.album.image,
1341                        *org_images,
1342                    ]
1343                )
1344        # Fetch the streamdetails, which could raise in case of an unplayable item.
1345        # For example, YT Music returns Radio Items that are not playable.
1346        queue_item.streamdetails = await get_stream_details(
1347            mass=self.mass,
1348            queue_item=queue_item,
1349            seek_position=seek_position,
1350            fade_in=fade_in,
1351            prefer_album_loudness=bool(playing_album_tracks),
1352        )
1353
1354    def track_loaded_in_buffer(self, queue_id: str, item_id: str) -> None:
1355        """Call when a player has (started) loading a track in the buffer."""
1356        queue = self.get(queue_id)
1357        if not queue:
1358            msg = f"PlayerQueue {queue_id} is not available"
1359            raise PlayerUnavailableError(msg)
1360        # store the index of the item that is currently (being) loaded in the buffer
1361        # which helps us a bit to determine how far the player has buffered ahead
1362        queue.index_in_buffer = self.index_by_id(queue_id, item_id)
1363        self.logger.debug("PlayerQueue %s loaded item %s in buffer", queue.display_name, item_id)
1364        self.signal_update(queue_id)
1365        # preload next streamdetails
1366        self._preload_next_item(queue_id, item_id)
1367
1368    # Main queue manipulation methods
1369
1370    async def load(
1371        self,
1372        queue_id: str,
1373        queue_items: list[QueueItem],
1374        insert_at_index: int = 0,
1375        keep_remaining: bool = True,
1376        keep_played: bool = True,
1377        shuffle: bool = False,
1378    ) -> None:
1379        """Load new items at index.
1380
1381        - queue_id: id of the queue to process this request.
1382        - queue_items: a list of QueueItems
1383        - insert_at_index: insert the item(s) at this index
1384        - keep_remaining: keep the remaining items after the insert
1385        - shuffle: (re)shuffle the items after insert index
1386        """
1387        prev_items = self._queue_items[queue_id][:insert_at_index] if keep_played else []
1388        next_items = queue_items
1389
1390        # if keep_remaining, append the old 'next' items
1391        if keep_remaining:
1392            next_items += self._queue_items[queue_id][insert_at_index:]
1393
1394        # we set the original insert order as attribute so we can un-shuffle
1395        for index, item in enumerate(next_items):
1396            item.sort_index += insert_at_index + index
1397        # (re)shuffle the final batch if needed
1398        if shuffle:
1399            next_items = await _smart_shuffle(next_items)
1400        self.update_items(queue_id, prev_items + next_items)
1401
1402    def update_items(self, queue_id: str, queue_items: list[QueueItem]) -> None:
1403        """Update the existing queue items, mostly caused by reordering."""
1404        self._queue_items[queue_id] = queue_items
1405        queue = self._queues[queue_id]
1406        queue.items = len(self._queue_items[queue_id])
1407        # to track if the queue items changed we set a timestamp
1408        # this is a simple way to detect changes in the list of items
1409        # without having to compare the entire list
1410        queue.items_last_updated = time.time()
1411        self.signal_update(queue_id, True)
1412        if (
1413            queue.state == PlaybackState.PLAYING
1414            and queue.index_in_buffer is not None
1415            and queue.index_in_buffer == queue.current_index
1416        ):
1417            # if the queue is playing,
1418            # ensure to (re)queue the next track because it might have changed
1419            # note that we only do this if the player has loaded the current track
1420            # if not, we wait until it has loaded to prevent conflicts
1421            if next_item := self.get_next_item(queue_id, queue.index_in_buffer):
1422                self._enqueue_next_item(queue_id, next_item)
1423
1424    # Helper methods
1425
1426    def get_item(self, queue_id: str, item_id_or_index: int | str | None) -> QueueItem | None:
1427        """Get queue item by index or item_id."""
1428        if item_id_or_index is None:
1429            return None
1430        if (queue_items := self._queue_items.get(queue_id)) is None:
1431            return None
1432        if isinstance(item_id_or_index, int) and len(queue_items) > item_id_or_index:
1433            return queue_items[item_id_or_index]
1434        if isinstance(item_id_or_index, str):
1435            return next((x for x in queue_items if x.queue_item_id == item_id_or_index), None)
1436        return None
1437
1438    def signal_update(self, queue_id: str, items_changed: bool = False) -> None:
1439        """Signal state changed of given queue."""
1440        queue = self._queues[queue_id]
1441        if items_changed:
1442            self.mass.signal_event(EventType.QUEUE_ITEMS_UPDATED, object_id=queue_id, data=queue)
1443            # save items in cache - only cache items with valid media_item
1444            cache_data = [
1445                x.to_cache() for x in self._queue_items[queue_id] if x.media_item is not None
1446            ]
1447            self.mass.create_task(
1448                self.mass.cache.set(
1449                    key=queue_id,
1450                    data=cache_data,
1451                    provider=self.domain,
1452                    category=CACHE_CATEGORY_PLAYER_QUEUE_ITEMS,
1453                )
1454            )
1455        # always send the base event
1456        self.mass.signal_event(EventType.QUEUE_UPDATED, object_id=queue_id, data=queue)
1457        # also signal update to the player itself so it can update its current_media
1458        self.mass.players.trigger_player_update(queue_id)
1459        # save state
1460        self.mass.create_task(
1461            self.mass.cache.set(
1462                key=queue_id,
1463                data=queue.to_cache(),
1464                provider=self.domain,
1465                category=CACHE_CATEGORY_PLAYER_QUEUE_STATE,
1466            )
1467        )
1468
1469    def index_by_id(self, queue_id: str, queue_item_id: str) -> int | None:
1470        """Get index by queue_item_id."""
1471        queue_items = self._queue_items[queue_id]
1472        for index, item in enumerate(queue_items):
1473            if item.queue_item_id == queue_item_id:
1474                return index
1475        return None
1476
1477    async def player_media_from_queue_item(
1478        self, queue_item: QueueItem, flow_mode: bool
1479    ) -> PlayerMedia:
1480        """Parse PlayerMedia from QueueItem."""
1481        queue = self._queues[queue_item.queue_id]
1482        if flow_mode:
1483            duration = None
1484        elif queue_item.streamdetails:
1485            # prefer netto duration
1486            # when seeking, the player only receives the remaining duration
1487            duration = queue_item.streamdetails.duration or queue_item.duration
1488            if duration and queue_item.streamdetails.seek_position:
1489                duration = duration - queue_item.streamdetails.seek_position
1490        else:
1491            duration = queue_item.duration
1492        if queue.session_id is None:
1493            # handle error or return early
1494            raise InvalidDataError("Queue session_id is None")
1495        media = PlayerMedia(
1496            uri=await self.mass.streams.resolve_stream_url(
1497                queue.session_id, queue_item, flow_mode=flow_mode
1498            ),
1499            media_type=MediaType.FLOW_STREAM if flow_mode else queue_item.media_type,
1500            title="Music Assistant" if flow_mode else queue_item.name,
1501            image_url=MASS_LOGO_ONLINE,
1502            duration=duration,
1503            source_id=queue_item.queue_id,
1504            queue_item_id=queue_item.queue_item_id,
1505        )
1506        if not flow_mode and queue_item.media_item:
1507            media.title = queue_item.media_item.name
1508            media.artist = getattr(queue_item.media_item, "artist_str", "")
1509            media.album = (
1510                album.name if (album := getattr(queue_item.media_item, "album", None)) else ""
1511            )
1512            if queue_item.image:
1513                # the image format needs to be 500x500 jpeg for maximum compatibility with players
1514                # we prefer the imageproxy on the streamserver here because this request is sent
1515                # to the player itself which may not be able to reach the regular webserver
1516                media.image_url = self.mass.metadata.get_image_url(
1517                    queue_item.image, size=500, prefer_stream_server=True
1518                )
1519        return media
1520
1521    async def get_artist_tracks(self, artist: Artist) -> list[Track]:
1522        """Return tracks for given artist, based on user preference."""
1523        artist_items_conf = self.mass.config.get_raw_core_config_value(
1524            self.domain,
1525            CONF_DEFAULT_ENQUEUE_SELECT_ARTIST,
1526            ENQUEUE_SELECT_ARTIST_DEFAULT_VALUE,
1527        )
1528        self.logger.info(
1529            "Fetching tracks to play for artist %s",
1530            artist.name,
1531        )
1532        if artist_items_conf in ("library_tracks", "all_tracks"):
1533            all_items = await self.mass.music.artists.tracks(
1534                artist.item_id,
1535                artist.provider,
1536                in_library_only=artist_items_conf == "library_tracks",
1537            )
1538            random.shuffle(all_items)
1539            return all_items
1540        if artist_items_conf in ("library_album_tracks", "all_album_tracks"):
1541            all_tracks: list[Track] = []
1542            for library_album in await self.mass.music.artists.albums(
1543                artist.item_id,
1544                artist.provider,
1545                in_library_only=artist_items_conf == "library_album_tracks",
1546            ):
1547                for album_track in await self.mass.music.albums.tracks(
1548                    library_album.item_id, library_album.provider
1549                ):
1550                    if album_track not in all_tracks:
1551                        all_tracks.append(album_track)
1552            random.shuffle(all_tracks)
1553            return all_tracks
1554        return []
1555
1556    async def get_album_tracks(self, album: Album, start_item: str | None) -> list[Track]:
1557        """Return tracks for given album, based on user preference."""
1558        album_items_conf = self.mass.config.get_raw_core_config_value(
1559            self.domain,
1560            CONF_DEFAULT_ENQUEUE_SELECT_ALBUM,
1561            ENQUEUE_SELECT_ALBUM_DEFAULT_VALUE,
1562        )
1563        result: list[Track] = []
1564        start_item_found = False
1565        self.logger.info(
1566            "Fetching tracks to play for album %s",
1567            album.name,
1568        )
1569        for album_track in await self.mass.music.albums.tracks(
1570            item_id=album.item_id,
1571            provider_instance_id_or_domain=album.provider,
1572            in_library_only=album_items_conf == "library_tracks",
1573        ):
1574            if not album_track.available:
1575                continue
1576            if start_item in (album_track.item_id, album_track.uri):
1577                start_item_found = True
1578            if start_item is not None and not start_item_found:
1579                continue
1580            result.append(album_track)
1581        return result
1582
1583    async def get_playlist_tracks(self, playlist: Playlist, start_item: str | None) -> list[Track]:
1584        """Return tracks for given playlist, based on user preference."""
1585        result: list[Track] = []
1586        start_item_found = False
1587        self.logger.info(
1588            "Fetching tracks to play for playlist %s",
1589            playlist.name,
1590        )
1591        # TODO: Handle other sort options etc.
1592        async for playlist_track in self.mass.music.playlists.tracks(
1593            playlist.item_id, playlist.provider
1594        ):
1595            if not playlist_track.available:
1596                continue
1597            if start_item in (playlist_track.item_id, playlist_track.uri):
1598                start_item_found = True
1599            if start_item is not None and not start_item_found:
1600                continue
1601            result.append(playlist_track)
1602        return result
1603
1604    async def get_audiobook_resume_point(
1605        self, audio_book: Audiobook, chapter: str | int | None = None, userid: str | None = None
1606    ) -> int:
1607        """Return resume point (in milliseconds) for given audio book."""
1608        self.logger.debug(
1609            "Fetching resume point to play for audio book %s",
1610            audio_book.name,
1611        )
1612        if chapter is not None:
1613            # user explicitly selected a chapter to play
1614            start_chapter = int(chapter) if isinstance(chapter, str) else chapter
1615            if chapters := audio_book.metadata.chapters:
1616                if _chapter := next((x for x in chapters if x.position == start_chapter), None):
1617                    return int(_chapter.start * 1000)
1618            raise InvalidDataError(
1619                f"Unable to resolve chapter to play for Audiobook {audio_book.name}"
1620            )
1621        full_played, resume_position_ms = await self.mass.music.get_resume_position(
1622            audio_book, userid=userid
1623        )
1624        return 0 if full_played else resume_position_ms
1625
1626    async def get_next_podcast_episodes(
1627        self,
1628        podcast: Podcast | None,
1629        episode: PodcastEpisode | str | None,
1630        userid: str | None = None,
1631    ) -> UniqueList[PodcastEpisode]:
1632        """Return (next) episode(s) and resume point for given podcast."""
1633        if podcast is None and isinstance(episode, str | NoneType):
1634            raise InvalidDataError("Either podcast or episode must be provided")
1635        if podcast is None:
1636            # single podcast episode requested
1637            assert isinstance(episode, PodcastEpisode)  # checked above
1638            self.logger.debug(
1639                "Fetching resume point to play for Podcast episode %s",
1640                episode.name,
1641            )
1642            (
1643                fully_played,
1644                resume_position_ms,
1645            ) = await self.mass.music.get_resume_position(episode, userid=userid)
1646            episode.fully_played = fully_played
1647            episode.resume_position_ms = 0 if fully_played else resume_position_ms
1648            return UniqueList([episode])
1649        # podcast with optional start episode requested
1650        self.logger.debug(
1651            "Fetching episode(s) and resume point to play for Podcast %s",
1652            podcast.name,
1653        )
1654        all_episodes = [
1655            x async for x in self.mass.music.podcasts.episodes(podcast.item_id, podcast.provider)
1656        ]
1657        all_episodes.sort(key=lambda x: x.position)
1658        # if a episode was provided, a user explicitly selected a episode to play
1659        # so we need to find the index of the episode in the list
1660        resolved_episode: PodcastEpisode | None = None
1661        if isinstance(episode, PodcastEpisode):
1662            resolved_episode = next((x for x in all_episodes if x.uri == episode.uri), None)
1663            if resolved_episode:
1664                # ensure we have accurate resume info
1665                (
1666                    fully_played,
1667                    resume_position_ms,
1668                ) = await self.mass.music.get_resume_position(resolved_episode, userid=userid)
1669                resolved_episode.resume_position_ms = 0 if fully_played else resume_position_ms
1670        elif isinstance(episode, str):
1671            resolved_episode = next(
1672                (x for x in all_episodes if episode in (x.uri, x.item_id)), None
1673            )
1674            if resolved_episode:
1675                # ensure we have accurate resume info
1676                (
1677                    fully_played,
1678                    resume_position_ms,
1679                ) = await self.mass.music.get_resume_position(resolved_episode, userid=userid)
1680                resolved_episode.resume_position_ms = 0 if fully_played else resume_position_ms
1681        else:
1682            # get first episode that is not fully played
1683            for ep in all_episodes:
1684                if ep.fully_played:
1685                    continue
1686                # ensure we have accurate resume info
1687                (
1688                    fully_played,
1689                    resume_position_ms,
1690                ) = await self.mass.music.get_resume_position(ep, userid=userid)
1691                if fully_played:
1692                    continue
1693                ep.resume_position_ms = resume_position_ms
1694                resolved_episode = ep
1695                break
1696            else:
1697                # no episodes found that are not fully played, so we start at the beginning
1698                resolved_episode = next((x for x in all_episodes), None)
1699        if resolved_episode is None:
1700            raise InvalidDataError(f"Unable to resolve episode to play for Podcast {podcast.name}")
1701        # get the index of the episode
1702        episode_index = all_episodes.index(resolved_episode)
1703        # return the (remaining) episode(s) to play
1704        return UniqueList(all_episodes[episode_index:])
1705
1706    def _get_next_index(
1707        self,
1708        queue_id: str,
1709        cur_index: int | None,
1710        is_skip: bool = False,
1711        allow_repeat: bool = True,
1712    ) -> int | None:
1713        """
1714        Return the next index for the queue, accounting for repeat settings.
1715
1716        Will return None if there are no (more) items in the queue.
1717        """
1718        queue = self._queues[queue_id]
1719        queue_items = self._queue_items[queue_id]
1720        if not queue_items or cur_index is None:
1721            # queue is empty
1722            return None
1723        # handle repeat single track
1724        if queue.repeat_mode == RepeatMode.ONE and not is_skip:
1725            return cur_index if allow_repeat else None
1726        # handle cur_index is last index of the queue
1727        if cur_index >= (len(queue_items) - 1):
1728            if allow_repeat and queue.repeat_mode == RepeatMode.ALL:
1729                # if repeat all is enabled, we simply start again from the beginning
1730                return 0
1731            return None
1732        # all other: just the next index
1733        return cur_index + 1
1734
1735    def get_next_item(self, queue_id: str, cur_index: int | str) -> QueueItem | None:
1736        """Return next QueueItem for given queue."""
1737        index: int
1738        if isinstance(cur_index, str):
1739            resolved_index = self.index_by_id(queue_id, cur_index)
1740            if resolved_index is None:
1741                return None  # guard
1742            index = resolved_index
1743        else:
1744            index = cur_index
1745        # At this point index is guaranteed to be int
1746        for skip in range(5):
1747            if (next_index := self._get_next_index(queue_id, index + skip)) is None:
1748                break
1749            next_item = self.get_item(queue_id, next_index)
1750            if next_item is None:
1751                continue
1752            if not next_item.available:
1753                # ensure that we skip unavailable items (set by load_next track logic)
1754                continue
1755            return next_item
1756        return None
1757
1758    async def _fill_radio_tracks(self, queue_id: str) -> None:
1759        """Fill a Queue with (additional) Radio tracks."""
1760        self.logger.debug(
1761            "Filling radio tracks for queue %s",
1762            queue_id,
1763        )
1764        tracks = await self._get_radio_tracks(queue_id=queue_id, is_initial_radio_mode=False)
1765        # fill queue - filter out unavailable items
1766        queue_items = [QueueItem.from_media_item(queue_id, x) for x in tracks if x.available]
1767        await self.load(
1768            queue_id,
1769            queue_items,
1770            insert_at_index=len(self._queue_items[queue_id]) + 1,
1771        )
1772
1773    def _enqueue_next_item(self, queue_id: str, next_item: QueueItem | None) -> None:
1774        """Enqueue the next item on the player."""
1775        if not next_item:
1776            # no next item, nothing to do...
1777            return
1778
1779        queue = self._queues[queue_id]
1780        if queue.flow_mode:
1781            # ignore this for flow mode
1782            return
1783
1784        async def _enqueue_next_item_on_player(next_item: QueueItem) -> None:
1785            await self.mass.players.enqueue_next_media(
1786                player_id=queue_id,
1787                media=await self.player_media_from_queue_item(next_item, False),
1788            )
1789            if queue.next_item_id_enqueued != next_item.queue_item_id:
1790                queue.next_item_id_enqueued = next_item.queue_item_id
1791                self.logger.debug(
1792                    "Enqueued next track %s on queue %s",
1793                    next_item.name,
1794                    self._queues[queue_id].display_name,
1795                )
1796
1797        task_id = f"enqueue_next_item_{queue_id}"
1798        self.mass.call_later(0.5, _enqueue_next_item_on_player, next_item, task_id=task_id)
1799
1800    def _preload_next_item(self, queue_id: str, item_id_in_buffer: str) -> None:
1801        """
1802        Preload the streamdetails for the next item in the queue/buffer.
1803
1804        This basically ensures the item is playable and fetches the stream details.
1805        If an error occurs, the item will be skipped and the next item will be loaded.
1806        """
1807        queue = self._queues[queue_id]
1808
1809        async def _preload_streamdetails(item_id_in_buffer: str) -> None:
1810            try:
1811                # wait for the item that was loaded in the buffer is the actually playing item
1812                # this prevents a race condition when we preload the next item too soon
1813                # while the player is actually preloading the previously enqueued item.
1814                retries = 120
1815                while retries > 0:
1816                    if not queue.current_item:
1817                        return  # guard
1818                    if queue.current_item.queue_item_id == item_id_in_buffer:
1819                        break
1820                    retries -= 1
1821                    await asyncio.sleep(1)
1822                if next_item := await self.load_next_queue_item(queue_id, item_id_in_buffer):
1823                    self.logger.debug(
1824                        "Preloaded next item %s for queue %s",
1825                        next_item.name,
1826                        queue.display_name,
1827                    )
1828                    # enqueue the next item on the player
1829                    self._enqueue_next_item(queue_id, next_item)
1830
1831            except QueueEmpty:
1832                return
1833
1834        if not (current_item := self.get_item(queue_id, item_id_in_buffer)):
1835            # this should not happen, but guard anyways
1836            return
1837        if current_item.media_type == MediaType.RADIO or not current_item.duration:
1838            # radio items or no duration, nothing to do
1839            return
1840
1841        task_id = f"preload_next_item_{queue_id}"
1842        self.mass.create_task(
1843            _preload_streamdetails,
1844            item_id_in_buffer,
1845            task_id=task_id,
1846            abort_existing=True,
1847        )
1848
1849    async def _resolve_media_items(
1850        self,
1851        media_item: MediaItemType | ItemMapping | BrowseFolder,
1852        start_item: str | None = None,
1853        userid: str | None = None,
1854        queue_id: str | None = None,
1855    ) -> list[MediaItemType]:
1856        """Resolve/unwrap media items to enqueue."""
1857        # resolve Itemmapping to full media item
1858        if isinstance(media_item, ItemMapping):
1859            if media_item.uri is None:
1860                raise InvalidDataError("ItemMapping has no URI")
1861            media_item = await self.mass.music.get_item_by_uri(media_item.uri)
1862        if media_item.media_type == MediaType.PLAYLIST:
1863            media_item = cast("Playlist", media_item)
1864            self.mass.create_task(
1865                self.mass.music.mark_item_played(
1866                    media_item, userid=userid, queue_id=queue_id, user_initiated=True
1867                )
1868            )
1869            return list(await self.get_playlist_tracks(media_item, start_item))
1870        if media_item.media_type == MediaType.ARTIST:
1871            media_item = cast("Artist", media_item)
1872            self.mass.create_task(
1873                self.mass.music.mark_item_played(media_item, queue_id=queue_id, user_initiated=True)
1874            )
1875            return list(await self.get_artist_tracks(media_item))
1876        if media_item.media_type == MediaType.ALBUM:
1877            media_item = cast("Album", media_item)
1878            self.mass.create_task(
1879                self.mass.music.mark_item_played(
1880                    media_item, userid=userid, queue_id=queue_id, user_initiated=True
1881                )
1882            )
1883            return list(await self.get_album_tracks(media_item, start_item))
1884        if media_item.media_type == MediaType.AUDIOBOOK:
1885            media_item = cast("Audiobook", media_item)
1886            # ensure we grab the correct/latest resume point info
1887            media_item.resume_position_ms = await self.get_audiobook_resume_point(
1888                media_item, start_item, userid=userid
1889            )
1890            return [media_item]
1891        if media_item.media_type == MediaType.PODCAST:
1892            media_item = cast("Podcast", media_item)
1893            self.mass.create_task(
1894                self.mass.music.mark_item_played(
1895                    media_item, userid=userid, queue_id=queue_id, user_initiated=True
1896                )
1897            )
1898            return list(await self.get_next_podcast_episodes(media_item, start_item, userid=userid))
1899        if media_item.media_type == MediaType.PODCAST_EPISODE:
1900            media_item = cast("PodcastEpisode", media_item)
1901            return list(await self.get_next_podcast_episodes(None, media_item, userid=userid))
1902        if media_item.media_type == MediaType.FOLDER:
1903            media_item = cast("BrowseFolder", media_item)
1904            return list(await self._get_folder_tracks(media_item))
1905        # all other: single track or radio item
1906        return [cast("MediaItemType", media_item)]
1907
1908    async def _try_resume_from_playlog(self, queue: PlayerQueue) -> bool:
1909        """Try to resume playback from playlog when queue is empty.
1910
1911        Attempts to find user-initiated recently played items in the following order:
1912        1. By userid AND queue_id
1913        2. By queue_id only
1914        3. By userid only (if available)
1915        4. Any recently played item
1916
1917        :param queue: The queue to resume playback on.
1918        :return: True if playback was started, False otherwise.
1919        """
1920        # Try different filter combinations in order of specificity
1921        filter_attempts: list[tuple[str | None, str | None, str]] = []
1922        if queue.userid:
1923            filter_attempts.append((queue.userid, queue.queue_id, "userid + queue_id match"))
1924        filter_attempts.append((None, queue.queue_id, "queue_id match"))
1925        if queue.userid:
1926            filter_attempts.append((queue.userid, None, "userid match"))
1927        filter_attempts.append((None, None, "any recent item"))
1928
1929        for userid, queue_id, match_type in filter_attempts:
1930            items = await self.mass.music.recently_played(
1931                limit=5,
1932                fully_played_only=False,
1933                user_initiated_only=True,
1934                userid=userid,
1935                queue_id=queue_id,
1936            )
1937            for item in items:
1938                if not item.uri:
1939                    continue
1940                try:
1941                    await self.play_media(queue.queue_id, item)
1942                    self.logger.info(
1943                        "Resumed queue %s from playlog (%s)", queue.display_name, match_type
1944                    )
1945                    return True
1946                except MusicAssistantError as err:
1947                    self.logger.debug("Failed to resume with item %s: %s", item.name, err)
1948                    continue
1949
1950        return False
1951
1952    async def _get_radio_tracks(
1953        self, queue_id: str, is_initial_radio_mode: bool = False
1954    ) -> list[Track]:
1955        """Call the registered music providers for dynamic tracks."""
1956        queue = self._queues[queue_id]
1957        queue_track_items: list[Track] = [
1958            q.media_item
1959            for q in self._queue_items[queue_id]
1960            if q.media_item and isinstance(q.media_item, Track)
1961        ]
1962        if not queue.radio_source:
1963            # this may happen during race conditions as this method is called delayed
1964            return []
1965        self.logger.info(
1966            "Fetching radio tracks for queue %s based on: %s",
1967            queue.display_name,
1968            ", ".join([x.name for x in queue.radio_source]),
1969        )
1970
1971        # Get user's preferred provider instances for steering provider selection
1972        preferred_provider_instances: list[str] | None = None
1973        if (
1974            queue.userid
1975            and (playback_user := await self.mass.webserver.auth.get_user(queue.userid))
1976            and playback_user.provider_filter
1977        ):
1978            preferred_provider_instances = playback_user.provider_filter
1979
1980        available_base_tracks: list[Track] = []
1981        base_track_sample_size = 5
1982        # Some providers have very deterministic similar track algorithms when providing
1983        # a single track item. When we have a radio mode based on 1 track and we have to
1984        # refill the queue (ie not initial radio mode), we use the play history as base tracks
1985        if (
1986            len(queue.radio_source) == 1
1987            and queue.radio_source[0].media_type == MediaType.TRACK
1988            and not is_initial_radio_mode
1989        ):
1990            available_base_tracks = queue_track_items
1991        else:
1992            # Grab all the available base tracks based on the selected source items.
1993            # shuffle the source items, just in case
1994            for radio_item in random.sample(queue.radio_source, len(queue.radio_source)):
1995                ctrl = self.mass.music.get_controller(radio_item.media_type)
1996                try:
1997                    available_base_tracks += [
1998                        track
1999                        for track in await ctrl.radio_mode_base_tracks(
2000                            radio_item,  # type: ignore[arg-type]
2001                            preferred_provider_instances,
2002                        )
2003                        # Avoid duplicate base tracks
2004                        if track not in available_base_tracks
2005                    ]
2006                except UnsupportedFeaturedException as err:
2007                    self.logger.debug(
2008                        "Skip loading radio items for %s: %s ",
2009                        radio_item.uri,
2010                        str(err),
2011                    )
2012            if not available_base_tracks:
2013                raise UnsupportedFeaturedException("Radio mode not available for source items")
2014
2015        # Sample tracks from the base tracks, which will be used to calculate the dynamic ones
2016        base_tracks = random.sample(
2017            available_base_tracks,
2018            min(base_track_sample_size, len(available_base_tracks)),
2019        )
2020        # Use a set to avoid duplicate dynamic tracks
2021        dynamic_tracks: set[Track] = set()
2022        # Use base tracks + Trackcontroller to obtain similar tracks for every base Track
2023        for allow_lookup in (False, True):
2024            if dynamic_tracks:
2025                break
2026            for base_track in base_tracks:
2027                try:
2028                    _similar_tracks = await self.mass.music.tracks.similar_tracks(
2029                        base_track.item_id,
2030                        base_track.provider,
2031                        allow_lookup=allow_lookup,
2032                        preferred_provider_instances=preferred_provider_instances,
2033                    )
2034                except MediaNotFoundError:
2035                    # Some providers don't have similar tracks for all items. For example,
2036                    # Tidal can sometimes return a 404 when the 'similar_tracks' endpoint is called.
2037                    # in that case, just skip the track.
2038                    self.logger.debug("Similar tracks not found for track %s", base_track.name)
2039                    continue
2040                for track in _similar_tracks:
2041                    if (
2042                        track not in base_tracks
2043                        # Exclude tracks we have already played / queued
2044                        and track not in queue_track_items
2045                        # Ignore tracks that are too long for radio mode, e.g. mixes
2046                        and track.duration <= RADIO_TRACK_MAX_DURATION_SECS
2047                    ):
2048                        dynamic_tracks.add(track)
2049                if len(dynamic_tracks) >= 50:
2050                    break
2051        queue_tracks: list[Track] = []
2052        dynamic_tracks_list = list(dynamic_tracks)
2053        # Only include the sampled base tracks when the radio mode is first initialized
2054        if is_initial_radio_mode:
2055            queue_tracks += [base_tracks[0]]
2056            # Exhaust base tracks with the pattern of BDDBDDBDD (1 base track + 2 dynamic tracks)
2057            if len(base_tracks) > 1:
2058                for base_track in base_tracks[1:]:
2059                    queue_tracks += [base_track]
2060                    if len(dynamic_tracks_list) > 2:
2061                        queue_tracks += random.sample(dynamic_tracks_list, 2)
2062                    else:
2063                        queue_tracks += dynamic_tracks_list
2064        # Add dynamic tracks to the queue, make sure to exclude already picked tracks
2065        remaining_dynamic_tracks = [t for t in dynamic_tracks_list if t not in queue_tracks]
2066        if remaining_dynamic_tracks:
2067            queue_tracks += random.sample(
2068                remaining_dynamic_tracks, min(len(remaining_dynamic_tracks), 25)
2069            )
2070        return queue_tracks
2071
2072    async def _get_folder_tracks(self, folder: BrowseFolder) -> list[Track]:
2073        """Fetch (playable) tracks for given browse folder."""
2074        self.logger.info(
2075            "Fetching tracks to play for folder %s",
2076            folder.name,
2077        )
2078        tracks: list[Track] = []
2079        for item in await self.mass.music.browse(folder.path):
2080            if not item.is_playable:
2081                continue
2082            # recursively fetch tracks from all media types
2083            resolved = await self._resolve_media_items(item)
2084            tracks += [x for x in resolved if isinstance(x, Track)]
2085
2086        return tracks
2087
2088    def _update_queue_from_player(
2089        self,
2090        player: Player,
2091    ) -> None:
2092        """Update the Queue when the player state changed."""
2093        queue_id = player.player_id
2094        queue = self._queues[queue_id]
2095
2096        # basic properties
2097        queue.display_name = player.display_name
2098        queue.available = player.available
2099        queue.items = len(self._queue_items[queue_id])
2100
2101        queue.state = (
2102            player.playback_state or PlaybackState.IDLE if queue.active else PlaybackState.IDLE
2103        )
2104        # update current item/index from player report
2105        if queue.active and queue.state in (
2106            PlaybackState.PLAYING,
2107            PlaybackState.PAUSED,
2108        ):
2109            # NOTE: If the queue is not playing (yet) we will not update the current index
2110            # to ensure we keep the previously known current index
2111            if queue.flow_mode:
2112                # flow mode active, the player is playing one long stream
2113                # so we need to calculate the current index and elapsed time
2114                current_index, elapsed_time = self._get_flow_queue_stream_index(queue, player)
2115            elif item_id := self._parse_player_current_item_id(queue_id, player):
2116                # normal mode, the player itself will report the current item
2117                elapsed_time = int(player.corrected_elapsed_time or 0)
2118                current_index = self.index_by_id(queue_id, item_id)
2119            else:
2120                # this may happen if the player is still transitioning between tracks
2121                # we ignore this for now and keep the current index as is
2122                return
2123
2124            # get current/next item based on current index
2125            queue.current_index = current_index
2126            queue.current_item = current_item = self.get_item(queue_id, current_index)
2127            queue.next_item = (
2128                self.get_next_item(queue_id, current_index)
2129                if current_item and current_index is not None
2130                else None
2131            )
2132
2133            # correct elapsed time when seeking
2134            if (
2135                not queue.flow_mode
2136                and current_item
2137                and current_item.streamdetails
2138                and current_item.streamdetails.seek_position
2139            ):
2140                elapsed_time += current_item.streamdetails.seek_position
2141            queue.elapsed_time = elapsed_time
2142            queue.elapsed_time_last_updated = time.time()
2143
2144        elif not queue.current_item and queue.current_index is not None:
2145            current_index = queue.current_index
2146            queue.current_item = current_item = self.get_item(queue_id, current_index)
2147            queue.next_item = (
2148                self.get_next_item(queue_id, current_index)
2149                if current_item and current_index is not None
2150                else None
2151            )
2152
2153        # This is enough to detect any changes in the DSPDetails
2154        # (so child count changed, or any output format changed)
2155        output_formats = []
2156        if output_format := player.extra_data.get("output_format"):
2157            output_formats.append(str(output_format))
2158        for child_id in player.group_members:
2159            if (child := self.mass.players.get(child_id)) and (
2160                output_format := child.extra_data.get("output_format")
2161            ):
2162                output_formats.append(str(output_format))
2163            else:
2164                output_formats.append("unknown")
2165
2166        # basic throttle: do not send state changed events if queue did not actually change
2167        prev_state: CompareState = self._prev_states.get(
2168            queue_id,
2169            CompareState(
2170                queue_id=queue_id,
2171                state=PlaybackState.IDLE,
2172                current_item_id=None,
2173                next_item_id=None,
2174                current_item=None,
2175                elapsed_time=0,
2176                last_playing_elapsed_time=0,
2177                stream_title=None,
2178                codec_type=None,
2179                output_formats=None,
2180            ),
2181        )
2182        # update last_playing_elapsed_time only when the player is actively playing
2183        # use corrected_elapsed_time which accounts for time since last update
2184        # this preserves the last known elapsed time when transitioning to idle/paused
2185        prev_playing_elapsed = prev_state["last_playing_elapsed_time"]
2186        prev_item_id = prev_state["current_item_id"]
2187        current_item_id = queue.current_item.queue_item_id if queue.current_item else None
2188        if queue.state == PlaybackState.PLAYING:
2189            current_elapsed = int(queue.corrected_elapsed_time)
2190            if current_item_id != prev_item_id:
2191                # new track started, reset the elapsed time tracker
2192                last_playing_elapsed_time = current_elapsed
2193            else:
2194                # same track, use the max of current and previous to handle timing issues
2195                last_playing_elapsed_time = max(current_elapsed, prev_playing_elapsed)
2196        else:
2197            last_playing_elapsed_time = prev_playing_elapsed
2198        new_state = CompareState(
2199            queue_id=queue_id,
2200            state=queue.state,
2201            current_item_id=queue.current_item.queue_item_id if queue.current_item else None,
2202            next_item_id=queue.next_item.queue_item_id if queue.next_item else None,
2203            current_item=queue.current_item,
2204            elapsed_time=int(queue.elapsed_time),
2205            last_playing_elapsed_time=last_playing_elapsed_time,
2206            stream_title=(
2207                queue.current_item.streamdetails.stream_title
2208                if queue.current_item and queue.current_item.streamdetails
2209                else None
2210            ),
2211            codec_type=(
2212                queue.current_item.streamdetails.audio_format.codec_type
2213                if queue.current_item and queue.current_item.streamdetails
2214                else None
2215            ),
2216            output_formats=output_formats,
2217        )
2218        changed_keys = get_changed_keys(dict(prev_state), dict(new_state))
2219        with suppress(KeyError):
2220            changed_keys.remove("next_item_id")
2221        with suppress(KeyError):
2222            changed_keys.remove("last_playing_elapsed_time")
2223
2224        # store the new state
2225        if queue.active:
2226            self._prev_states[queue_id] = new_state
2227        else:
2228            self._prev_states.pop(queue_id, None)
2229
2230        # return early if nothing changed
2231        if len(changed_keys) == 0:
2232            return
2233
2234        # signal update and store state
2235        send_update = True
2236        if changed_keys == {"elapsed_time"}:
2237            # only elapsed time changed, do not send full queue update
2238            send_update = False
2239            prev_time = prev_state.get("elapsed_time") or 0
2240            cur_time = new_state.get("elapsed_time") or 0
2241            if abs(cur_time - prev_time) > 2:
2242                # send dedicated event for time updates when seeking
2243                self.mass.signal_event(
2244                    EventType.QUEUE_TIME_UPDATED,
2245                    object_id=queue_id,
2246                    data=queue.elapsed_time,
2247                )
2248                # also signal update to the player itself so it can update its current_media
2249                self.mass.players.trigger_player_update(queue_id)
2250
2251        if send_update:
2252            self.signal_update(queue_id)
2253
2254        if "output_formats" in changed_keys:
2255            # refresh DSP details since they may have changed
2256            dsp = get_stream_dsp_details(self.mass, queue_id)
2257            if queue.current_item and queue.current_item.streamdetails:
2258                queue.current_item.streamdetails.dsp = dsp
2259            if queue.next_item and queue.next_item.streamdetails:
2260                queue.next_item.streamdetails.dsp = dsp
2261
2262        # handle updating stream_metadata if needed
2263        if (
2264            queue.current_item
2265            and (streamdetails := queue.current_item.streamdetails)
2266            and streamdetails.stream_metadata_update_callback
2267            and (
2268                streamdetails.stream_metadata_last_updated is None
2269                or (
2270                    time.time() - streamdetails.stream_metadata_last_updated
2271                    >= streamdetails.stream_metadata_update_interval
2272                )
2273            )
2274        ):
2275            streamdetails.stream_metadata_last_updated = time.time()
2276            self.mass.create_task(
2277                streamdetails.stream_metadata_update_callback(
2278                    streamdetails, int(queue.corrected_elapsed_time)
2279                )
2280            )
2281
2282        # handle sending a playback progress report
2283        # we do this every 30 seconds or when the state changes
2284        if (
2285            changed_keys.intersection({"state", "current_item_id"})
2286            or int(queue.elapsed_time) % 30 == 0
2287        ):
2288            self._handle_playback_progress_report(queue, prev_state, new_state)
2289
2290        # check if we need to clear the queue if we reached the end
2291        if "state" in changed_keys and queue.state == PlaybackState.IDLE:
2292            self._handle_end_of_queue(queue, prev_state, new_state)
2293
2294        # watch dynamic radio items refill if needed
2295        if "current_item_id" in changed_keys:
2296            # auto enable radio mode if dont stop the music is enabled
2297            if (
2298                queue.dont_stop_the_music_enabled
2299                and queue.enqueued_media_items
2300                and queue.current_index is not None
2301                and (queue.items - queue.current_index) <= 1
2302            ):
2303                # We have received the last item in the queue and Don't stop the music is enabled
2304                # set the played media item(s) as radio items (which will refill the queue)
2305                # note that this will fail if there are no media items for which we have
2306                # a dynamic radio source.
2307                self.logger.debug(
2308                    "End of queue detected and Don't stop the music is enabled for %s"
2309                    " - setting enqueued media items as radio source: %s",
2310                    queue.display_name,
2311                    ", ".join([x.uri for x in queue.enqueued_media_items]),  # type: ignore[misc]  # uri set in __post_init__
2312                )
2313                queue.radio_source = queue.enqueued_media_items
2314            # auto fill radio tracks if less than 5 tracks left in the queue
2315            if (
2316                queue.radio_source
2317                and queue.current_index is not None
2318                and (queue.items - queue.current_index) < 5
2319            ):
2320                task_id = f"fill_radio_tracks_{queue_id}"
2321                self.mass.call_later(5, self._fill_radio_tracks, queue_id, task_id=task_id)
2322
2323    def _get_flow_queue_stream_index(
2324        self, queue: PlayerQueue, player: Player
2325    ) -> tuple[int | None, int]:
2326        """Calculate current queue index and current track elapsed time when flow mode is active."""
2327        elapsed_time_queue_total = player.corrected_elapsed_time or 0
2328        if queue.current_index is None and not queue.flow_mode_stream_log:
2329            return queue.current_index, int(queue.elapsed_time)
2330
2331        # For each track that has been streamed/buffered to the player,
2332        # a playlog entry will be created with the queue item id
2333        # and the amount of seconds streamed. We traverse the playlog to figure
2334        # out where we are in the queue, accounting for actual streamed
2335        # seconds (and not duration) and skipped seconds. If a track has been repeated,
2336        # it will simply be in the playlog multiple times.
2337        played_time = 0.0
2338        queue_index: int | None = queue.current_index or 0
2339        track_time = 0.0
2340        for play_log_entry in queue.flow_mode_stream_log:
2341            queue_item_duration = (
2342                # NOTE: 'seconds_streamed' can actually be 0 if there was a stream error!
2343                play_log_entry.seconds_streamed
2344                if play_log_entry.seconds_streamed is not None
2345                else play_log_entry.duration or 3600 * 24 * 7
2346            )
2347            if elapsed_time_queue_total > (queue_item_duration + played_time):
2348                # total elapsed time is more than (streamed) track duration
2349                # this track has been fully played, move on.
2350                played_time += queue_item_duration
2351            else:
2352                # no more seconds left to divide, this is our track
2353                # account for any seeking by adding the skipped/seeked seconds
2354                queue_index = self.index_by_id(queue.queue_id, play_log_entry.queue_item_id)
2355                queue_item = self.get_item(queue.queue_id, queue_index)
2356                if queue_item and queue_item.streamdetails:
2357                    track_sec_skipped = queue_item.streamdetails.seek_position
2358                else:
2359                    track_sec_skipped = 0
2360                track_time = elapsed_time_queue_total + track_sec_skipped - played_time
2361                break
2362        if player.playback_state != PlaybackState.PLAYING:
2363            # if the player is not playing, we can't be sure that the elapsed time is correct
2364            # so we just return the queue index and the elapsed time
2365            return queue.current_index, int(queue.elapsed_time)
2366        return queue_index, int(track_time)
2367
2368    def _parse_player_current_item_id(self, queue_id: str, player: Player) -> str | None:
2369        """Parse QueueItem ID from Player's current url."""
2370        if not player._current_media:
2371            # YES, we use player._current_media on purpose here because we need the raw metadata
2372            return None
2373        # prefer queue_id and queue_item_id within the current media
2374        if player._current_media.source_id == queue_id and player._current_media.queue_item_id:
2375            return player._current_media.queue_item_id
2376        # special case for sonos players
2377        if player._current_media.uri and player._current_media.uri.startswith(f"mass:{queue_id}"):
2378            if player._current_media.queue_item_id:
2379                return player._current_media.queue_item_id
2380            return player._current_media.uri.split(":")[-1]
2381        # try to extract the item id from a mass stream url
2382        if (
2383            player._current_media.uri
2384            and queue_id in player._current_media.uri
2385            and self.mass.streams.base_url in player._current_media.uri
2386        ):
2387            current_item_id = player._current_media.uri.rsplit("/")[-1].split(".")[0]
2388            if self.get_item(queue_id, current_item_id):
2389                return current_item_id
2390        # try to extract the item id from a queue_id/item_id combi
2391        if (
2392            player._current_media.uri
2393            and queue_id in player._current_media.uri
2394            and "/" in player._current_media.uri
2395        ):
2396            current_item_id = player._current_media.uri.split("/")[1]
2397            if self.get_item(queue_id, current_item_id):
2398                return current_item_id
2399
2400        return None
2401
2402    def _handle_end_of_queue(
2403        self, queue: PlayerQueue, prev_state: CompareState, new_state: CompareState
2404    ) -> None:
2405        """Check if the queue should be cleared after the current item."""
2406        # check if queue state changed to stopped (from playing/paused to idle)
2407        if not (
2408            prev_state["state"] in (PlaybackState.PLAYING, PlaybackState.PAUSED)
2409            and new_state["state"] == PlaybackState.IDLE
2410        ):
2411            return
2412        # check if no more items in the queue (next_item should be None at end of queue)
2413        if queue.next_item is not None:
2414            return
2415        # check if we had a previous item playing
2416        if prev_state["current_item_id"] is None:
2417            return
2418
2419        async def _clear_queue_delayed() -> None:
2420            for _ in range(5):
2421                await asyncio.sleep(1)
2422                if queue.state != PlaybackState.IDLE:
2423                    return
2424                if queue.next_item is not None:
2425                    return
2426            self.logger.info("End of queue reached, clearing items")
2427            self.clear(queue.queue_id)
2428
2429        # all checks passed, we stopped playback at the last (or single) track of the queue
2430        # now determine if the item was fully played before clearing
2431
2432        # For flow mode, check if the last track was fully streamed using the stream log
2433        # This is more reliable than elapsed_time which can be reset/incorrect
2434        if queue.flow_mode and queue.flow_mode_stream_log:
2435            last_log_entry = queue.flow_mode_stream_log[-1]
2436            if last_log_entry.seconds_streamed is not None:
2437                # The last track finished streaming, safe to clear queue
2438                self.mass.create_task(_clear_queue_delayed())
2439            return
2440
2441        # For non-flow mode, use prev_state values since queue state may have been updated/reset
2442        prev_item = prev_state["current_item"]
2443        if prev_item and (streamdetails := prev_item.streamdetails):
2444            duration = streamdetails.duration or prev_item.duration or 24 * 3600
2445        elif prev_item:
2446            duration = prev_item.duration or 24 * 3600
2447        else:
2448            # No current item means player has already cleared it, safe to clear queue
2449            self.mass.create_task(_clear_queue_delayed())
2450            return
2451
2452        # use last_playing_elapsed_time which preserves the elapsed time from when the player
2453        # was still playing (before transitioning to idle where elapsed_time may be reset to 0)
2454        seconds_played = int(prev_state["last_playing_elapsed_time"])
2455        # debounce this a bit to make sure we're not clearing the queue by accident
2456        # only clear if the last track was played to near completion (within 5 seconds of end)
2457        if seconds_played >= (duration or 3600) - 5:
2458            self.mass.create_task(_clear_queue_delayed())
2459
2460    def _handle_playback_progress_report(
2461        self, queue: PlayerQueue, prev_state: CompareState, new_state: CompareState
2462    ) -> None:
2463        """Handle playback progress report."""
2464        # detect change in current index to report that a item has been played
2465        prev_item_id = prev_state["current_item_id"]
2466        cur_item_id = new_state["current_item_id"]
2467        if prev_item_id is None and cur_item_id is None:
2468            return
2469
2470        if prev_item_id is not None and prev_item_id != cur_item_id:
2471            # we have a new item, so we need report the previous one
2472            is_current_item = False
2473            item_to_report = prev_state["current_item"]
2474            seconds_played = int(prev_state["elapsed_time"])
2475        else:
2476            # report on current item
2477            is_current_item = True
2478            item_to_report = self.get_item(queue.queue_id, cur_item_id) or new_state["current_item"]
2479            seconds_played = int(new_state["elapsed_time"])
2480
2481        if not item_to_report:
2482            return  # guard against invalid items
2483
2484        if not (media_item := item_to_report.media_item):
2485            # only report on media items
2486            return
2487        assert media_item.uri is not None  # uri is set in __post_init__
2488
2489        if item_to_report.streamdetails and item_to_report.streamdetails.stream_error:
2490            #  Ignore items that had a stream error
2491            return
2492
2493        if item_to_report.streamdetails and item_to_report.streamdetails.duration:
2494            duration = int(item_to_report.streamdetails.duration)
2495        else:
2496            duration = int(item_to_report.duration or 3 * 3600)
2497
2498        if seconds_played < 5:
2499            # ignore items that have been played less than 5 seconds
2500            # this also filters out a bounce effect where the previous item
2501            # gets reported with 0 elapsed seconds after a new item starts playing
2502            return
2503
2504        # determine if item is fully played
2505        # for podcasts and audiobooks we account for the last 60 seconds
2506        percentage_played = percentage(seconds_played, duration)
2507        if not is_current_item and item_to_report.media_type in (
2508            MediaType.AUDIOBOOK,
2509            MediaType.PODCAST_EPISODE,
2510        ):
2511            fully_played = seconds_played >= duration - 60
2512        elif not is_current_item:
2513            # 90% of the track must be played to be considered fully played
2514            fully_played = percentage_played >= 90
2515        else:
2516            fully_played = seconds_played >= duration - 10
2517
2518        is_playing = is_current_item and queue.state == PlaybackState.PLAYING
2519        if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
2520            self.logger.debug(
2521                "%s %s '%s' (%s) - Fully played: %s - Progress: %s (%s/%ss)",
2522                queue.display_name,
2523                "is playing" if is_playing else "played",
2524                item_to_report.name,
2525                item_to_report.uri,
2526                fully_played,
2527                f"{percentage_played}%",
2528                seconds_played,
2529                duration,
2530            )
2531        # add entry to playlog - this also handles resume of podcasts/audiobooks
2532        self.mass.create_task(
2533            self.mass.music.mark_item_played(
2534                media_item,
2535                fully_played=fully_played,
2536                seconds_played=seconds_played,
2537                is_playing=is_playing,
2538                userid=queue.userid,
2539                queue_id=queue.queue_id,
2540                user_initiated=False,
2541            )
2542        )
2543
2544        album: Album | ItemMapping | None = getattr(media_item, "album", None)
2545        # signal 'media item played' event,
2546        # which is useful for plugins that want to do scrobbling
2547        artists: list[Artist | ItemMapping] = getattr(media_item, "artists", [])
2548        artists_names = [a.name for a in artists]
2549        self.mass.signal_event(
2550            EventType.MEDIA_ITEM_PLAYED,
2551            object_id=media_item.uri,
2552            data=MediaItemPlaybackProgressReport(
2553                uri=media_item.uri,
2554                media_type=media_item.media_type,
2555                name=media_item.name,
2556                version=getattr(media_item, "version", None),
2557                artist=(
2558                    getattr(media_item, "artist_str", None) or artists_names[0]
2559                    if artists_names
2560                    else None
2561                ),
2562                artists=artists_names,
2563                artist_mbids=[a.mbid for a in artists if a.mbid] if artists else None,
2564                album=album.name if album else None,
2565                album_mbid=album.mbid if album else None,
2566                album_artist=(album.artist_str if isinstance(album, Album) else None),
2567                album_artist_mbids=(
2568                    [a.mbid for a in album.artists if a.mbid] if isinstance(album, Album) else None
2569                ),
2570                image_url=(
2571                    self.mass.metadata.get_image_url(
2572                        item_to_report.media_item.image, prefer_proxy=False
2573                    )
2574                    if item_to_report.media_item.image
2575                    else None
2576                ),
2577                duration=duration,
2578                mbid=(getattr(media_item, "mbid", None)),
2579                seconds_played=seconds_played,
2580                fully_played=fully_played,
2581                is_playing=is_playing,
2582                userid=queue.userid,
2583            ),
2584        )
2585
2586
2587async def _smart_shuffle(items: list[QueueItem]) -> list[QueueItem]:
2588    """Shuffle queue items, avoiding identical tracks next to each other.
2589
2590    Best-effort approach to prevent the same track from appearing adjacent.
2591    Does a random shuffle first, then makes a limited number of passes to
2592    swap adjacent duplicates with a random item further in the list.
2593
2594    :param items: List of queue items to shuffle.
2595    """
2596    if len(items) <= 2:
2597        return random.sample(items, len(items)) if len(items) == 2 else items
2598
2599    # Start with a random shuffle
2600    shuffled = random.sample(items, len(items))
2601
2602    # Make a few passes to fix adjacent duplicates
2603    max_passes = 3
2604    for _ in range(max_passes):
2605        swapped = False
2606        for i in range(len(shuffled) - 1):
2607            if shuffled[i].name == shuffled[i + 1].name:
2608                # Found adjacent duplicate - swap with random position at least 2 away
2609                swap_candidates = [j for j in range(len(shuffled)) if abs(j - i - 1) >= 2]
2610                if swap_candidates:
2611                    swap_pos = random.choice(swap_candidates)
2612                    shuffled[i + 1], shuffled[swap_pos] = shuffled[swap_pos], shuffled[i + 1]
2613                    swapped = True
2614        if not swapped:
2615            break
2616        # Yield to event loop between passes
2617        await asyncio.sleep(0)
2618
2619    return shuffled
2620