Fix issues with items become unavailable (#1567)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 15 Aug 2024 09:19:13 +0000 (11:19 +0200)
committerGitHub <noreply@github.com>
Thu, 15 Aug 2024 09:19:13 +0000 (11:19 +0200)
18 files changed:
music_assistant/client/client.py
music_assistant/common/models/media_items.py
music_assistant/server/controllers/media/base.py
music_assistant/server/controllers/metadata.py
music_assistant/server/controllers/music.py
music_assistant/server/models/music_provider.py
music_assistant/server/providers/apple_music/__init__.py
music_assistant/server/providers/builtin/__init__.py
music_assistant/server/providers/deezer/__init__.py
music_assistant/server/providers/fanarttv/__init__.py
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/radiobrowser/__init__.py
music_assistant/server/providers/soundcloud/__init__.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/theaudiodb/__init__.py
music_assistant/server/providers/tidal/__init__.py
music_assistant/server/providers/tunein/__init__.py
music_assistant/server/providers/ytmusic/__init__.py

index cc0ae4ce6a4e6d67d9cf7f11eb43e0fc05037944..48d6cdbaf7af5a87758c0dee2fa124b058690424 100644 (file)
@@ -9,11 +9,7 @@ import uuid
 from collections.abc import Callable, Coroutine
 from typing import TYPE_CHECKING, Any
 
-from music_assistant.client.exceptions import (
-    ConnectionClosed,
-    InvalidServerVersion,
-    InvalidState,
-)
+from music_assistant.client.exceptions import ConnectionClosed, InvalidServerVersion, InvalidState
 from music_assistant.common.models.api import (
     CommandMessage,
     ErrorResultMessage,
@@ -123,6 +119,8 @@ class MusicAssistantClient:
                 return None
             provider_instance_or_domain = prov.domain
         # fallback to match on domain
+        # note that this can be tricky if the provider has multiple instances
+        # and has unique data (e.g. filesystem)
         for prov in self._providers.values():
             if prov.domain != provider_instance_or_domain:
                 continue
index deba07898940525819890a96f2755ce4bb1589cd..e8742bda423f55acf5cb8c2e87cdec0ab7db873e 100644 (file)
@@ -121,19 +121,6 @@ class ProviderMapping(DataClassDictMixin):
             quality += 1
         return quality
 
-    def __post_serialize__(self, d: dict[Any, Any]) -> dict[Any, Any]:
-        """Execute action(s) on serialization."""
-        # prevent sending back unavailable items in the api if a provider has been disabled.
-        # by overriding the available flag here.
-        if not (available_providers := get_global_cache_value("unique_providers")):
-            # this is probably the client
-            return d
-        if TYPE_CHECKING:
-            available_providers = cast(set[str], available_providers)
-        if not available_providers.intersection({d["provider_domain"], d["provider_instance"]}):
-            d["available"] = False
-        return d
-
     def __hash__(self) -> int:
         """Return custom hash."""
         return hash((self.provider_instance, self.item_id))
@@ -169,7 +156,7 @@ class MediaItemImage(DataClassDictMixin):
 
     type: ImageType
     path: str
-    provider: str
+    provider: str  # provider lookup key (only use instance id for fileproviders)
     remotely_accessible: bool = False  # url that is accessible from anywhere
 
     def __hash__(self) -> int:
@@ -182,16 +169,6 @@ class MediaItemImage(DataClassDictMixin):
             return False
         return self.__hash__() == other.__hash__()
 
-    @classmethod
-    def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]:
-        """Handle actions before deserialization."""
-        # migrate from url provider --> builtin
-        # TODO: remove this after 2.0 is launched
-        if d["provider"] == "url":
-            d["provider"] = "builtin"
-            d["remotely_accessible"] = True
-        return d
-
 
 @dataclass(frozen=True, kw_only=True)
 class MediaItemChapter(DataClassDictMixin):
@@ -362,7 +339,15 @@ class MediaItem(_MediaItemBase):
     @property
     def available(self) -> bool:
         """Return (calculated) availability."""
-        return any(x.available for x in self.provider_mappings)
+        if not (available_providers := get_global_cache_value("unique_providers")):
+            # this is probably the client
+            return any(x.available for x in self.provider_mappings)
+        if TYPE_CHECKING:
+            available_providers = cast(set[str], available_providers)
+        for x in self.provider_mappings:
+            if available_providers.intersection({x.provider_domain, x.provider_instance}):
+                return True
+        return False
 
     @property
     def image(self) -> MediaItemImage | None:
index be8876dbba5b2cf9bd60fad4262ff61bf8ee02dd..3ddb1dc65019ce3812e16ebb0438bd3506031b01 100644 (file)
@@ -113,8 +113,14 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta):
         )
         return library_item
 
-    async def _get_library_item_by_match(self, item: Track) -> int | None:
-        if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider):
+    async def _get_library_item_by_match(self, item: Track | ItemMapping) -> int | None:
+        if item.provider == "library":
+            return int(item.item_id)
+        # search by provider mappings
+        if isinstance(item, ItemMapping):
+            if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider):
+                return cur_item.item_id
+        elif cur_item := await self.get_library_item_by_prov_mappings(item.provider_mappings):
             return cur_item.item_id
         if cur_item := await self.get_library_item_by_external_ids(item.external_ids):
             # existing item match by external id
index d5fc57b0bd2f21a334a8b7f200766c67acdf426c..eb25ad337b1688a33baf978d499d631559e4f7cc 100644 (file)
@@ -121,7 +121,7 @@ class MetaDataController(CoreController):
         )
         self.manifest.icon = "book-information-variant"
         self._reset_online_slots()
-        self._scanner_running: bool = False
+        self._scanner_task: asyncio.Task | None = None
 
     async def get_config_entries(
         self,
@@ -175,6 +175,7 @@ class MetaDataController(CoreController):
 
     async def close(self) -> None:
         """Handle logic on server stop."""
+        self.stop_metadata_scanner()
         self.mass.streams.unregister_dynamic_route("/imageproxy")
 
     @property
@@ -249,61 +250,24 @@ class MetaDataController(CoreController):
         if item.media_type == MediaType.RADIO:
             await self._update_radio_metadata(item, force_refresh=force_refresh)
 
-    @api_command("metadata/scan")
-    async def metadata_scanner(self) -> None:
-        """Scanner for (missing) metadata."""
-        if self._scanner_running:
+    @api_command("metadata/start_scan")
+    def start_metadata_scanner(self) -> None:
+        """
+        Start scanner for (missing) metadata.
+
+        Usually this is triggered by the music controller after finishing a library sync.
+        """
+        if self._scanner_task and not self._scanner_task.done():
             # already running
             return
-        self._scanner_running = True
-        try:
-            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=50, order_by="random", extra_query=query
-            ):
-                await self._update_artist_metadata(artist)
-                # we really need to throttle this
-                await asyncio.sleep(30)
+        self._scanner_task = self.mass.create_task(self._metadata_scanner())
 
-            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=50, order_by="random", extra_query=query
-            ):
-                await self._update_album_metadata(album)
-                # we really need to throttle this
-                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=50, order_by="random", extra_query=query
-            ):
-                await self._update_playlist_metadata(playlist)
-                # we really need to throttle this
-                await asyncio.sleep(30)
-
-            query = (
-                f"WHERE json_extract({DB_TABLE_TRACKS}.metadata,'$.last_refresh') ISNULL "
-                f"OR json_extract({DB_TABLE_TRACKS}.metadata,'$.last_refresh') < {timestamp}"
-            )
-            for track in await self.mass.music.tracks.library_items(
-                limit=50, order_by="random", extra_query=query
-            ):
-                await self._update_track_metadata(track)
-                # we really need to throttle this
-                await asyncio.sleep(30)
-
-        finally:
-            self._scanner_running = False
+    @api_command("metadata/stop_scan")
+    def stop_metadata_scanner(self) -> None:
+        """Stop scanner for (missing) metadata."""
+        if self._scanner_task and not self._scanner_task.done():
+            self._scanner_task.cancel()
+            self._scanner_task = None
 
     async def get_image_data_for_item(
         self,
@@ -712,6 +676,53 @@ class MetaDataController(CoreController):
         )
         return None
 
+    async def _metadata_scanner(self) -> None:
+        """Scanner for (missing) metadata."""
+        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=25, order_by="random", extra_query=query
+        ):
+            await self._update_artist_metadata(artist)
+            # we really need to throttle this
+            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=25, order_by="random", extra_query=query
+        ):
+            await self._update_album_metadata(album)
+            # we really need to throttle this
+            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=25, order_by="random", extra_query=query
+        ):
+            await self._update_playlist_metadata(playlist)
+            # we really need to throttle this
+            await asyncio.sleep(30)
+
+        query = (
+            f"WHERE json_extract({DB_TABLE_TRACKS}.metadata,'$.last_refresh') ISNULL "
+            f"OR json_extract({DB_TABLE_TRACKS}.metadata,'$.last_refresh') < {timestamp}"
+        )
+        for track in await self.mass.music.tracks.library_items(
+            limit=25, order_by="random", extra_query=query
+        ):
+            await self._update_track_metadata(track)
+            # we really need to throttle this
+            await asyncio.sleep(30)
+
     def _reset_online_slots(self) -> None:
         self._online_slots_available = MAX_ONLINE_CALLS_PER_DAY
         # reschedule self in 24 hours
index 88c924a67e6751f5994f2d61ebf7c2109752012f..d50c0aa314960056be39b5648a34181db772ffb0 100644 (file)
@@ -62,11 +62,12 @@ if TYPE_CHECKING:
     from music_assistant.common.models.config_entries import CoreConfig
     from music_assistant.server.models.music_provider import MusicProvider
 
+CONF_RESET_DB = "reset_db"
 DEFAULT_SYNC_INTERVAL = 3 * 60  # default sync interval in minutes
 CONF_SYNC_INTERVAL = "sync_interval"
 CONF_DELETED_PROVIDERS = "deleted_providers"
 CONF_ADD_LIBRARY_ON_PLAY = "add_library_on_play"
-DB_SCHEMA_VERSION: Final[int] = 5
+DB_SCHEMA_VERSION: Final[int] = 6
 
 
 class MusicController(CoreController):
@@ -100,7 +101,7 @@ class MusicController(CoreController):
         values: dict[str, ConfigValueType] | None = None,
     ) -> tuple[ConfigEntry, ...]:
         """Return all Config Entries for this core module (if any)."""
-        return (
+        entries = (
             ConfigEntry(
                 key=CONF_SYNC_INTERVAL,
                 type=ConfigEntryType.INTEGER,
@@ -118,7 +119,29 @@ class MusicController(CoreController):
                 description="Automatically add a track or radio station to "
                 "the library when played (if its not already in the library).",
             ),
+            ConfigEntry(
+                key=CONF_RESET_DB,
+                type=ConfigEntryType.ACTION,
+                label="Reset library database",
+                description="This will issue a full reset of the library "
+                "database and trigger a full sync. Only use this option as a last resort "
+                "if you are seeing issues with the library database.",
+                category="advanced",
+            ),
         )
+        if action == CONF_RESET_DB:
+            await self._reset_database()
+            await self.mass.cache.clear()
+            self.start_sync()
+            entries = (
+                *entries,
+                ConfigEntry(
+                    key=CONF_RESET_DB,
+                    type=ConfigEntryType.LABEL,
+                    label="The database has been reset.",
+                ),
+            )
+        return entries
 
     async def setup(self, config: CoreConfig) -> None:
         """Async initialize of module."""
@@ -539,22 +562,24 @@ class MusicController(CoreController):
 
         media_type = media_item.media_type
         ctrl = self.get_controller(media_type)
-        is_library_item = media_item.provider == "library"
+        library_id = media_item.item_id if media_item.provider == "library" else None
 
-        available_providers = get_global_cache_value("provider_instance_ids")
+        available_providers = get_global_cache_value("available_providers")
         if TYPE_CHECKING:
             available_providers = cast(set[str], available_providers)
 
         # fetch the first (available) provider item
         for prov_mapping in media_item.provider_mappings:
-            provider = prov_mapping.provider_instance
-            if provider not in available_providers:
-                continue
-            item_id = prov_mapping.item_id
-            if prov_mapping.available:
-                break
+            if self.mass.get_provider(prov_mapping.provider_instance):
+                with suppress(MediaNotFoundError):
+                    media_item = await ctrl.get_provider_item(
+                        prov_mapping.item_id, prov_mapping.provider_instance, force_refresh=True
+                    )
+                    provider = media_item.provider
+                    item_id = media_item.item_id
+                    break
         else:
-            # try to find a substitute
+            # try to find a substitute using search
             searchresult = await self.search(media_item.name, [media_item.media_type], 20)
             if media_item.media_type == MediaType.ARTIST:
                 result = searchresult.artists
@@ -567,6 +592,8 @@ class MusicController(CoreController):
             else:
                 result = searchresult.radio
             for item in result:
+                if item == media_item or item.provider == "library":
+                    continue
                 if item.available:
                     provider = item.provider
                     item_id = item.item_id
@@ -577,8 +604,8 @@ class MusicController(CoreController):
         # fetch full (provider) item
         media_item = await ctrl.get_provider_item(item_id, provider, force_refresh=True)
         # update library item if needed (including refresh of the metadata etc.)
-        if is_library_item:
-            library_item = await ctrl.add_item_to_library(media_item, overwrite_existing=True)
+        if library_id is not None:
+            library_item = await ctrl.update_item_in_library(library_id, media_item, overwrite=True)
             await self.mass.metadata.update_metadata(library_item, force_refresh=True)
             return library_item
 
@@ -773,7 +800,7 @@ class MusicController(CoreController):
             # schedule db cleanup + metadata scan after sync
             if not self.in_progress_syncs:
                 self.mass.create_task(self._cleanup_database())
-                self.mass.create_task(self.mass.metadata.metadata_scanner())
+                self.mass.metadata.start_metadata_scanner()
 
         task.add_done_callback(on_sync_task_done)
 
@@ -978,7 +1005,7 @@ class MusicController(CoreController):
             await self.__create_database_tables()
             return
 
-        if prev_version < 3:
+        if prev_version <= 2:
             # convert musicbrainz external id's
             await self.database.execute(
                 f"UPDATE {DB_TABLE_ARTISTS} SET external_ids = "
@@ -994,7 +1021,7 @@ class MusicController(CoreController):
                 "replace(external_ids, 'musicbrainz', 'musicbrainz_recordingid')"
             )
 
-        if prev_version < 4:
+        if prev_version <= 3:
             # remove all additional track provider mappings to cleanup the mess caused
             # by a bug that mapped the wrong track artists.
             async for track in self.tracks.iter_library_items():
@@ -1027,7 +1054,7 @@ class MusicController(CoreController):
                     )
                     await self.tracks.remove_item_from_library(track.item_id)
 
-        if prev_version < 5:
+        if prev_version <= 4:
             # remove corrupted provider mappings
             for ctrl in (self.artists, self.albums, self.tracks, self.playlists, self.radio):
                 query = (
@@ -1040,9 +1067,48 @@ class MusicController(CoreController):
                     }
                     await ctrl.update_item_in_library(item.item_id, item, True)
 
+        if prev_version <= 5:
+            # mark all provider mappings as available to recover from the bug
+            # that caused some items to be marked as unavailable
+            await self.database.execute(f"UPDATE {DB_TABLE_PROVIDER_MAPPINGS} SET available = 1")
+            for ctrl in (self.artists, self.albums, self.tracks, self.playlists, self.radio):
+                await self.database.execute(
+                    f"UPDATE {ctrl.db_table} SET provider_mappings = "
+                    "replace (provider_mappings, '\"available\":false', '\"available\":true')"
+                )
+
+        if prev_version <= 5:
+            # migrate images to lookup key
+            unique_provs = ("filesystem", "jellyfin", "plex", "opensubsonic")
+            for ctrl in (self.artists, self.albums, self.tracks, self.playlists, self.radio):
+                async for item in ctrl.iter_library_items():
+                    if not item.metadata or not item.metadata.images:
+                        continue
+                    changes = False
+                    for item in item.metadata.images:  # noqa: PLW2901, B020
+                        if "--" not in item.provider:
+                            continue
+                        if item.provider.startswith(unique_provs):
+                            continue
+                        item.provider = item.provider.split("--")[0]
+                        changes = True
+                    if changes:
+                        await ctrl.update_item_in_library(item.item_id, item, True)
+
         # save changes
         await self.database.commit()
 
+        # always clear the cache after a db migration
+        await self.mass.cache.clear()
+
+    async def _reset_database(self) -> None:
+        """Reset the database."""
+        self.mass.metadata.stop_metadata_scanner()
+        await self.close()
+        db_path = os.path.join(self.mass.storage_path, "library.db")
+        await asyncio.to_thread(os.remove, db_path)
+        await self._setup_database()
+
     async def __create_database_tables(self) -> None:
         """Create database tables."""
         await self.database.execute(
index 8c42d9c95c3081ce582aa949f8e914e25472672c..ff28640f4ec3449a9cc33c0aca7cb6e28e338448 100644 (file)
@@ -414,6 +414,11 @@ class MusicProvider(Provider):
                         library_item = await controller.update_item_in_library(
                             library_item.item_id, prov_item
                         )
+                    elif library_item.available != prov_item.available:
+                        # existing item availability changed
+                        library_item = await controller.update_item_in_library(
+                            library_item.item_id, prov_item
+                        )
                     cur_db_ids.add(library_item.item_id)
                     await asyncio.sleep(0)  # yield to eventloop
                 except MusicAssistantError as err:
index c4e07526890befb8db1f6bd13d1cc8142f5c043f..4481ba6d10bd8175795038ecc1fec60dc0847c11 100644 (file)
@@ -410,7 +410,7 @@ class AppleMusicProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=artwork["url"].format(w=artwork["width"], h=artwork["height"]),
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -477,7 +477,7 @@ class AppleMusicProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=artwork["url"].format(w=artwork["width"], h=artwork["height"]),
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -557,7 +557,7 @@ class AppleMusicProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=artwork["url"].format(w=artwork["width"], h=artwork["height"]),
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -595,7 +595,7 @@ class AppleMusicProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=url,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
index 8ab9368c37734fb63766b727ec2d56fb091fe85d..38b0c2dea9b7b2a83398c5a07de9b54944ecdead 100644 (file)
@@ -173,7 +173,7 @@ class BuiltinProvider(MusicProvider):
                         MediaItemImage(
                             type=ImageType.THUMB,
                             path=image_url,
-                            provider=self.instance_id,
+                            provider=self.domain,
                             remotely_accessible=image_url.startswith("http"),
                         )
                     ]
@@ -194,7 +194,7 @@ class BuiltinProvider(MusicProvider):
                         MediaItemImage(
                             type=ImageType.THUMB,
                             path=image_url,
-                            provider=self.instance_id,
+                            provider=self.domain,
                             remotely_accessible=image_url.startswith("http"),
                         )
                     ]
@@ -269,7 +269,7 @@ class BuiltinProvider(MusicProvider):
                     MediaItemImage(
                         type=ImageType.THUMB,
                         path=image_url,
-                        provider=self.instance_id,
+                        provider=self.domain,
                         remotely_accessible=image_url.startswith("http"),
                     )
                 ]
@@ -481,7 +481,7 @@ class BuiltinProvider(MusicProvider):
                     MediaItemImage(
                         type=ImageType.THUMB,
                         path=url,
-                        provider=self.instance_id,
+                        provider=self.domain,
                         remotely_accessible=False,
                     )
                 ]
index 530be676835e79dbe57a853bef0bb4b36149f913..7423ed00ad82ec000b0ec529e9adfb538f790a4b 100644 (file)
@@ -520,7 +520,7 @@ class DeezerProvider(MusicProvider):  # pylint: disable=W0223
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=track.album.cover_big,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -534,7 +534,7 @@ class DeezerProvider(MusicProvider):  # pylint: disable=W0223
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=album.cover_big,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ],
@@ -547,7 +547,7 @@ class DeezerProvider(MusicProvider):  # pylint: disable=W0223
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=artist.picture_big,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ],
@@ -620,7 +620,7 @@ class DeezerProvider(MusicProvider):  # pylint: disable=W0223
                     MediaItemImage(
                         type=ImageType.THUMB,
                         path=playlist.picture_big,
-                        provider=self.instance_id,
+                        provider=self.lookup_key,
                         remotely_accessible=True,
                     )
                 ],
index 2f2b22d44362cdaefbbfa60e2d3f55a09685ee13..100c0c9feccf37f0c0c354b6e48a444fb8db2388 100644 (file)
@@ -125,7 +125,7 @@ class FanartTvMetadataProvider(MetadataProvider):
                         MediaItemImage(
                             type=img_type,
                             path=item["url"],
-                            provider=self.instance_id,
+                            provider=self.domain,
                             remotely_accessible=True,
                         )
                     )
@@ -153,7 +153,7 @@ class FanartTvMetadataProvider(MetadataProvider):
                             MediaItemImage(
                                 type=img_type,
                                 path=item["url"],
-                                provider=self.instance_id,
+                                provider=self.domain,
                                 remotely_accessible=True,
                             )
                         )
index 5d2459760fc77670e3b9b01d2a54f95ee85c2c45..10f5cfaf6f9d230577715114905ef4bfa2fd87a4 100644 (file)
@@ -508,7 +508,7 @@ class QobuzProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=img,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -565,7 +565,7 @@ class QobuzProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=img,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -660,7 +660,7 @@ class QobuzProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=img,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -692,7 +692,7 @@ class QobuzProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=img,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
index d873407819eae16cdb83e030bcabcaa026d24c8d..80bdae87f0bf473358ed63980bf1c4195916ece1 100644 (file)
@@ -2,12 +2,18 @@
 
 from __future__ import annotations
 
-from collections.abc import Sequence
-from typing import TYPE_CHECKING
+from collections.abc import AsyncGenerator, Sequence
+from typing import TYPE_CHECKING, cast
 
 from radios import FilterBy, Order, RadioBrowser, RadioBrowserError, Station
 
-from music_assistant.common.models.enums import LinkType, ProviderFeature, StreamType
+from music_assistant.common.models.config_entries import ConfigEntry
+from music_assistant.common.models.enums import (
+    ConfigEntryType,
+    LinkType,
+    ProviderFeature,
+    StreamType,
+)
 from music_assistant.common.models.errors import MediaNotFoundError
 from music_assistant.common.models.media_items import (
     AudioFormat,
@@ -27,18 +33,24 @@ from music_assistant.common.models.streamdetails import StreamDetails
 from music_assistant.server.controllers.cache import use_cache
 from music_assistant.server.models.music_provider import MusicProvider
 
-SUPPORTED_FEATURES = (ProviderFeature.SEARCH, ProviderFeature.BROWSE)
+SUPPORTED_FEATURES = (
+    ProviderFeature.SEARCH,
+    ProviderFeature.BROWSE,
+    # RadioBrowser doesn't support a library feature at all
+    # but MA users like to favorite their radio stations and
+    # have that included in backups so we store it in the config.
+    ProviderFeature.LIBRARY_RADIOS,
+    ProviderFeature.LIBRARY_RADIOS_EDIT,
+)
 
 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
 
+CONF_STORED_RADIOS = "stored_radios"
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
@@ -63,7 +75,20 @@ async def get_config_entries(
     values: the (intermediate) raw values for config entries sent with the action.
     """
     # ruff: noqa: ARG001 D205
-    return ()  # we do not have any config entries (yet)
+    return (
+        ConfigEntry(
+            # RadioBrowser doesn't support a library feature at all
+            # but MA users like to favorite their radio stations and
+            # have that included in backups so we store it in the config.
+            key=CONF_STORED_RADIOS,
+            type=ConfigEntryType.STRING,
+            label=CONF_STORED_RADIOS,
+            default_value=[],
+            required=False,
+            multi_value=True,
+            hidden=True,
+        ),
+    )
 
 
 class RadioBrowserProvider(MusicProvider):
@@ -85,6 +110,15 @@ class RadioBrowserProvider(MusicProvider):
         except RadioBrowserError as err:
             self.logger.exception("%s", err)
 
+        # copy the radiobrowser items that were added to the library
+        # TODO: remove this logic after version 2.3.0 or later
+        if not self.config.get_value(CONF_STORED_RADIOS) and self.mass.music.database:
+            async for db_row in self.mass.music.database.iter_items(
+                "provider_mappings",
+                {"media_type": "radio", "provider_domain": "radiobrowser"},
+            ):
+                await self.library_add(await self.get_radio(db_row["provider_item_id"]))
+
     async def search(
         self, search_query: str, media_types: list[MediaType], limit: int = 10
     ) -> SearchResults:
@@ -171,7 +205,7 @@ class RadioBrowserProvider(MusicProvider):
                         MediaItemImage(
                             type=ImageType.THUMB,
                             path=country.favicon,
-                            provider=self.instance_id,
+                            provider=self.lookup_key,
                             remotely_accessible=True,
                         )
                     ]
@@ -186,6 +220,42 @@ class RadioBrowserProvider(MusicProvider):
             return await self.get_by_country(subsubpath)
         return []
 
+    async def get_library_radios(self) -> AsyncGenerator[Radio, None]:
+        """Retrieve library/subscribed radio stations from the provider."""
+        stored_radios = self.config.get_value(CONF_STORED_RADIOS)
+        if TYPE_CHECKING:
+            stored_radios = cast(list[str], stored_radios)
+        for item in stored_radios:
+            yield await self.get_radio(item)
+
+    async def library_add(self, item: MediaItemType) -> bool:
+        """Add item to provider's library. Return true on success."""
+        stored_radios = self.config.get_value(CONF_STORED_RADIOS)
+        if TYPE_CHECKING:
+            stored_radios = cast(list[str], stored_radios)
+        if item.item_id in stored_radios:
+            return False
+        self.logger.debug("Adding radio %s to stored radios", item.item_id)
+        stored_radios = [*stored_radios, item.item_id]
+        await self.mass.config.set_provider_config_value(
+            self.instance_id, CONF_STORED_RADIOS, stored_radios
+        )
+        return True
+
+    async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
+        """Remove item from provider's library. Return true on success."""
+        stored_radios = self.config.get_value(CONF_STORED_RADIOS)
+        if TYPE_CHECKING:
+            stored_radios = cast(list[str], stored_radios)
+        if prov_item_id not in stored_radios:
+            return False
+        self.logger.debug("Removing radio %s from stored radios", prov_item_id)
+        stored_radios = [x for x in stored_radios if x != prov_item_id]
+        await self.mass.config.set_provider_config_value(
+            self.instance_id, CONF_STORED_RADIOS, stored_radios
+        )
+        return True
+
     @use_cache(3600 * 24)
     async def get_tag_names(self) -> Sequence[str]:
         """Get a list of tag names."""
@@ -282,7 +352,7 @@ class RadioBrowserProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=radio_obj.favicon,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
index d988e160a54d67e9c12f95e528ad4a4dc2178da4..4f6585afdb0f3f456497a5dcc7fc81e183b9084a 100644 (file)
@@ -353,7 +353,7 @@ class SoundcloudMusicProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=img_url,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -381,7 +381,7 @@ class SoundcloudMusicProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=self._transform_artwork_url(playlist_obj["artwork_url"]),
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -424,7 +424,7 @@ class SoundcloudMusicProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=self._transform_artwork_url(track_obj["artwork_url"]),
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
index f5bd1fe5ca139f104dc1d9e31c219ff7401202da..dad6476391280d2941abbb72e18b4064dfc3d703 100644 (file)
@@ -342,7 +342,7 @@ class SpotifyProvider(MusicProvider):
             MediaItemImage(
                 type=ImageType.THUMB,
                 path="https://misc.scdn.co/liked-songs/liked-songs-64.png",
-                provider=self.domain,
+                provider=self.lookup_key,
                 remotely_accessible=True,
             )
         ]
@@ -549,7 +549,7 @@ class SpotifyProvider(MusicProvider):
                         MediaItemImage(
                             type=ImageType.THUMB,
                             path=img_url,
-                            provider=self.instance_id,
+                            provider=self.lookup_key,
                             remotely_accessible=True,
                         )
                     ]
@@ -594,7 +594,7 @@ class SpotifyProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=album_obj["images"][0]["url"],
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -659,7 +659,7 @@ class SpotifyProvider(MusicProvider):
                     MediaItemImage(
                         type=ImageType.THUMB,
                         path=track_obj["album"]["images"][0]["url"],
-                        provider=self.instance_id,
+                        provider=self.lookup_key,
                         remotely_accessible=True,
                     )
                 ]
@@ -695,7 +695,7 @@ class SpotifyProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=playlist_obj["images"][0]["url"],
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
index d02cc63474f7d5884235a0343ef1502985a7aa3d..578069d1d84c1c4e9f15de51d6f74d568ab9b62a 100644 (file)
@@ -243,7 +243,7 @@ class AudioDbMetadataProvider(MetadataProvider):
                         MediaItemImage(
                             type=img_type,
                             path=img,
-                            provider=self.instance_id,
+                            provider=self.lookup_key,
                             remotely_accessible=True,
                         )
                     )
@@ -291,7 +291,7 @@ class AudioDbMetadataProvider(MetadataProvider):
                         MediaItemImage(
                             type=img_type,
                             path=img,
-                            provider=self.instance_id,
+                            provider=self.lookup_key,
                             remotely_accessible=True,
                         )
                     )
@@ -327,7 +327,7 @@ class AudioDbMetadataProvider(MetadataProvider):
                         MediaItemImage(
                             type=img_type,
                             path=img,
-                            provider=self.instance_id,
+                            provider=self.lookup_key,
                             remotely_accessible=True,
                         )
                     )
index 63a18446885223126eaadde958493d90942bf591..ab49f7eca9bda487be6caab8c2e91dc3ff8a8876 100644 (file)
@@ -711,7 +711,7 @@ class TidalProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=image_url,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -770,7 +770,7 @@ class TidalProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=image_url,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
@@ -831,7 +831,7 @@ class TidalProvider(MusicProvider):
                     MediaItemImage(
                         type=ImageType.THUMB,
                         path=image_url,
-                        provider=self.instance_id,
+                        provider=self.lookup_key,
                         remotely_accessible=True,
                     )
                 ]
@@ -868,7 +868,7 @@ class TidalProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=image_url,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
index 084c111fe2ea7934d23fc77c13f299c9c9e2b825..555fa899318caf1df730ad9c640ab89f8bc8e339 100644 (file)
@@ -219,7 +219,7 @@ class TuneInProvider(MusicProvider):
                 MediaItemImage(
                     type=ImageType.THUMB,
                     path=img,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             ]
index 15c1c025f703a4646d743f887ae19ec132b2e32f..1cadd66c6e739beb6398e14befa9ef46200f86f4 100644 (file)
@@ -849,7 +849,7 @@ class YoutubeMusicProvider(MusicProvider):
                 MediaItemImage(
                     type=image_type,
                     path=url,
-                    provider=self.instance_id,
+                    provider=self.lookup_key,
                     remotely_accessible=True,
                 )
             )