Improve metadata handling (#1552)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 11 Aug 2024 01:17:09 +0000 (03:17 +0200)
committerGitHub <noreply@github.com>
Sun, 11 Aug 2024 01:17:09 +0000 (03:17 +0200)
21 files changed:
music_assistant/common/models/provider.py
music_assistant/server/controllers/config.py
music_assistant/server/controllers/metadata.py
music_assistant/server/controllers/music.py
music_assistant/server/helpers/compare.py
music_assistant/server/providers/builtin/manifest.json
music_assistant/server/providers/fanarttv/__init__.py
music_assistant/server/providers/fanarttv/manifest.json
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/fully_kiosk/manifest.json
music_assistant/server/providers/hass/manifest.json
music_assistant/server/providers/hass_players/manifest.json
music_assistant/server/providers/musicbrainz/manifest.json
music_assistant/server/providers/slimproto/manifest.json
music_assistant/server/providers/snapcast/manifest.json
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/test/manifest.json
music_assistant/server/providers/theaudiodb/__init__.py
music_assistant/server/providers/theaudiodb/manifest.json
tests/server/providers/filesystem/__init__.py [new file with mode: 0644]
tests/server/providers/filesystem/test_helpers.py [new file with mode: 0644]

index 7b706b6b0d8b58c8edf9f42c5a65736c1dd7a192..d9a9e895d026d13aefcf02675e607feb8df9e617 100644 (file)
@@ -31,12 +31,10 @@ class ProviderManifest(DataClassORJSONMixin):
     documentation: str | None = None
     # multi_instance: whether multiple instances of the same provider are allowed/possible
     multi_instance: bool = False
-    # builtin: whether this provider is a system/builtin and can not disabled/removed
+    # builtin: whether this provider is a system/builtin provider, loaded by default
     builtin: bool = False
-    # hidden: hide entry in the UI
-    hidden: bool = False
-    # load_by_default: load this provider by default (may be used together with `builtin`)
-    load_by_default: bool = False
+    # allow_disable: whether this provider can be disabled (used with builtin)
+    allow_disable: bool = True
     # depends_on: depends on another provider to function
     depends_on: str | None = None
     # icon: name of the material design icon (https://pictogrammers.com/library/mdi)
index 5af24ec3070901a98d16f0e5abf8ddf5559fa71a..bd0815939ed6cecde337a6163daec1f5fc19b2d3 100644 (file)
@@ -276,15 +276,6 @@ class ConfigController:
             msg = f"Provider {instance_id} does not exist"
             raise KeyError(msg)
         prov_manifest = self.mass.get_provider_manifest(existing["domain"])
-        if prov_manifest.load_by_default and instance_id == prov_manifest.domain:
-            # Guard for a provider that is loaded by default
-            LOGGER.warning(
-                "Provider %s can not be removed, disabling instead...",
-                prov_manifest.name,
-            )
-            existing["enabled"] = False
-            await self._update_provider_config(instance_id, existing)
-            return
         if prov_manifest.builtin:
             msg = f"Builtin provider {prov_manifest.name} can not be removed."
             raise RuntimeError(msg)
@@ -756,8 +747,8 @@ class ConfigController:
         else:
             # disable provider
             prov_manifest = self.mass.get_provider_manifest(config.domain)
-            if prov_manifest.builtin:
-                msg = "Builtin provider can not be disabled."
+            if not prov_manifest.allow_disable:
+                msg = "Provider can not be disabled."
                 raise RuntimeError(msg)
             # also unload any other providers dependent of this provider
             for dep_prov in self.mass.providers:
index db6f7e1e968a1f61d1145cc1d5f2a564725ec10c..84fb4a6b4ef58ab1d95b81fb10daae273a772790 100644 (file)
@@ -101,12 +101,14 @@ LOCALES = {
 DEFAULT_LANGUAGE = "en_US"
 REFRESH_INTERVAL = 60 * 60 * 24 * 90
 MAX_ONLINE_CALLS_PER_DAY = 30
+CONF_ENABLE_ONLINE_METADATA = "enable_online_metadata"
 
 
 class MetaDataController(CoreController):
     """Several helpers to search and store metadata for mediaitems."""
 
     domain: str = "metadata"
+    config: CoreConfig
 
     def __init__(self, *args, **kwargs) -> None:
         """Initialize class."""
@@ -139,10 +141,26 @@ class MetaDataController(CoreController):
                 "in your preferred language is not available.",
                 options=tuple(ConfigValueOption(value, key) for key, value in LOCALES.items()),
             ),
+            ConfigEntry(
+                key=CONF_ENABLE_ONLINE_METADATA,
+                type=ConfigEntryType.BOOLEAN,
+                label="Enable metadata retrieval from online metadata providers",
+                required=False,
+                default_value=True,
+                description="Enable online metadata lookups.\n\n"
+                "This will allow Music Assistant to fetch additional metadata from (enabled) "
+                "metadata providers, such as The Audio DB and Fanart.tv.\n\n"
+                "Note that these online sources are only queried when no information is already "
+                "available from local files or the music providers.\n\n"
+                "The retrieval of additional rich metadata is a process that is executed slowly "
+                "in the background to not overload these free services with requests. "
+                "You can speedup the process by storing the images and other metadata locally.",
+            ),
         )
 
     async def setup(self, config: CoreConfig) -> None:
         """Async initialize of module."""
+        self.config = config
         if not self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
             # silence PIL logger
             logging.getLogger("PIL").setLevel(logging.WARNING)
@@ -219,15 +237,15 @@ class MetaDataController(CoreController):
             # this shouldn't happen but just in case.
             raise RuntimeError("Metadata can only be updated for library items")
         if item.media_type == MediaType.ARTIST:
-            await self._update_artist_metadata(item)
+            await self._update_artist_metadata(item, force_refresh=force_refresh)
         if item.media_type == MediaType.ALBUM:
-            await self._update_album_metadata(item)
+            await self._update_album_metadata(item, force_refresh=force_refresh)
         if item.media_type == MediaType.TRACK:
-            await self._update_track_metadata(item)
+            await self._update_track_metadata(item, force_refresh=force_refresh)
         if item.media_type == MediaType.PLAYLIST:
-            await self._update_playlist_metadata(item)
+            await self._update_playlist_metadata(item, force_refresh=force_refresh)
         if item.media_type == MediaType.RADIO:
-            await self._update_radio_metadata(item)
+            await self._update_radio_metadata(item, force_refresh=force_refresh)
 
     @api_command("metadata/scan")
     async def metadata_scanner(self) -> None:
@@ -237,39 +255,39 @@ class MetaDataController(CoreController):
             return
         self._scanner_running = True
         try:
-            timestamp = int(time() - 60 * 60 * 24 * 7)
+            timestamp = int(time() - 60 * 60 * 24 * 30)
             query = (
                 f"WHERE json_extract({DB_TABLE_ARTISTS}.metadata,'$.last_refresh') ISNULL "
                 f"OR json_extract({DB_TABLE_ARTISTS}.metadata,'$.last_refresh') < {timestamp}"
             )
             for artist in await self.mass.music.artists.library_items(
-                limit=250, order_by="random", extra_query=query
+                limit=50, order_by="random", extra_query=query
             ):
                 await self._update_artist_metadata(artist)
                 # we really need to throttle this
-                await asyncio.sleep(10)
+                await asyncio.sleep(30)
 
             query = (
                 f"WHERE json_extract({DB_TABLE_ALBUMS}.metadata,'$.last_refresh') ISNULL "
                 f"OR json_extract({DB_TABLE_ALBUMS}.metadata,'$.last_refresh') < {timestamp}"
             )
             for album in await self.mass.music.albums.library_items(
-                limit=250, order_by="random", extra_query=query
+                limit=50, order_by="random", extra_query=query
             ):
                 await self._update_album_metadata(album)
                 # we really need to throttle this
-                await asyncio.sleep(10)
+                await asyncio.sleep(30)
 
             query = (
                 f"WHERE json_extract({DB_TABLE_PLAYLISTS}.metadata,'$.last_refresh') ISNULL "
                 f"OR json_extract({DB_TABLE_PLAYLISTS}.metadata,'$.last_refresh') < {timestamp}"
             )
             for playlist in await self.mass.music.playlists.library_items(
-                limit=250, order_by="random", extra_query=query
+                limit=50, order_by="random", extra_query=query
             ):
                 await self._update_playlist_metadata(playlist)
                 # we really need to throttle this
-                await asyncio.sleep(10)
+                await asyncio.sleep(30)
 
             query = (
                 f"WHERE json_extract({DB_TABLE_TRACKS}.metadata,'$.last_refresh') ISNULL "
@@ -452,9 +470,12 @@ class MetaDataController(CoreController):
         # to not overload the (free) metadata providers with api calls
         # TODO: Utilize a global (cloud) cache for metadata lookups to save on API calls
 
-        if force_refresh or (
-            self._online_slots_available
-            and ((time() - (artist.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
+        if self.config.get_value(CONF_ENABLE_ONLINE_METADATA) and (
+            force_refresh
+            or (
+                self._online_slots_available
+                and ((time() - (artist.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
+            )
         ):
             self._online_slots_available -= 1
             # set timestamp, used to determine when this function was last called
@@ -506,10 +527,13 @@ class MetaDataController(CoreController):
         # NOTE: we only allow this every REFRESH_INTERVAL and a max amount of calls per day
         # to not overload the (free) metadata providers with api calls
         # TODO: Utilize a global (cloud) cache for metadata lookups to save on API calls
-        if force_refresh or (
-            self._online_slots_available
-            and ((time() - (album.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
-            and (album.mbid or album.artists)
+        if self.config.get_value(CONF_ENABLE_ONLINE_METADATA) and (
+            force_refresh
+            or (
+                self._online_slots_available
+                and ((time() - (album.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
+                and (album.mbid or album.artists)
+            )
         ):
             self._online_slots_available -= 1
             # set timestamp, used to determine when this function was last called
@@ -551,10 +575,13 @@ class MetaDataController(CoreController):
         # NOTE: we only allow this every REFRESH_INTERVAL and a max amount of calls per day
         # to not overload the (free) metadata providers with api calls
         # TODO: Utilize a global (cloud) cache for metadata lookups to save on API calls
-        if force_refresh or (
-            self._online_slots_available
-            and ((time() - (track.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
-            and (track.mbid or track.artists or track.album)
+        if self.config.get_value(CONF_ENABLE_ONLINE_METADATA) and (
+            force_refresh
+            or (
+                self._online_slots_available
+                and ((time() - (track.metadata.last_refresh or 0)) > REFRESH_INTERVAL)
+                and (track.mbid or track.artists or track.album)
+            )
         ):
             self._online_slots_available -= 1
             # set timestamp, used to determine when this function was last called
index 069d688229014e6feec256be601680750f97a43a..0dc7864cf6b64343feb4cf0479ae21e4c489b24d 100644 (file)
@@ -942,7 +942,7 @@ class MusicController(CoreController):
             return
 
         # all other versions: reset the database
-        # we only migrate from prtev version to current we do not try to handle
+        # we only migrate from prev version to current, we do not try to handle
         # more complex migrations
         self.logger.warning(
             "Database schema too old - Resetting library/database - "
index ba9afaabb149977fcdeaac05d01b6814a0e0f085..93b120270b9d7fb6ec23789b4ea8d5f3e90f9709 100644 (file)
@@ -387,11 +387,12 @@ def compare_external_ids(
     return None
 
 
-def create_safe_string(input_str: str) -> str:
+def create_safe_string(input_str: str, lowercase: bool = True, replace_space: bool = False) -> str:
     """Return clean lowered string for compare actions."""
-    input_str = input_str.lower().strip()
+    input_str = input_str.lower().strip() if lowercase else input_str.strip()
     unaccented_string = unidecode.unidecode(input_str)
-    return re.sub(r"[^a-zA-Z0-9]", "", unaccented_string)
+    regex = r"[^a-zA-Z0-9]" if replace_space else r"[^a-zA-Z0-9 ]"
+    return re.sub(regex, "", unaccented_string)
 
 
 def loose_compare_strings(base: str, alt: str) -> bool:
index 4ca5046eedd763665aecfd043a9ecce07ce7f930..c66423bd1c8c22e78bb6adad74474388fe50b04a 100644 (file)
@@ -3,12 +3,9 @@
   "domain": "builtin",
   "name": "Music Assistant",
   "description": "Built-in/generic provider that handles generic urls and playlists.",
-  "codeowners": [
-    "@music-assistant"
-  ],
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "https://music-assistant.io/music-providers/builtin/",
   "multi_instance": false,
-  "builtin": true,
-  "hidden": false
+  "builtin": true
 }
index 2016cdadf381e95f803c4995c1f4e01622dc18b7..2f2b22d44362cdaefbbfa60e2d3f55a09685ee13 100644 (file)
@@ -7,7 +7,8 @@ from typing import TYPE_CHECKING
 
 import aiohttp.client_exceptions
 
-from music_assistant.common.models.enums import ExternalID, ProviderFeature
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.enums import ConfigEntryType, ExternalID, ProviderFeature
 from music_assistant.common.models.media_items import ImageType, MediaItemImage, MediaItemMetadata
 from music_assistant.server.controllers.cache import use_cache
 from music_assistant.server.helpers.app_vars import app_var  # pylint: disable=no-name-in-module
@@ -15,11 +16,7 @@ from music_assistant.server.helpers.throttle_retry import Throttler
 from music_assistant.server.models.metadata_provider import MetadataProvider
 
 if TYPE_CHECKING:
-    from music_assistant.common.models.config_entries import (
-        ConfigEntry,
-        ConfigValueType,
-        ProviderConfig,
-    )
+    from music_assistant.common.models.config_entries import ConfigValueType, ProviderConfig
     from music_assistant.common.models.media_items import Album, Artist
     from music_assistant.common.models.provider import ProviderManifest
     from music_assistant.server import MusicAssistant
@@ -30,8 +27,9 @@ SUPPORTED_FEATURES = (
     ProviderFeature.ALBUM_METADATA,
 )
 
-# TODO: add support for personal api keys ?
-
+CONF_ENABLE_ARTIST_IMAGES = "enable_artist_images"
+CONF_ENABLE_ALBUM_IMAGES = "enable_album_images"
+CONF_CLIENT_KEY = "client_key"
 
 IMG_MAPPING = {
     "artistthumb": ImageType.THUMB,
@@ -64,7 +62,29 @@ async def get_config_entries(
     values: the (intermediate) raw values for config entries sent with the action.
     """
     # ruff: noqa: ARG001
-    return ()  # we do not have any config entries (yet)
+    return (
+        ConfigEntry(
+            key=CONF_ENABLE_ARTIST_IMAGES,
+            type=ConfigEntryType.BOOLEAN,
+            label="Enable retrieval of artist images.",
+            default_value=True,
+        ),
+        ConfigEntry(
+            key=CONF_ENABLE_ALBUM_IMAGES,
+            type=ConfigEntryType.BOOLEAN,
+            label="Enable retrieval of album image(s).",
+            default_value=True,
+        ),
+        ConfigEntry(
+            key=CONF_CLIENT_KEY,
+            type=ConfigEntryType.SECURE_STRING,
+            label="VIP Member Personal API Key (optional)",
+            description="Support this metadata provider by becoming a VIP Member, "
+            "resulting in higher rate limits and faster response times among other benefits. "
+            "See https://wiki.fanart.tv/General/personal%20api/ for more information.",
+            required=False,
+        ),
+    )
 
 
 class FanartTvMetadataProvider(MetadataProvider):
@@ -75,7 +95,11 @@ class FanartTvMetadataProvider(MetadataProvider):
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         self.cache = self.mass.cache
-        self.throttler = Throttler(rate_limit=1, period=30)
+        if self.config.get_value(CONF_CLIENT_KEY):
+            # loosen the throttler when a personal client key is used
+            self.throttler = Throttler(rate_limit=1, period=1)
+        else:
+            self.throttler = Throttler(rate_limit=1, period=30)
 
     @property
     def supported_features(self) -> tuple[ProviderFeature, ...]:
@@ -86,6 +110,8 @@ class FanartTvMetadataProvider(MetadataProvider):
         """Retrieve metadata for artist on fanart.tv."""
         if not artist.mbid:
             return None
+        if not self.config.get_value(CONF_ENABLE_ARTIST_IMAGES):
+            return None
         self.logger.debug("Fetching metadata for Artist %s on Fanart.tv", artist.name)
         if data := await self._get_data(f"music/{artist.mbid}"):
             metadata = MediaItemMetadata()
@@ -110,6 +136,8 @@ class FanartTvMetadataProvider(MetadataProvider):
         """Retrieve metadata for album on fanart.tv."""
         if (mbid := album.get_external_id(ExternalID.MB_RELEASEGROUP)) is None:
             return None
+        if not self.config.get_value(CONF_ENABLE_ALBUM_IMAGES):
+            return None
         self.logger.debug("Fetching metadata for Album %s on Fanart.tv", album.name)
         if data := await self._get_data(f"music/albums/{mbid}"):
             if data and data.get("albums"):
@@ -136,10 +164,14 @@ class FanartTvMetadataProvider(MetadataProvider):
     async def _get_data(self, endpoint, **kwargs) -> dict | None:
         """Get data from api."""
         url = f"http://webservice.fanart.tv/v3/{endpoint}"
-        kwargs["api_key"] = app_var(4)
+        headers = {
+            "api-key": app_var(4),
+        }
+        if client_key := self.config.get_value(CONF_CLIENT_KEY):
+            headers["client_key"] = client_key
         async with (
             self.throttler,
-            self.mass.http_session.get(url, params=kwargs, ssl=False) as response,
+            self.mass.http_session.get(url, params=kwargs, headers=headers, ssl=False) as response,
         ):
             try:
                 result = await response.json()
index 083e76069dac8d54c3a47244234caedbc6140e14..a39b25932ee9305482258032504ef1c47c023c8d 100644 (file)
@@ -1,11 +1,9 @@
 {
   "type": "metadata",
   "domain": "fanarttv",
-  "name": "fanart.tv Metadata provider",
+  "name": "fanart.tv",
   "description": "fanart.tv is a community database of artwork for movies, tv series and music.",
-  "codeowners": [
-    "@music-assistant"
-  ],
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "",
   "multi_instance": false,
index 57f40ef04be2735154c5ca23d773caf7a500a8d0..21b111e1b0cddec9bca0d38188dcc7184c2be125 100644 (file)
@@ -49,7 +49,7 @@ from music_assistant.constants import (
     DB_TABLE_TRACK_ARTISTS,
     VARIOUS_ARTISTS_NAME,
 )
-from music_assistant.server.helpers.compare import compare_strings
+from music_assistant.server.helpers.compare import compare_strings, create_safe_string
 from music_assistant.server.helpers.playlists import parse_m3u, parse_pls
 from music_assistant.server.helpers.tags import parse_tags, split_items
 from music_assistant.server.models.music_provider import MusicProvider
@@ -894,10 +894,11 @@ class FileSystemProviderBase(MusicProvider):
                     break
             else:
                 # check if we have an artist folder for this artist at root level
+                safe_artist_name = create_safe_string(name, lowercase=False, replace_space=False)
                 if await self.exists(name):
                     artist_path = name
-                elif await self.exists(name.title()):
-                    artist_path = name.title()
+                elif await self.exists(safe_artist_name):
+                    artist_path = safe_artist_name
 
         if artist_path:  # noqa: SIM108
             # prefer the path as id
index 39e616de6be73987c5be26fb92ed7e76ae95579a..1059df8ebef28280b0964bd5a7a9ac48cf58974c 100644 (file)
@@ -7,6 +7,5 @@
   "requirements": ["python-fullykiosk==0.0.14"],
   "documentation": "https://music-assistant.io/player-support/fully-kiosk/",
   "multi_instance": true,
-  "builtin": false,
-  "load_by_default": false
+  "builtin": false
 }
index 6e25489eec9400f77052472babde299120e37e30..4fee6af8eb2a0b37c6c6d39372740d32e6d68856 100644 (file)
@@ -7,7 +7,6 @@
   "documentation": "",
   "multi_instance": false,
   "builtin": false,
-  "load_by_default": false,
   "icon": "md:webhook",
   "requirements": ["hass-client==1.2.0"]
 }
index 30ab0068dc718c7e01172171a7ab65cc6255f070..3c0fae3e0ad14c052cbf973ce0c5753897f2b2ab 100644 (file)
@@ -3,13 +3,10 @@
   "domain": "hass_players",
   "name": "Home Assistant MediaPlayers",
   "description": "Use (supported) Home Assistant media players as players in Music Assistant.",
-  "codeowners": [
-    "@music-assistant"
-  ],
+  "codeowners": ["@music-assistant"],
   "documentation": "https://music-assistant.io/player-support/ha/",
   "multi_instance": false,
   "builtin": false,
-  "load_by_default": false,
   "icon": "md:webhook",
   "depends_on": "hass",
   "requirements": []
index 5fbdd54dd1cd0e89c256571111df91d9eb179423..bbf49fd1eef38d7f176a7e70a34f024328daabdf 100644 (file)
@@ -1,14 +1,13 @@
 {
   "type": "metadata",
   "domain": "musicbrainz",
-  "name": "MusicBrainz Metadata provider",
-  "description": "MusicBrainz is an open music encyclopedia that collects music metadata and makes it available to the public.",
-  "codeowners": [
-    "@music-assistant"
-  ],
+  "name": "MusicBrainz",
+  "description": "MusicBrainz is an open music encyclopedia that collects music metadata and makes it available to the public. Music Assistant uses MusicBrainz primary to identify (unique) media items and therefore the provider can not be disabled. However note that lookups will only be performed if this info is absent locally.",
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "",
   "multi_instance": false,
   "builtin": true,
+  "allow_disable": false,
   "icon": "mdi-folder-information"
 }
index 47ad234181ccf191d195a72f47e89c4a5cf46aab..96a10ba0bcce301017f11937715175cad419852b 100644 (file)
@@ -3,14 +3,9 @@
   "domain": "slimproto",
   "name": "Slimproto (Squeezebox players)",
   "description": "Support for slimproto based players (e.g. squeezebox, squeezelite).",
-  "codeowners": [
-    "@music-assistant"
-  ],
-  "requirements": [
-    "aioslimproto==3.0.1"
-  ],
+  "codeowners": ["@music-assistant"],
+  "requirements": ["aioslimproto==3.0.1"],
   "documentation": "https://music-assistant.io/player-support/slimproto/",
   "multi_instance": false,
-  "builtin": false,
-  "load_by_default": false
+  "builtin": false
 }
index cd63716cb9302795da296a535b7be8e615c904bc..0f75f6f26a30ac9da0132d7c649072c87dd6b669 100644 (file)
@@ -3,15 +3,9 @@
   "domain": "snapcast",
   "name": "Snapcast",
   "description": "Support for snapcast server and clients.",
-  "codeowners": [
-    "@SantigoSotoC"
-  ],
-  "requirements": [
-    "snapcast==2.3.6",
-    "bidict==0.23.1"
-  ],
+  "codeowners": ["@SantigoSotoC"],
+  "requirements": ["snapcast==2.3.6", "bidict==0.23.1"],
   "documentation": "https://music-assistant.io/player-support/snapcast/",
   "multi_instance": false,
-  "builtin": false,
-  "load_by_default": false
+  "builtin": false
 }
index be7e6ebe7f794b648814dbedd45fd44ba699f794..06d176c814a2affeb7ed53f6819fa91b9b472b1c 100644 (file)
@@ -122,7 +122,7 @@ async def get_config_entries(
         ConfigEntry(
             key=CONF_CLIENT_ID,
             type=ConfigEntryType.SECURE_STRING,
-            label="Client ID",
+            label="Client ID (optional)",
             description="By default, a generic client ID is used which is heavy rate limited. "
             "It is advised that you create your own Spotify Developer account and use "
             "that client ID here to speedup performance.",
index 1f4808081a07afee7575685559442d33321414f4..a8cd64d7acc3a1ded7d5db6296de1657189822de 100644 (file)
@@ -3,12 +3,9 @@
   "domain": "test",
   "name": "Test / demo provider",
   "description": "Test/Demo provider that creates a collection of fake media items.",
-  "codeowners": [
-    "@music-assistant"
-  ],
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "",
   "multi_instance": false,
-  "builtin": false,
-  "hidden": false
+  "builtin": false
 }
index a957f457b8d2b4c00e39d96023d01cd83ca860d3..d02cc63474f7d5884235a0343ef1502985a7aa3d 100644 (file)
@@ -7,7 +7,8 @@ from typing import TYPE_CHECKING, Any, cast
 
 import aiohttp.client_exceptions
 
-from music_assistant.common.models.enums import ExternalID, ProviderFeature
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.enums import ConfigEntryType, ExternalID, ProviderFeature
 from music_assistant.common.models.media_items import (
     Album,
     AlbumType,
@@ -27,11 +28,7 @@ from music_assistant.server.helpers.throttle_retry import Throttler
 from music_assistant.server.models.metadata_provider import MetadataProvider
 
 if TYPE_CHECKING:
-    from music_assistant.common.models.config_entries import (
-        ConfigEntry,
-        ConfigValueType,
-        ProviderConfig,
-    )
+    from music_assistant.common.models.config_entries import ConfigValueType, ProviderConfig
     from music_assistant.common.models.provider import ProviderManifest
     from music_assistant.server import MusicAssistant
     from music_assistant.server.models import ProviderInstanceType
@@ -75,6 +72,11 @@ ALBUMTYPE_MAPPING = {
     "EP": AlbumType.EP,
 }
 
+CONF_ENABLE_IMAGES = "enable_images"
+CONF_ENABLE_ARTIST_METADATA = "enable_artist_metadata"
+CONF_ENABLE_ALBUM_METADATA = "enable_album_metadata"
+CONF_ENABLE_TRACK_METADATA = "enable_track_metadata"
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
@@ -99,7 +101,32 @@ async def get_config_entries(
     values: the (intermediate) raw values for config entries sent with the action.
     """
     # ruff: noqa: ARG001
-    return ()  # we do not have any config entries (yet)
+    return (
+        ConfigEntry(
+            key=CONF_ENABLE_ARTIST_METADATA,
+            type=ConfigEntryType.BOOLEAN,
+            label="Enable retrieval of artist metadata.",
+            default_value=True,
+        ),
+        ConfigEntry(
+            key=CONF_ENABLE_ALBUM_METADATA,
+            type=ConfigEntryType.BOOLEAN,
+            label="Enable retrieval of album metadata.",
+            default_value=True,
+        ),
+        ConfigEntry(
+            key=CONF_ENABLE_TRACK_METADATA,
+            type=ConfigEntryType.BOOLEAN,
+            label="Enable retrieval of track metadata.",
+            default_value=False,
+        ),
+        ConfigEntry(
+            key=CONF_ENABLE_IMAGES,
+            type=ConfigEntryType.BOOLEAN,
+            label="Enable retrieval of artist/album/track images",
+            default_value=True,
+        ),
+    )
 
 
 class AudioDbMetadataProvider(MetadataProvider):
@@ -110,7 +137,7 @@ class AudioDbMetadataProvider(MetadataProvider):
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         self.cache = self.mass.cache
-        self.throttler = Throttler(rate_limit=1, period=30)
+        self.throttler = Throttler(rate_limit=1, period=1)
 
     @property
     def supported_features(self) -> tuple[ProviderFeature, ...]:
@@ -119,6 +146,8 @@ class AudioDbMetadataProvider(MetadataProvider):
 
     async def get_artist_metadata(self, artist: Artist) -> MediaItemMetadata | None:
         """Retrieve metadata for artist on theaudiodb."""
+        if not self.config.get_value(CONF_ENABLE_ARTIST_METADATA):
+            return None
         if not artist.mbid:
             # for 100% accuracy we require the musicbrainz id for all lookups
             return None
@@ -129,6 +158,8 @@ class AudioDbMetadataProvider(MetadataProvider):
 
     async def get_album_metadata(self, album: Album) -> MediaItemMetadata | None:
         """Retrieve metadata for album on theaudiodb."""
+        if not self.config.get_value(CONF_ENABLE_ALBUM_METADATA):
+            return None
         if (mbid := album.get_external_id(ExternalID.MB_RELEASEGROUP)) is None:
             return None
         result = await self._get_data("album-mb.php", i=mbid)
@@ -148,6 +179,8 @@ class AudioDbMetadataProvider(MetadataProvider):
 
     async def get_track_metadata(self, track: Track) -> MediaItemMetadata | None:
         """Retrieve metadata for track on theaudiodb."""
+        if not self.config.get_value(CONF_ENABLE_TRACK_METADATA):
+            return None
         if track.mbid:
             result = await self._get_data("track-mb.php", i=track.mbid)
             if result and result.get("track"):
@@ -200,6 +233,8 @@ class AudioDbMetadataProvider(MetadataProvider):
         else:
             metadata.description = artist_obj.get("strBiographyEN")
         # images
+        if not self.config.get_value(CONF_ENABLE_IMAGES):
+            return metadata
         metadata.images = UniqueList()
         for key, img_type in IMG_MAPPING.items():
             for postfix in ("", "2", "3", "4", "5", "6", "7", "8", "9", "10"):
@@ -246,6 +281,8 @@ class AudioDbMetadataProvider(MetadataProvider):
             metadata.description = album_obj.get("strDescriptionEN")
         metadata.review = album_obj.get("strReview")
         # images
+        if not self.config.get_value(CONF_ENABLE_IMAGES):
+            return metadata
         metadata.images = UniqueList()
         for key, img_type in IMG_MAPPING.items():
             for postfix in ("", "2", "3", "4", "5", "6", "7", "8", "9", "10"):
@@ -280,6 +317,8 @@ class AudioDbMetadataProvider(MetadataProvider):
         else:
             metadata.description = track_obj.get("strDescriptionEN")
         # images
+        if not self.config.get_value(CONF_ENABLE_IMAGES):
+            return metadata
         metadata.images = UniqueList([])
         for key, img_type in IMG_MAPPING.items():
             for postfix in ("", "2", "3", "4", "5", "6", "7", "8", "9", "10"):
index d7133ce77b4527e8d92852bbc47a32aeccc72928..9b2eaecf8f28b7bee4e3fb622576ab346928511c 100644 (file)
@@ -1,11 +1,9 @@
 {
   "type": "metadata",
   "domain": "theaudiodb",
-  "name": "TheAudioDB Metadata provider",
+  "name": "The Audio DB",
   "description": "TheAudioDB is a community Database of audio artwork and metadata with a JSON API.",
-  "codeowners": [
-    "@music-assistant"
-  ],
+  "codeowners": ["@music-assistant"],
   "requirements": [],
   "documentation": "",
   "multi_instance": false,
diff --git a/tests/server/providers/filesystem/__init__.py b/tests/server/providers/filesystem/__init__.py
new file mode 100644 (file)
index 0000000..8803e35
--- /dev/null
@@ -0,0 +1 @@
+"""Tests for Filesystem provider."""
diff --git a/tests/server/providers/filesystem/test_helpers.py b/tests/server/providers/filesystem/test_helpers.py
new file mode 100644 (file)
index 0000000..2da4bea
--- /dev/null
@@ -0,0 +1,33 @@
+"""Tests for utility/helper functions."""
+
+from music_assistant.server.providers.filesystem_local import helpers
+
+# ruff: noqa: S108
+
+
+def test_get_artist_dir() -> None:
+    """Test the extraction of an artist dir."""
+    album_path = "/tmp/Artist/Album"
+    artist_name = "Artist"
+    assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/Artist"
+    album_path = "/tmp/artist/Album"
+    assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/artist"
+    album_path = "/tmp/Album"
+    assert helpers.get_artist_dir(album_path, artist_name) is None
+    album_path = "/tmp/ARTIST!/Album"
+    assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/ARTIST!"
+    album_path = "/tmp/Artist/Album"
+    artist_name = "Artist!"
+    assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/Artist"
+    album_path = "/tmp/REM/Album"
+    artist_name = "R.E.M."
+    assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/REM"
+    album_path = "/tmp/ACDC/Album"
+    artist_name = "AC/DC"
+    assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/ACDC"
+    album_path = "/tmp/Celine Dion/Album"
+    artist_name = "Céline Dion"
+    assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/Celine Dion"
+    album_path = "/tmp/Antonin Dvorak/Album"
+    artist_name = "Antonín Dvořák"
+    assert helpers.get_artist_dir(album_path, artist_name) == "/tmp/Antonin Dvorak"