Add capabilities attributes/properties for MusicProviders (#423)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 15 Jul 2022 21:10:53 +0000 (23:10 +0200)
committerGitHub <noreply@github.com>
Fri, 15 Jul 2022 21:10:53 +0000 (23:10 +0200)
* Add capability attributes to MusicProvider model

* implement on existing providers

Co-authored-by: Marvin Schenkel <marvinschenkel@gmail.com>
music_assistant/controllers/music/albums.py
music_assistant/controllers/music/artists.py
music_assistant/controllers/music/tracks.py
music_assistant/models/enums.py
music_assistant/models/music_provider.py
music_assistant/music_providers/filesystem.py
music_assistant/music_providers/qobuz.py
music_assistant/music_providers/spotify.py
music_assistant/music_providers/tunein.py
music_assistant/music_providers/url.py
music_assistant/music_providers/ytmusic/ytmusic.py

index 58997f469757d54d0888b9de5715412cba95ef89..c9b87b9c947a7e90002c85ea14b9a2d8871e60f9 100644 (file)
@@ -9,7 +9,7 @@ from music_assistant.helpers.compare import compare_album, compare_artist
 from music_assistant.helpers.database import TABLE_ALBUMS, TABLE_TRACKS
 from music_assistant.helpers.json import json_serializer
 from music_assistant.helpers.tags import FALLBACK_ARTIST
-from music_assistant.models.enums import ProviderType
+from music_assistant.models.enums import MusicProviderFeature, ProviderType
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import (
     Album,
@@ -281,7 +281,7 @@ class AlbumsController(MediaControllerBase[Album]):
         for provider in self.mass.music.providers:
             if provider.type in cur_prov_types:
                 continue
-            if MediaType.ALBUM not in provider.supported_mediatypes:
+            if MusicProviderFeature.SEARCH not in provider.supported_features:
                 continue
             if await find_prov_match(provider):
                 cur_prov_types.add(provider.type)
index 0646b8070efb5a4887359919507b18e79c56d11c..fc241bd97f911f554d90ecd2bf97ec6c4db0513f 100644 (file)
@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional
 
 from music_assistant.helpers.database import TABLE_ALBUMS, TABLE_ARTISTS, TABLE_TRACKS
 from music_assistant.helpers.json import json_serializer
-from music_assistant.models.enums import ProviderType
+from music_assistant.models.enums import MusicProviderFeature, ProviderType
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import (
     Album,
@@ -111,7 +111,7 @@ class ArtistsController(MediaControllerBase[Artist]):
         for provider in self.mass.music.providers:
             if provider.type in cur_prov_types:
                 continue
-            if MediaType.ARTIST not in provider.supported_mediatypes:
+            if MusicProviderFeature.SEARCH not in provider.supported_features:
                 continue
             if await self._match(db_artist, provider):
                 cur_prov_types.add(provider.type)
index ecf319846fe8523b7989a4612e6c712bf6a8528f..d90a30a6d7034cd38e1fe3d272d13689e1573787 100644 (file)
@@ -7,7 +7,7 @@ from typing import List, Optional, Union
 from music_assistant.helpers.compare import compare_artists, compare_track
 from music_assistant.helpers.database import TABLE_TRACKS
 from music_assistant.helpers.json import json_serializer
-from music_assistant.models.enums import MediaType, ProviderType
+from music_assistant.models.enums import MediaType, MusicProviderFeature, ProviderType
 from music_assistant.models.media_controller import MediaControllerBase
 from music_assistant.models.media_items import (
     Album,
@@ -83,7 +83,7 @@ class TracksController(MediaControllerBase[Track]):
             # matching only works if we have a full track object
             db_track = await self.get_db_item(db_track.item_id)
         for provider in self.mass.music.providers:
-            if MediaType.TRACK not in provider.supported_mediatypes:
+            if MusicProviderFeature.SEARCH not in provider.supported_features:
                 continue
             self.logger.debug(
                 "Trying to match track %s on provider %s", db_track.name, provider.name
index 3e7b91e1f95322eaeef20984c412fdbd1dea4661..00a3d918ebbb16a744a28c9d5e00ae24543b1f3b 100644 (file)
@@ -221,6 +221,33 @@ class JobStatus(Enum):
     ERROR = "error"
 
 
+class MusicProviderFeature(Enum):
+    """Enum with features for a MusicProvider."""
+
+    # browse/explore/recommendations
+    BROWSE = "browse"
+    SEARCH = "search"
+    RECOMMENDATIONS = "recommendations"
+    # library feature per mediatype
+    LIBRARY_ARTISTS = "library_artists"
+    LIBRARY_ALBUMS = "library_albums"
+    LIBRARY_TRACKS = "library_tracks"
+    LIBRARY_PLAYLISTS = "library_playlists"
+    LIBRARY_RADIOS = "library_radios"
+    # additional library features
+    ARTIST_ALBUMS = "artist_albums"
+    ARTIST_TOPTRACKS = "artist_toptracks"
+    # library edit (=add/remove) feature per mediatype
+    LIBRARY_ARTISTS_EDIT = "library_artists_edit"
+    LIBRARY_ALBUMS_EDIT = "library_albums_edit"
+    LIBRARY_TRACKS_EDIT = "library_tracks_edit"
+    LIBRARY_PLAYLISTS_EDIT = "library_playlists_edit"
+    LIBRARY_RADIOS_EDIT = "library_radios_edit"
+    # playlist-specific features
+    PLAYLIST_TRACKS_EDIT = "playlist_tracks_edit"
+    PLAYLIST_CREATE = "playlist_create"
+
+
 class ProviderType(Enum):
     """Enum with supported music providers."""
 
index c802d7b8b78436c4892224334c40e30d1b9bba74..6fe44bd570adae3d482c71724f64485f8dcd4cee 100644 (file)
@@ -5,7 +5,7 @@ from abc import abstractmethod
 from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Tuple
 
 from music_assistant.models.config import MusicProviderConfig
-from music_assistant.models.enums import MediaType, ProviderType
+from music_assistant.models.enums import MediaType, MusicProviderFeature, ProviderType
 from music_assistant.models.media_items import (
     Album,
     Artist,
@@ -27,8 +27,6 @@ class MusicProvider:
     _attr_name: str = None
     _attr_type: ProviderType = None
     _attr_available: bool = True
-    _attr_supports_browse: bool = True
-    _attr_supported_mediatypes: List[MediaType] = []
 
     def __init__(self, mass: MusicAssistant, config: MusicProviderConfig) -> None:
         """Initialize MusicProvider."""
@@ -37,6 +35,11 @@ class MusicProvider:
         self.logger = mass.logger
         self.cache = mass.cache
 
+    @property
+    def supported_features(self) -> Tuple[MusicProviderFeature]:
+        """Return the features supported by this MusicProvider."""
+        return tuple()
+
     @abstractmethod
     async def setup(self) -> bool:
         """
@@ -63,16 +66,6 @@ class MusicProvider:
         """Return boolean if this provider is available/initialized."""
         return self._attr_available
 
-    @property
-    def supports_browse(self) -> bool:
-        """Return boolean if this provider supports browsing."""
-        return self._attr_supports_browse
-
-    @property
-    def supported_mediatypes(self) -> List[MediaType]:
-        """Return MediaTypes the provider supports."""
-        return self._attr_supported_mediatypes
-
     async def search(
         self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
     ) -> List[MediaItemType]:
@@ -83,31 +76,32 @@ class MusicProvider:
             :param media_types: A list of media_types to include. All types if None.
             :param limit: Number of items to return in the search (per type).
         """
-        raise NotImplementedError
+        if MusicProviderFeature.SEARCH in self.supported_features:
+            raise NotImplementedError
 
     async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
         """Retrieve library artists from the provider."""
-        if MediaType.ARTIST in self.supported_mediatypes:
+        if MusicProviderFeature.LIBRARY_ARTISTS in self.supported_features:
             raise NotImplementedError
 
     async def get_library_albums(self) -> AsyncGenerator[Album, None]:
         """Retrieve library albums from the provider."""
-        if MediaType.ALBUM in self.supported_mediatypes:
+        if MusicProviderFeature.LIBRARY_ALBUMS in self.supported_features:
             raise NotImplementedError
 
     async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
         """Retrieve library tracks from the provider."""
-        if MediaType.TRACK in self.supported_mediatypes:
+        if MusicProviderFeature.LIBRARY_TRACKS in self.supported_features:
             raise NotImplementedError
 
     async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
         """Retrieve library/subscribed playlists from the provider."""
-        if MediaType.PLAYLIST in self.supported_mediatypes:
+        if MusicProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
             raise NotImplementedError
 
     async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
         """Retrieve library/subscribed radio stations from the provider."""
-        if MediaType.RADIO in self.supported_mediatypes:
+        if MusicProviderFeature.LIBRARY_RADIOS in self.supported_features:
             raise NotImplementedError
 
     async def get_artist(self, prov_artist_id: str) -> Artist:
@@ -116,13 +110,13 @@ class MusicProvider:
 
     async def get_artist_albums(self, prov_artist_id: str) -> List[Album]:
         """Get a list of all albums for the given artist."""
-        if MediaType.ALBUM in self.supported_mediatypes:
+        if MusicProviderFeature.ARTIST_ALBUMS in self.supported_features:
             raise NotImplementedError
         return []
 
     async def get_artist_toptracks(self, prov_artist_id: str) -> List[Track]:
         """Get a list of most popular tracks for the given artist."""
-        if MediaType.TRACK in self.supported_mediatypes:
+        if MusicProviderFeature.ARTIST_TOPTRACKS in self.supported_features:
             raise NotImplementedError
         return []
 
@@ -140,41 +134,94 @@ class MusicProvider:
 
     async def get_radio(self, prov_radio_id: str) -> Radio:
         """Get full radio details by id."""
-        if MediaType.RADIO in self.supported_mediatypes:
-            raise NotImplementedError
+        raise NotImplementedError
 
     async def get_album_tracks(self, prov_album_id: str) -> List[Track]:
         """Get album tracks for given album id."""
-        if MediaType.ALBUM in self.supported_mediatypes:
-            raise NotImplementedError
-        return []
+        raise NotImplementedError
 
     async def get_playlist_tracks(self, prov_playlist_id: str) -> List[Track]:
         """Get all playlist tracks for given playlist id."""
-        if MediaType.PLAYLIST in self.supported_mediatypes:
-            raise NotImplementedError
-        return []
+        raise NotImplementedError
 
     async def library_add(self, prov_item_id: str, media_type: MediaType) -> bool:
         """Add item to provider's library. Return true on succes."""
-        return True
+        if (
+            media_type == MediaType.ARTIST
+            and MusicProviderFeature.LIBRARY_ARTISTS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        if (
+            media_type == MediaType.ALBUM
+            and MusicProviderFeature.LIBRARY_ALBUMS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        if (
+            media_type == MediaType.TRACK
+            and MusicProviderFeature.LIBRARY_TRACKS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        if (
+            media_type == MediaType.PLAYLIST
+            and MusicProviderFeature.LIBRARY_PLAYLISTS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        if (
+            media_type == MediaType.RADIO
+            and MusicProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        self.logger.info(
+            "Provider %s does not support library edit, "
+            "the action will only be performed in the local database.",
+            self.type.value,
+        )
 
     async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
         """Remove item from provider's library. Return true on succes."""
-        return True
+        if (
+            media_type == MediaType.ARTIST
+            and MusicProviderFeature.LIBRARY_ARTISTS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        if (
+            media_type == MediaType.ALBUM
+            and MusicProviderFeature.LIBRARY_ALBUMS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        if (
+            media_type == MediaType.TRACK
+            and MusicProviderFeature.LIBRARY_TRACKS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        if (
+            media_type == MediaType.PLAYLIST
+            and MusicProviderFeature.LIBRARY_PLAYLISTS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        if (
+            media_type == MediaType.RADIO
+            and MusicProviderFeature.LIBRARY_RADIOS_EDIT in self.supported_features
+        ):
+            raise NotImplementedError
+        self.logger.info(
+            "Provider %s does not support library edit, "
+            "the action will only be performed in the local database.",
+            self.type.value,
+        )
 
     async def add_playlist_tracks(
         self, prov_playlist_id: str, prov_track_ids: List[str]
     ) -> None:
         """Add track(s) to playlist."""
-        if MediaType.PLAYLIST in self.supported_mediatypes:
+        if MusicProviderFeature.PLAYLIST_TRACKS_EDIT in self.supported_features:
             raise NotImplementedError
 
     async def remove_playlist_tracks(
         self, prov_playlist_id: str, prov_track_ids: List[str]
     ) -> None:
         """Remove track(s) from playlist."""
-        if MediaType.PLAYLIST in self.supported_mediatypes:
+        if MusicProviderFeature.PLAYLIST_TRACKS_EDIT in self.supported_features:
             raise NotImplementedError
 
     async def get_stream_details(self, item_id: str) -> StreamDetails | None:
@@ -205,11 +252,14 @@ class MusicProvider:
 
             :param path: The path to browse, (e.g. artists) or None for root level.
         """
+        if MusicProviderFeature.BROWSE not in self.supported_features:
+            # we may NOT use the default implementation if the browser does not support browse
+            raise NotImplementedError
         # this reference implementation can be overridden with provider specific approach
         if not path:
             # return main listing
             root_items = []
-            if MediaType.ARTIST in self.supported_mediatypes:
+            if MusicProviderFeature.LIBRARY_ARTISTS in self.supported_features:
                 root_items.append(
                     BrowseFolder(
                         item_id="artists",
@@ -219,7 +269,7 @@ class MusicProvider:
                         uri=f"{self.type.value}://artists",
                     )
                 )
-            if MediaType.ALBUM in self.supported_mediatypes:
+            if MusicProviderFeature.LIBRARY_ALBUMS in self.supported_features:
                 root_items.append(
                     BrowseFolder(
                         item_id="albums",
@@ -229,7 +279,7 @@ class MusicProvider:
                         uri=f"{self.type.value}://albums",
                     )
                 )
-            if MediaType.TRACK in self.supported_mediatypes:
+            if MusicProviderFeature.LIBRARY_TRACKS in self.supported_features:
                 root_items.append(
                     BrowseFolder(
                         item_id="tracks",
@@ -239,7 +289,7 @@ class MusicProvider:
                         uri=f"{self.type.value}://tracks",
                     )
                 )
-            if MediaType.PLAYLIST in self.supported_mediatypes:
+            if MusicProviderFeature.LIBRARY_PLAYLISTS in self.supported_features:
                 root_items.append(
                     BrowseFolder(
                         item_id="playlists",
@@ -249,7 +299,7 @@ class MusicProvider:
                         uri=f"{self.type.value}://playlists",
                     )
                 )
-            if MediaType.RADIO in self.supported_mediatypes:
+            if MusicProviderFeature.LIBRARY_RADIOS in self.supported_features:
                 root_items.append(
                     BrowseFolder(
                         item_id="radios",
@@ -272,14 +322,14 @@ class MusicProvider:
         if path == "playlists":
             return [x async for x in self.get_library_playlists()]
 
-    @abstractmethod
     async def recommendations(self) -> List[BrowseFolder]:
         """
         Get this provider's recommendations.
 
             Returns a list of BrowseFolder items with (max 25) mediaitems in the items attribute.
         """
-        return []
+        if MusicProviderFeature.RECOMMENDATIONS in self.supported_features:
+            raise NotImplementedError
 
     async def sync_library(
         self, media_types: Optional[Tuple[MediaType]] = None
@@ -289,8 +339,10 @@ class MusicProvider:
         # this logic is aimed at streaming/online providers,
         # which all have more or less the same structure.
         # filesystem implementation(s) just override this.
-        for media_type in self.supported_mediatypes:
-            if media_types is not None and media_type not in media_types:
+        if media_types is None:
+            media_types = (x for x in MediaType)
+        for media_type in media_types:
+            if not self.library_supported(media_type):
                 continue
             self.logger.debug("Start sync of %s items.", media_type.value)
             controller = self.mass.music.get_controller(media_type)
@@ -342,9 +394,22 @@ class MusicProvider:
             "type": self.type.value,
             "name": self.name,
             "id": self.id,
-            "supported_mediatypes": [x.value for x in self.supported_mediatypes],
+            "supported_features": [x.value for x in self.supported_features],
         }
 
+    def library_supported(self, media_type: MediaType) -> bool:
+        """Return if Library is upported for given MediaType on this provider."""
+        if media_type == MediaType.ARTIST:
+            return MusicProviderFeature.LIBRARY_ARTISTS in self.supported_features
+        if media_type == MediaType.ALBUM:
+            return MusicProviderFeature.LIBRARY_ALBUMS in self.supported_features
+        if media_type == MediaType.TRACK:
+            return MusicProviderFeature.LIBRARY_TRACKS in self.supported_features
+        if media_type == MediaType.PLAYLIST:
+            return MusicProviderFeature.LIBRARY_PLAYLISTS in self.supported_features
+        if media_type == MediaType.RADIO:
+            return MusicProviderFeature.LIBRARY_RADIOS in self.supported_features
+
     def _get_library_gen(self, media_type: MediaType) -> AsyncGenerator[MediaItemType]:
         """Return library generator for given media_type."""
         if media_type == MediaType.ARTIST:
index b492a71112eb81b1b4622e2137869be82d316125..fb02d8f86214879d8c912a783db1a1f9d42b8b4c 100644 (file)
@@ -18,7 +18,7 @@ from aiofiles.threadpool.binary import AsyncFileIO
 from music_assistant.helpers.compare import compare_strings
 from music_assistant.helpers.tags import FALLBACK_ARTIST, parse_tags, split_items
 from music_assistant.helpers.util import create_safe_string, parse_title_and_version
-from music_assistant.models.enums import ProviderType
+from music_assistant.models.enums import MusicProviderFeature, ProviderType
 from music_assistant.models.errors import MediaNotFoundError, MusicAssistantError
 from music_assistant.models.media_items import (
     Album,
@@ -92,12 +92,27 @@ class FileSystemProvider(MusicProvider):
 
     _attr_name = "Filesystem"
     _attr_type = ProviderType.FILESYSTEM_LOCAL
-    _attr_supported_mediatypes = [
-        MediaType.TRACK,
-        MediaType.PLAYLIST,
-        MediaType.ARTIST,
-        MediaType.ALBUM,
-    ]
+
+    @property
+    def supported_features(self) -> Tuple[MusicProviderFeature]:
+        """Return the features supported by this MusicProvider."""
+        return (
+            MusicProviderFeature.LIBRARY_ARTISTS,
+            MusicProviderFeature.LIBRARY_ALBUMS,
+            MusicProviderFeature.LIBRARY_TRACKS,
+            MusicProviderFeature.LIBRARY_PLAYLISTS,
+            MusicProviderFeature.LIBRARY_RADIOS,
+            MusicProviderFeature.LIBRARY_ARTISTS_EDIT,
+            MusicProviderFeature.LIBRARY_ALBUMS_EDIT,
+            MusicProviderFeature.LIBRARY_PLAYLISTS_EDIT,
+            MusicProviderFeature.LIBRARY_RADIOS_EDIT,
+            MusicProviderFeature.LIBRARY_TRACKS_EDIT,
+            MusicProviderFeature.PLAYLIST_TRACKS_EDIT,
+            MusicProviderFeature.BROWSE,
+            MusicProviderFeature.SEARCH,
+            MusicProviderFeature.ARTIST_ALBUMS,
+            MusicProviderFeature.ARTIST_TOPTRACKS,
+        )
 
     async def setup(self) -> bool:
         """Handle async initialization of the provider."""
index ae6a18e04ef0d0799d2f243068b8abdfe496720c..7a8f926b9d75824ba72dcd4f8abdbad3fdd2bc53 100644 (file)
@@ -5,7 +5,7 @@ import datetime
 import hashlib
 import time
 from json import JSONDecodeError
-from typing import AsyncGenerator, List, Optional
+from typing import AsyncGenerator, List, Optional, Tuple
 
 import aiohttp
 from asyncio_throttle import Throttler
@@ -14,7 +14,7 @@ from music_assistant.helpers.app_vars import (  # pylint: disable=no-name-in-mod
     app_var,
 )
 from music_assistant.helpers.util import parse_title_and_version, try_parse_int
-from music_assistant.models.enums import ProviderType
+from music_assistant.models.enums import MusicProviderFeature, ProviderType
 from music_assistant.models.errors import LoginFailed, MediaNotFoundError
 from music_assistant.models.media_items import (
     Album,
@@ -39,15 +39,30 @@ class QobuzProvider(MusicProvider):
 
     _attr_type = ProviderType.QOBUZ
     _attr_name = "Qobuz"
-    _attr_supported_mediatypes = [
-        MediaType.ARTIST,
-        MediaType.ALBUM,
-        MediaType.TRACK,
-        MediaType.PLAYLIST,
-    ]
     _user_auth_info = None
     _throttler = Throttler(rate_limit=4, period=1)
 
+    @property
+    def supported_features(self) -> Tuple[MusicProviderFeature]:
+        """Return the features supported by this MusicProvider."""
+        return (
+            MusicProviderFeature.LIBRARY_ARTISTS,
+            MusicProviderFeature.LIBRARY_ALBUMS,
+            MusicProviderFeature.LIBRARY_TRACKS,
+            MusicProviderFeature.LIBRARY_PLAYLISTS,
+            MusicProviderFeature.LIBRARY_RADIOS,
+            MusicProviderFeature.LIBRARY_ARTISTS_EDIT,
+            MusicProviderFeature.LIBRARY_ALBUMS_EDIT,
+            MusicProviderFeature.LIBRARY_PLAYLISTS_EDIT,
+            MusicProviderFeature.LIBRARY_RADIOS_EDIT,
+            MusicProviderFeature.LIBRARY_TRACKS_EDIT,
+            MusicProviderFeature.PLAYLIST_TRACKS_EDIT,
+            MusicProviderFeature.BROWSE,
+            MusicProviderFeature.SEARCH,
+            MusicProviderFeature.ARTIST_ALBUMS,
+            MusicProviderFeature.ARTIST_TOPTRACKS,
+        )
+
     async def setup(self) -> bool:
         """Handle async initialization of the provider."""
         if not self.config.enabled:
index b3d9011fe32eb9659a9dde35e0ab94cbcb0d3756..4e4aa613586285f58265becb1d786c5cc994cf50 100644 (file)
@@ -8,7 +8,7 @@ import platform
 import time
 from json.decoder import JSONDecodeError
 from tempfile import gettempdir
-from typing import AsyncGenerator, List, Optional
+from typing import AsyncGenerator, List, Optional, Tuple
 
 import aiohttp
 from asyncio_throttle import Throttler
@@ -18,7 +18,7 @@ from music_assistant.helpers.app_vars import (  # noqa # pylint: disable=no-name
 )
 from music_assistant.helpers.process import AsyncProcess
 from music_assistant.helpers.util import parse_title_and_version
-from music_assistant.models.enums import ProviderType
+from music_assistant.models.enums import MusicProviderFeature, ProviderType
 from music_assistant.models.errors import LoginFailed, MediaNotFoundError
 from music_assistant.models.media_items import (
     Album,
@@ -45,13 +45,6 @@ class SpotifyProvider(MusicProvider):
 
     _attr_type = ProviderType.SPOTIFY
     _attr_name = "Spotify"
-    _attr_supported_mediatypes = [
-        MediaType.ARTIST,
-        MediaType.ALBUM,
-        MediaType.TRACK,
-        MediaType.PLAYLIST
-        # TODO: Return spotify radio
-    ]
     _auth_token = None
     _sp_user = None
     _librespot_bin = None
@@ -59,6 +52,27 @@ class SpotifyProvider(MusicProvider):
     _cache_dir = CACHE_DIR
     _ap_workaround = False
 
+    @property
+    def supported_features(self) -> Tuple[MusicProviderFeature]:
+        """Return the features supported by this MusicProvider."""
+        return (
+            MusicProviderFeature.LIBRARY_ARTISTS,
+            MusicProviderFeature.LIBRARY_ALBUMS,
+            MusicProviderFeature.LIBRARY_TRACKS,
+            MusicProviderFeature.LIBRARY_PLAYLISTS,
+            MusicProviderFeature.LIBRARY_RADIOS,
+            MusicProviderFeature.LIBRARY_ARTISTS_EDIT,
+            MusicProviderFeature.LIBRARY_ALBUMS_EDIT,
+            MusicProviderFeature.LIBRARY_PLAYLISTS_EDIT,
+            MusicProviderFeature.LIBRARY_RADIOS_EDIT,
+            MusicProviderFeature.LIBRARY_TRACKS_EDIT,
+            MusicProviderFeature.PLAYLIST_TRACKS_EDIT,
+            MusicProviderFeature.BROWSE,
+            MusicProviderFeature.SEARCH,
+            MusicProviderFeature.ARTIST_ALBUMS,
+            MusicProviderFeature.ARTIST_TOPTRACKS,
+        )
+
     async def setup(self) -> bool:
         """Handle async initialization of the provider."""
         if not self.config.enabled:
index b28affe57ca5fb968d8219a6308ab0f3029e4c84..82f03590ca70a5f6d57c1ba381a218482055b508 100644 (file)
@@ -2,21 +2,20 @@
 from __future__ import annotations
 
 from time import time
-from typing import AsyncGenerator, List, Optional
+from typing import AsyncGenerator, List, Optional, Tuple
 
 from asyncio_throttle import Throttler
 
 from music_assistant.helpers.audio import get_radio_stream
 from music_assistant.helpers.playlists import fetch_playlist
 from music_assistant.helpers.util import create_sort_name
-from music_assistant.models.enums import ProviderType
+from music_assistant.models.enums import MusicProviderFeature, ProviderType
 from music_assistant.models.errors import LoginFailed, MediaNotFoundError
 from music_assistant.models.media_items import (
     ContentType,
     ImageType,
     MediaItemImage,
     MediaItemProviderId,
-    MediaItemType,
     MediaQuality,
     MediaType,
     Radio,
@@ -30,10 +29,16 @@ class TuneInProvider(MusicProvider):
 
     _attr_type = ProviderType.TUNEIN
     _attr_name = "Tune-in Radio"
-    _attr_supports_browse: bool = False
-    _attr_supported_mediatypes = [MediaType.RADIO]
     _throttler = Throttler(rate_limit=1, period=1)
 
+    @property
+    def supported_features(self) -> Tuple[MusicProviderFeature]:
+        """Return the features supported by this MusicProvider."""
+        return (
+            MusicProviderFeature.LIBRARY_RADIOS,
+            MusicProviderFeature.BROWSE,
+        )
+
     async def setup(self) -> bool:
         """Handle async initialization of the provider."""
         if not self.config.enabled:
@@ -47,19 +52,6 @@ class TuneInProvider(MusicProvider):
             )
         return True
 
-    async def search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> List[MediaItemType]:
-        """
-        Perform search on musicprovider.
-
-            :param search_query: Search query.
-            :param media_types: A list of media_types to include. All types if None.
-            :param limit: Number of items to return in the search (per type).
-        """
-        # TODO: search for radio stations
-        return []
-
     async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
         """Retrieve library/subscribed radio stations from the provider."""
 
index 90a138ca52c065e898cc8711beadcb4a3ace6a9a..302366ca054fed3374cc9fd19db25571e5e769db 100644 (file)
@@ -2,7 +2,7 @@
 from __future__ import annotations
 
 import os
-from typing import AsyncGenerator, List, Optional, Tuple
+from typing import AsyncGenerator, Tuple
 
 from music_assistant.helpers.audio import (
     get_file_stream,
@@ -17,6 +17,7 @@ from music_assistant.models.enums import (
     ImageType,
     MediaQuality,
     MediaType,
+    MusicProviderFeature,
     ProviderType,
 )
 from music_assistant.models.media_items import (
@@ -41,10 +42,14 @@ class URLProvider(MusicProvider):
     _attr_name: str = "URL"
     _attr_type: ProviderType = ProviderType.URL
     _attr_available: bool = True
-    _attr_supports_browse: bool = False
-    _attr_supported_mediatypes: List[MediaType] = []
     _full_url = {}
 
+    @property
+    def supported_features(self) -> Tuple[MusicProviderFeature]:
+        """Return the features supported by this MusicProvider."""
+        # return empty tuple because we do not really support features directly here
+        return tuple()
+
     async def setup(self) -> bool:
         """
         Handle async initialization of the provider.
@@ -157,12 +162,6 @@ class URLProvider(MusicProvider):
             await self.mass.cache.set(cache_key, media_info.raw)
         return (item_id, url, media_info)
 
-    async def search(
-        self, search_query: str, media_types=Optional[List[MediaType]], limit: int = 5
-    ) -> List[MediaItemType]:
-        """Perform search on musicprovider."""
-        return []
-
     async def get_stream_details(self, item_id: str) -> StreamDetails | None:
         """Get streamdetails for a track/radio."""
         item_id, url, media_info = await self._get_media_info(item_id)
index 1a614f352e34785544b9321fee1d5b506ae5d2d5..df74edd22250e0022973692d08fdf511074a39c6 100644 (file)
@@ -2,13 +2,13 @@
 import re
 from operator import itemgetter
 from time import time
-from typing import AsyncGenerator, Dict, List, Optional
+from typing import AsyncGenerator, Dict, List, Optional, Tuple
 from urllib.parse import unquote
 
 import pytube
 import ytmusicapi
 
-from music_assistant.models.enums import ProviderType
+from music_assistant.models.enums import MusicProviderFeature, ProviderType
 from music_assistant.models.errors import (
     InvalidDataError,
     LoginFailed,
@@ -50,18 +50,26 @@ class YoutubeMusicProvider(MusicProvider):
 
     _attr_type = ProviderType.YTMUSIC
     _attr_name = "Youtube Music"
-    _attr_supported_mediatypes = [
-        MediaType.ARTIST,
-        MediaType.ALBUM,
-        MediaType.TRACK,
-        MediaType.PLAYLIST,
-    ]
     _headers = None
     _context = None
     _cookies = None
     _signature_timestamp = 0
     _cipher = None
 
+    @property
+    def supported_features(self) -> Tuple[MusicProviderFeature]:
+        """Return the features supported by this MusicProvider."""
+        return (
+            MusicProviderFeature.LIBRARY_ARTISTS,
+            MusicProviderFeature.LIBRARY_ALBUMS,
+            MusicProviderFeature.LIBRARY_TRACKS,
+            MusicProviderFeature.LIBRARY_PLAYLISTS,
+            MusicProviderFeature.BROWSE,
+            MusicProviderFeature.SEARCH,
+            MusicProviderFeature.ARTIST_ALBUMS,
+            MusicProviderFeature.ARTIST_TOPTRACKS,
+        )
+
     async def setup(self) -> bool:
         """Set up the YTMusic provider."""
         if not self.config.enabled: