From 559198dfbe70e7e00590d9ec8f86455269a4c394 Mon Sep 17 00:00:00 2001 From: OzGav Date: Tue, 25 Nov 2025 17:40:15 +1000 Subject: [PATCH] Typing fixes for the player queues controller (#2611) * Initial drafting * Fix pyproject.toml * Formatting * Filter for track items * Replace assert with error checks * Fix grammar typo * Lint * Lint * Revert assert * Revert logic change * Revert logic change * add return type hints * PR review fixes * Fix play_media * Remove some asserts * Apply feedback --------- Co-authored-by: Marvin Schenkel --- music_assistant/controllers/player_queues.py | 417 ++++++++++++------- pyproject.toml | 1 - 2 files changed, 273 insertions(+), 145 deletions(-) diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 5398a1e7..8bb82156 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -21,7 +21,11 @@ from types import NoneType from typing import TYPE_CHECKING, Any, TypedDict, cast import shortuuid -from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType +from music_assistant_models.config_entries import ( + ConfigEntry, + ConfigValueOption, + ConfigValueType, +) from music_assistant_models.enums import ( ConfigEntryType, ContentType, @@ -44,15 +48,23 @@ from music_assistant_models.errors import ( UnsupportedFeaturedException, ) from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, BrowseFolder, ItemMapping, MediaItemType, PlayableMediaItemType, Playlist, + Podcast, PodcastEpisode, + Track, + UniqueList, media_from_dict, ) -from music_assistant_models.playback_progress_report import MediaItemPlaybackProgressReport +from music_assistant_models.playback_progress_report import ( + MediaItemPlaybackProgressReport, +) from music_assistant_models.player_queue import PlayerQueue from music_assistant_models.queue_item import QueueItem @@ -72,14 +84,7 @@ from music_assistant.models.player import Player, PlayerMedia if TYPE_CHECKING: from collections.abc import Iterator - from music_assistant_models.media_items import ( - Album, - Artist, - Audiobook, - Podcast, - Track, - UniqueList, - ) + from music_assistant_models.media_items.metadata import MediaItemImage from music_assistant import MusicAssistant from music_assistant.models.player import Player @@ -393,8 +398,7 @@ class PlayerQueuesController(CoreController): return # a single item or list of items may be provided - if not isinstance(media, list): - media = [media] + media_list = media if isinstance(media, list) else [media] # clear queue if needed if option == QueueOption.REPLACE: @@ -406,18 +410,29 @@ class PlayerQueuesController(CoreController): media_items: list[MediaItemType] = [] radio_source: list[MediaItemType] = [] # resolve all media items - for item in media: + for item in media_list: try: # parse provided uri into a MA MediaItem or Basic QueueItem from URL + media_item: MediaItemType | ItemMapping | BrowseFolder if isinstance(item, str): media_item = await self.mass.music.get_item_by_uri(item) - elif isinstance(item, dict): - media_item = media_from_dict(item) + elif isinstance(item, dict): # type: ignore[unreachable] + # TODO: Investigate why the API parser sometimes passes raw dicts instead of + # converting them to MediaItem objects. The parse_value function in api.py + # should handle dict-to-object conversion, but dicts are slipping through + # in some cases. This is defensive handling for that parser bug. + media_item = media_from_dict(item) # type: ignore[unreachable] + self.logger.debug("Converted to: %s", type(media_item)) else: + # item is MediaItemType | ItemMapping at this point media_item = item + # Save requested media item to play on the queue so we can use it as a source # for Don't stop the music. Use FIFO list to keep track of the last 10 played items - if media_item.media_type in ( + # Skip ItemMapping and BrowseFolder - only queue full MediaItemType objects + if not isinstance( + media_item, (ItemMapping, BrowseFolder) + ) and media_item.media_type in ( MediaType.TRACK, MediaType.ALBUM, MediaType.PLAYLIST, @@ -426,21 +441,31 @@ class PlayerQueuesController(CoreController): queue.enqueued_media_items.append(media_item) if len(queue.enqueued_media_items) > 10: queue.enqueued_media_items.pop(0) + # handle default enqueue option if needed if option is None: - option = QueueOption( - await self.mass.config.get_core_config_value( - self.domain, - f"default_enqueue_option_{media_item.media_type.value}", - ) + config_value = await self.mass.config.get_core_config_value( + self.domain, + f"default_enqueue_option_{media_item.media_type.value}", + return_type=str, ) + option = QueueOption(config_value) if option == QueueOption.REPLACE: self.clear(queue_id, skip_stop=True) + # collect media_items to play if radio_mode: - radio_source.append(media_item) + # Type guard for mypy - only add full MediaItemType to radio_source + if not isinstance(media_item, (ItemMapping, BrowseFolder)): + radio_source.append(media_item) else: - media_items += await self._resolve_media_items(media_item, start_item) + # Convert start_item to string URI if needed + start_item_uri: str | None = None + if isinstance(start_item, str): + start_item_uri = start_item + elif start_item is not None: + start_item_uri = start_item.uri + media_items += await self._resolve_media_items(media_item, start_item_uri) except MusicAssistantError as err: # invalid MA uri or item not found error @@ -453,14 +478,19 @@ class PlayerQueuesController(CoreController): queue.radio_source += radio_source # Use collected media items to calculate the radio if radio mode is on if radio_mode: - media_items = await self._get_radio_tracks( + radio_tracks = await self._get_radio_tracks( queue_id=queue_id, is_initial_radio_mode=True ) + media_items = list(radio_tracks) # only add valid/available items - queue_items = [ - QueueItem.from_media_item(queue_id, x) for x in media_items if x and x.available - ] + queue_items: list[QueueItem] = [] + for x in media_items: + if not x or not x.available: + continue + queue_items.append( + QueueItem.from_media_item(queue_id, cast("PlayableMediaItemType", x)) + ) if not queue_items: raise MediaNotFoundError("No playable items found") @@ -545,7 +575,9 @@ class PlayerQueuesController(CoreController): """ queue = self._queues[queue_id] item_index = self.index_by_id(queue_id, queue_item_id) - if item_index <= queue.index_in_buffer: + if item_index is None: + raise InvalidDataError(f"Item {queue_item_id} not found in queue") + if queue.index_in_buffer is not None and item_index <= queue.index_in_buffer: msg = f"{item_index} is already played/buffered" raise IndexError(msg) @@ -569,6 +601,8 @@ class PlayerQueuesController(CoreController): """Delete item (by id or index) from the queue.""" if isinstance(item_id_or_index, str): item_index = self.index_by_id(queue_id, item_id_or_index) + if item_index is None: + raise InvalidDataError(f"Item {item_id_or_index} not found in queue") else: item_index = item_id_or_index queue = self._queues[queue_id] @@ -601,13 +635,15 @@ class PlayerQueuesController(CoreController): - queue_id: queue_id of the playerqueue to handle the command. """ - queue_player: Player = self.mass.players.get(queue_id, True) + queue_player = self.mass.players.get(queue_id, True) + if queue_player is None: + raise PlayerUnavailableError(f"Player {queue_id} is not available") if (queue := self.get(queue_id)) and queue.active: if queue.state == PlaybackState.PLAYING: - queue.resume_pos = queue.corrected_elapsed_time - # forward the actual command to the player - if queue_player := self.mass.players.get(queue_id): - await queue_player.stop() + queue.resume_pos = int(queue.corrected_elapsed_time) + # forward the actual command to the player + if temp_player := self.mass.players.get(queue_id): + await temp_player.stop() @api_command("player_queues/play") async def play(self, queue_id: str) -> None: @@ -616,7 +652,9 @@ class PlayerQueuesController(CoreController): - queue_id: queue_id of the playerqueue to handle the command. """ - queue_player: Player = self.mass.players.get(queue_id, True) + queue_player = self.mass.players.get(queue_id, True) + if queue_player is None: + raise PlayerUnavailableError(f"Player {queue_id} is not available") if ( (queue := self._queues.get(queue_id)) and queue.active @@ -636,7 +674,7 @@ class PlayerQueuesController(CoreController): """ if queue := self._queues.get(queue_id): if queue.state == PlaybackState.PLAYING: - queue.resume_pos = queue.corrected_elapsed_time + queue.resume_pos = int(queue.corrected_elapsed_time) # forward the actual command to the player controller queue_player = self.mass.players.get(queue_id) assert queue_player is not None # for type checking @@ -691,6 +729,9 @@ class PlayerQueuesController(CoreController): # TODO: forward to underlying player if not active return idx = self._queues[queue_id].current_index + if idx is None: + self.logger.warning("Queue %s has no current index", queue.display_name) + return attempts = 5 while attempts: try: @@ -729,7 +770,7 @@ class PlayerQueuesController(CoreController): if (queue := self.get(queue_id)) is None or not queue.active: # TODO: forward to underlying player if not active return - await self.seek(queue_id, self._queues[queue_id].elapsed_time + seconds) + await self.seek(queue_id, int(self._queues[queue_id].elapsed_time + seconds)) @api_command("player_queues/seek") async def seek(self, queue_id: str, position: int = 10) -> None: @@ -740,7 +781,9 @@ class PlayerQueuesController(CoreController): """ if not (queue := self.get(queue_id)): return - queue_player: Player = self.mass.players.get(queue_id, True) + queue_player = self.mass.players.get(queue_id, True) + if queue_player is None: + raise PlayerUnavailableError(f"Player {queue_id} is not available") if not queue.current_item: raise InvalidCommand(f"Queue {queue_player.display_name} has no item(s) loaded.") if not queue.current_item.duration: @@ -748,6 +791,8 @@ class PlayerQueuesController(CoreController): position = max(0, int(position)) if position > queue.current_item.duration: raise InvalidCommand("Can not seek outside of duration range.") + if queue.current_index is None: + raise InvalidCommand(f"Queue {queue_player.display_name} has no current index.") await self.play_index(queue_id, queue.current_index, seek_position=position) @api_command("player_queues/resume") @@ -777,6 +822,8 @@ class PlayerQueuesController(CoreController): if resume_item is not None: queue_player = self.mass.players.get(queue_id) + if queue_player is None: + raise PlayerUnavailableError(f"Player {queue_id} is not available") if ( fade_in is None and queue_player.playback_state == PlaybackState.IDLE @@ -787,7 +834,9 @@ class PlayerQueuesController(CoreController): if resume_item.media_type == MediaType.RADIO: # we're not able to skip in online radio so this is pointless resume_pos = 0 - await self.play_index(queue_id, resume_item.queue_item_id, resume_pos, fade_in) + await self.play_index( + queue_id, resume_item.queue_item_id, int(resume_pos), fade_in or False + ) else: msg = f"Resume queue requested but queue {queue.display_name} is empty" raise QueueEmpty(msg) @@ -805,7 +854,11 @@ class PlayerQueuesController(CoreController): queue = self._queues[queue_id] queue.resume_pos = 0 if isinstance(index, str): - index = self.index_by_id(queue_id, index) + temp_index = self.index_by_id(queue_id, index) + if temp_index is None: + raise InvalidDataError(f"Item {index} not found in queue") + index = temp_index + # At this point index is guaranteed to be int queue.current_index = index # update current item and elapsed time and signal update # this way the UI knows immediately that a new item is loading @@ -814,13 +867,16 @@ class PlayerQueuesController(CoreController): self.signal_update(queue_id) queue.index_in_buffer = index queue.flow_mode_stream_log = [] - prefer_flow_mode = await self.mass.config.get_player_config_value(queue_id, CONF_FLOW_MODE) + prefer_flow_mode = await self.mass.config.get_player_config_value( + queue_id, CONF_FLOW_MODE, return_type=bool + ) target_player = self.mass.players.get(queue_id) + if target_player is None: + raise PlayerUnavailableError(f"Player {queue_id} is not available") enqueue_supported = PlayerFeature.ENQUEUE in target_player.supported_features queue.next_item_id_enqueued = None # always update session id when we start a new playback session queue.session_id = shortuuid.random(length=8) - # handle resume point of audiobook(chapter) or podcast(episode) if ( not seek_position @@ -853,11 +909,17 @@ class PlayerQueuesController(CoreController): break except (MediaNotFoundError, AudioError): # the requested index can not be played. - self.logger.warning( - "Skipping unplayable item %s (%s)", queue_item.name, queue_item.uri - ) - queue_item.available = False - index = self._get_next_index(queue_id, index, allow_repeat=False) + if queue_item: + self.logger.warning( + "Skipping unplayable item %s (%s)", + queue_item.name, + queue_item.uri, + ) + queue_item.available = False + next_index = self._get_next_index(queue_id, index, allow_repeat=False) + if next_index is None: + raise MediaNotFoundError("No next item available") + index = next_index else: # all attempts to find a playable item failed raise MediaNotFoundError("No playable item found to start playback") @@ -907,13 +969,15 @@ class PlayerQueuesController(CoreController): auto_play = source_queue.state == PlaybackState.PLAYING target_player = self.mass.players.get(target_queue_id) + if target_player is None: + raise PlayerUnavailableError(f"Player {target_queue_id} is not available") if target_player.active_group or target_player.synced_to: # edge case: the user wants to move playback from the group as a whole, to a single # player in the group or it is grouped and the command targeted at the single player. # We need to dissolve the group first. - await self.mass.players.cmd_ungroup( - target_player.active_group or target_player.synced_to - ) + group_id = target_player.active_group or target_player.synced_to + assert group_id is not None # checked in if condition above + await self.mass.players.cmd_ungroup(group_id) await asyncio.sleep(3) source_items = self._queue_items[source_queue_id] @@ -922,7 +986,7 @@ class PlayerQueuesController(CoreController): target_queue.dont_stop_the_music_enabled = source_queue.dont_stop_the_music_enabled target_queue.radio_source = source_queue.radio_source target_queue.enqueued_media_items = source_queue.enqueued_media_items - target_queue.resume_pos = source_queue.elapsed_time + target_queue.resume_pos = int(source_queue.elapsed_time) target_queue.current_index = source_queue.current_index if source_queue.current_item: target_queue.current_item = source_queue.current_item @@ -944,7 +1008,9 @@ class PlayerQueuesController(CoreController): queue = None # try to restore previous state if prev_state := await self.mass.cache.get( - key=queue_id, provider=self.domain, category=CACHE_CATEGORY_PLAYER_QUEUE_STATE + key=queue_id, + provider=self.domain, + category=CACHE_CATEGORY_PLAYER_QUEUE_STATE, ): try: queue = PlayerQueue.from_dict(prev_state) @@ -957,11 +1023,14 @@ class PlayerQueuesController(CoreController): queue_items = [QueueItem.from_cache(x) for x in prev_items] if queue.enqueued_media_items: # we need to restore the MediaItem objects for the enqueued media items - restored_enqueued_items = [] - for item in queue.enqueued_media_items: + # Items from cache may be dicts that need deserialization + + restored_enqueued_items: list[MediaItemType] = [] + cached_items: list[Any] = cast("list[Any]", queue.enqueued_media_items) + for item in cached_items: if isinstance(item, dict): restored_item = media_from_dict(item) - restored_enqueued_items.append(restored_item) + restored_enqueued_items.append(cast("MediaItemType", restored_item)) else: restored_enqueued_items.append(item) queue.enqueued_media_items = restored_enqueued_items @@ -1025,7 +1094,9 @@ class PlayerQueuesController(CoreController): # if the player is permanently removed, we also remove the cached queue data self.mass.create_task( self.mass.cache.delete( - key=player_id, provider=self.domain, category=CACHE_CATEGORY_PLAYER_QUEUE_STATE + key=player_id, + provider=self.domain, + category=CACHE_CATEGORY_PLAYER_QUEUE_STATE, ) ) self.mass.create_task( @@ -1129,22 +1200,23 @@ class PlayerQueuesController(CoreController): ) ) current_index = self.index_by_id(queue_id, queue_item.queue_item_id) - previous_track_from_same_album = ( - (previous_index := max(current_index - 1, 0)) - and (previous_index > 0) - and (previous_item := self.get_item(queue_id, previous_index)) - and ( - queue_item.media_item - and hasattr(queue_item.media_item, "album") - and queue_item.media_item.album - and previous_item.media_item + if current_index is None: + previous_track_from_same_album = False + else: + previous_index = max(current_index - 1, 0) + previous_track_from_same_album = ( + previous_index > 0 + and (previous_item := self.get_item(queue_id, previous_index)) is not None + and previous_item.media_item is not None and hasattr(previous_item.media_item, "album") - and previous_item.media_item.album + and previous_item.media_item.album is not None + and queue_item.media_item is not None + and hasattr(queue_item.media_item, "album") + and queue_item.media_item.album is not None and queue_item.media_item.album.item_id == previous_item.media_item.album.item_id ) - ) playing_album_tracks = next_track_from_same_album or previous_track_from_same_album - if queue_item.media_item and queue_item.media_item.media_type == MediaType.TRACK: + if queue_item.media_item and isinstance(queue_item.media_item, Track): album = queue_item.media_item.album # prefer the full library media item so we have all metadata and provider(quality) info # always request the full library item as there might be other qualities available @@ -1153,13 +1225,15 @@ class PlayerQueuesController(CoreController): queue_item.media_item.item_id, queue_item.media_item.provider, ): - queue_item.media_item = library_item + queue_item.media_item = cast("Track", library_item) elif not queue_item.media_item.image or queue_item.media_item.provider.startswith( "ytmusic" ): # Youtube Music has poor thumbs by default, so we always fetch the full item # this also catches the case where they have an unavailable item in a listing - queue_item.media_item = await self.mass.music.get_item_by_uri(queue_item.uri) + fetched_item = await self.mass.music.get_item_by_uri(queue_item.uri) + queue_item.media_item = cast("Track", fetched_item) + # ensure we got the full (original) album set if album and ( library_album := await self.mass.music.get_library_item_by_prov_id( @@ -1168,17 +1242,19 @@ class PlayerQueuesController(CoreController): album.provider, ) ): - queue_item.media_item.album = library_album + queue_item.media_item.album = cast("Album", library_album) elif album: # Restore original album if we have no better alternative from the library queue_item.media_item.album = album # prefer album image over track image if queue_item.media_item.album and queue_item.media_item.album.image: - org_images = queue_item.media_item.metadata.images or [] - queue_item.media_item.metadata.images = [ - queue_item.media_item.album.image, - *org_images, - ] + org_images: list[MediaItemImage] = queue_item.media_item.metadata.images or [] + queue_item.media_item.metadata.images = UniqueList( + [ + queue_item.media_item.album.image, + *org_images, + ] + ) # Fetch the streamdetails, which could raise in case of an unplayable item. # For example, YT Music returns Radio Items that are not playable. queue_item.streamdetails = await get_stream_details( @@ -1186,7 +1262,7 @@ class PlayerQueuesController(CoreController): queue_item=queue_item, seek_position=seek_position, fade_in=fade_in, - prefer_album_loudness=playing_album_tracks, + prefer_album_loudness=bool(playing_album_tracks), ) def track_loaded_in_buffer(self, queue_id: str, item_id: str) -> None: @@ -1247,7 +1323,11 @@ class PlayerQueuesController(CoreController): # without having to compare the entire list queue.items_last_updated = time.time() self.signal_update(queue_id, True) - if queue.state == PlaybackState.PLAYING and queue.index_in_buffer == queue.current_index: + if ( + queue.state == PlaybackState.PLAYING + and queue.index_in_buffer is not None + and queue.index_in_buffer == queue.current_index + ): # if the queue is playing, # ensure to (re)queue the next track because it might have changed # note that we only do this if the player has loaded the current track @@ -1317,6 +1397,9 @@ class PlayerQueuesController(CoreController): duration = duration - queue_item.streamdetails.seek_position else: duration = queue_item.duration + if queue.session_id is None: + # handle error or return early + raise InvalidDataError("Queue session_id is None") media = PlayerMedia( uri=await self.mass.streams.resolve_stream_url( queue.session_id, queue_item, flow_mode=flow_mode @@ -1357,9 +1440,8 @@ class PlayerQueuesController(CoreController): ) random.shuffle(all_items) return all_items - if artist_items_conf in ("library_album_tracks", "all_album_tracks"): - all_items: list[Track] = [] + all_tracks: list[Track] = [] for library_album in await self.mass.music.artists.albums( artist.item_id, artist.provider, @@ -1368,11 +1450,10 @@ class PlayerQueuesController(CoreController): for album_track in await self.mass.music.albums.tracks( library_album.item_id, library_album.provider ): - if album_track not in all_items: - all_items.append(album_track) - random.shuffle(all_items) - return all_items - + if album_track not in all_tracks: + all_tracks.append(album_track) + random.shuffle(all_tracks) + return all_tracks return [] async def get_album_tracks(self, album: Album, start_item: str | None) -> list[Track]: @@ -1433,11 +1514,10 @@ class PlayerQueuesController(CoreController): ) if chapter is not None: # user explicitly selected a chapter to play - if isinstance(chapter, str): - start_chapter = int(chapter) + start_chapter = int(chapter) if isinstance(chapter, str) else chapter if chapters := audio_book.metadata.chapters: if _chapter := next((x for x in chapters if x.position == start_chapter), None): - return _chapter.start * 1000 + return int(_chapter.start * 1000) raise InvalidDataError( f"Unable to resolve chapter to play for Audiobook {audio_book.name}" ) @@ -1452,15 +1532,18 @@ class PlayerQueuesController(CoreController): raise InvalidDataError("Either podcast or episode must be provided") if podcast is None: # single podcast episode requested + assert isinstance(episode, PodcastEpisode) # checked above self.logger.debug( "Fetching resume point to play for Podcast episode %s", episode.name, ) - episode = cast("PodcastEpisode", episode) - fully_played, resume_position_ms = await self.mass.music.get_resume_position(episode) + ( + fully_played, + resume_position_ms, + ) = await self.mass.music.get_resume_position(episode) episode.fully_played = fully_played episode.resume_position_ms = 0 if fully_played else resume_position_ms - return [episode] + return UniqueList([episode]) # podcast with optional start episode requested self.logger.debug( "Fetching episode(s) and resume point to play for Podcast %s", @@ -1472,38 +1555,51 @@ class PlayerQueuesController(CoreController): all_episodes.sort(key=lambda x: x.position) # if a episode was provided, a user explicitly selected a episode to play # so we need to find the index of the episode in the list + resolved_episode: PodcastEpisode | None = None if isinstance(episode, PodcastEpisode): - episode = next((x for x in all_episodes if x.uri == episode.uri), None) - # ensure we have accurate resume info - fully_played, resume_position_ms = await self.mass.music.get_resume_position(episode) - episode.resume_position_ms = 0 if fully_played else resume_position_ms + resolved_episode = next((x for x in all_episodes if x.uri == episode.uri), None) + if resolved_episode: + # ensure we have accurate resume info + ( + fully_played, + resume_position_ms, + ) = await self.mass.music.get_resume_position(resolved_episode) + resolved_episode.resume_position_ms = 0 if fully_played else resume_position_ms elif isinstance(episode, str): - episode = next((x for x in all_episodes if episode in (x.uri, x.item_id)), None) - # ensure we have accurate resume info - fully_played, resume_position_ms = await self.mass.music.get_resume_position(episode) - episode.resume_position_ms = 0 if fully_played else resume_position_ms + resolved_episode = next( + (x for x in all_episodes if episode in (x.uri, x.item_id)), None + ) + if resolved_episode: + # ensure we have accurate resume info + ( + fully_played, + resume_position_ms, + ) = await self.mass.music.get_resume_position(resolved_episode) + resolved_episode.resume_position_ms = 0 if fully_played else resume_position_ms else: # get first episode that is not fully played - for episode in all_episodes: - if episode.fully_played: + for ep in all_episodes: + if ep.fully_played: continue # ensure we have accurate resume info - fully_played, resume_position_ms = await self.mass.music.get_resume_position( - episode - ) + ( + fully_played, + resume_position_ms, + ) = await self.mass.music.get_resume_position(ep) if fully_played: continue - episode.resume_position_ms = resume_position_ms + ep.resume_position_ms = resume_position_ms + resolved_episode = ep break else: # no episodes found that are not fully played, so we start at the beginning - episode = next((x for x in all_episodes), None) - if episode is None: + resolved_episode = next((x for x in all_episodes), None) + if resolved_episode is None: raise InvalidDataError(f"Unable to resolve episode to play for Podcast {podcast.name}") # get the index of the episode - episode_index = all_episodes.index(episode) + episode_index = all_episodes.index(resolved_episode) # return the (remaining) episode(s) to play - return all_episodes[episode_index:] + return UniqueList(all_episodes[episode_index:]) def _get_next_index( self, @@ -1536,14 +1632,21 @@ class PlayerQueuesController(CoreController): def get_next_item(self, queue_id: str, cur_index: int | str) -> QueueItem | None: """Return next QueueItem for given queue.""" + index: int if isinstance(cur_index, str): - cur_index = self.index_by_id(queue_id, cur_index) - if cur_index is None: - return None # guard + resolved_index = self.index_by_id(queue_id, cur_index) + if resolved_index is None: + return None # guard + index = resolved_index + else: + index = cur_index + # At this point index is guaranteed to be int for skip in range(5): - if (next_index := self._get_next_index(queue_id, cur_index + skip)) is None: + if (next_index := self._get_next_index(queue_id, index + skip)) is None: break next_item = self.get_item(queue_id, next_index) + if next_item is None: + continue if not next_item.available: # ensure that we skip unavailable items (set by load_next track logic) continue @@ -1635,7 +1738,10 @@ class PlayerQueuesController(CoreController): task_id = f"preload_next_item_{queue_id}" self.mass.create_task( - _preload_streamdetails, item_id_in_buffer, task_id=task_id, abort_existing=True + _preload_streamdetails, + item_id_in_buffer, + task_id=task_id, + abort_existing=True, ) async def _resolve_media_items( @@ -1644,41 +1750,54 @@ class PlayerQueuesController(CoreController): """Resolve/unwrap media items to enqueue.""" # resolve Itemmapping to full media item if isinstance(media_item, ItemMapping): + if media_item.uri is None: + raise InvalidDataError("ItemMapping has no URI") media_item = await self.mass.music.get_item_by_uri(media_item.uri) if media_item.media_type == MediaType.PLAYLIST: + media_item = cast("Playlist", media_item) self.mass.create_task(self.mass.music.mark_item_played(media_item)) - return await self.get_playlist_tracks(media_item, start_item) + return list(await self.get_playlist_tracks(media_item, start_item)) if media_item.media_type == MediaType.ARTIST: + media_item = cast("Artist", media_item) self.mass.create_task(self.mass.music.mark_item_played(media_item)) - return await self.get_artist_tracks(media_item) + return list(await self.get_artist_tracks(media_item)) if media_item.media_type == MediaType.ALBUM: + media_item = cast("Album", media_item) self.mass.create_task(self.mass.music.mark_item_played(media_item)) - return await self.get_album_tracks(media_item, start_item) + return list(await self.get_album_tracks(media_item, start_item)) if media_item.media_type == MediaType.AUDIOBOOK: + media_item = cast("Audiobook", media_item) # ensure we grab the correct/latest resume point info media_item.resume_position_ms = await self.get_audiobook_resume_point( media_item, start_item ) return [media_item] if media_item.media_type == MediaType.PODCAST: + media_item = cast("Podcast", media_item) self.mass.create_task(self.mass.music.mark_item_played(media_item)) - return await self.get_next_podcast_episodes(media_item, start_item) + return list(await self.get_next_podcast_episodes(media_item, start_item)) if media_item.media_type == MediaType.PODCAST_EPISODE: - return await self.get_next_podcast_episodes(None, media_item) + media_item = cast("PodcastEpisode", media_item) + return list(await self.get_next_podcast_episodes(None, media_item)) if media_item.media_type == MediaType.FOLDER: - return await self._get_folder_tracks(media_item) + media_item = cast("BrowseFolder", media_item) + return list(await self._get_folder_tracks(media_item)) # all other: single track or radio item - return [media_item] + return [cast("MediaItemType", media_item)] async def _get_radio_tracks( self, queue_id: str, is_initial_radio_mode: bool = False ) -> list[Track]: """Call the registered music providers for dynamic tracks.""" queue = self._queues[queue_id] - queue_track_items = [q.media_item for q in self._queue_items[queue_id] if q.media_item] + queue_track_items: list[Track] = [ + q.media_item + for q in self._queue_items[queue_id] + if q.media_item and isinstance(q.media_item, Track) + ] if not queue.radio_source: # this may happen during race conditions as this method is called delayed - return None + return [] self.logger.info( "Fetching radio tracks for queue %s based on: %s", queue.display_name, @@ -1740,7 +1859,7 @@ class PlayerQueuesController(CoreController): # Some providers don't have similar tracks for all items. For example, # Tidal can sometimes return a 404 when the 'similar_tracks' endpoint is called. # in that case, just skip the track. - self.logger.debug("No similar tracks not found for track %s", base_track.name) + self.logger.debug("Similar tracks not found for track %s", base_track.name) continue for track in _similar_tracks: if ( @@ -1754,7 +1873,7 @@ class PlayerQueuesController(CoreController): if len(dynamic_tracks) >= 50: break queue_tracks: list[Track] = [] - dynamic_tracks = list(dynamic_tracks) + dynamic_tracks_list = list(dynamic_tracks) # Only include the sampled base tracks when the radio mode is first initialized if is_initial_radio_mode: queue_tracks += [base_tracks[0]] @@ -1762,12 +1881,12 @@ class PlayerQueuesController(CoreController): if len(base_tracks) > 1: for base_track in base_tracks[1:]: queue_tracks += [base_track] - if len(dynamic_tracks) > 2: - queue_tracks += random.sample(dynamic_tracks, 2) + if len(dynamic_tracks_list) > 2: + queue_tracks += random.sample(dynamic_tracks_list, 2) else: - queue_tracks += dynamic_tracks + queue_tracks += dynamic_tracks_list # Add dynamic tracks to the queue, make sure to exclude already picked tracks - remaining_dynamic_tracks = [t for t in dynamic_tracks if t not in queue_tracks] + remaining_dynamic_tracks = [t for t in dynamic_tracks_list if t not in queue_tracks] if remaining_dynamic_tracks: queue_tracks += random.sample( remaining_dynamic_tracks, min(len(remaining_dynamic_tracks), 25) @@ -1785,7 +1904,8 @@ class PlayerQueuesController(CoreController): if not item.is_playable: continue # recursively fetch tracks from all media types - tracks += await self._resolve_media_items(item) + resolved = await self._resolve_media_items(item) + tracks += [x for x in resolved if isinstance(x, Track)] return tracks @@ -1795,7 +1915,6 @@ class PlayerQueuesController(CoreController): ) -> None: """Update the Queue when the player state changed.""" queue_id = player.player_id - player = self.mass.players.get(queue_id) queue = self._queues[queue_id] # basic properties @@ -1807,7 +1926,10 @@ class PlayerQueuesController(CoreController): player.playback_state or PlaybackState.IDLE if queue.active else PlaybackState.IDLE ) # update current item/index from player report - if queue.active and queue.state in (PlaybackState.PLAYING, PlaybackState.PAUSED): + if queue.active and queue.state in ( + PlaybackState.PLAYING, + PlaybackState.PAUSED, + ): # NOTE: If the queue is not playing (yet) we will not update the current index # to ensure we keep the previously known current index if queue.flow_mode: @@ -1826,7 +1948,11 @@ class PlayerQueuesController(CoreController): # get current/next item based on current index queue.current_index = current_index queue.current_item = current_item = self.get_item(queue_id, current_index) - queue.next_item = self.get_next_item(queue_id, current_index) if current_item else None + queue.next_item = ( + self.get_next_item(queue_id, current_index) + if current_item and current_index is not None + else None + ) # correct elapsed time when seeking if ( @@ -1863,6 +1989,7 @@ class PlayerQueuesController(CoreController): current_item=None, elapsed_time=0, stream_title=None, + codec_type=None, output_formats=None, ), ) @@ -1872,7 +1999,7 @@ class PlayerQueuesController(CoreController): current_item_id=queue.current_item.queue_item_id if queue.current_item else None, next_item_id=queue.next_item.queue_item_id if queue.next_item else None, current_item=queue.current_item, - elapsed_time=queue.elapsed_time, + elapsed_time=int(queue.elapsed_time), stream_title=( queue.current_item.streamdetails.stream_title if queue.current_item and queue.current_item.streamdetails @@ -1885,7 +2012,7 @@ class PlayerQueuesController(CoreController): ), output_formats=output_formats, ) - changed_keys = get_changed_keys(prev_state, new_state) + changed_keys = get_changed_keys(dict(prev_state), dict(new_state)) with suppress(KeyError): changed_keys.remove("next_item_id") @@ -1954,7 +2081,7 @@ class PlayerQueuesController(CoreController): "End of queue detected and Don't stop the music is enabled for %s" " - setting enqueued media items as radio source: %s", queue.display_name, - ", ".join([x.uri for x in queue.enqueued_media_items]), + ", ".join([x.uri for x in queue.enqueued_media_items]), # type: ignore[misc] # uri set in __post_init__ ) queue.radio_source = queue.enqueued_media_items # auto fill radio tracks if less than 5 tracks left in the queue @@ -1972,7 +2099,7 @@ class PlayerQueuesController(CoreController): """Calculate current queue index and current track elapsed time when flow mode is active.""" elapsed_time_queue_total = player.corrected_elapsed_time or 0 if queue.current_index is None and not queue.flow_mode_stream_log: - return queue.current_index, queue.elapsed_time + return queue.current_index, int(queue.elapsed_time) # For each track that has been streamed/buffered to the player, # a playlog entry will be created with the queue item id @@ -1980,9 +2107,9 @@ class PlayerQueuesController(CoreController): # out where we are in the queue, accounting for actual streamed # seconds (and not duration) and skipped seconds. If a track has been repeated, # it will simply be in the playlog multiple times. - played_time = 0 - queue_index = queue.current_index or 0 - track_time = 0 + played_time = 0.0 + queue_index: int | None = queue.current_index or 0 + track_time = 0.0 for play_log_entry in queue.flow_mode_stream_log: queue_item_duration = ( # NOTE: 'seconds_streamed' can actually be 0 if there was a stream error! @@ -2008,8 +2135,8 @@ class PlayerQueuesController(CoreController): if player.playback_state != PlaybackState.PLAYING: # if the player is not playing, we can't be sure that the elapsed time is correct # so we just return the queue index and the elapsed time - return queue.current_index, queue.elapsed_time - return queue_index, track_time + return queue.current_index, int(queue.elapsed_time) + return queue_index, int(track_time) def _parse_player_current_item_id(self, queue_id: str, player: Player) -> str | None: """Parse QueueItem ID from Player's current url.""" @@ -2062,7 +2189,7 @@ class PlayerQueuesController(CoreController): if prev_state["current_item_id"] is None: return - async def _clear_queue_delayed(): + async def _clear_queue_delayed() -> None: for _ in range(5): await asyncio.sleep(1) if queue.state != PlaybackState.IDLE: @@ -2108,13 +2235,15 @@ class PlayerQueuesController(CoreController): # report on current item is_current_item = True item_to_report = self.get_item(queue.queue_id, cur_item_id) or new_state["current_item"] - if not item_to_report: - return # guard against invalid items seconds_played = int(new_state["elapsed_time"]) + if not item_to_report: + return # guard against invalid items + if not item_to_report.media_item: # only report on media items return + assert item_to_report.media_item.uri is not None # uri is set in __post_init__ if item_to_report.streamdetails and item_to_report.streamdetails.duration: duration = int(item_to_report.streamdetails.duration) diff --git a/pyproject.toml b/pyproject.toml index d0ca55ef..360836db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -135,7 +135,6 @@ exclude = [ '^music_assistant/controllers/media/podcasts.py*$', '^music_assistant/controllers/media/tracks.py*$', '^music_assistant/controllers/music.py$', - '^music_assistant/controllers/player_queues.py$', '^music_assistant/helpers/app_vars.py', '^music_assistant/providers/apple_music/.*$', '^music_assistant/providers/bluesound/.*$', -- 2.34.1