From: Marcel van der Veldt Date: Thu, 13 Feb 2025 16:11:23 +0000 (+0100) Subject: Chore: some code cleanup and fixes for browsing X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=f51d16963eb35f77afbc40bdf80533b009bcd0d5;p=music-assistant-server.git Chore: some code cleanup and fixes for browsing --- diff --git a/music_assistant/controllers/media/base.py b/music_assistant/controllers/media/base.py index f9b0a5e3..aef30608 100644 --- a/music_assistant/controllers/media/base.py +++ b/music_assistant/controllers/media/base.py @@ -21,6 +21,7 @@ from music_assistant_models.media_items import ( Album, ItemMapping, MediaItemType, + MediaItemTypeOrItemMapping, ProviderMapping, SearchResults, Track, @@ -35,7 +36,7 @@ if TYPE_CHECKING: from music_assistant import MusicAssistant -MediaItemTypeBound = MediaItemType | ItemMapping +MediaItemTypeBound = MediaItemTypeOrItemMapping ItemCls = TypeVar("ItemCls", bound="MediaItemType") LibraryUpdate = TypeVar("LibraryUpdate", bound="MediaItemTypeBound") diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index eb176005..50cef528 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -31,6 +31,7 @@ from music_assistant_models.media_items import ( BrowseFolder, ItemMapping, MediaItemType, + MediaItemTypeOrItemMapping, SearchResults, ) from music_assistant_models.provider import SyncTask @@ -541,6 +542,13 @@ class MusicController(CoreController): if media_type == MediaType.PODCAST_EPISODE: # special case for podcast episodes return await self.podcasts.episode(item_id, provider_instance_id_or_domain) + if media_type == MediaType.FOLDER: + # special case for folders + return BrowseFolder( + item_id=item_id, + provider=provider_instance_id_or_domain, + name=item_id, + ) ctrl = self.get_controller(media_type) return await ctrl.get( item_id=item_id, @@ -563,7 +571,7 @@ class MusicController(CoreController): @api_command("music/favorites/add_item") async def add_item_to_favorites( self, - item: str | MediaItemType, + item: str | MediaItemTypeOrItemMapping, ) -> None: """Add an item to the favorites.""" if isinstance(item, str): @@ -794,7 +802,7 @@ class MusicController(CoreController): @api_command("music/mark_played") async def mark_item_played( self, - media_item: MediaItemType | ItemMapping, + media_item: MediaItemTypeOrItemMapping, fully_played: bool = True, seconds_played: int | None = None, ) -> None: @@ -863,7 +871,7 @@ class MusicController(CoreController): @api_command("music/mark_unplayed") async def mark_item_unplayed( self, - media_item: MediaItemType | ItemMapping, + media_item: MediaItemTypeOrItemMapping, ) -> None: """Mark item as unplayed in playlog.""" # update generic playlog table diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index cc572a23..6fed50e0 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -41,7 +41,10 @@ from music_assistant_models.errors import ( UnsupportedFeaturedException, ) from music_assistant_models.media_items import ( + BrowseFolder, + ItemMapping, MediaItemType, + MediaItemTypeOrItemMapping, PlayableMediaItemType, Playlist, PodcastEpisode, @@ -1415,9 +1418,12 @@ class PlayerQueuesController(CoreController): ) async def _resolve_media_items( - self, media_item: MediaItemType, start_item: str | None = None + self, media_item: MediaItemTypeOrItemMapping, start_item: str | None = None ) -> list[MediaItemType]: """Resolve/unwrap media items to enqueue.""" + # resolve Itemmapping to full media item + if isinstance(media_item, ItemMapping): + media_item = await self.mass.music.get_item_by_uri(media_item.uri) if media_item.media_type == MediaType.PLAYLIST: self.mass.create_task(self.mass.music.mark_item_played(media_item)) return await self.get_playlist_tracks(media_item, start_item) @@ -1438,6 +1444,8 @@ class PlayerQueuesController(CoreController): return 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) + if media_item.media_type == MediaType.FOLDER: + return await self._get_folder_tracks(media_item) # all other: single track or radio item return [media_item] @@ -1524,6 +1532,21 @@ class PlayerQueuesController(CoreController): ) return queue_tracks + async def _get_folder_tracks(self, folder: BrowseFolder) -> list[Track]: + """Fetch (playable) tracks for given browse folder.""" + self.logger.info( + "Fetching tracks to play for folder %s", + folder.name, + ) + tracks: list[Track] = [] + for item in await self.mass.music.browse(folder.path): + if not item.is_playable: + continue + # recursively fetch tracks from all media types + tracks += await self._resolve_media_items(item) + + return tracks + def _update_queue_from_player( self, player: Player, diff --git a/music_assistant/helpers/api.py b/music_assistant/helpers/api.py index ddc7f0cd..95bd75cf 100644 --- a/music_assistant/helpers/api.py +++ b/music_assistant/helpers/api.py @@ -11,6 +11,8 @@ from enum import Enum from types import NoneType, UnionType from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints +from mashumaro.exceptions import MissingField + LOGGER = logging.getLogger(__name__) _F = TypeVar("_F", bound=Callable[..., Any]) @@ -88,7 +90,11 @@ def parse_utc_timestamp(datetime_string: str) -> datetime: def parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) -> Any: """Try to parse a value from raw (json) data and type annotations.""" if isinstance(value, dict) and hasattr(value_type, "from_dict"): - if "media_type" in value and value["media_type"] != value_type.media_type: + if ( + "media_type" in value + and value_type.__name__ != "ItemMapping" + and value["media_type"] != value_type.media_type + ): msg = "Invalid MediaType" raise ValueError(msg) return value_type.from_dict(value) @@ -122,7 +128,7 @@ def parse_value(name: str, value: Any, value_type: Any, default: Any = MISSING) # try them all until one succeeds try: return parse_value(name, value, sub_arg_type) - except (KeyError, TypeError, ValueError): + except (KeyError, TypeError, ValueError, MissingField): pass # if we get to this point, all possibilities failed # find out if we should raise or log this diff --git a/music_assistant/helpers/compare.py b/music_assistant/helpers/compare.py index 76ee9315..b3e6d0f9 100644 --- a/music_assistant/helpers/compare.py +++ b/music_assistant/helpers/compare.py @@ -14,7 +14,7 @@ from music_assistant_models.media_items import ( ItemMapping, MediaItem, MediaItemMetadata, - MediaItemType, + MediaItemTypeOrItemMapping, Playlist, Podcast, Radio, @@ -30,8 +30,8 @@ IGNORE_VERSIONS = ( def compare_media_item( - base_item: MediaItemType | ItemMapping, - compare_item: MediaItemType | ItemMapping, + base_item: MediaItemTypeOrItemMapping, + compare_item: MediaItemTypeOrItemMapping, strict: bool = True, ) -> bool | None: """Compare two media items and return True if they match.""" diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index 8fca1d1f..0d1eedc5 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -13,8 +13,8 @@ from music_assistant_models.media_items import ( Artist, Audiobook, BrowseFolder, - ItemMapping, MediaItemType, + MediaItemTypeOrItemMapping, Playlist, Podcast, PodcastEpisode, @@ -82,45 +82,38 @@ class MusicProvider(Provider): async def get_library_artists(self) -> AsyncGenerator[Artist, None]: """Retrieve library artists from the provider.""" - if ProviderFeature.LIBRARY_ARTISTS in self.supported_features: - raise NotImplementedError - yield # type: ignore + yield + raise NotImplementedError async def get_library_albums(self) -> AsyncGenerator[Album, None]: """Retrieve library albums from the provider.""" - if ProviderFeature.LIBRARY_ALBUMS in self.supported_features: - raise NotImplementedError - yield # type: ignore + yield + raise NotImplementedError async def get_library_tracks(self) -> AsyncGenerator[Track, None]: """Retrieve library tracks from the provider.""" - if ProviderFeature.LIBRARY_TRACKS in self.supported_features: - raise NotImplementedError - yield # type: ignore + yield + raise NotImplementedError async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]: """Retrieve library/subscribed playlists from the provider.""" - if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features: - raise NotImplementedError - yield # type: ignore + yield + raise NotImplementedError async def get_library_radios(self) -> AsyncGenerator[Radio, None]: """Retrieve library/subscribed radio stations from the provider.""" - if ProviderFeature.LIBRARY_RADIOS in self.supported_features: - raise NotImplementedError - yield # type: ignore + yield + raise NotImplementedError async def get_library_audiobooks(self) -> AsyncGenerator[Audiobook, None]: """Retrieve library/subscribed audiobooks from the provider.""" - if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features: - raise NotImplementedError - yield # type: ignore + yield + raise NotImplementedError async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]: """Retrieve library/subscribed podcasts from the provider.""" - if ProviderFeature.LIBRARY_AUDIOBOOKS in self.supported_features: - raise NotImplementedError - yield # type: ignore + yield + raise NotImplementedError async def get_artist(self, prov_artist_id: str) -> Artist: """Get full artist details by id.""" @@ -404,7 +397,7 @@ class MusicProvider(Provider): return await self.get_podcast_episode(prov_item_id) return await self.get_track(prov_item_id) - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping]: # noqa: PLR0915 + async def browse(self, path: str) -> Sequence[MediaItemTypeOrItemMapping]: # noqa: PLR0911, PLR0915 """Browse this provider's items. :param path: The path to browse, (e.g. provider_id://artists). @@ -416,14 +409,14 @@ class MusicProvider(Provider): subpath = path.split("://", 1)[1] # this reference implementation can be overridden with a provider specific approach if subpath == "artists": - library_items = await self.mass.cache.get( + library_item_ids = await self.mass.cache.get( "artist", category=CacheCategory.LIBRARY_ITEMS, base_key=self.instance_id, ) - if library_items is None: - return await self.mass.music.artists.library_items(provider=self.instance_id) - library_items = cast(list[int], library_items) + if not library_item_ids: + return [x async for x in self.get_library_artists()] + library_items = cast(list[int], library_item_ids) query = "artists.item_id in :ids" query_params = {"ids": library_items} return await self.mass.music.artists.library_items( @@ -432,86 +425,86 @@ class MusicProvider(Provider): extra_query_params=query_params, ) if subpath == "albums": - library_items = await self.mass.cache.get( + library_item_ids = await self.mass.cache.get( "album", category=CacheCategory.LIBRARY_ITEMS, base_key=self.instance_id, ) - if library_items is None: - return await self.mass.music.albums.library_items(provider=self.instance_id) - library_items = cast(list[int], library_items) + if not library_item_ids: + return [x async for x in self.get_library_albums()] + library_item_ids = cast(list[int], library_item_ids) query = "albums.item_id in :ids" - query_params = {"ids": library_items} + query_params = {"ids": library_item_ids} return await self.mass.music.albums.library_items( extra_query=query, extra_query_params=query_params ) if subpath == "tracks": - library_items = await self.mass.cache.get( + library_item_ids = await self.mass.cache.get( "track", category=CacheCategory.LIBRARY_ITEMS, base_key=self.instance_id, ) - if library_items is None: - return await self.mass.music.tracks.library_items(provider=self.instance_id) - library_items = cast(list[int], library_items) + if not library_item_ids: + return [x async for x in self.get_library_tracks()] + library_item_ids = cast(list[int], library_item_ids) query = "tracks.item_id in :ids" query_params = {"ids": library_items} return await self.mass.music.tracks.library_items( extra_query=query, extra_query_params=query_params ) if subpath == "radios": - library_items = await self.mass.cache.get( + library_item_ids = await self.mass.cache.get( "radio", category=CacheCategory.LIBRARY_ITEMS, base_key=self.instance_id, ) - if library_items is None: - return await self.mass.music.radio.library_items(provider=self.instance_id) - library_items = cast(list[int], library_items) + if not library_item_ids: + return [x async for x in self.get_library_radios()] + library_item_ids = cast(list[int], library_item_ids) query = "radios.item_id in :ids" - query_params = {"ids": library_items} + query_params = {"ids": library_item_ids} return await self.mass.music.radio.library_items( extra_query=query, extra_query_params=query_params ) if subpath == "playlists": - library_items = await self.mass.cache.get( + library_item_ids = await self.mass.cache.get( "playlist", category=CacheCategory.LIBRARY_ITEMS, base_key=self.instance_id, ) - if library_items is None: - return await self.mass.music.playlists.library_items(provider=self.instance_id) - library_items = cast(list[int], library_items) + if not library_item_ids: + return [x async for x in self.get_library_playlists()] + library_item_ids = cast(list[int], library_item_ids) query = "playlists.item_id in :ids" - query_params = {"ids": library_items} + query_params = {"ids": library_item_ids} return await self.mass.music.playlists.library_items( extra_query=query, extra_query_params=query_params ) if subpath == "audiobooks": - library_items = await self.mass.cache.get( + library_item_ids = await self.mass.cache.get( "audiobook", category=CacheCategory.LIBRARY_ITEMS, base_key=self.instance_id, ) - if library_items is None: - return await self.mass.music.audiobooks.library_items(provider=self.instance_id) - library_items = cast(list[int], library_items) + if not library_item_ids: + return [x async for x in self.get_library_audiobooks()] + library_item_ids = cast(list[int], library_item_ids) query = "audiobooks.item_id in :ids" - query_params = {"ids": library_items} + query_params = {"ids": library_item_ids} return await self.mass.music.audiobooks.library_items( extra_query=query, extra_query_params=query_params ) if subpath == "podcasts": - library_items = await self.mass.cache.get( + library_item_ids = await self.mass.cache.get( "podcast", category=CacheCategory.LIBRARY_ITEMS, base_key=self.instance_id, ) - if library_items is None: - return await self.mass.music.podcasts.library_items(provider=self.instance_id) - library_items = cast(list[int], library_items) + if not library_item_ids: + return [x async for x in self.get_library_podcasts()] + library_item_ids = cast(list[int], library_item_ids) query = "podcasts.item_id in :ids" - query_params = {"ids": library_items} + query_params = {"ids": library_item_ids} return await self.mass.music.podcasts.library_items( extra_query=query, extra_query_params=query_params ) @@ -526,20 +519,22 @@ class MusicProvider(Provider): items.append( BrowseFolder( item_id="artists", - provider=self.domain, + provider=self.instance_id, path=path + "artists", name="", label="artists", + is_playable=True, ) ) if ProviderFeature.LIBRARY_ALBUMS in self.supported_features: items.append( BrowseFolder( item_id="albums", - provider=self.domain, + provider=self.instance_id, path=path + "albums", name="", label="albums", + is_playable=True, ) ) if ProviderFeature.LIBRARY_TRACKS in self.supported_features: @@ -550,23 +545,25 @@ class MusicProvider(Provider): path=path + "tracks", name="", label="tracks", + is_playable=True, ) ) if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features: items.append( BrowseFolder( item_id="playlists", - provider=self.domain, + provider=self.instance_id, path=path + "playlists", name="", label="playlists", + is_playable=True, ) ) if ProviderFeature.LIBRARY_RADIOS in self.supported_features: items.append( BrowseFolder( item_id="radios", - provider=self.domain, + provider=self.instance_id, path=path + "radios", name="", label="radios", @@ -576,7 +573,7 @@ class MusicProvider(Provider): items.append( BrowseFolder( item_id="audiobooks", - provider=self.domain, + provider=self.instance_id, path=path + "audiobooks", name="", label="audiobooks", @@ -586,12 +583,15 @@ class MusicProvider(Provider): items.append( BrowseFolder( item_id="podcasts", - provider=self.domain, + provider=self.instance_id, path=path + "podcasts", name="", label="podcasts", ) ) + if len(items) == 1: + # only one level, return the items directly + return await self.browse(items[0].path) return items async def recommendations(self) -> list[MediaItemType]: diff --git a/music_assistant/providers/_template_music_provider/__init__.py b/music_assistant/providers/_template_music_provider/__init__.py index 9f6fdfc3..7bb596e3 100644 --- a/music_assistant/providers/_template_music_provider/__init__.py +++ b/music_assistant/providers/_template_music_provider/__init__.py @@ -45,8 +45,8 @@ from music_assistant_models.media_items import ( Album, Artist, AudioFormat, - ItemMapping, MediaItemType, + MediaItemTypeOrItemMapping, Playlist, ProviderMapping, Radio, @@ -482,7 +482,7 @@ class MyDemoMusicprovider(MusicProvider): # to false in a MediaItemImage object. return path - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping]: + async def browse(self, path: str) -> Sequence[MediaItemTypeOrItemMapping]: """Browse this provider's items. :param path: The path to browse, (e.g. provider_id://artists). diff --git a/music_assistant/providers/audiobookshelf/__init__.py b/music_assistant/providers/audiobookshelf/__init__.py index 2c579148..31cc4dc9 100644 --- a/music_assistant/providers/audiobookshelf/__init__.py +++ b/music_assistant/providers/audiobookshelf/__init__.py @@ -26,7 +26,7 @@ from music_assistant_models.media_items import ( ItemMapping, MediaItemChapter, MediaItemImage, - MediaItemType, + MediaItemTypeOrItemMapping, Podcast, PodcastEpisode, ProviderMapping, @@ -563,13 +563,13 @@ class Audiobookshelf(MusicProvider): async def _browse_root( self, library_list: list[LibraryWithItemIDs], item_path: str - ) -> Sequence[MediaItemType | ItemMapping]: + ) -> Sequence[MediaItemTypeOrItemMapping]: """Browse root folder in browse view. Helper functions. Shows the library name, ABS supports multiple libraries of both podcasts and audiobooks. """ - items: list[MediaItemType | ItemMapping] = [] + items: list[MediaItemTypeOrItemMapping] = [] for library in library_list: items.append( BrowseFolder( @@ -586,7 +586,7 @@ class Audiobookshelf(MusicProvider): library_id: str, library_list: list[LibraryWithItemIDs], media_type: MediaType, - ) -> Sequence[MediaItemType | ItemMapping]: + ) -> Sequence[MediaItemTypeOrItemMapping]: """Browse lib folder in browse view. Helper functions. Shows the items which are part of an ABS library. @@ -598,7 +598,7 @@ class Audiobookshelf(MusicProvider): if library is None: raise MediaNotFoundError("Lib missing.") - items: list[MediaItemType | ItemMapping] = [] + items: list[MediaItemTypeOrItemMapping] = [] if media_type in [MediaType.PODCAST, MediaType.AUDIOBOOK]: for item_id in library.item_ids: mass_item = await self.mass.music.get_library_item_by_prov_id( @@ -612,7 +612,7 @@ class Audiobookshelf(MusicProvider): raise RuntimeError(f"Media type must not be {media_type}") return items - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping]: + async def browse(self, path: str) -> Sequence[MediaItemTypeOrItemMapping]: """Browse features shows libraries names.""" item_path = path.split("://", 1)[1] if not item_path: # root diff --git a/music_assistant/providers/filesystem_local/__init__.py b/music_assistant/providers/filesystem_local/__init__.py index 93920f87..e1f1c8bd 100644 --- a/music_assistant/providers/filesystem_local/__init__.py +++ b/music_assistant/providers/filesystem_local/__init__.py @@ -35,7 +35,7 @@ from music_assistant_models.media_items import ( ItemMapping, MediaItemChapter, MediaItemImage, - MediaItemType, + MediaItemTypeOrItemMapping, Playlist, Podcast, PodcastEpisode, @@ -249,16 +249,17 @@ class LocalFileSystemProvider(MusicProvider): ) return result - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping]: + async def browse(self, path: str) -> Sequence[MediaItemTypeOrItemMapping]: """Browse this provider's items. :param path: The path to browse, (e.g. provid://artists). """ - if self.media_content_type in ("audiobooks", "podcasts"): - # for audiobooks and podcasts just use the default implementation - # so we dont have to deal with multi-part audiobooks and podcast episodes - return await super().browse(path) - items: list[MediaItemType | ItemMapping] = [] + # for audiobooks and podcasts we just return all library items + if self.media_content_type == "podcasts": + return await self.mass.music.podcasts.library_items(provider=self.instance_id) + if self.media_content_type == "audiobooks": + return await self.mass.music.audiobooks.library_items(provider=self.instance_id) + items: list[MediaItemTypeOrItemMapping] = [] item_path = path.split("://", 1)[1] if not item_path: item_path = "" @@ -275,6 +276,8 @@ class LocalFileSystemProvider(MusicProvider): provider=self.instance_id, path=f"{self.instance_id}://{item.relative_path}", name=item.filename, + # mark folder as playable, assuming it contains tracks underneath + is_playable=True, ) ) elif item.ext in TRACK_EXTENSIONS: diff --git a/music_assistant/providers/siriusxm/__init__.py b/music_assistant/providers/siriusxm/__init__.py index 0649caba..466eef25 100644 --- a/music_assistant/providers/siriusxm/__init__.py +++ b/music_assistant/providers/siriusxm/__init__.py @@ -18,10 +18,9 @@ from music_assistant_models.enums import ( from music_assistant_models.errors import LoginFailed, MediaNotFoundError from music_assistant_models.media_items import ( AudioFormat, - ItemMapping, MediaItemImage, MediaItemLink, - MediaItemType, + MediaItemTypeOrItemMapping, ProviderMapping, Radio, ) @@ -243,7 +242,7 @@ class SiriusXMProvider(MusicProvider): return self._current_stream_details - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping]: + async def browse(self, path: str) -> Sequence[MediaItemTypeOrItemMapping]: """Browse this provider's items. :param path: The path to browse, (e.g. provider_id://artists).