From: Marcel van der Veldt Date: Thu, 15 Aug 2024 09:19:13 +0000 (+0200) Subject: Fix issues with items become unavailable (#1567) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=3d6f7abbeebeb47a30864f760d01674b20b148f4;p=music-assistant-server.git Fix issues with items become unavailable (#1567) --- diff --git a/music_assistant/client/client.py b/music_assistant/client/client.py index cc0ae4ce..48d6cdba 100644 --- a/music_assistant/client/client.py +++ b/music_assistant/client/client.py @@ -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 diff --git a/music_assistant/common/models/media_items.py b/music_assistant/common/models/media_items.py index deba0789..e8742bda 100644 --- a/music_assistant/common/models/media_items.py +++ b/music_assistant/common/models/media_items.py @@ -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: diff --git a/music_assistant/server/controllers/media/base.py b/music_assistant/server/controllers/media/base.py index be8876db..3ddb1dc6 100644 --- a/music_assistant/server/controllers/media/base.py +++ b/music_assistant/server/controllers/media/base.py @@ -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 diff --git a/music_assistant/server/controllers/metadata.py b/music_assistant/server/controllers/metadata.py index d5fc57b0..eb25ad33 100644 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -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 diff --git a/music_assistant/server/controllers/music.py b/music_assistant/server/controllers/music.py index 88c924a6..d50c0aa3 100644 --- a/music_assistant/server/controllers/music.py +++ b/music_assistant/server/controllers/music.py @@ -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( diff --git a/music_assistant/server/models/music_provider.py b/music_assistant/server/models/music_provider.py index 8c42d9c9..ff28640f 100644 --- a/music_assistant/server/models/music_provider.py +++ b/music_assistant/server/models/music_provider.py @@ -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: diff --git a/music_assistant/server/providers/apple_music/__init__.py b/music_assistant/server/providers/apple_music/__init__.py index c4e07526..4481ba6d 100644 --- a/music_assistant/server/providers/apple_music/__init__.py +++ b/music_assistant/server/providers/apple_music/__init__.py @@ -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, ) ] diff --git a/music_assistant/server/providers/builtin/__init__.py b/music_assistant/server/providers/builtin/__init__.py index 8ab9368c..38b0c2de 100644 --- a/music_assistant/server/providers/builtin/__init__.py +++ b/music_assistant/server/providers/builtin/__init__.py @@ -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, ) ] diff --git a/music_assistant/server/providers/deezer/__init__.py b/music_assistant/server/providers/deezer/__init__.py index 530be676..7423ed00 100644 --- a/music_assistant/server/providers/deezer/__init__.py +++ b/music_assistant/server/providers/deezer/__init__.py @@ -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, ) ], diff --git a/music_assistant/server/providers/fanarttv/__init__.py b/music_assistant/server/providers/fanarttv/__init__.py index 2f2b22d4..100c0c9f 100644 --- a/music_assistant/server/providers/fanarttv/__init__.py +++ b/music_assistant/server/providers/fanarttv/__init__.py @@ -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, ) ) diff --git a/music_assistant/server/providers/qobuz/__init__.py b/music_assistant/server/providers/qobuz/__init__.py index 5d245976..10f5cfaf 100644 --- a/music_assistant/server/providers/qobuz/__init__.py +++ b/music_assistant/server/providers/qobuz/__init__.py @@ -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, ) ] diff --git a/music_assistant/server/providers/radiobrowser/__init__.py b/music_assistant/server/providers/radiobrowser/__init__.py index d8734078..80bdae87 100644 --- a/music_assistant/server/providers/radiobrowser/__init__.py +++ b/music_assistant/server/providers/radiobrowser/__init__.py @@ -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, ) ] diff --git a/music_assistant/server/providers/soundcloud/__init__.py b/music_assistant/server/providers/soundcloud/__init__.py index d988e160..4f6585af 100644 --- a/music_assistant/server/providers/soundcloud/__init__.py +++ b/music_assistant/server/providers/soundcloud/__init__.py @@ -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, ) ] diff --git a/music_assistant/server/providers/spotify/__init__.py b/music_assistant/server/providers/spotify/__init__.py index f5bd1fe5..dad64763 100644 --- a/music_assistant/server/providers/spotify/__init__.py +++ b/music_assistant/server/providers/spotify/__init__.py @@ -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, ) ] diff --git a/music_assistant/server/providers/theaudiodb/__init__.py b/music_assistant/server/providers/theaudiodb/__init__.py index d02cc634..578069d1 100644 --- a/music_assistant/server/providers/theaudiodb/__init__.py +++ b/music_assistant/server/providers/theaudiodb/__init__.py @@ -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, ) ) diff --git a/music_assistant/server/providers/tidal/__init__.py b/music_assistant/server/providers/tidal/__init__.py index 63a18446..ab49f7ec 100644 --- a/music_assistant/server/providers/tidal/__init__.py +++ b/music_assistant/server/providers/tidal/__init__.py @@ -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, ) ] diff --git a/music_assistant/server/providers/tunein/__init__.py b/music_assistant/server/providers/tunein/__init__.py index 084c111f..555fa899 100644 --- a/music_assistant/server/providers/tunein/__init__.py +++ b/music_assistant/server/providers/tunein/__init__.py @@ -219,7 +219,7 @@ class TuneInProvider(MusicProvider): MediaItemImage( type=ImageType.THUMB, path=img, - provider=self.instance_id, + provider=self.lookup_key, remotely_accessible=True, ) ] diff --git a/music_assistant/server/providers/ytmusic/__init__.py b/music_assistant/server/providers/ytmusic/__init__.py index 15c1c025..1cadd66c 100644 --- a/music_assistant/server/providers/ytmusic/__init__.py +++ b/music_assistant/server/providers/ytmusic/__init__.py @@ -849,7 +849,7 @@ class YoutubeMusicProvider(MusicProvider): MediaItemImage( type=image_type, path=url, - provider=self.instance_id, + provider=self.lookup_key, remotely_accessible=True, ) )