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