/
/
/
1"""
2Media resolution for the Player Queues controller.
3
4Resolves source media items (artist, album, genre, playlist, audiobook, podcast, browse folder)
5into the concrete tracks / playable items that enqueueing them produces, honoring the user's
6per-type selection preferences. Pure media->tracks logic dispatched out of the controller; it reads
7config and the music controller via its owning controller, and holds no per-queue state.
8"""
9
10from __future__ import annotations
11
12import asyncio
13import random
14from types import NoneType
15from typing import TYPE_CHECKING, Any, cast
16
17from music_assistant_models.enums import ArtistType, MediaType
18from music_assistant_models.errors import InvalidDataError, MediaNotFoundError
19from music_assistant_models.media_items import (
20 Album,
21 Artist,
22 Audiobook,
23 BrowseFolder,
24 Genre,
25 ItemMapping,
26 MediaCollection,
27 MediaItemType,
28 Playlist,
29 Podcast,
30 PodcastEpisode,
31 Radio,
32 Track,
33 UniqueList,
34)
35
36from music_assistant.constants import PlaylistPlayableItem
37from music_assistant.controllers.player_queues.constants import (
38 CONF_DEFAULT_ENQUEUE_SELECT_ALBUM,
39 CONF_DEFAULT_ENQUEUE_SELECT_ARTIST,
40 ENQUEUE_SELECT_ALBUM_DEFAULT_VALUE,
41 ENQUEUE_SELECT_ARTIST_DEFAULT_VALUE,
42)
43from music_assistant.controllers.player_queues.helpers import sort_tracks
44from music_assistant.controllers.webserver.helpers.auth_middleware import ImpersonatedUser
45from music_assistant.helpers.collections import (
46 get_collection_item_id,
47 get_collection_item_media_type_from_item_id,
48)
49
50if TYPE_CHECKING:
51 from collections.abc import Sequence
52
53 from music_assistant.controllers.player_queues.controller import PlayerQueuesController
54
55_LATEST_EPISODE_KEYWORDS = frozenset({"latest", "newest"})
56_START_ITEM_SUBSTRING_MIN_LEN = 3
57
58
59def _start_item_matches(start_item: str, item: Any) -> bool:
60 """
61 Return whether `item` satisfies a `start_item` directive.
62
63 :param start_item: Exact `item_id` / `uri`, or a case-insensitive
64 substring of the item's name.
65 :param item: Candidate media item.
66 """
67 if start_item in (getattr(item, "item_id", None), getattr(item, "uri", None)):
68 return True
69 if len(start_item) < _START_ITEM_SUBSTRING_MIN_LEN:
70 return False
71 name = getattr(item, "name", None)
72 return bool(name and start_item.lower() in name.lower())
73
74
75class MediaResolver:
76 """Resolve source media items into the concrete tracks/playable items to enqueue."""
77
78 def __init__(self, queues: PlayerQueuesController) -> None:
79 """
80 Initialize the media resolver.
81
82 :param queues: The owning player queues controller.
83 """
84 self.queues = queues
85 self.mass = queues.mass
86 self.logger = queues.logger.getChild("media_resolver")
87
88 async def get_tracks_for_playback(self, media_item: MediaItemType) -> list[Track]:
89 """
90 Return the playable tracks for a media item, honoring the user's selection preferences.
91
92 Resolves an umbrella media item (artist, album, genre, playlist) to the tracks that
93 playing it would enqueue; a track resolves to itself, other types to an empty list.
94
95 :param media_item: The media item to resolve to playable tracks.
96 """
97 if media_item.media_type == MediaType.TRACK:
98 return [cast("Track", media_item)]
99 if media_item.media_type == MediaType.ALBUM:
100 return await self.get_album_tracks(cast("Album", media_item), None)
101 if media_item.media_type == MediaType.ARTIST:
102 return await self.get_artist_tracks(cast("Artist", media_item))
103 if media_item.media_type == MediaType.GENRE:
104 return await self.get_genre_tracks(cast("Genre", media_item), None)
105 if media_item.media_type == MediaType.PLAYLIST:
106 return [
107 track
108 for track in await self.get_playlist_tracks(cast("Playlist", media_item), None)
109 if isinstance(track, Track)
110 ]
111 return []
112
113 async def get_artist_tracks(self, artist: Artist) -> list[Track]:
114 """Return the tracks to play for the given artist, based on user preference."""
115 artist_items_conf = self.mass.config.get_raw_core_config_value(
116 self.queues.domain,
117 CONF_DEFAULT_ENQUEUE_SELECT_ARTIST,
118 ENQUEUE_SELECT_ARTIST_DEFAULT_VALUE,
119 )
120 self.logger.info(
121 "Fetching tracks to play for artist %s (selection: %s)", artist.name, artist_items_conf
122 )
123 if artist_items_conf == "top_tracks":
124 tracks = await self.mass.music.artists.top_tracks(artist.item_id, artist.provider)
125 random.shuffle(tracks)
126 return tracks
127 # legacy "library_album_tracks" also resolves to the in-library tracks
128 if artist_items_conf in ("library_tracks", "library_album_tracks"):
129 tracks = await self._library_artist_tracks(artist)
130 random.shuffle(tracks)
131 return tracks
132 if artist_items_conf == "prefer_library":
133 tracks = await self._library_artist_tracks(artist)
134 if not tracks:
135 tracks = await self.mass.music.artists.top_tracks(artist.item_id, artist.provider)
136 random.shuffle(tracks)
137 return tracks
138 result: list[Track] = []
139 seen: set[str] = set()
140 sources = await asyncio.gather(
141 self._library_artist_tracks(artist),
142 self._provider_artist_tracks(artist),
143 return_exceptions=True,
144 )
145 for source in sources:
146 if isinstance(source, BaseException):
147 self.logger.warning(
148 "Error resolving some tracks for artist %s", artist.name, exc_info=source
149 )
150 continue
151 for track in source:
152 unique_id = f"{track.name}.{track.version}"
153 if unique_id in seen:
154 continue
155 seen.add(unique_id)
156 result.append(track)
157 random.shuffle(result)
158 return result
159
160 async def get_album_tracks(
161 self,
162 album: Album,
163 start_item: str | None,
164 sort_by: str | None = None,
165 keep_preceding_items: bool = False,
166 ) -> list[Track]:
167 """
168 Return tracks for given album, based on user preference.
169
170 :param album: The album to fetch the tracks for.
171 :param start_item: Optional item_id/uri of the track to start from.
172 :param sort_by: Optional sort key to order the tracks by before applying start_item.
173 :param keep_preceding_items: Move the tracks before start_item behind the rest instead
174 of dropping them, so the full album is returned with start_item first.
175 """
176 album_items_conf = self.mass.config.get_raw_core_config_value(
177 self.queues.domain,
178 CONF_DEFAULT_ENQUEUE_SELECT_ALBUM,
179 ENQUEUE_SELECT_ALBUM_DEFAULT_VALUE,
180 )
181 result: list[Track] = []
182 self.logger.info(
183 "Fetching tracks to play for album %s",
184 album.name,
185 )
186 for album_track in await self.mass.music.albums.tracks(
187 item_id=album.item_id,
188 provider_instance_id_or_domain=album.provider,
189 in_library_only=album_items_conf == "library_tracks",
190 ):
191 if not album_track.available:
192 continue
193 result.append(album_track)
194 if sort_by and sort_by != "track_number":
195 result = sort_tracks(result, sort_by)
196 if start_item is not None:
197 for idx, track in enumerate(result):
198 if start_item in (track.item_id, track.uri):
199 return result[idx:] + (result[:idx] if keep_preceding_items else [])
200 return []
201 return result
202
203 async def get_genre_tracks(self, genre: Genre, start_item: str | None) -> list[Track]:
204 """
205 Return tracks for given genre, based on alias mappings.
206
207 Limits results to avoid loading thousands of tracks for broad genres.
208 Directly mapped tracks are fetched with random ordering, then supplemented
209 with tracks from a limited set of mapped albums and artists.
210 """
211 result: list[Track] = []
212 start_item_found = False
213 self.logger.info(
214 "Fetching tracks to play for genre %s",
215 genre.name,
216 )
217 tracks, albums, artists = await self.mass.music.genres.mapped_media(
218 genre,
219 track_limit=25,
220 album_limit=5,
221 artist_limit=5,
222 order_by="random",
223 )
224
225 for genre_track in tracks:
226 if not genre_track.available:
227 continue
228 if start_item in (genre_track.item_id, genre_track.uri):
229 start_item_found = True
230 if start_item is not None and not start_item_found:
231 continue
232 result.append(genre_track)
233
234 for album in albums:
235 album_tracks = await self.get_album_tracks(album, None)
236 result.extend(album_tracks[:5])
237
238 for artist in artists:
239 artist_tracks = await self.mass.music.artists.top_tracks(
240 artist.item_id, artist.provider
241 )
242 if not artist_tracks:
243 # not get_artist_tracks: a top_tracks preference would repeat the empty lookup
244 artist_tracks = await self.mass.music.artists.tracks(
245 artist.item_id, artist.provider
246 )
247 random.shuffle(artist_tracks)
248 result.extend(artist_tracks[:5])
249 return result
250
251 async def get_dynamic_source_tracks(self, item: MediaItemType) -> list[Track]:
252 """
253 Return a fresh batch of tracks for a dynamic playlist or radio station.
254
255 :param item: The dynamic source to fetch the next batch for.
256 """
257 if isinstance(item, Radio):
258 return await self.mass.music.radio.dynamic_tracks(item)
259 if isinstance(item, Playlist):
260 tracks = await self.get_playlist_tracks(item, start_item=None)
261 return [track for track in tracks if isinstance(track, Track)]
262 return []
263
264 async def get_playlist_tracks(
265 self,
266 playlist: Playlist,
267 start_item: str | None,
268 sort_by: str | None = None,
269 keep_preceding_items: bool = False,
270 ) -> list[PlaylistPlayableItem]:
271 """
272 Return tracks for given playlist, based on user preference.
273
274 :param playlist: The playlist to fetch the tracks for.
275 :param start_item: Optional item_id/uri/name of the track to start from.
276 :param sort_by: Optional sort key to order the tracks by before applying start_item.
277 :param keep_preceding_items: Move the tracks before start_item behind the rest instead
278 of dropping them, so the full playlist is returned with start_item first.
279 """
280 result: list[PlaylistPlayableItem] = []
281 self.logger.info(
282 "Fetching tracks to play for playlist %s",
283 playlist.name,
284 )
285 force_refresh = playlist.is_dynamic
286 needs_sort = sort_by is not None and sort_by != "position"
287 # Fast path: no re-sort needed and the preceding tracks are dropped anyway, so
288 # skip-until-found in a single pass and never materialize huge playlists when
289 # starting near the end.
290 if not needs_sort and not keep_preceding_items:
291 start_item_found = False
292 async for playlist_track in self.mass.music.playlists.tracks(
293 playlist.item_id,
294 playlist.provider,
295 force_refresh=force_refresh,
296 allow_dynamic_tracks=playlist.is_dynamic,
297 ):
298 if not playlist_track.available:
299 continue
300 if start_item is not None and _start_item_matches(start_item, playlist_track):
301 start_item_found = True
302 if start_item is not None and not start_item_found:
303 continue
304 result.append(playlist_track)
305 return result
306 # Sort/rotate path: must materialize all tracks before sorting or rotating, then slice.
307 async for playlist_track in self.mass.music.playlists.tracks(
308 playlist.item_id,
309 playlist.provider,
310 force_refresh=force_refresh,
311 allow_dynamic_tracks=playlist.is_dynamic,
312 ):
313 if not playlist_track.available:
314 continue
315 result.append(playlist_track)
316 if needs_sort:
317 result = sort_tracks(result, cast("str", sort_by))
318 if start_item is not None:
319 for idx, track in enumerate(result):
320 if _start_item_matches(start_item, track):
321 return result[idx:] + (result[:idx] if keep_preceding_items else [])
322 return []
323 return result
324
325 async def get_audiobook_resume_point(
326 self, audio_book: Audiobook, chapter: str | int | None = None, userid: str | None = None
327 ) -> int:
328 """Return resume point (in milliseconds) for given audio book."""
329 self.logger.debug(
330 "Fetching resume point to play for audio book %s",
331 audio_book.name,
332 )
333 if chapter is not None:
334 # user explicitly selected a chapter to play
335 start_chapter = int(chapter) if isinstance(chapter, str) else chapter
336 if chapters := audio_book.metadata.chapters:
337 if _chapter := next((x for x in chapters if x.position == start_chapter), None):
338 return int(_chapter.start * 1000)
339 raise InvalidDataError(
340 f"Unable to resolve chapter to play for Audiobook {audio_book.name}"
341 )
342 full_played, resume_position_ms = await self.mass.music.get_resume_position(
343 audio_book, userid=userid
344 )
345 return 0 if full_played else resume_position_ms
346
347 async def get_next_podcast_episodes(
348 self,
349 podcast: Podcast | None,
350 episode: PodcastEpisode | str | None,
351 userid: str | None = None,
352 start_from_beginning: bool = False,
353 ) -> UniqueList[PodcastEpisode]:
354 """
355 Return the next episode(s) and resume point for the given podcast.
356
357 :param podcast: Podcast to enqueue, or `None` if `episode` is a
358 concrete `PodcastEpisode`.
359 :param episode: A concrete `PodcastEpisode`, an `item_id` / `uri`,
360 a case-insensitive substring of an episode name, or one of the
361 reserved lowercase keywords `"latest"` / `"newest"`.
362 :param userid: User whose resume position should be applied.
363 :param start_from_beginning: When True, the resolved episode starts at position 0,
364 ignoring any saved resume position. The stored progress itself is left untouched.
365 """
366 if podcast is None and isinstance(episode, str | NoneType):
367 raise InvalidDataError("Either podcast or episode must be provided")
368 if podcast is None:
369 # single podcast episode requested
370 assert isinstance(episode, PodcastEpisode) # checked above
371 self.logger.debug(
372 "Fetching resume point to play for Podcast episode %s",
373 episode.name,
374 )
375 await self._set_episode_resume_point(episode, userid, start_from_beginning)
376 return UniqueList([episode])
377 # podcast with optional start episode requested
378 self.logger.debug(
379 "Fetching episode(s) and resume point to play for Podcast %s",
380 podcast.name,
381 )
382 # Require exact case and keyword match to minimise false positives.
383 if isinstance(episode, str) and episode in _LATEST_EPISODE_KEYWORDS:
384 # provider yields newest-first, so only pull the first episode here and skip
385 # materialising the rest, which avoids a per-episode resume lookup on each one
386 latest = await anext(
387 self.mass.music.podcasts.episodes(podcast.item_id, podcast.provider), None
388 )
389 if latest is None:
390 raise InvalidDataError(
391 f"Unable to resolve episode to play for Podcast {podcast.name}"
392 )
393 await self._set_episode_resume_point(latest, userid, start_from_beginning)
394 return UniqueList([latest])
395 all_episodes = [
396 x async for x in self.mass.music.podcasts.episodes(podcast.item_id, podcast.provider)
397 ]
398 all_episodes.sort(key=lambda x: x.position)
399 # if a episode was provided, a user explicitly selected a episode to play
400 # so we need to find the index of the episode in the list
401 resolved_episode: PodcastEpisode | None = None
402 if isinstance(episode, PodcastEpisode):
403 resolved_episode = next((x for x in all_episodes if x.uri == episode.uri), None)
404 if resolved_episode:
405 # ensure we have accurate resume info
406 (
407 fully_played,
408 resume_position_ms,
409 ) = await self.mass.music.get_resume_position(resolved_episode, userid=userid)
410 resolved_episode.resume_position_ms = 0 if fully_played else resume_position_ms
411 elif isinstance(episode, str):
412 resolved_episode = next(
413 (x for x in all_episodes if _start_item_matches(episode, x)), None
414 )
415 if resolved_episode:
416 # ensure we have accurate resume info
417 (
418 fully_played,
419 resume_position_ms,
420 ) = await self.mass.music.get_resume_position(resolved_episode, userid=userid)
421 resolved_episode.resume_position_ms = 0 if fully_played else resume_position_ms
422 else:
423 # get first episode that is not fully played
424 for ep in all_episodes:
425 if ep.fully_played:
426 continue
427 # ensure we have accurate resume info
428 (
429 fully_played,
430 resume_position_ms,
431 ) = await self.mass.music.get_resume_position(ep, userid=userid)
432 if fully_played:
433 continue
434 ep.resume_position_ms = resume_position_ms
435 resolved_episode = ep
436 break
437 else:
438 # no episodes found that are not fully played, so we start at the beginning
439 resolved_episode = next((x for x in all_episodes), None)
440 if resolved_episode is None:
441 raise InvalidDataError(f"Unable to resolve episode to play for Podcast {podcast.name}")
442 if start_from_beginning:
443 # play the resolved episode from position 0 without touching stored progress
444 resolved_episode.fully_played = False
445 resolved_episode.resume_position_ms = 0
446 # get the index of the episode
447 episode_index = all_episodes.index(resolved_episode)
448 # return the (remaining) episode(s) to play
449 return UniqueList(all_episodes[episode_index:])
450
451 async def get_next_podcast_episode(
452 self, episode: PodcastEpisode, userid: str | None = None
453 ) -> PodcastEpisode | None:
454 """
455 Return the episode to play after the given one, or None if there is none left.
456
457 Episodes are walked in the same order a full podcast enqueue produces, skipping the
458 ones that were already fully played.
459
460 :param episode: The episode that is being continued.
461 :param userid: User whose resume position should be applied.
462 """
463 podcast = episode.podcast
464 all_episodes = [
465 x async for x in self.mass.music.podcasts.episodes(podcast.item_id, podcast.provider)
466 ]
467 all_episodes.sort(key=lambda x: x.position)
468 current_index = next(
469 (idx for idx, x in enumerate(all_episodes) if x.uri == episode.uri), None
470 )
471 if current_index is None:
472 # the episode is no longer part of the feed, so we have nothing to continue from
473 return None
474 for candidate in all_episodes[current_index + 1 :]:
475 if candidate.fully_played:
476 continue
477 # ensure we have accurate resume info
478 fully_played, resume_position_ms = await self.mass.music.get_resume_position(
479 candidate, userid=userid
480 )
481 if fully_played:
482 continue
483 candidate.resume_position_ms = resume_position_ms
484 return candidate
485 return None
486
487 async def get_next_audiobook(
488 self, audiobook: Audiobook, userid: str | None = None
489 ) -> Audiobook | None:
490 """
491 Return the next book in the collection(s) the given book belongs to, if there is one.
492
493 Returns None for a standalone book and for a book whose collection has no not-fully-played
494 book left after it.
495
496 :param audiobook: The audiobook that is being continued.
497 :param userid: User whose resume position should be applied.
498 """
499 # collections are built from the library metadata, so a book that is not in the
500 # library has no series to continue with
501 library_item = (
502 audiobook
503 if audiobook.provider == "library"
504 else await self.mass.music.audiobooks.get_library_item_by_prov_id(
505 audiobook.item_id, audiobook.provider
506 )
507 )
508 if library_item is None:
509 return None
510 for collection in library_item.metadata.collections or []:
511 try:
512 media_collection = await self.mass.music.audiobooks.get_collection(
513 get_collection_item_id(collection.title, MediaType.AUDIOBOOK)
514 )
515 except MediaNotFoundError:
516 continue
517 if next_book := await self._next_unplayed_book(media_collection, library_item, userid):
518 return next_book
519 return None
520
521 async def get_author_narrator_audiobooks(
522 self, author_narrator: Artist, userid: str | None
523 ) -> list[Audiobook]:
524 """
525 Return audiobooks to play of a given artist.
526
527 If all books are played, enqueue all of them. If not, enqueue books in a collection's order
528 if they are part of a collection.
529 """
530 audiobooks: UniqueList[Audiobook] = UniqueList([])
531 async with ImpersonatedUser(self.mass, user=userid):
532 # ensure we get the position status on the current user
533 all_audiobooks = await self.mass.music.artists.audiobooks(
534 author_narrator.item_id, author_narrator.provider, author_narrator.artist_type
535 )
536 for book in all_audiobooks:
537 # do not use get_resume_position here, as an artist may potentially have a lot of audiobooks,
538 # resulting in many API calls.
539 if book.fully_played:
540 continue
541 audiobooks.append(book)
542 if len(audiobooks) == 0:
543 audiobooks = UniqueList(all_audiobooks)
544
545 # treat books part of a collection separately by keeping the collections order
546 collections: list[MediaCollection[Audiobook]] = []
547 collection_item_ids: list[str] = []
548
549 books_with_collection: dict[str, set[str]] = {} # book_item_id: {collection_ids}
550 for book in audiobooks:
551 for media_item_collection in book.metadata.collections or []:
552 collection_item_id = get_collection_item_id(
553 media_item_collection.title, MediaType.AUDIOBOOK
554 )
555 if collection_item_id not in collection_item_ids:
556 collection_item_ids.append(collection_item_id)
557 entry = books_with_collection.get(book.item_id, set())
558 entry.add(collection_item_id)
559 books_with_collection[book.item_id] = entry
560 async with ImpersonatedUser(self.mass, user=userid):
561 for collection_item_id in collection_item_ids:
562 try:
563 collection = await self.mass.music.audiobooks.get_collection(collection_item_id)
564 collections.append(collection)
565 except MediaNotFoundError:
566 # Remove invalid collection everywhere
567 for book_collections in books_with_collection.values():
568 book_collections.discard(collection_item_id)
569 continue
570 # ensure, that books with collection only holds books which have a verified collection
571 books_with_collection = {
572 book_item_id: collection_ids
573 for book_item_id, collection_ids in books_with_collection.items()
574 if collection_ids
575 }
576
577 # remove books which are part of a collection
578 audiobooks = UniqueList(
579 [book for book in audiobooks if book.item_id not in books_with_collection]
580 )
581 # enqueue books which are part of a collection in the collection's order, however, as a collection
582 # may have books of different artists, only enqueue the books which belong to the artist.
583 # if a book happens to be part of multiple collections, only enqueue once
584 books_with_collection_sorted: list[Audiobook] = []
585 for collection in collections:
586 for book in collection.items:
587 if (
588 book.item_id in books_with_collection
589 and book not in books_with_collection_sorted
590 ):
591 books_with_collection_sorted.append(book)
592
593 return list(audiobooks) + books_with_collection_sorted
594
595 async def _set_episode_resume_point(
596 self, episode: PodcastEpisode, userid: str | None, start_from_beginning: bool
597 ) -> None:
598 """
599 Apply the resume point to a resolved podcast episode.
600
601 When start_from_beginning is set the episode starts at position 0 and the resume
602 lookup is skipped; the stored progress itself is left untouched.
603 """
604 if start_from_beginning:
605 episode.fully_played = False
606 episode.resume_position_ms = 0
607 return
608 fully_played, resume_position_ms = await self.mass.music.get_resume_position(
609 episode, userid=userid
610 )
611 episode.fully_played = fully_played
612 episode.resume_position_ms = 0 if fully_played else resume_position_ms
613
614 async def _next_unplayed_book(
615 self,
616 collection: MediaCollection[Audiobook],
617 current: Audiobook,
618 userid: str | None,
619 ) -> Audiobook | None:
620 """Return the first not fully played book after `current` in the given collection."""
621 books = [x for x in collection.items if isinstance(x, Audiobook)]
622 current_index = next(
623 (idx for idx, x in enumerate(books) if x.item_id == current.item_id), None
624 )
625 if current_index is None:
626 return None
627 for candidate in books[current_index + 1 :]:
628 fully_played, resume_position_ms = await self.mass.music.get_resume_position(
629 candidate, userid=userid
630 )
631 if fully_played:
632 continue
633 candidate.resume_position_ms = resume_position_ms
634 return candidate
635 return None
636
637 async def _resolve_library_artist(self, artist: Artist) -> Artist | None:
638 """
639 Resolve the in-library artist for the given (possibly provider) artist item.
640
641 :param artist: The artist item, which may be a library or a provider item.
642 """
643 if artist.provider == "library":
644 return artist
645 return await self.mass.music.artists.get_library_item_by_prov_id(
646 artist.item_id, artist.provider
647 )
648
649 async def _library_artist_tracks(self, artist: Artist) -> list[Track]:
650 """
651 Return the in-library tracks for the given artist (empty if it is not saved).
652
653 :param artist: The artist to resolve in-library tracks for.
654 """
655 if (library_artist := await self._resolve_library_artist(artist)) is None:
656 return []
657 return await self.mass.music.artists.tracks(library_artist.item_id, "library")
658
659 async def _provider_artist_tracks(self, artist: Artist) -> list[Track]:
660 """
661 Return all of the artist's tracks across its (streaming) providers.
662
663 :param artist: The artist to resolve provider tracks for.
664 """
665 unique_providers = self.mass.music.get_unique_providers()
666 tracks: list[Track] = []
667 for mapping in artist.provider_mappings:
668 if mapping.provider_instance not in unique_providers:
669 continue
670 tracks.extend(
671 await self.mass.music.artists.tracks(mapping.item_id, mapping.provider_instance)
672 )
673 return tracks
674
675 async def _resolve_media_items(
676 self,
677 media_item: MediaItemType | ItemMapping | BrowseFolder,
678 start_item: str | None = None,
679 userid: str | None = None,
680 queue_id: str | None = None,
681 sort_by: str | None = None,
682 start_from_beginning: bool = False,
683 keep_preceding_items: bool = False,
684 ) -> list[MediaItemType]:
685 """
686 Resolve/unwrap media items to enqueue.
687
688 :param media_item: The media item to resolve into playable items.
689 :param start_item: Optional item to start a playlist/album/genre from, or the chapter
690 to start an audiobook/podcast episode at.
691 :param userid: Optional user the playback is attributed to.
692 :param queue_id: Optional queue the playback is requested for.
693 :param sort_by: Optional sort key to order tracks by before applying start_item.
694 :param start_from_beginning: Ignore any saved resume position for a podcast episode.
695 :param keep_preceding_items: For a playlist/album, move the tracks before start_item
696 behind the rest instead of dropping them, so the full item is returned with
697 start_item first.
698 """
699 # resolve Itemmapping to full media item
700 if isinstance(media_item, ItemMapping):
701 if media_item.uri is None:
702 raise InvalidDataError("ItemMapping has no URI")
703 media_item = await self.mass.music.get_item_by_uri(media_item.uri)
704 if media_item.media_type == MediaType.PLAYLIST:
705 media_item = cast("Playlist", media_item)
706 playlist_tracks = await self.get_playlist_tracks(
707 media_item,
708 start_item,
709 sort_by=sort_by,
710 keep_preceding_items=keep_preceding_items,
711 )
712 self._mark_container_played(media_item, playlist_tracks, userid, queue_id)
713 return list(playlist_tracks)
714 if media_item.media_type == MediaType.ARTIST:
715 media_item = cast("Artist", media_item)
716 artist_items: list[Audiobook] | list[Track]
717 if media_item.artist_type in [ArtistType.AUTHOR, ArtistType.NARRATOR]:
718 artist_items = await self.get_author_narrator_audiobooks(media_item, userid)
719 else:
720 artist_items = await self.get_artist_tracks(media_item)
721 self._mark_container_played(media_item, artist_items, userid, queue_id)
722 return list(artist_items)
723 if media_item.media_type == MediaType.ALBUM:
724 media_item = cast("Album", media_item)
725 return list(
726 await self.get_album_tracks(
727 media_item,
728 start_item,
729 sort_by=sort_by,
730 keep_preceding_items=keep_preceding_items,
731 )
732 )
733 if media_item.media_type == MediaType.GENRE:
734 media_item = cast("Genre", media_item)
735 genre_tracks = await self.get_genre_tracks(media_item, start_item)
736 self._mark_container_played(media_item, genre_tracks, userid, queue_id)
737 return list(genre_tracks)
738 if media_item.media_type == MediaType.AUDIOBOOK:
739 media_item = cast("Audiobook", media_item)
740 # ensure we grab the correct/latest resume point info
741 media_item.resume_position_ms = await self.get_audiobook_resume_point(
742 media_item, start_item, userid=userid
743 )
744 return [media_item]
745 if media_item.media_type == MediaType.COLLECTION:
746 collection_item_media_type = get_collection_item_media_type_from_item_id(
747 media_item.item_id
748 )
749 if collection_item_media_type != MediaType.AUDIOBOOK:
750 self.logger.error("Collections are only available for audiobooks.")
751 return []
752 if TYPE_CHECKING:
753 assert isinstance(media_item, MediaCollection)
754 book: Audiobook | None = None
755 for item in media_item.items:
756 if TYPE_CHECKING:
757 assert isinstance(item, Audiobook)
758 # enqueue the first not fully finished audiobook
759 fully_played, resume_position_ms = await self.mass.music.get_resume_position(
760 item, userid=userid
761 )
762 if not fully_played:
763 item.resume_position_ms = resume_position_ms
764 book = item
765 break
766 if book is None:
767 if len(media_item.items) > 0:
768 return [media_item.items[0]]
769 return []
770 return [book]
771
772 if media_item.media_type == MediaType.PODCAST:
773 media_item = cast("Podcast", media_item)
774 episodes = await self.get_next_podcast_episodes(
775 media_item, start_item, userid=userid, start_from_beginning=start_from_beginning
776 )
777 self._mark_container_played(media_item, episodes, userid, queue_id)
778 return list(episodes)
779 if media_item.media_type == MediaType.PODCAST_EPISODE:
780 media_item = cast("PodcastEpisode", media_item)
781 return list(
782 await self.get_next_podcast_episodes(
783 None, media_item, userid=userid, start_from_beginning=start_from_beginning
784 )
785 )
786 if media_item.media_type == MediaType.FOLDER:
787 media_item = cast("BrowseFolder", media_item)
788 return list(await self._get_folder_tracks(media_item))
789 # all other: single track or radio item
790 return [cast("MediaItemType", media_item)]
791
792 async def _get_folder_tracks(self, folder: BrowseFolder) -> list[Track]:
793 """Fetch (playable) tracks for given browse folder."""
794 self.logger.info(
795 "Fetching tracks to play for folder %s",
796 folder.name,
797 )
798 try:
799 folder_items = await self.mass.music.browse(folder.path)
800 except OSError as err:
801 # e.g. the (top-level) folder URI points at a path that no longer exists
802 raise MediaNotFoundError(f"Folder '{folder.path}' could not be found") from err
803 tracks: list[Track] = []
804 for item in folder_items:
805 if not item.is_playable:
806 continue
807 try:
808 # recursively fetch tracks from all media types
809 resolved = await self._resolve_media_items(item)
810 except MediaNotFoundError:
811 # best-effort: skip child items/subfolders that are empty or unreachable
812 # so a single bad entry does not abort playback of the whole folder
813 continue
814 tracks += [x for x in resolved if isinstance(x, Track)]
815
816 return tracks
817
818 def _mark_container_played(
819 self,
820 container: MediaItemType,
821 resolved_items: Sequence[MediaItemType],
822 userid: str | None,
823 queue_id: str | None,
824 ) -> None:
825 """
826 Credit a container the user asked to play with an explicit play.
827
828 Only credits when the container actually resolved to something, so an empty
829 playlist/artist/genre/podcast never lands in the play history.
830
831 :param container: The playlist, artist, genre or podcast that was asked for.
832 :param resolved_items: The items the container resolved to.
833 :param userid: Optional user the playback is attributed to.
834 :param queue_id: Optional queue the playback is requested for.
835 """
836 if not resolved_items:
837 return
838 self.mass.create_task(
839 self.mass.music.mark_item_played(
840 container, userid=userid, queue_id=queue_id, user_initiated=True
841 )
842 )
843