Add config options to control how library items are synced to MA (#2405)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 22 Sep 2025 11:42:23 +0000 (13:42 +0200)
committerGitHub <noreply@github.com>
Mon, 22 Sep 2025 11:42:23 +0000 (13:42 +0200)
93 files changed:
music_assistant/constants.py
music_assistant/controllers/config.py
music_assistant/controllers/media/albums.py
music_assistant/controllers/media/artists.py
music_assistant/controllers/media/audiobooks.py
music_assistant/controllers/media/base.py
music_assistant/controllers/media/playlists.py
music_assistant/controllers/media/podcasts.py
music_assistant/controllers/media/radio.py
music_assistant/controllers/media/tracks.py
music_assistant/controllers/metadata.py
music_assistant/controllers/music.py
music_assistant/helpers/database.py
music_assistant/mass.py
music_assistant/models/__init__.py
music_assistant/models/metadata_provider.py
music_assistant/models/music_provider.py
music_assistant/models/provider.py
music_assistant/providers/_demo_music_provider/__init__.py
music_assistant/providers/_demo_player_provider/__init__.py
music_assistant/providers/_demo_player_provider/provider.py
music_assistant/providers/_demo_plugin_provider/__init__.py
music_assistant/providers/airplay/__init__.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/alexa/__init__.py
music_assistant/providers/apple_music/__init__.py
music_assistant/providers/ard_audiothek/__init__.py
music_assistant/providers/audible/__init__.py
music_assistant/providers/audiobookshelf/__init__.py
music_assistant/providers/bluesound/__init__.py
music_assistant/providers/bluesound/provider.py
music_assistant/providers/builtin/__init__.py
music_assistant/providers/builtin/constants.py [new file with mode: 0644]
music_assistant/providers/builtin_player/__init__.py
music_assistant/providers/builtin_player/provider.py
music_assistant/providers/chromecast/__init__.py
music_assistant/providers/chromecast/provider.py
music_assistant/providers/deezer/__init__.py
music_assistant/providers/dlna/__init__.py
music_assistant/providers/fanarttv/__init__.py
music_assistant/providers/filesystem_local/__init__.py
music_assistant/providers/filesystem_local/constants.py
music_assistant/providers/filesystem_smb/__init__.py
music_assistant/providers/fully_kiosk/__init__.py
music_assistant/providers/genius_lyrics/__init__.py
music_assistant/providers/gpodder/__init__.py
music_assistant/providers/hass/__init__.py
music_assistant/providers/ibroadcast/__init__.py
music_assistant/providers/itunes_podcasts/__init__.py
music_assistant/providers/jellyfin/__init__.py
music_assistant/providers/lastfm_scrobble/__init__.py
music_assistant/providers/listenbrainz_scrobble/__init__.py
music_assistant/providers/lrclib/__init__.py
music_assistant/providers/musicbrainz/__init__.py
music_assistant/providers/musiccast/__init__.py
music_assistant/providers/musiccast/provider.py
music_assistant/providers/nugs/__init__.py
music_assistant/providers/opensubsonic/__init__.py
music_assistant/providers/opensubsonic/sonic_provider.py
music_assistant/providers/plex/__init__.py
music_assistant/providers/podcast-index/__init__.py
music_assistant/providers/podcast-index/provider.py
music_assistant/providers/podcastfeed/__init__.py
music_assistant/providers/qobuz/__init__.py
music_assistant/providers/radiobrowser/__init__.py
music_assistant/providers/radioparadise/__init__.py
music_assistant/providers/radioparadise/provider.py
music_assistant/providers/siriusxm/__init__.py
music_assistant/providers/snapcast/__init__.py
music_assistant/providers/snapcast/provider.py
music_assistant/providers/sonos/__init__.py
music_assistant/providers/sonos/provider.py
music_assistant/providers/sonos_s1/__init__.py
music_assistant/providers/sonos_s1/provider.py
music_assistant/providers/soundcloud/__init__.py
music_assistant/providers/spotify/__init__.py
music_assistant/providers/spotify/constants.py
music_assistant/providers/spotify/provider.py
music_assistant/providers/spotify_connect/__init__.py
music_assistant/providers/squeezelite/__init__.py
music_assistant/providers/squeezelite/provider.py
music_assistant/providers/test/__init__.py
music_assistant/providers/theaudiodb/__init__.py
music_assistant/providers/tidal/__init__.py
music_assistant/providers/tunein/__init__.py
music_assistant/providers/universal_group/__init__.py
music_assistant/providers/universal_group/provider.py
music_assistant/providers/ytmusic/__init__.py
pyproject.toml
requirements_all.txt
tests/providers/jellyfin/__snapshots__/test_parsers.ambr
tests/providers/jellyfin/test_init.py
tests/providers/opensubsonic/__snapshots__/test_parsers.ambr

index d482ec7a32c3ba9301a136b10b21c5da054f485b..ec471dbd649be43cb8bd0823b16b7c92a79709df 100644 (file)
@@ -634,6 +634,254 @@ CONF_ENTRY_MANUAL_DISCOVERY_IPS = ConfigEntry(
     multi_value=True,
 )
 
+CONF_LIBRARY_IMPORT_OPTIONS = [
+    ConfigValueOption("Import into the library only", "import_only"),
+    ConfigValueOption("Import into the library, and mark as favorite", "import_as_favorite"),
+    ConfigValueOption("Do not import into the library", "no_import"),
+]
+CONF_ENTRY_LIBRARY_IMPORT_ARTISTS = ConfigEntry(
+    key="library_import_artists",
+    type=ConfigEntryType.STRING,
+    label="Import Artists from this provider into Music Assistant",
+    description="Whether to import (favourite/library) artists from this "
+    "provider into the Music Assistant Library.",
+    options=CONF_LIBRARY_IMPORT_OPTIONS,
+    default_value="import_as_favorite",
+    category="sync_options",
+)
+CONF_ENTRY_LIBRARY_IMPORT_ALBUMS = ConfigEntry(
+    key="library_import_albums",
+    type=ConfigEntryType.STRING,
+    label="Import Albums from this provider into Music Assistant",
+    description="Whether to import (favourite/library) albums from this "
+    "provider into the Music Assistant Library. \n\n"
+    "Please note that by adding an Album into the Music Assistant library, "
+    "the album artists will always be imported as well (not as favorites though).",
+    options=CONF_LIBRARY_IMPORT_OPTIONS,
+    default_value="import_as_favorite",
+    category="sync_options",
+)
+CONF_ENTRY_LIBRARY_IMPORT_TRACKS = ConfigEntry(
+    key="library_import_tracks",
+    type=ConfigEntryType.STRING,
+    label="Import Tracks from this provider into Music Assistant",
+    description="Whether to import (favourite/library) tracks from this "
+    "provider into the Music Assistant Library. \n\n"
+    "Please note that by adding a Track into the Music Assistant library, "
+    "the track artists and album will always be imported as well (not as favorites though).",
+    options=CONF_LIBRARY_IMPORT_OPTIONS,
+    default_value="import_as_favorite",
+    category="sync_options",
+)
+CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS = ConfigEntry(
+    key="library_import_playlists",
+    type=ConfigEntryType.STRING,
+    label="Import Playlists from this provider into Music Assistant",
+    description="Whether to import (favourite/library) playlists from this "
+    "provider into the Music Assistant Library.",
+    options=CONF_LIBRARY_IMPORT_OPTIONS,
+    default_value="import_as_favorite",
+    category="sync_options",
+)
+CONF_ENTRY_LIBRARY_IMPORT_PODCASTS = ConfigEntry(
+    key="library_import_podcasts",
+    type=ConfigEntryType.STRING,
+    label="Import Podcasts from this provider into Music Assistant",
+    description="Whether to import (favourite/library) podcasts from this "
+    "provider into the Music Assistant Library.",
+    options=CONF_LIBRARY_IMPORT_OPTIONS,
+    default_value="import_as_favorite",
+    category="sync_options",
+)
+CONF_ENTRY_LIBRARY_IMPORT_AUDIOBOOKS = ConfigEntry(
+    key="library_import_audiobooks",
+    type=ConfigEntryType.STRING,
+    label="Import Audiobooks from this provider into Music Assistant",
+    description="Whether to import (favourite/library) audiobooks from this "
+    "provider into the Music Assistant Library.",
+    options=CONF_LIBRARY_IMPORT_OPTIONS,
+    default_value="import_as_favorite",
+    category="sync_options",
+)
+CONF_ENTRY_LIBRARY_IMPORT_RADIOS = ConfigEntry(
+    key="library_import_radios",
+    type=ConfigEntryType.STRING,
+    label="Import Radios from this provider into Music Assistant",
+    description="Whether to import (favourite/library) radios from this "
+    "provider into the Music Assistant Library.",
+    options=CONF_LIBRARY_IMPORT_OPTIONS,
+    default_value="import_as_favorite",
+    category="sync_options",
+)
+CONF_ENTRY_LIBRARY_IMPORT_ALBUM_TRACKS = ConfigEntry(
+    key="library_import_album_tracks",
+    type=ConfigEntryType.BOOLEAN,
+    label="Import album tracks",
+    description="By default, when importing albums into the library, "
+    "only the Album itself will be imported into the Music Assistant Library, "
+    "allowing you to manually browse and select which tracks you want to import. \n\n"
+    "If you want to override this default behavior, "
+    "you can use this configuration option.\n\n"
+    "Please note that some streaming providers may already define this behavior unsolicited, "
+    "by automatically adding all tracks from the album to their library/favorites.",
+    default_value=False,
+    category="sync_options",
+)
+CONF_ENTRY_LIBRARY_IMPORT_PLAYLIST_TRACKS = ConfigEntry(
+    key="library_import_playlist_tracks",
+    type=ConfigEntryType.STRING,
+    label="Import playlist tracks",
+    description="By default, when importing playlists into the library, "
+    "only the Playlist itself will be imported into the Music Assistant Library, "
+    "allowing you to browse and play the playlist and optionally add any individual "
+    "tracks of the playlist to the Music Assistant Library manually. \n\n"
+    "Use this configuration option to override this default behavior, "
+    "by specifying the playlists for which you'd like to import all tracks.\n"
+    "You can either enter the Playlist name (case sensitive) or the Playlist URI.",
+    default_value=[],
+    category="sync_options",
+    multi_value=True,
+)
+
+CONF_ENTRY_LIBRARY_EXPORT_ADD = ConfigEntry(
+    key="library_export_add",
+    type=ConfigEntryType.STRING,
+    label="Sync back library additions",
+    description="Specify the behavior if an item is (manually) added to the "
+    "Music Assistant Library (or favorites). \n"
+    "Should we synchronise that action back to the provider?\n\n"
+    "You can choose to add items to the provider's library as soon as you "
+    "add it to the Music Assistant Library or only do that when you mark the item as "
+    "favorite. \nIf you do not want to sync back to the provider at all, you can choose "
+    "the 'Don't sync back to the provider' option.",
+    default_value="export_favorite",
+    category="sync_options",
+    options=[
+        ConfigValueOption("When an item is added to the library", "export_library"),
+        ConfigValueOption("When an item is marked as favorite", "export_favorite"),
+        ConfigValueOption("Don't sync back to the provider", "no_export"),
+    ],
+)
+CONF_ENTRY_LIBRARY_EXPORT_REMOVE = ConfigEntry(
+    key="library_export_remove",
+    type=ConfigEntryType.STRING,
+    label="Sync back library removals",
+    description="Specify the behavior if an item is (manually) removed from the "
+    "Music Assistant Library (or favorites). \n"
+    "Should we synchronise that action back to the provider?\n\n"
+    "You can choose to remove items from the provider's library as soon as you (manually) "
+    "remove it from the Music Assistant Library or only do that when you unmark the item as "
+    "favorite. \nIf you do not want to sync back to the provider at all, you can choose "
+    "the 'Don't sync back to the provider' option.\n\n"
+    "Please note that if you you don't sync removals back to the provider and you have enabled "
+    "automatic sync/import for this provider, the item may reappear in the library "
+    "the next time a sync is performed.",
+    default_value="export_favorite",
+    category="sync_options",
+    options=[
+        ConfigValueOption("When an item is removed from the library", "export_library"),
+        ConfigValueOption("When an item is unmarked as favorite", "export_favorite"),
+        ConfigValueOption("Don't sync back to the provider", "no_export"),
+    ],
+)
+
+CONF_PROVIDER_SYNC_INTERVAL_OPTIONS = [
+    ConfigValueOption("Disable automatic sync for this mediatype", 0),
+    ConfigValueOption("Every 30 minutes", 30),
+    ConfigValueOption("Every hour", 60),
+    ConfigValueOption("Every 3 hours", 180),
+    ConfigValueOption("Every 6 hours", 360),
+    ConfigValueOption("Every 12 hours", 720),
+    ConfigValueOption("Every 24 hours", 1440),
+    ConfigValueOption("Every 36 hours", 2160),
+    ConfigValueOption("Every 48 hours", 2880),
+    ConfigValueOption("Once a week", 10080),
+]
+CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ARTISTS = ConfigEntry(
+    key="provider_sync_interval_artists",
+    type=ConfigEntryType.INTEGER,
+    label="Automatic Sync Interval for Artists",
+    description="The interval at which the Artists are synced to the library for this provider.",
+    options=CONF_PROVIDER_SYNC_INTERVAL_OPTIONS,
+    default_value=720,
+    category="sync_options",
+    depends_on=CONF_ENTRY_LIBRARY_IMPORT_ARTISTS.key,
+    depends_on_value_not="no_import",
+    required=True,
+)
+CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ALBUMS = ConfigEntry(
+    key="provider_sync_interval_albums",
+    type=ConfigEntryType.INTEGER,
+    label="Automatic Sync Interval for Albums",
+    description="The interval at which the Albums are synced to the library for this provider.",
+    options=CONF_PROVIDER_SYNC_INTERVAL_OPTIONS,
+    default_value=720,
+    category="sync_options",
+    depends_on=CONF_ENTRY_LIBRARY_IMPORT_ALBUMS.key,
+    depends_on_value_not="no_import",
+    required=True,
+)
+CONF_ENTRY_PROVIDER_SYNC_INTERVAL_TRACKS = ConfigEntry(
+    key="provider_sync_interval_tracks",
+    type=ConfigEntryType.INTEGER,
+    label="Automatic Sync Interval for Tracks",
+    description="The interval at which the Tracks are synced to the library for this provider.",
+    options=CONF_PROVIDER_SYNC_INTERVAL_OPTIONS,
+    default_value=720,
+    category="sync_options",
+    depends_on=CONF_ENTRY_LIBRARY_IMPORT_TRACKS.key,
+    depends_on_value_not="no_import",
+    required=True,
+)
+CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PLAYLISTS = ConfigEntry(
+    key="provider_sync_interval_playlists",
+    type=ConfigEntryType.INTEGER,
+    label="Automatic Sync Interval for Playlists",
+    description="The interval at which the Playlists are synced to the library for this provider.",
+    options=CONF_PROVIDER_SYNC_INTERVAL_OPTIONS,
+    default_value=720,
+    category="sync_options",
+    depends_on=CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS.key,
+    depends_on_value_not="no_import",
+    required=True,
+)
+CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PODCASTS = ConfigEntry(
+    key="provider_sync_interval_podcasts",
+    type=ConfigEntryType.INTEGER,
+    label="Automatic Sync Interval for Podcasts",
+    description="The interval at which the Podcasts are synced to the library for this provider.",
+    options=CONF_PROVIDER_SYNC_INTERVAL_OPTIONS,
+    default_value=720,
+    category="sync_options",
+    depends_on=CONF_ENTRY_LIBRARY_IMPORT_PODCASTS.key,
+    depends_on_value_not="no_import",
+    required=True,
+)
+CONF_ENTRY_PROVIDER_SYNC_INTERVAL_AUDIOBOOKS = ConfigEntry(
+    key="provider_sync_interval_audiobooks",
+    type=ConfigEntryType.INTEGER,
+    label="Automatic Sync Interval for Audiobooks",
+    description="The interval at which the Audiobooks are synced to the library for this provider.",
+    options=CONF_PROVIDER_SYNC_INTERVAL_OPTIONS,
+    default_value=720,
+    category="sync_options",
+    depends_on=CONF_ENTRY_LIBRARY_IMPORT_AUDIOBOOKS.key,
+    depends_on_value_not="no_import",
+    required=True,
+)
+CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS = ConfigEntry(
+    key="provider_sync_interval_radios",
+    type=ConfigEntryType.INTEGER,
+    label="Automatic Sync Interval for Radios",
+    description="The interval at which the Radios are synced to the library for this provider.",
+    options=CONF_PROVIDER_SYNC_INTERVAL_OPTIONS,
+    default_value=720,
+    category="sync_options",
+    depends_on=CONF_ENTRY_LIBRARY_IMPORT_RADIOS.key,
+    depends_on_value_not="no_import",
+    required=True,
+)
+
 
 def create_sample_rates_config_entry(
     supported_sample_rates: list[int] | None = None,
index 2d49e91ae33a88eb1d1db7337f516772bf90c3e1..e87f419769eb09e2b368c5a44448f617e58446a7 100644 (file)
@@ -22,7 +22,7 @@ from music_assistant_models.config_entries import (
     ProviderConfig,
 )
 from music_assistant_models.dsp import DSPConfig, DSPConfigPreset, ToneControlFilter
-from music_assistant_models.enums import EventType, ProviderType
+from music_assistant_models.enums import EventType, ProviderFeature, ProviderType
 from music_assistant_models.errors import (
     ActionUnavailable,
     InvalidDataError,
@@ -35,6 +35,24 @@ from music_assistant.constants import (
     CONF_DEPRECATED_EQ_BASS,
     CONF_DEPRECATED_EQ_MID,
     CONF_DEPRECATED_EQ_TREBLE,
+    CONF_ENTRY_LIBRARY_EXPORT_ADD,
+    CONF_ENTRY_LIBRARY_EXPORT_REMOVE,
+    CONF_ENTRY_LIBRARY_IMPORT_ALBUM_TRACKS,
+    CONF_ENTRY_LIBRARY_IMPORT_ALBUMS,
+    CONF_ENTRY_LIBRARY_IMPORT_ARTISTS,
+    CONF_ENTRY_LIBRARY_IMPORT_AUDIOBOOKS,
+    CONF_ENTRY_LIBRARY_IMPORT_PLAYLIST_TRACKS,
+    CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS,
+    CONF_ENTRY_LIBRARY_IMPORT_PODCASTS,
+    CONF_ENTRY_LIBRARY_IMPORT_RADIOS,
+    CONF_ENTRY_LIBRARY_IMPORT_TRACKS,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ALBUMS,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ARTISTS,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_AUDIOBOOKS,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PLAYLISTS,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PODCASTS,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_TRACKS,
     CONF_ONBOARD_DONE,
     CONF_PLAYER_DSP,
     CONF_PLAYER_DSP_PRESETS,
@@ -231,7 +249,7 @@ class ConfigController:
         instance_id: str | None = None,
         action: str | None = None,
         values: dict[str, ConfigValueType] | None = None,
-    ) -> tuple[ConfigEntry, ...]:
+    ) -> list[ConfigEntry]:
         """
         Return Config entries to setup/configure a provider.
 
@@ -241,9 +259,9 @@ class ConfigController:
         values: the (intermediate) raw values for config entries sent with the action.
         """
         # lookup provider manifest and module
-        for prov in self.mass.get_provider_manifests():
-            if prov.domain == provider_domain:
-                prov_mod = await load_provider_module(provider_domain, prov.requirements)
+        for manifest in self.mass.get_provider_manifests():
+            if manifest.domain == provider_domain:
+                prov_mod = await load_provider_module(provider_domain, manifest.requirements)
                 break
         else:
             msg = f"Unknown provider domain: {provider_domain}"
@@ -251,12 +269,72 @@ class ConfigController:
         if values is None:
             values = self.get(f"{CONF_PROVIDERS}/{instance_id}/values", {}) if instance_id else {}
 
-        return (
-            await prov_mod.get_config_entries(
-                self.mass, instance_id=instance_id, action=action, values=values
+        # add dynamic optional config entries that depend on features
+        if instance_id and (provider := self.mass.get_provider(instance_id)):
+            supported_features = provider.supported_features
+        else:
+            provider = None
+            supported_features: set[ProviderFeature] = getattr(
+                prov_mod, "SUPPORTED_FEATURES", set()
             )
-            + DEFAULT_PROVIDER_CONFIG_ENTRIES
-        )
+        extra_entries: list[ConfigEntry] = []
+        if manifest.type == ProviderType.MUSIC:
+            # library sync settings
+            if ProviderFeature.LIBRARY_ARTISTS in supported_features:
+                extra_entries.append(CONF_ENTRY_LIBRARY_IMPORT_ARTISTS)
+            if ProviderFeature.LIBRARY_ALBUMS in supported_features:
+                extra_entries.append(CONF_ENTRY_LIBRARY_IMPORT_ALBUMS)
+                if provider and provider.is_streaming_provider:
+                    extra_entries.append(CONF_ENTRY_LIBRARY_IMPORT_ALBUM_TRACKS)
+            if ProviderFeature.LIBRARY_TRACKS in supported_features:
+                extra_entries.append(CONF_ENTRY_LIBRARY_IMPORT_TRACKS)
+            if ProviderFeature.LIBRARY_PLAYLISTS in supported_features:
+                extra_entries.append(CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS)
+                if provider and provider.is_streaming_provider:
+                    extra_entries.append(CONF_ENTRY_LIBRARY_IMPORT_PLAYLIST_TRACKS)
+            if ProviderFeature.LIBRARY_AUDIOBOOKS in supported_features:
+                extra_entries.append(CONF_ENTRY_LIBRARY_IMPORT_AUDIOBOOKS)
+            if ProviderFeature.LIBRARY_PODCASTS in supported_features:
+                extra_entries.append(CONF_ENTRY_LIBRARY_IMPORT_PODCASTS)
+            if ProviderFeature.LIBRARY_RADIOS in supported_features:
+                extra_entries.append(CONF_ENTRY_LIBRARY_IMPORT_RADIOS)
+            # sync interval settings
+            if ProviderFeature.LIBRARY_ARTISTS in supported_features:
+                extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ARTISTS)
+            if ProviderFeature.LIBRARY_ALBUMS in supported_features:
+                extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ALBUMS)
+            if ProviderFeature.LIBRARY_TRACKS in supported_features:
+                extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_TRACKS)
+            if ProviderFeature.LIBRARY_PLAYLISTS in supported_features:
+                extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PLAYLISTS)
+            if ProviderFeature.LIBRARY_AUDIOBOOKS in supported_features:
+                extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_AUDIOBOOKS)
+            if ProviderFeature.LIBRARY_PODCASTS in supported_features:
+                extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PODCASTS)
+            if ProviderFeature.LIBRARY_RADIOS in supported_features:
+                extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS)
+            # sync export settings
+            if supported_features.intersection(
+                {
+                    ProviderFeature.LIBRARY_ARTISTS_EDIT,
+                    ProviderFeature.LIBRARY_ALBUMS_EDIT,
+                    ProviderFeature.LIBRARY_TRACKS_EDIT,
+                    ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
+                    ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT,
+                    ProviderFeature.LIBRARY_PODCASTS_EDIT,
+                    ProviderFeature.LIBRARY_RADIOS_EDIT,
+                }
+            ):
+                extra_entries.append(CONF_ENTRY_LIBRARY_EXPORT_ADD)
+                extra_entries.append(CONF_ENTRY_LIBRARY_EXPORT_REMOVE)
+
+        return [
+            *DEFAULT_PROVIDER_CONFIG_ENTRIES,
+            *extra_entries,
+            *await prov_mod.get_config_entries(
+                self.mass, instance_id=instance_id, action=action, values=values
+            ),
+        ]
 
     @api_command("config/providers/save")
     async def save_provider_config(
@@ -1046,7 +1124,4 @@ class ConfigController:
             # loading failed, remove config
             self.remove(conf_key)
             raise
-        if prov.type == ProviderType.MUSIC:
-            # kick off initial library scan
-            self.mass.music.start_sync(None, [config.instance_id])
         return config
index 7f9ee1965df7054fbed68667d1678cee60340fd5..0e00545642a04f5ba9804afd29cfb028034e8a0b 100644 (file)
@@ -52,7 +52,8 @@ class AlbumsController(MediaControllerBase[Album]):
                         'available', provider_mappings.available,
                         'audio_format', json(provider_mappings.audio_format),
                         'url', provider_mappings.url,
-                        'details', provider_mappings.details
+                        'details', provider_mappings.details,
+                        'in_library', provider_mappings.in_library
                 )) FROM provider_mappings WHERE provider_mappings.item_id = albums.item_id AND media_type = 'album') AS provider_mappings,
             (SELECT JSON_GROUP_ARRAY(
                 json_object(
@@ -207,7 +208,7 @@ class AlbumsController(MediaControllerBase[Album]):
         return await self.mass.music.database.get_count_from_query(sql_query, query_params)
 
     async def remove_item_from_library(self, item_id: str | int, recursive: bool = True) -> None:
-        """Delete record from the database."""
+        """Delete item from the library(database)."""
         db_id = int(item_id)  # ensure integer
         # recursively also remove album tracks
         for db_track in await self.get_library_album_tracks(db_id):
@@ -307,14 +308,23 @@ class AlbumsController(MediaControllerBase[Album]):
             extra_query_parts=[f"WHERE album_tracks.album_id = {item_id}"],
         )
 
+    async def add_item_mapping_as_album_to_library(
+        self, item: ItemMapping, import_as_favorite: bool = False
+    ) -> Album:
+        """
+        Add an ItemMapping as an Album to the library.
+
+        This is only used in special occasions as is basically adds an album
+        to the db without a lot of mandatory data, such as artists.
+        """
+        album = self.album_from_item_mapping(item)
+        return await self.add_item_to_library(album)
+
     async def _add_library_item(self, item: Album) -> int:
         """Add a new record to the database."""
         if not isinstance(item, Album):
             msg = "Not a valid Album object (ItemMapping can not be added to db)"
             raise InvalidDataError(msg)
-        if not item.artists:
-            msg = "Album is missing artist(s)"
-            raise InvalidDataError(msg)
         db_id = await self.mass.music.database.insert(
             self.db_table,
             {
@@ -331,7 +341,7 @@ class AlbumsController(MediaControllerBase[Album]):
             },
         )
         # update/set provider_mappings table
-        await self._set_provider_mappings(db_id, item.provider_mappings)
+        await self.set_provider_mappings(db_id, item.provider_mappings)
         # set track artist(s)
         await self._set_album_artists(db_id, item.artists)
         self.logger.debug("added %s to database (id: %s)", item.name, db_id)
@@ -349,11 +359,6 @@ class AlbumsController(MediaControllerBase[Album]):
         else:
             album_type = cur_item.album_type
         cur_item.external_ids.update(update.external_ids)
-        provider_mappings = (
-            update.provider_mappings
-            if overwrite
-            else {*cur_item.provider_mappings, *update.provider_mappings}
-        )
         name = update.name if overwrite else cur_item.name
         sort_name = update.sort_name if overwrite else cur_item.sort_name or update.sort_name
         await self.mass.music.database.update(
@@ -374,7 +379,12 @@ class AlbumsController(MediaControllerBase[Album]):
             },
         )
         # update/set provider_mappings table
-        await self._set_provider_mappings(db_id, provider_mappings, overwrite)
+        provider_mappings = (
+            update.provider_mappings
+            if overwrite
+            else {*update.provider_mappings, *cur_item.provider_mappings}
+        )
+        await self.set_provider_mappings(db_id, provider_mappings, overwrite)
         # set album artist(s)
         artists = update.artists if overwrite else cur_item.artists + update.artists
         await self._set_album_artists(db_id, artists, overwrite=overwrite)
@@ -534,3 +544,23 @@ class AlbumsController(MediaControllerBase[Album]):
                     db_album.name,
                     provider.name,
                 )
+
+    def album_from_item_mapping(self, item: ItemMapping) -> Album:
+        """Create an Album object from an ItemMapping object."""
+        domain, instance_id = None, None
+        if prov := self.mass.get_provider(item.provider):
+            domain = prov.domain
+            instance_id = prov.instance_id
+        return Album.from_dict(
+            {
+                **item.to_dict(),
+                "provider_mappings": [
+                    {
+                        "item_id": item.item_id,
+                        "provider_domain": domain,
+                        "provider_instance": instance_id,
+                        "available": item.available,
+                    }
+                ],
+            }
+        )
index f13aa0400b422a28191441c755a0861b600f549d..4b7d91fc034456ce19a7a8db56f3eab63a378a86 100644 (file)
@@ -360,7 +360,7 @@ class ArtistsController(MediaControllerBase[Artist]):
             },
         )
         # update/set provider_mappings table
-        await self._set_provider_mappings(db_id, item.provider_mappings)
+        await self.set_provider_mappings(db_id, item.provider_mappings)
         self.logger.debug("added %s to database (id: %s)", item.name, db_id)
         return db_id
 
@@ -407,9 +407,9 @@ class ArtistsController(MediaControllerBase[Artist]):
         provider_mappings = (
             update.provider_mappings
             if overwrite
-            else {*cur_item.provider_mappings, *update.provider_mappings}
+            else {*update.provider_mappings, *cur_item.provider_mappings}
         )
-        await self._set_provider_mappings(db_id, provider_mappings, overwrite)
+        await self.set_provider_mappings(db_id, provider_mappings, overwrite)
         self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
 
     async def radio_mode_base_tracks(
index 5663db237b0cc19b1d3c1952705cd67e5043537c..8ac8a8c424e5f3b564cf05765e4a309850090b49 100644 (file)
@@ -46,7 +46,8 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
                         'available', provider_mappings.available,
                         'audio_format', json(provider_mappings.audio_format),
                         'url', provider_mappings.url,
-                        'details', provider_mappings.details
+                        'details', provider_mappings.details,
+                        'in_library', provider_mappings.in_library
                 )) FROM provider_mappings WHERE provider_mappings.item_id = audiobooks.item_id AND media_type = 'audiobook') AS provider_mappings,
             playlog.fully_played AS fully_played,
             playlog.seconds_played AS seconds_played,
@@ -146,7 +147,7 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
             },
         )
         # update/set provider_mappings table
-        await self._set_provider_mappings(db_id, item.provider_mappings)
+        await self.set_provider_mappings(db_id, item.provider_mappings)
         self.logger.debug("added %s to database (id: %s)", item.name, db_id)
         await self._set_playlog(db_id, item)
         return db_id
@@ -159,11 +160,6 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
         cur_item = await self.get_library_item(db_id)
         metadata = update.metadata if overwrite else cur_item.metadata.update(update.metadata)
         cur_item.external_ids.update(update.external_ids)
-        provider_mappings = (
-            update.provider_mappings
-            if overwrite
-            else {*cur_item.provider_mappings, *update.provider_mappings}
-        )
         name = update.name if overwrite else cur_item.name
         sort_name = update.sort_name if overwrite else cur_item.sort_name or update.sort_name
         await self.mass.music.database.update(
@@ -190,7 +186,12 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
             },
         )
         # update/set provider_mappings table
-        await self._set_provider_mappings(db_id, provider_mappings, overwrite)
+        provider_mappings = (
+            update.provider_mappings
+            if overwrite
+            else {*update.provider_mappings, *cur_item.provider_mappings}
+        )
+        await self.set_provider_mappings(db_id, provider_mappings, overwrite)
         self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
         await self._set_playlog(db_id, update)
 
index af33381eb685a6cf9c816c3d3ee55f9b6ff327e8..5372fd9f6abd9c52919a2eb2be6b6ec95c9279fd 100644 (file)
@@ -78,11 +78,11 @@ SORT_KEYS = {
 }
 
 
-class MediaControllerBase[ItemCls](metaclass=ABCMeta):
+class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
     """Base model for controller managing a MediaType."""
 
     media_type: MediaType
-    item_cls: MediaItemType
+    item_cls: type[MediaItemType]
     db_table: str
 
     def __init__(self, mass: MusicAssistant) -> None:
@@ -99,7 +99,8 @@ class MediaControllerBase[ItemCls](metaclass=ABCMeta):
                         'available', provider_mappings.available,
                         'audio_format', json(provider_mappings.audio_format),
                         'url', provider_mappings.url,
-                        'details', provider_mappings.details
+                        'details', provider_mappings.details,
+                        'in_library', provider_mappings.in_library
                 )) FROM provider_mappings WHERE provider_mappings.item_id = {self.db_table}.item_id
                     AND provider_mappings.media_type = '{self.media_type.value}') AS provider_mappings
             FROM {self.db_table} """  # noqa: E501
@@ -584,7 +585,7 @@ class MediaControllerBase[ItemCls](metaclass=ABCMeta):
         if provider_mapping in library_item.provider_mappings:
             return
         library_item.provider_mappings.add(provider_mapping)
-        await self._set_provider_mappings(db_id, library_item.provider_mappings)
+        await self.set_provider_mappings(db_id, library_item.provider_mappings)
 
     async def remove_provider_mapping(
         self, item_id: str | int, provider_instance_id: str, provider_item_id: str
@@ -668,6 +669,39 @@ class MediaControllerBase[ItemCls](metaclass=ABCMeta):
             with suppress(AssertionError):
                 await self.remove_item_from_library(db_id)
 
+    async def set_provider_mappings(
+        self,
+        item_id: str | int,
+        provider_mappings: Iterable[ProviderMapping],
+        overwrite: bool = False,
+    ) -> None:
+        """Update the provider_items table for the media item."""
+        db_id = int(item_id)  # ensure integer
+        if overwrite:
+            # on overwrite, clear the provider_mappings table first
+            # this is done for filesystem provider changing the path (and thus item_id)
+            await self.mass.music.database.delete(
+                DB_TABLE_PROVIDER_MAPPINGS,
+                {"media_type": self.media_type.value, "item_id": db_id},
+            )
+        for provider_mapping in provider_mappings:
+            prov_map_obj = {
+                "media_type": self.media_type.value,
+                "item_id": db_id,
+                "provider_domain": provider_mapping.provider_domain,
+                "provider_instance": provider_mapping.provider_instance,
+                "provider_item_id": provider_mapping.item_id,
+                "available": provider_mapping.available,
+                "audio_format": serialize_to_json(provider_mapping.audio_format),
+            }
+            for key in ("url", "details", "in_library"):
+                if (value := getattr(provider_mapping, key, None)) is not None:
+                    prov_map_obj[key] = value
+            await self.mass.music.database.upsert(
+                DB_TABLE_PROVIDER_MAPPINGS,
+                prov_map_obj,
+            )
+
     @abstractmethod
     async def _add_library_item(
         self,
@@ -839,39 +873,6 @@ class MediaControllerBase[ItemCls](metaclass=ABCMeta):
 
         return sql_query
 
-    async def _set_provider_mappings(
-        self,
-        item_id: str | int,
-        provider_mappings: Iterable[ProviderMapping],
-        overwrite: bool = False,
-    ) -> None:
-        """Update the provider_items table for the media item."""
-        db_id = int(item_id)  # ensure integer
-        if overwrite:
-            # on overwrite, clear the provider_mappings table first
-            # this is done for filesystem provider changing the path (and thus item_id)
-            await self.mass.music.database.delete(
-                DB_TABLE_PROVIDER_MAPPINGS,
-                {"media_type": self.media_type.value, "item_id": db_id},
-            )
-        for provider_mapping in provider_mappings:
-            if not provider_mapping.provider_instance:
-                continue
-            await self.mass.music.database.insert_or_replace(
-                DB_TABLE_PROVIDER_MAPPINGS,
-                {
-                    "media_type": self.media_type.value,
-                    "item_id": db_id,
-                    "provider_domain": provider_mapping.provider_domain,
-                    "provider_instance": provider_mapping.provider_instance,
-                    "provider_item_id": provider_mapping.item_id,
-                    "available": provider_mapping.available,
-                    "url": provider_mapping.url,
-                    "audio_format": serialize_to_json(provider_mapping.audio_format),
-                    "details": provider_mapping.details,
-                },
-            )
-
     @staticmethod
     def _parse_db_row(db_row: Mapping) -> dict[str, Any]:
         """Parse raw db Mapping into a dict."""
index d961625f0f099d6e800f31321bbfe4c8f090efb9..35d3a178418252ac98bd2f1c2edf9fdec679ae76 100644 (file)
@@ -324,7 +324,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
             },
         )
         # update/set provider_mappings table
-        await self._set_provider_mappings(db_id, item.provider_mappings)
+        await self.set_provider_mappings(db_id, item.provider_mappings)
         self.logger.debug("added %s to database (id: %s)", item.name, db_id)
         return db_id
 
@@ -360,9 +360,9 @@ class PlaylistController(MediaControllerBase[Playlist]):
         provider_mappings = (
             update.provider_mappings
             if overwrite
-            else {*cur_item.provider_mappings, *update.provider_mappings}
+            else {*update.provider_mappings, *cur_item.provider_mappings}
         )
-        await self._set_provider_mappings(db_id, provider_mappings, overwrite)
+        await self.set_provider_mappings(db_id, provider_mappings, overwrite)
         self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
 
     async def _get_provider_playlist_tracks(
index c7708536f17c1f7c467c50cb8c2da579891d1752..4a7aea7dcde8f944297e9e557013a5c411841639 100644 (file)
@@ -35,20 +35,6 @@ class PodcastsController(MediaControllerBase[Podcast]):
     def __init__(self, *args, **kwargs) -> None:
         """Initialize class."""
         super().__init__(*args, **kwargs)
-        self.base_query = """
-        SELECT
-            podcasts.*,
-            (SELECT JSON_GROUP_ARRAY(
-                json_object(
-                'item_id', provider_mappings.provider_item_id,
-                    'provider_domain', provider_mappings.provider_domain,
-                        'provider_instance', provider_mappings.provider_instance,
-                        'available', provider_mappings.available,
-                        'audio_format', json(provider_mappings.audio_format),
-                        'url', provider_mappings.url,
-                        'details', provider_mappings.details
-                )) FROM provider_mappings WHERE provider_mappings.item_id = podcasts.item_id AND media_type = 'podcast') AS provider_mappings
-            FROM podcasts"""  # noqa: E501
         # register (extra) api handlers
         api_base = self.api_base
         self.mass.register_api_command(f"music/{api_base}/podcast_episodes", self.episodes)
@@ -174,7 +160,7 @@ class PodcastsController(MediaControllerBase[Podcast]):
             },
         )
         # update/set provider_mappings table
-        await self._set_provider_mappings(db_id, item.provider_mappings)
+        await self.set_provider_mappings(db_id, item.provider_mappings)
         self.logger.debug("added %s to database (id: %s)", item.name, db_id)
         return db_id
 
@@ -186,11 +172,6 @@ class PodcastsController(MediaControllerBase[Podcast]):
         cur_item = await self.get_library_item(db_id)
         metadata = update.metadata if overwrite else cur_item.metadata.update(update.metadata)
         cur_item.external_ids.update(update.external_ids)
-        provider_mappings = (
-            update.provider_mappings
-            if overwrite
-            else {*cur_item.provider_mappings, *update.provider_mappings}
-        )
         name = update.name if overwrite else cur_item.name
         sort_name = update.sort_name if overwrite else cur_item.sort_name or update.sort_name
         await self.mass.music.database.update(
@@ -211,7 +192,12 @@ class PodcastsController(MediaControllerBase[Podcast]):
             },
         )
         # update/set provider_mappings table
-        await self._set_provider_mappings(db_id, provider_mappings, overwrite)
+        provider_mappings = (
+            update.provider_mappings
+            if overwrite
+            else {*update.provider_mappings, *cur_item.provider_mappings}
+        )
+        await self.set_provider_mappings(db_id, provider_mappings, overwrite)
         self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
 
     async def _get_provider_podcast_episodes(
index c9ae11514cfe60a2d138ead6a893f66167e0992d..3ceb02ec830f1d38bea67ccba2b507b4f5b05177 100644 (file)
@@ -69,7 +69,7 @@ class RadioController(MediaControllerBase[Radio]):
             },
         )
         # update/set provider_mappings table
-        await self._set_provider_mappings(db_id, item.provider_mappings)
+        await self.set_provider_mappings(db_id, item.provider_mappings)
         self.logger.debug("added %s to database (id: %s)", item.name, db_id)
         return db_id
 
@@ -103,9 +103,9 @@ class RadioController(MediaControllerBase[Radio]):
         provider_mappings = (
             update.provider_mappings
             if overwrite
-            else {*cur_item.provider_mappings, *update.provider_mappings}
+            else {*update.provider_mappings, *cur_item.provider_mappings}
         )
-        await self._set_provider_mappings(db_id, provider_mappings, overwrite)
+        await self.set_provider_mappings(db_id, provider_mappings, overwrite)
         self.logger.debug("updated %s in database: (id %s)", update.name, db_id)
 
     async def radio_mode_base_tracks(
index 5ad4d61533243b30750f1489ce3316c4516c86fe..3c24674c0ff4254f8cbf31b62ff694cbfb1cb613 100644 (file)
@@ -4,13 +4,11 @@ from __future__ import annotations
 
 import urllib.parse
 from collections.abc import Iterable
-from contextlib import suppress
 from typing import Any
 
 from music_assistant_models.enums import MediaType, ProviderFeature, ProviderType
 from music_assistant_models.errors import (
     InvalidDataError,
-    MediaNotFoundError,
     MusicAssistantError,
     UnsupportedFeaturedException,
 )
@@ -63,7 +61,8 @@ class TracksController(MediaControllerBase[Track]):
                         'available', provider_mappings.available,
                         'audio_format', json(provider_mappings.audio_format),
                         'url', provider_mappings.url,
-                        'details', provider_mappings.details
+                        'details', provider_mappings.details,
+                        'in_library', provider_mappings.in_library
                 )) FROM provider_mappings WHERE provider_mappings.item_id = tracks.item_id AND media_type = 'track') AS provider_mappings,
 
             (SELECT JSON_GROUP_ARRAY(
@@ -452,7 +451,7 @@ class TracksController(MediaControllerBase[Track]):
             },
         )
         # update/set provider_mappings table
-        await self._set_provider_mappings(db_id, item.provider_mappings)
+        await self.set_provider_mappings(db_id, item.provider_mappings)
         # set track artist(s)
         await self._set_track_artists(db_id, item.artists)
         # handle track album
@@ -496,9 +495,9 @@ class TracksController(MediaControllerBase[Track]):
         provider_mappings = (
             update.provider_mappings
             if overwrite
-            else {*cur_item.provider_mappings, *update.provider_mappings}
+            else {*update.provider_mappings, *cur_item.provider_mappings}
         )
-        await self._set_provider_mappings(db_id, provider_mappings, overwrite)
+        await self.set_provider_mappings(db_id, provider_mappings, overwrite)
         # set track artist(s)
         artists = update.artists if overwrite else cur_item.artists + update.artists
         await self._set_track_artists(db_id, artists, overwrite=overwrite)
@@ -541,23 +540,12 @@ class TracksController(MediaControllerBase[Track]):
         if not db_album or overwrite:
             # ensure we have an actual album object
             if isinstance(album, ItemMapping):
-                album = await self.mass.music.albums.get_provider_item(
-                    album.item_id, album.provider, fallback=album
-                )
-            with suppress(MediaNotFoundError, AssertionError, InvalidDataError):
+                db_album = await self.mass.music.albums.add_item_mapping_as_album_to_library(album)
+            else:
                 db_album = await self.mass.music.albums.add_item_to_library(
                     album,
                     overwrite_existing=overwrite,
                 )
-        if not db_album:
-            # this should not happen but streaming providers can be awful sometimes
-            self.logger.warning(
-                "Unable to resolve Album %s for track %s, "
-                "track will be added to the library without this album!",
-                album.uri,
-                db_id,
-            )
-            return
         # write (or update) record in album_tracks table
         await self.mass.music.database.insert_or_replace(
             DB_TABLE_ALBUM_TRACKS,
index c14a6d4027fb83dd685b5cc2591b3ff5c5a4a130..255f308722480d6f54612f7e7db5f73e8f884b63 100644 (file)
@@ -293,6 +293,8 @@ class MetaDataController(CoreController):
         """Schedule metadata update for given MediaItem uri."""
         if "library" not in uri:
             return
+        if self._lookup_jobs.exists(uri):
+            return
         with suppress(asyncio.QueueFull):
             self._lookup_jobs.put_nowait(uri)
 
@@ -799,8 +801,7 @@ class MetaDataController(CoreController):
                 )
 
     async def _scan_missing_metadata(self) -> None:
-        """Scanner for (missing) metadata, periodically in the background."""
-        self._periodic_scan = None
+        """Scanner for (missing) metadata, runs periodically in the background."""
         # Scan for missing artist images
         self.logger.debug("Start lookup for missing artist images...")
         query = (
@@ -808,7 +809,9 @@ class MetaDataController(CoreController):
             f"AND (json_extract({DB_TABLE_ARTISTS}.metadata,'$.images') ISNULL "
             f"OR json_extract({DB_TABLE_ARTISTS}.metadata,'$.images') = '[]')"
         )
-        for artist in await self.mass.music.artists.library_items(extra_query=query):
+        for artist in await self.mass.music.artists.library_items(
+            limit=25, order_by="random", extra_query=query
+        ):
             if artist.uri:
                 self.schedule_update_metadata(artist.uri)
 
@@ -820,7 +823,7 @@ class MetaDataController(CoreController):
             f"OR json_extract({DB_TABLE_ALBUMS}.metadata,'$.images') = '[]')"
         )
         for album in await self.mass.music.albums.library_items(
-            limit=50, order_by="random", extra_query=query
+            limit=5, order_by="random", extra_query=query
         ):
             if album.uri:
                 self.schedule_update_metadata(album.uri)
index 3a8ee257bfb51000f6271487c1ac7724f7d518db..0dd420d0258aaabd59738b74b66a3132e30d2a2f 100644 (file)
@@ -42,6 +42,8 @@ from music_assistant_models.unique_list import UniqueList
 
 from music_assistant.constants import (
     CACHE_CATEGORY_MUSIC_SEARCH,
+    CONF_ENTRY_LIBRARY_EXPORT_ADD,
+    CONF_ENTRY_LIBRARY_EXPORT_REMOVE,
     DB_TABLE_ALBUM_ARTISTS,
     DB_TABLE_ALBUM_TRACKS,
     DB_TABLE_ALBUMS,
@@ -86,8 +88,7 @@ CONF_RESET_DB = "reset_db"
 DEFAULT_SYNC_INTERVAL = 12 * 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] = 18
+DB_SCHEMA_VERSION: Final[int] = 19
 
 
 class MusicController(CoreController):
@@ -115,7 +116,6 @@ class MusicController(CoreController):
             "Music Assistant's core controller which manages all music from all providers."
         )
         self.manifest.icon = "archive-music"
-        self._sync_task: asyncio.Task | None = None
 
     async def get_config_entries(
         self,
@@ -124,23 +124,6 @@ class MusicController(CoreController):
     ) -> tuple[ConfigEntry, ...]:
         """Return all Config Entries for this core module (if any)."""
         entries = (
-            ConfigEntry(
-                key=CONF_SYNC_INTERVAL,
-                type=ConfigEntryType.INTEGER,
-                range=(5, 720),
-                default_value=DEFAULT_SYNC_INTERVAL,
-                label="Sync interval",
-                description="Interval (in minutes) that a (delta) sync "
-                "of all providers should be performed.",
-            ),
-            ConfigEntry(
-                key=CONF_ADD_LIBRARY_ON_PLAY,
-                type=ConfigEntryType.BOOLEAN,
-                default_value=False,
-                label="Add item to the library as soon as its played",
-                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,
@@ -154,7 +137,7 @@ class MusicController(CoreController):
         if action == CONF_RESET_DB:
             await self._reset_database()
             await self.mass.cache.clear()
-            self.start_sync()
+            await self.start_sync()
             entries = (
                 *entries,
                 ConfigEntry(
@@ -170,19 +153,14 @@ class MusicController(CoreController):
         self.config = config
         # setup library database
         await self._setup_database()
-        sync_interval = config.get_value(CONF_SYNC_INTERVAL)
-        self.logger.info("Using a sync interval of %s minutes.", sync_interval)
         # make sure to finish any removal jobs
         for removed_provider in self.mass.config.get_raw_core_config_value(
             self.domain, CONF_DELETED_PROVIDERS, []
         ):
             await self.cleanup_provider(removed_provider)
-        self._schedule_sync()
 
     async def close(self) -> None:
         """Cleanup on exit."""
-        if self._sync_task and not self._sync_task.done():
-            self._sync_task.cancel()
         if self.database:
             await self.database.close()
 
@@ -192,7 +170,7 @@ class MusicController(CoreController):
         return self.mass.get_providers(ProviderType.MUSIC)
 
     @api_command("music/sync")
-    def start_sync(
+    async def start_sync(
         self,
         media_types: list[MediaType] | None = None,
         providers: list[str] | None = None,
@@ -213,7 +191,15 @@ class MusicController(CoreController):
                     continue
                 if not provider.library_supported(media_type):
                     continue
-                self._start_provider_sync(provider, media_type)
+                # handle mediatype specific sync config
+                conf_key = f"library_import_{media_type}s"
+                sync_conf = await self.mass.config.get_provider_config_value(
+                    provider.instance_id, conf_key
+                )
+                if sync_conf == "no_import":
+                    continue
+                import_as_favorite = sync_conf == "import_as_favorite"
+                self._start_provider_sync(provider, media_type, import_as_favorite)
 
     @api_command("music/synctasks")
     def get_running_sync_tasks(self) -> list[SyncTask]:
@@ -621,13 +607,6 @@ class MusicController(CoreController):
         """Add an item to the favorites."""
         if isinstance(item, str):
             item = await self.get_item_by_uri(item)
-        # ensure item is added to streaming provider library
-        if (
-            (provider := self.mass.get_provider(item.provider))
-            and provider.is_streaming_provider
-            and provider.library_edit_supported(item.media_type)
-        ):
-            await provider.library_add(item)
         # make sure we have a full library item
         # a favorite must always be in the library
         full_item = await self.get_item(
@@ -643,6 +622,26 @@ class MusicController(CoreController):
             full_item.item_id,
             True,
         )
+        # add to provider(s) library if needed/wanted
+        provider_mappings_updated = False
+        for prov_mapping in full_item.provider_mappings:
+            provider = self.mass.get_provider(prov_mapping.provider_instance)
+            if not provider.library_edit_supported(item.media_type):
+                continue
+            if prov_mapping.in_library:
+                continue
+            conf_export_library = provider.config.get_value(
+                CONF_ENTRY_LIBRARY_EXPORT_ADD.key, CONF_ENTRY_LIBRARY_EXPORT_ADD.default_value
+            )
+            if conf_export_library != "export_favorite":
+                continue
+            prov_item = full_item
+            prov_item.provider = prov_mapping.provider_instance
+            prov_item.item_id = prov_mapping.item_id
+            self.mass.create_task(provider.library_add(prov_item))
+            provider_mappings_updated = True
+        if provider_mappings_updated:
+            await ctrl.set_provider_mappings(full_item.item_id, full_item.provider_mappings)
 
     @api_command("music/favorites/remove_item")
     async def remove_item_from_favorites(
@@ -656,6 +655,25 @@ class MusicController(CoreController):
             library_item_id,
             False,
         )
+        # remove from provider(s) library if needed
+        provider_mappings_updated = False
+        full_item = await ctrl.get_library_item(library_item_id)
+        for prov_mapping in full_item.provider_mappings:
+            if not prov_mapping.in_library:
+                continue
+            provider = self.mass.get_provider(prov_mapping.provider_instance)
+            if not provider.library_edit_supported(full_item.media_type):
+                continue
+            conf_export_library = provider.config.get_value(
+                CONF_ENTRY_LIBRARY_EXPORT_REMOVE.key, CONF_ENTRY_LIBRARY_EXPORT_REMOVE.default_value
+            )
+            if conf_export_library != "export_favorite":
+                continue
+            self.mass.create_task(provider.library_remove(prov_mapping.item_id, media_type))
+            prov_mapping.in_library = False
+            provider_mappings_updated = True
+        if provider_mappings_updated:
+            await ctrl.set_provider_mappings(library_item_id, full_item.provider_mappings)
 
     @api_command("music/library/remove_item")
     async def remove_item_from_library(
@@ -667,15 +685,21 @@ class MusicController(CoreController):
         Destructive! Will remove the item and all dependants.
         """
         ctrl = self.get_controller(media_type)
-        item = await ctrl.get_library_item(library_item_id)
-        # remove from all providers
-        for provider_mapping in item.provider_mappings:
-            if prov_controller := self.mass.get_provider(provider_mapping.provider_instance):
-                # we simply try to remove it on the provider library
-                # NOTE that the item may not be in the provider's library at all
-                # so we need to be a bit forgiving here
-                with suppress(NotImplementedError):
-                    await prov_controller.library_remove(provider_mapping.item_id, item.media_type)
+        # remove from provider(s) library
+        full_item = await ctrl.get_library_item(library_item_id)
+        for prov_mapping in full_item.provider_mappings:
+            if not prov_mapping.in_library:
+                continue
+            provider = self.mass.get_provider(prov_mapping.provider_instance)
+            if not provider.library_edit_supported(full_item.media_type):
+                continue
+            conf_export_library = provider.config.get_value(
+                CONF_ENTRY_LIBRARY_EXPORT_REMOVE.key, CONF_ENTRY_LIBRARY_EXPORT_REMOVE.default_value
+            )
+            if conf_export_library != "export_library":
+                continue
+            self.mass.create_task(provider.library_remove(prov_mapping.item_id, media_type))
+        # remove from library
         await ctrl.remove_item_from_library(library_item_id, recursive)
 
     @api_command("music/library/add_item")
@@ -694,11 +718,18 @@ class MusicController(CoreController):
         # add to provider(s) library first
         for prov_mapping in item.provider_mappings:
             provider = self.mass.get_provider(prov_mapping.provider_instance)
-            if provider.library_edit_supported(item.media_type):
-                prov_item = item
-                prov_item.provider = prov_mapping.provider_instance
-                prov_item.item_id = prov_mapping.item_id
-                await provider.library_add(prov_item)
+            if not provider.library_edit_supported(item.media_type):
+                continue
+            conf_export_library = provider.config.get_value(
+                CONF_ENTRY_LIBRARY_EXPORT_ADD.key, CONF_ENTRY_LIBRARY_EXPORT_ADD.default_value
+            )
+            if conf_export_library != "export_library":
+                continue
+            prov_item = item
+            prov_item.provider = prov_mapping.provider_instance
+            prov_item.item_id = prov_mapping.item_id
+            prov_mapping.in_library = True
+            self.mass.create_task(provider.library_add(prov_item))
         # add (or overwrite) to library
         ctrl = self.get_controller(item.media_type)
         library_item = await ctrl.add_item_to_library(item, overwrite_existing)
@@ -910,14 +941,6 @@ class MusicController(CoreController):
             # skip non media items (e.g. plugin source)
             return
         db_item = await ctrl.get_library_item_by_prov_id(media_item.item_id, media_item.provider)
-        if (
-            not db_item
-            and media_item.media_type in (MediaType.TRACK, MediaType.RADIO)
-            and self.mass.config.get_raw_core_config_value(self.domain, CONF_ADD_LIBRARY_ON_PLAY)
-        ):
-            # handle feature to add to the lib on playback
-            db_item = await self.add_item_to_library(media_item)
-
         if db_item:
             await self.database.execute(
                 f"UPDATE {ctrl.db_table} SET play_count = play_count + 1, "
@@ -1215,6 +1238,27 @@ class MusicController(CoreController):
                 "Provider %s was not not fully removed from library", provider_instance
             )
 
+    async def schedule_provider_sync(self, provider_instance_id: str) -> None:
+        """Schedule Library sync for given provider."""
+        if not (provider := self.mass.get_provider(provider_instance_id)):
+            return
+        self.unschedule_provider_sync(provider.instance_id)
+        for media_type in MediaType:
+            if not provider.library_supported(media_type):
+                continue
+            await self._schedule_provider_mediatype_sync(provider, media_type, True)
+
+    def unschedule_provider_sync(self, provider_instance_id: str) -> None:
+        """Unschedule Library sync for given provider."""
+        # cancel all scheduled sync tasks
+        for media_type in MediaType:
+            key = f"sync_{provider_instance_id}_{media_type.value}"
+            self.mass.cancel_timer(key)
+        # cancel any running sync tasks
+        for sync_task in self.in_progress_syncs:
+            if sync_task.provider_instance == provider_instance_id:
+                sync_task.task.cancel()
+
     async def _get_default_recommendations(self) -> list[RecommendationFolder]:
         """Return default recommendations."""
         return [
@@ -1295,16 +1339,21 @@ class MusicController(CoreController):
             )
             return []
 
-    def _start_provider_sync(self, provider: MusicProvider, media_type: MediaType) -> None:
+    def _start_provider_sync(
+        self, provider: MusicProvider, media_type: MediaType, import_as_favorite: bool
+    ) -> None:
         """Start sync task on provider and track progress."""
         # check if we're not already running a sync task for this provider/mediatype
         for sync_task in self.in_progress_syncs:
             if sync_task.provider_instance != provider.instance_id:
                 continue
+            if sync_task.task.done():
+                continue
             if media_type in sync_task.media_types:
                 self.logger.debug(
-                    "Skip sync task for %s because another task is already in progress",
+                    "Skip sync task for %s/%ss because another task is already in progress",
                     provider.name,
+                    media_type.value,
                 )
                 return
 
@@ -1312,12 +1361,7 @@ class MusicController(CoreController):
             # Wrap the provider sync into a lock to prevent
             # race conditions when multiple providers are syncing at the same time.
             async with self._sync_lock:
-                await provider.sync_library(media_type)
-            # precache playlist tracks
-            if media_type == MediaType.PLAYLIST:
-                for playlist in await self.playlists.library_items(provider=provider.instance_id):
-                    async for _ in self.playlists.tracks(playlist.item_id, playlist.provider):
-                        pass
+                await provider.sync_library(media_type, import_as_favorite)
 
         # we keep track of running sync tasks
         task = self.mass.create_task(run_sync())
@@ -1337,31 +1381,24 @@ class MusicController(CoreController):
                 return
             if task_err := task.exception():
                 self.logger.warning(
-                    "Sync task for %s completed with errors",
+                    "Sync task for %s/%ss completed with errors",
                     provider.name,
+                    media_type.value,
                     exc_info=task_err if self.logger.isEnabledFor(10) else None,
                 )
             else:
-                self.logger.info("Sync task for %s completed", provider.name)
+                self.logger.info("Sync task for %s/%ss completed", provider.name, media_type.value)
             self.mass.signal_event(EventType.SYNC_TASKS_UPDATED, data=self.in_progress_syncs)
+            cache_key = f"last_library_sync_{provider.instance_id}_{media_type.value}"
+            self.mass.create_task(self.mass.cache.set, cache_key, self.mass.loop.time())
             # schedule db cleanup after sync
             if not self.in_progress_syncs:
                 self.mass.create_task(self._cleanup_database())
+            # reschedule next execution
+            self.mass.create_task(self._schedule_provider_mediatype_sync(provider, media_type))
 
         task.add_done_callback(on_sync_task_done)
-
-    def _schedule_sync(self) -> None:
-        """Schedule the periodic sync."""
-        sync_interval = self.config.get_value(CONF_SYNC_INTERVAL) * 60
-
-        def run_scheduled_sync() -> None:
-            # kickoff the sync job
-            self.start_sync()
-            # reschedule ourselves
-            self.mass.loop.call_later(sync_interval, self._schedule_sync)
-
-        # schedule the first sync run
-        self.mass.loop.call_later(sync_interval, run_scheduled_sync)
+        return
 
     def _sort_search_result(
         self,
@@ -1403,6 +1440,46 @@ class MusicController(CoreController):
         # exact name matches and library items.
         return UniqueList([*[x[1] for x in scored_items], *items])
 
+    async def _schedule_provider_mediatype_sync(
+        self, provider: MusicProvider, media_type: MediaType, is_initial: bool = False
+    ) -> None:
+        """Schedule Library sync for given provider and media type."""
+        job_key = f"sync_{provider.instance_id}_{media_type.value}"
+        # cancel any existing timers
+        self.mass.cancel_timer(job_key)
+        # handle mediatype specific sync config
+        conf_key = f"library_import_{media_type}s"
+        sync_conf = await self.mass.config.get_provider_config_value(provider.instance_id, conf_key)
+        if sync_conf == "no_import":
+            return
+        conf_key = f"provider_sync_interval_{media_type.value}s"
+        sync_interval = cast(
+            "int",
+            await self.mass.config.get_provider_config_value(provider.instance_id, conf_key),
+        )
+        if sync_interval <= 0:
+            # sync disabled for this media type
+            return
+        sync_interval = sync_interval * 60  # config interval is in minutes - convert to seconds
+        import_as_favorite = sync_conf == "import_as_favorite"
+
+        if is_initial:
+            # schedule the first sync run
+            cache_key = f"last_library_sync_{provider.instance_id}_{media_type.value}"
+            initial_interval = 10
+            if last_sync := await self.mass.cache.get(cache_key):
+                initial_interval += max(0, sync_interval - (self.mass.loop.time() - last_sync))
+            sync_interval = initial_interval
+
+        self.mass.call_later(
+            sync_interval,
+            self._start_provider_sync,
+            provider,
+            media_type,
+            import_as_favorite,
+            task_id=job_key,
+        )
+
     async def _cleanup_database(self) -> None:
         """Perform database cleanup/maintenance."""
         self.logger.debug("Performing database cleanup...")
@@ -1654,6 +1731,18 @@ class MusicController(CoreController):
             ):
                 await self.database.execute(f"DROP TRIGGER IF EXISTS update_{db_table}_timestamp;")
 
+        if prev_version <= 18:
+            # add in_library column to provider_mappings table
+            await self.database.execute(
+                f"ALTER TABLE {DB_TABLE_PROVIDER_MAPPINGS} ADD COLUMN in_library "
+                "BOOLEAN NOT NULL DEFAULT 0;"
+            )
+            # migrate existing entries in provider_mappings which are filesystem
+            await self.database.execute(
+                f"UPDATE {DB_TABLE_PROVIDER_MAPPINGS} SET in_library = 1 "
+                "WHERE provider_domain in ('filesystem_local', 'filesystem_smb');"
+            )
+
         # save changes
         await self.database.commit()
 
@@ -1667,7 +1756,7 @@ class MusicController(CoreController):
         await asyncio.to_thread(os.remove, db_path)
         await self._setup_database()
         # initiate full sync
-        self.start_sync()
+        await self.start_sync()
 
     async def __create_database_tables(self) -> None:
         """Create database tables."""
@@ -1699,13 +1788,13 @@ class MusicController(CoreController):
                     [version] TEXT,
                     [album_type] TEXT NOT NULL,
                     [year] INTEGER,
-                    [favorite] BOOLEAN DEFAULT 0,
+                    [favorite] BOOLEAN NOT NULL DEFAULT 0,
                     [metadata] json NOT NULL,
                     [external_ids] json NOT NULL,
-                    [play_count] INTEGER DEFAULT 0,
-                    [last_played] INTEGER DEFAULT 0,
+                    [play_count] INTEGER NOT NULL DEFAULT 0,
+                    [last_played] INTEGER NOT NULL DEFAULT 0,
                     [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)),
-                    [timestamp_modified] INTEGER,
+                    [timestamp_modified] INTEGER NOT NULL DEFAULT 0,
                     [search_name] TEXT NOT NULL,
                     [search_sort_name] TEXT NOT NULL
                 );"""
@@ -1716,13 +1805,13 @@ class MusicController(CoreController):
             [item_id] INTEGER PRIMARY KEY AUTOINCREMENT,
             [name] TEXT NOT NULL,
             [sort_name] TEXT NOT NULL,
-            [favorite] BOOLEAN DEFAULT 0,
+            [favorite] BOOLEAN NOT NULL DEFAULT 0,
             [metadata] json NOT NULL,
             [external_ids] json NOT NULL,
             [play_count] INTEGER DEFAULT 0,
             [last_played] INTEGER DEFAULT 0,
             [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)),
-            [timestamp_modified] INTEGER,
+            [timestamp_modified] INTEGER NOT NULL DEFAULT 0,
             [search_name] TEXT NOT NULL,
             [search_sort_name] TEXT NOT NULL
             );"""
@@ -1735,13 +1824,13 @@ class MusicController(CoreController):
             [sort_name] TEXT NOT NULL,
             [version] TEXT,
             [duration] INTEGER,
-            [favorite] BOOLEAN DEFAULT 0,
+            [favorite] BOOLEAN NOT NULL DEFAULT 0,
             [metadata] json NOT NULL,
             [external_ids] json NOT NULL,
             [play_count] INTEGER DEFAULT 0,
             [last_played] INTEGER DEFAULT 0,
             [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)),
-            [timestamp_modified] INTEGER,
+            [timestamp_modified] INTEGER NOT NULL DEFAULT 0,
             [search_name] TEXT NOT NULL,
             [search_sort_name] TEXT NOT NULL
             );"""
@@ -1755,13 +1844,13 @@ class MusicController(CoreController):
             [owner] TEXT NOT NULL,
             [is_editable] BOOLEAN NOT NULL,
             [cache_checksum] TEXT DEFAULT '',
-            [favorite] BOOLEAN DEFAULT 0,
+            [favorite] BOOLEAN NOT NULL DEFAULT 0,
             [metadata] json NOT NULL,
             [external_ids] json NOT NULL,
             [play_count] INTEGER DEFAULT 0,
             [last_played] INTEGER DEFAULT 0,
             [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)),
-            [timestamp_modified] INTEGER,
+            [timestamp_modified] INTEGER NOT NULL DEFAULT 0,
             [search_name] TEXT NOT NULL,
             [search_sort_name] TEXT NOT NULL
             );"""
@@ -1772,13 +1861,13 @@ class MusicController(CoreController):
             [item_id] INTEGER PRIMARY KEY AUTOINCREMENT,
             [name] TEXT NOT NULL,
             [sort_name] TEXT NOT NULL,
-            [favorite] BOOLEAN DEFAULT 0,
+            [favorite] BOOLEAN NOT NULL DEFAULT 0,
             [metadata] json NOT NULL,
             [external_ids] json NOT NULL,
             [play_count] INTEGER DEFAULT 0,
             [last_played] INTEGER DEFAULT 0,
             [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)),
-            [timestamp_modified] INTEGER,
+            [timestamp_modified] INTEGER NOT NULL DEFAULT 0,
             [search_name] TEXT NOT NULL,
             [search_sort_name] TEXT NOT NULL
             );"""
@@ -1790,7 +1879,7 @@ class MusicController(CoreController):
             [name] TEXT NOT NULL,
             [sort_name] TEXT NOT NULL,
             [version] TEXT,
-            [favorite] BOOLEAN DEFAULT 0,
+            [favorite] BOOLEAN NOT NULL DEFAULT 0,
             [publisher] TEXT,
             [authors] json NOT NULL,
             [narrators] json NOT NULL,
@@ -1800,7 +1889,7 @@ class MusicController(CoreController):
             [play_count] INTEGER DEFAULT 0,
             [last_played] INTEGER DEFAULT 0,
             [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)),
-            [timestamp_modified] INTEGER,
+            [timestamp_modified] INTEGER NOT NULL DEFAULT 0,
             [search_name] TEXT NOT NULL,
             [search_sort_name] TEXT NOT NULL
             );"""
@@ -1812,15 +1901,15 @@ class MusicController(CoreController):
             [name] TEXT NOT NULL,
             [sort_name] TEXT NOT NULL,
             [version] TEXT,
-            [favorite] BOOLEAN DEFAULT 0,
+            [favorite] BOOLEAN NOT NULL DEFAULT 0,
             [publisher] TEXT,
-            [total_episodes] INTEGER,
+            [total_episodes] INTEGER NOT NULL,
             [metadata] json NOT NULL,
             [external_ids] json NOT NULL,
-            [play_count] INTEGER DEFAULT 0,
-            [last_played] INTEGER DEFAULT 0,
+            [play_count] INTEGER NOT NULL DEFAULT 0,
+            [last_played] INTEGER NOT NULL DEFAULT 0,
             [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)),
-            [timestamp_modified] INTEGER,
+            [timestamp_modified] INTEGER NOT NULL DEFAULT 0,
             [search_name] TEXT NOT NULL,
             [search_sort_name] TEXT NOT NULL
             );"""
@@ -1846,7 +1935,8 @@ class MusicController(CoreController):
             [provider_domain] TEXT NOT NULL,
             [provider_instance] TEXT NOT NULL,
             [provider_item_id] TEXT NOT NULL,
-            [available] BOOLEAN DEFAULT 1,
+            [available] BOOLEAN NOT NULL DEFAULT 1,
+            [in_library] BOOLEAN NOT NULL DEFAULT 0,
             [url] text,
             [audio_format] json,
             [details] TEXT,
@@ -1959,6 +2049,11 @@ class MusicController(CoreController):
             f"{DB_TABLE_PROVIDER_MAPPINGS}_media_type_provider_domain_idx "
             f"on {DB_TABLE_PROVIDER_MAPPINGS}(media_type,provider_domain);"
         )
+        await self.database.execute(
+            "CREATE INDEX IF NOT EXISTS "
+            f"{DB_TABLE_PROVIDER_MAPPINGS}_media_type_provider_instance_library_idx "
+            f"on {DB_TABLE_PROVIDER_MAPPINGS}(media_type,provider_instance,in_library);"
+        )
 
         # indexes on track_artists table
         await self.database.execute(
index 3dd534eddccbae0437c7ca985be1ee9e71907d00..95fb17e6a553298748bacfc7d843c425a5921187 100644 (file)
@@ -204,6 +204,16 @@ class DatabaseConnection:
         """Insert or replace data in given table."""
         return await self.insert(table=table, values=values, allow_replace=True)
 
+    async def upsert(self, table: str, values: dict[str, Any]) -> None:
+        """Upsert data in given table."""
+        keys = tuple(values.keys())
+        sql_query = (
+            f"INSERT INTO {table}({','.join(keys)}) VALUES ({','.join(f':{x}' for x in keys)})"
+        )
+        sql_query += f" ON CONFLICT DO UPDATE SET {','.join(f'{x}=:{x}' for x in keys)}"
+        await self._db.execute(sql_query, values)
+        await self._db.commit()
+
     async def update(
         self,
         table: str,
index c93caa40c6f3028557a2e6c6ea4c68e27c209706..76ee6b17053beada8e16b557341594f08c263187 100644 (file)
@@ -580,6 +580,7 @@ class MusicAssistant:
 
     async def unload_provider(self, instance_id: str, is_removed: bool = False) -> None:
         """Unload a provider."""
+        self.music.unschedule_provider_sync(instance_id)
         if provider := self._providers.get(instance_id):
             # remove mdns discovery if needed
             if provider.manifest.mdns_discovery:
@@ -715,6 +716,8 @@ class MusicAssistant:
         self.config.set(f"{CONF_PROVIDERS}/{conf.instance_id}/last_error", None)
         self.signal_event(EventType.PROVIDERS_UPDATED, data=self.get_providers())
         await self._update_available_providers_cache()
+        if isinstance(provider, MusicProvider):
+            await self.music.schedule_provider_sync(provider.instance_id)
 
     async def __load_provider_manifests(self) -> None:
         """Preload all available provider manifest files."""
index 468f8d7159b5f0b728dd68784f04a963a3ec4552..5eb0a982b6bfa58fdad8f9e90a470496528be1e3 100644 (file)
@@ -11,6 +11,7 @@ from .plugin import PluginProvider
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
+    from music_assistant_models.enums import ProviderFeature
     from music_assistant_models.provider import ProviderManifest
 
     from music_assistant.mass import MusicAssistant
@@ -22,6 +23,9 @@ ProviderInstanceType = MetadataProvider | MusicProvider | PlayerProvider | Plugi
 class ProviderModuleType(Protocol):
     """Model for a provider module to support type hints."""
 
+    """Return the (base) features supported by this Provider."""
+    SUPPORTED_FEATURES: set[ProviderFeature]
+
     @staticmethod
     async def setup(
         mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
index 8e4ee60a14bd25947366230faf8cc9c0974f083c..a5b6d3115c4c20d9b9fef23de42138cb79558cc9 100644 (file)
@@ -12,24 +12,12 @@ if TYPE_CHECKING:
     from music_assistant_models.media_items import Album, Artist, MediaItemMetadata, Track
 
 
-DEFAULT_SUPPORTED_FEATURES = {
-    ProviderFeature.ARTIST_METADATA,
-    ProviderFeature.ALBUM_METADATA,
-    ProviderFeature.TRACK_METADATA,
-}
-
-
 class MetadataProvider(Provider):
     """Base representation of a Metadata Provider (controller).
 
     Metadata Provider implementations should inherit from this base model.
     """
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return DEFAULT_SUPPORTED_FEATURES
-
     async def get_artist_metadata(self, artist: Artist) -> MediaItemMetadata | None:
         """Retrieve metadata for an artist on this Metadata provider."""
         if ProviderFeature.ARTIST_METADATA in self.supported_features:
index f489bcc63dea65a13a904d0af47c97a02e0fec7d..5961df4d9f9264fff53a995b07e965acae3d7d63 100644 (file)
@@ -28,7 +28,11 @@ from music_assistant_models.media_items import (
     Track,
 )
 
-from music_assistant.constants import CACHE_CATEGORY_LIBRARY_ITEMS
+from music_assistant.constants import (
+    CACHE_CATEGORY_LIBRARY_ITEMS,
+    CONF_ENTRY_LIBRARY_IMPORT_ALBUM_TRACKS,
+    CONF_ENTRY_LIBRARY_IMPORT_PLAYLIST_TRACKS,
+)
 
 from .provider import Provider
 
@@ -37,14 +41,6 @@ if TYPE_CHECKING:
 
     from music_assistant_models.streamdetails import StreamDetails
 
-    from music_assistant.controllers.media.albums import AlbumsController
-    from music_assistant.controllers.media.artists import ArtistsController
-    from music_assistant.controllers.media.audiobooks import AudiobooksController
-    from music_assistant.controllers.media.playlists import PlaylistController
-    from music_assistant.controllers.media.podcasts import PodcastsController
-    from music_assistant.controllers.media.radio import RadioController
-    from music_assistant.controllers.media.tracks import TracksController
-
 
 class MusicProvider(Provider):
     """Base representation of a Music Provider (controller).
@@ -445,7 +441,7 @@ class MusicProvider(Provider):
             return await self.get_podcast_episode(prov_item_id)
         return await self.get_track(prov_item_id)
 
-    async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:  # noqa: PLR0911
+    async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]:  # noqa: PLR0911, PLR0915
         """Browse this provider's items.
 
         :param path: The path to browse, (e.g. provider_id://artists).
@@ -653,188 +649,86 @@ class MusicProvider(Provider):
             raise NotImplementedError
         return []
 
-    async def sync_library(self, media_type: MediaType) -> None:
+    async def sync_library(self, media_type: MediaType, import_as_favorite: bool) -> None:
         """Run library sync for this provider."""
-        # ruff: noqa: PLR0915 # too many statements
-        # this reference implementation can be overridden
+        # this reference implementation may be overridden
         # with a provider specific approach if needed
 
-        async def _controller_update_item_in_library(
-            controller: ArtistsController
-            | AlbumsController
-            | TracksController
-            | RadioController
-            | PlaylistController
-            | AudiobooksController
-            | PodcastsController,
-            prov_item: MediaItemType,
-            item_id: str | int,
-        ) -> Artist | Album | Track | Radio | Playlist | Audiobook | Podcast:
-            """Update media item in controller including type checking.
-
-            all isinstance(...) for type checking. The statement
-            library_item = await controller.update_item_in_library(prov_item)
-            cannot be moved out of this scope.
-            """
-            library_item: Artist | Album | Track | Radio | Playlist | Audiobook | Podcast
-            if TYPE_CHECKING:
-                if isinstance(prov_item, Artist):
-                    assert isinstance(controller, ArtistsController)
-                    library_item = await controller.update_item_in_library(item_id, prov_item)
-                elif isinstance(prov_item, Album):
-                    assert isinstance(controller, AlbumsController)
-                    library_item = await controller.update_item_in_library(item_id, prov_item)
-                elif isinstance(prov_item, Track):
-                    assert isinstance(controller, TracksController)
-                    library_item = await controller.update_item_in_library(item_id, prov_item)
-                elif isinstance(prov_item, Radio):
-                    assert isinstance(controller, RadioController)
-                    library_item = await controller.update_item_in_library(item_id, prov_item)
-                elif isinstance(prov_item, Playlist):
-                    assert isinstance(controller, PlaylistController)
-                    library_item = await controller.update_item_in_library(item_id, prov_item)
-                elif isinstance(prov_item, Audiobook):
-                    assert isinstance(controller, AudiobooksController)
-                    library_item = await controller.update_item_in_library(item_id, prov_item)
-                elif isinstance(prov_item, Podcast):
-                    assert isinstance(controller, PodcastsController)
-                    library_item = await controller.update_item_in_library(item_id, prov_item)
-                else:
-                    raise TypeError("Prov item unknown in this context.")
-                return library_item
-            else:
-                return await controller.update_item_in_library(item_id, prov_item)
-
         if not self.library_supported(media_type):
             raise UnsupportedFeaturedException("Library sync not supported for this media type")
-        self.logger.debug("Start sync of %s items.", media_type.value)
-        controller = self.mass.music.get_controller(media_type)
-        cur_db_ids = set()
-        async for prov_item in self._get_library_gen(media_type):
-            library_item = await controller.get_library_item_by_prov_mappings(
-                prov_item.provider_mappings,
-            )
-            assert not isinstance(prov_item, PodcastEpisode)
-            try:
-                if not library_item and not prov_item.available:
-                    # skip unavailable tracks
-                    self.logger.debug(
-                        "Skipping sync of item %s because it is unavailable",
-                        prov_item.uri,
-                    )
-                    continue
-                if not library_item:
-                    # create full db item
-                    # note that we skip the metadata lookup purely to speed up the sync
-                    # the additional metadata is then lazy retrieved afterwards
-                    if self.is_streaming_provider:
-                        prov_item.favorite = True
-
-                    # all isinstance(...) for type checking. The statement
-                    # library_item = await controller.add_item_to_library(prov_item)
-                    # cannot be moved out of this scope.
-                    if TYPE_CHECKING:
-                        if isinstance(prov_item, Artist):
-                            assert isinstance(controller, ArtistsController)
-                            library_item = await controller.add_item_to_library(prov_item)
-                        elif isinstance(prov_item, Album):
-                            assert isinstance(controller, AlbumsController)
-                            library_item = await controller.add_item_to_library(prov_item)
-                        elif isinstance(prov_item, Track):
-                            assert isinstance(controller, TracksController)
-                            library_item = await controller.add_item_to_library(prov_item)
-                        elif isinstance(prov_item, Radio):
-                            assert isinstance(controller, RadioController)
-                            library_item = await controller.add_item_to_library(prov_item)
-                        elif isinstance(prov_item, Playlist):
-                            assert isinstance(controller, PlaylistController)
-                            library_item = await controller.add_item_to_library(prov_item)
-                        elif isinstance(prov_item, Audiobook):
-                            assert isinstance(controller, AudiobooksController)
-                            library_item = await controller.add_item_to_library(prov_item)
-                        elif isinstance(prov_item, Podcast):
-                            assert isinstance(controller, PodcastsController)
-                            library_item = await controller.add_item_to_library(prov_item)
-                        else:
-                            raise RuntimeError
-                    else:
-                        library_item = await controller.add_item_to_library(prov_item)
-                elif getattr(library_item, "cache_checksum", None) != getattr(
-                    prov_item, "cache_checksum", None
-                ):
-                    # existing dbitem checksum changed (playlists only)
-                    if TYPE_CHECKING:
-                        assert isinstance(prov_item, Playlist)
-                        assert isinstance(controller, PlaylistController)
-                    library_item = await controller.update_item_in_library(
-                        library_item.item_id, prov_item
-                    )
-                if library_item.available != prov_item.available:
-                    # existing item availability changed
-                    library_item = await _controller_update_item_in_library(
-                        controller, prov_item, library_item.item_id
-                    )
-                # check if resume_position_ms or fully_played changed (audiobook only)
-                resume_pos_prov = getattr(prov_item, "resume_position_ms", None)
-                fully_played_prov = getattr(prov_item, "fully_played", None)
-                if (
-                    resume_pos_prov is not None
-                    and fully_played_prov is not None
-                    and (
-                        getattr(library_item, "resume_position_ms", None) != resume_pos_prov
-                        or getattr(library_item, "fully_played", None) != fully_played_prov
-                    )
-                ):
-                    library_item = await _controller_update_item_in_library(
-                        controller, prov_item, library_item.item_id
-                    )
 
-                cur_db_ids.add(int(library_item.item_id))
-                await asyncio.sleep(0)  # yield to eventloop
-            except MusicAssistantError as err:
-                self.logger.warning(
-                    "Skipping sync of item %s - error details: %s",
-                    prov_item.uri,
-                    str(err),
-                )
+        if media_type == MediaType.ARTIST:
+            cur_db_ids = await self._sync_library_artists(import_as_favorite)
+        elif media_type == MediaType.ALBUM:
+            cur_db_ids = await self._sync_library_albums(import_as_favorite)
+        elif media_type == MediaType.TRACK:
+            cur_db_ids = await self._sync_library_tracks(import_as_favorite)
+        elif media_type == MediaType.PLAYLIST:
+            cur_db_ids = await self._sync_library_playlists(import_as_favorite)
+        elif media_type == MediaType.PODCAST:
+            cur_db_ids = await self._sync_library_podcasts(import_as_favorite)
+        elif media_type == MediaType.RADIO:
+            cur_db_ids = await self._sync_library_radios(import_as_favorite)
+        elif media_type == MediaType.AUDIOBOOK:
+            cur_db_ids = await self._sync_library_audiobooks(import_as_favorite)
+        else:
+            # this should not happen but catch it anyways
+            raise UnsupportedFeaturedException(f"Unexpected media type to sync: {media_type}")
 
         # process deletions (= no longer in library)
         cache_category = CACHE_CATEGORY_LIBRARY_ITEMS
         cache_base_key = self.instance_id
 
         prev_library_items: list[int] | None
+        controller = self.mass.music.get_controller(media_type)
         if prev_library_items := await self.mass.cache.get(
             media_type.value, category=cache_category, base_key=cache_base_key
         ):
             for db_id in prev_library_items:
                 if db_id not in cur_db_ids:
                     try:
-                        item = await controller.get_library_item(db_id)
+                        library_item = await controller.get_library_item(db_id)
                     except MediaNotFoundError:
                         # edge case: the item is already removed
                         continue
+                    # check if we have other provider-mappings (marked as in-library)
                     remaining_providers = {
-                        x.provider_domain
-                        for x in item.provider_mappings
-                        if x.provider_domain != self.domain
+                        x.provider_instance
+                        for x in library_item.provider_mappings
+                        if x.provider_instance != self.instance_id and x.in_library
                     }
                     if remaining_providers:
-                        continue
-                    # this item is removed from the provider's library
-                    # and we have no other providers attached to it
-                    # it is safe to remove it from the MA library too
-                    # note that we do not remove item's recursively on purpose
-                    try:
-                        await controller.remove_item_from_library(db_id, recursive=False)
-                    except MusicAssistantError as err:
-                        # this is probably because the item still has dependents
-                        self.logger.warning(
-                            "Error removing item %s from library: %s", db_id, str(err)
+                        # if we have other remaining providers, update the provider mappings
+                        for prov_map in library_item.provider_mappings:
+                            if prov_map.provider_instance == self.instance_id:
+                                prov_map.in_library = False
+                        await controller.set_provider_mappings(
+                            db_id, library_item.provider_mappings
                         )
-                        # just un-favorite the item if we can't remove it
-                        await controller.set_favorite(db_id, False)
+                    else:
+                        # this item is removed from the provider's library
+                        # and we have no other providers attached to it
+                        # it is safe to remove it from the MA library too
+                        try:
+                            await controller.remove_item_from_library(
+                                db_id, recursive=media_type == MediaType.ALBUM
+                            )
+                        except MusicAssistantError as err:
+                            # this is probably because the item still has dependents
+                            self.logger.warning(
+                                "Error removing item %s from library: %s", db_id, str(err)
+                            )
+                            # just un-favorite the item if we can't remove it
+                            if library_item.favorite:
+                                await controller.set_favorite(db_id, False)
+                            for prov_map in library_item.provider_mappings:
+                                if prov_map.provider_instance == self.instance_id:
+                                    prov_map.in_library = False
+                            await controller.set_provider_mappings(
+                                db_id, library_item.provider_mappings
+                            )
                     await asyncio.sleep(0)  # yield to eventloop
-
+        # store current list of id's in cache so we can track changes
         await self.mass.cache.set(
             media_type.value,
             list(cur_db_ids),
@@ -842,6 +736,349 @@ class MusicProvider(Provider):
             base_key=cache_base_key,
         )
 
+    async def _sync_library_artists(self, import_as_favorite: bool) -> set[int]:
+        """Sync Library Artists to Music Assistant library."""
+        self.logger.debug("Start sync of Artists to Music Assistant library.")
+        cur_db_ids: set[int] = set()
+        async for prov_item in self.get_library_artists():
+            library_item = await self.mass.music.artists.get_library_item_by_prov_mappings(
+                prov_item.provider_mappings,
+            )
+            try:
+                if not library_item:
+                    # add item to the library
+                    if import_as_favorite:
+                        prov_item.favorite = True
+                    library_item = await self.mass.music.artists.add_item_to_library(prov_item)
+                elif not library_item.favorite and import_as_favorite:
+                    # existing library item not favorite but should be
+                    await self.mass.music.artists.set_favorite(library_item.item_id, True)
+                elif not self._check_provider_mappings(library_item, prov_item, True):
+                    # existing library item but provider mapping doesn't match
+                    library_item = await self.mass.music.artists.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
+                cur_db_ids.add(int(library_item.item_id))
+                await asyncio.sleep(0)  # yield to eventloop
+            except MusicAssistantError as err:
+                self.logger.warning(
+                    "Skipping sync of artist %s - error details: %s",
+                    prov_item.uri,
+                    str(err),
+                )
+        return cur_db_ids
+
+    async def _sync_library_albums(self, import_as_favorite: bool) -> set[int]:
+        """Sync Library Albums to Music Assistant library."""
+        self.logger.debug("Start sync of Albums to Music Assistant library.")
+        cur_db_ids: set[int] = set()
+        conf_sync_album_tracks = self.config.get_value(
+            CONF_ENTRY_LIBRARY_IMPORT_ALBUM_TRACKS.key,
+            CONF_ENTRY_LIBRARY_IMPORT_ALBUM_TRACKS.default_value,
+        )
+        sync_album_tracks = bool(conf_sync_album_tracks)
+        async for prov_item in self.get_library_albums():
+            library_item = await self.mass.music.albums.get_library_item_by_prov_mappings(
+                prov_item.provider_mappings,
+            )
+            try:
+                if not library_item:
+                    # add item to the library
+                    if import_as_favorite:
+                        prov_item.favorite = True
+                    library_item = await self.mass.music.albums.add_item_to_library(prov_item)
+                elif not library_item.favorite and import_as_favorite:
+                    # existing library item not favorite but should be
+                    await self.mass.music.albums.set_favorite(library_item.item_id, True)
+                elif not self._check_provider_mappings(library_item, prov_item, True):
+                    # existing library item but provider mapping doesn't match
+                    library_item = await self.mass.music.albums.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
+                cur_db_ids.add(int(library_item.item_id))
+                await asyncio.sleep(0)  # yield to eventloop
+                # optionally add album tracks to library
+                if sync_album_tracks:
+                    await self._sync_album_tracks(prov_item)
+            except MusicAssistantError as err:
+                self.logger.warning(
+                    "Skipping sync of album %s - error details: %s",
+                    prov_item.uri,
+                    str(err),
+                )
+        return cur_db_ids
+
+    async def _sync_album_tracks(self, provider_album: Album) -> None:
+        """Sync Album Tracks to Music Assistant library."""
+        self.logger.debug(
+            "Start sync of Album Tracks to Music Assistant library for album %s.",
+            provider_album.name,
+        )
+        for prov_track in await self.get_album_tracks(provider_album.item_id):
+            library_track = await self.mass.music.tracks.get_library_item_by_prov_mappings(
+                prov_track.provider_mappings,
+            )
+            try:
+                if not library_track:
+                    # add item to the library
+                    library_track = await self.mass.music.tracks.add_item_to_library(prov_track)
+                elif not self._check_provider_mappings(library_track, prov_track, True):
+                    # existing library track but provider mapping doesn't match
+                    library_track = await self.mass.music.tracks.update_item_in_library(
+                        library_track.item_id, prov_track
+                    )
+                await asyncio.sleep(0)  # yield to eventloop
+            except MusicAssistantError as err:
+                self.logger.warning(
+                    "Skipping sync of album track %s - error details: %s",
+                    prov_track.uri,
+                    str(err),
+                )
+
+    async def _sync_library_audiobooks(self, import_as_favorite: bool) -> set[int]:
+        """Sync Library Audiobooks to Music Assistant library."""
+        self.logger.debug("Start sync of Audiobooks to Music Assistant library.")
+        cur_db_ids: set[int] = set()
+        async for prov_item in self.get_library_audiobooks():
+            library_item = await self.mass.music.audiobooks.get_library_item_by_prov_mappings(
+                prov_item.provider_mappings,
+            )
+            try:
+                if not library_item:
+                    # add item to the library
+                    if import_as_favorite:
+                        prov_item.favorite = True
+                    library_item = await self.mass.music.audiobooks.add_item_to_library(prov_item)
+                elif not library_item.favorite and import_as_favorite:
+                    # existing library item not favorite but should be
+                    await self.mass.music.audiobooks.set_favorite(library_item.item_id, True)
+                elif not self._check_provider_mappings(library_item, prov_item, True):
+                    # existing library item but provider mapping doesn't match
+                    library_item = await self.mass.music.audiobooks.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
+
+                # check if resume_position_ms or fully_played changed
+                if (
+                    prov_item.resume_position_ms is not None
+                    and prov_item.fully_played is not None
+                    and (
+                        library_item.resume_position_ms != prov_item.resume_position_ms
+                        or library_item.fully_played != prov_item.fully_played
+                    )
+                ):
+                    library_item = await self.mass.music.audiobooks.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
+
+                cur_db_ids.add(int(library_item.item_id))
+                await asyncio.sleep(0)  # yield to eventloop
+            except MusicAssistantError as err:
+                self.logger.warning(
+                    "Skipping sync of audiobook %s - error details: %s",
+                    prov_item.uri,
+                    str(err),
+                )
+        return cur_db_ids
+
+    async def _sync_library_playlists(self, import_as_favorite: bool) -> set[int]:
+        """Sync Library Playlists to Music Assistant library."""
+        self.logger.debug("Start sync of Playlists to Music Assistant library.")
+        conf_sync_playlist_tracks = self.config.get_value(
+            CONF_ENTRY_LIBRARY_IMPORT_PLAYLIST_TRACKS.key,
+            CONF_ENTRY_LIBRARY_IMPORT_PLAYLIST_TRACKS.default_value,
+        )
+        conf_sync_playlist_tracks = cast("list[str]", conf_sync_playlist_tracks)
+        cur_db_ids: set[int] = set()
+        async for prov_item in self.get_library_playlists():
+            library_item = await self.mass.music.playlists.get_library_item_by_prov_mappings(
+                prov_item.provider_mappings,
+            )
+            try:
+                if not library_item:
+                    # add item to the library
+                    if import_as_favorite:
+                        prov_item.favorite = True
+                    library_item = await self.mass.music.playlists.add_item_to_library(prov_item)
+                elif library_item.cache_checksum != prov_item.cache_checksum:
+                    # existing dbitem checksum changed (used to determine if a playlist has changed)
+                    library_item = await self.mass.music.playlists.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
+                elif not library_item.favorite and import_as_favorite:
+                    # existing library item not favorite but should be
+                    await self.mass.music.playlists.set_favorite(library_item.item_id, True)
+                elif not self._check_provider_mappings(library_item, prov_item, True):
+                    # existing library item but provider mapping doesn't match
+                    library_item = await self.mass.music.playlists.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
+                cur_db_ids.add(int(library_item.item_id))
+                await asyncio.sleep(0)  # yield to eventloop
+                # optionally sync playlist tracks
+                if (
+                    prov_item.name in conf_sync_playlist_tracks
+                    or prov_item.uri in conf_sync_playlist_tracks
+                ):
+                    await self._sync_playlist_tracks(prov_item)
+            except MusicAssistantError as err:
+                self.logger.warning(
+                    "Skipping sync of playlist %s - error details: %s",
+                    prov_item.uri,
+                    str(err),
+                )
+        return cur_db_ids
+
+    async def _sync_playlist_tracks(self, provider_playlist: Playlist) -> None:
+        """Sync Playlist Tracks to Music Assistant library."""
+        self.logger.debug(
+            "Start sync of Playlist Tracks to Music Assistant library for playlist %s.",
+            provider_playlist.name,
+        )
+        async for prov_track in self.iter_playlist_tracks(provider_playlist.item_id):
+            library_track = await self.mass.music.tracks.get_library_item_by_prov_mappings(
+                prov_track.provider_mappings,
+            )
+            try:
+                if not library_track:
+                    # add item to the library
+                    library_track = await self.mass.music.tracks.add_item_to_library(prov_track)
+                elif not self._check_provider_mappings(library_track, prov_track, True):
+                    # existing library track but provider mapping doesn't match
+                    library_track = await self.mass.music.tracks.update_item_in_library(
+                        library_track.item_id, prov_track
+                    )
+                await asyncio.sleep(0)  # yield to eventloop
+            except MusicAssistantError as err:
+                self.logger.warning(
+                    "Skipping sync of album track %s - error details: %s",
+                    prov_track.uri,
+                    str(err),
+                )
+
+    async def _sync_library_tracks(self, import_as_favorite: bool) -> set[int]:
+        """Sync Library Tracks to Music Assistant library."""
+        self.logger.debug("Start sync of Tracks to Music Assistant library.")
+        cur_db_ids: set[int] = set()
+        async for prov_item in self.get_library_tracks():
+            library_item = await self.mass.music.tracks.get_library_item_by_prov_mappings(
+                prov_item.provider_mappings,
+            )
+            try:
+                if not library_item and not prov_item.available:
+                    # skip unavailable tracks
+                    # TODO: do we want to search for substitutes at this point ?
+                    self.logger.debug(
+                        "Skipping sync of track %s because it is unavailable",
+                        prov_item.uri,
+                    )
+                    continue
+                if not library_item:
+                    # add item to the library
+                    if import_as_favorite:
+                        prov_item.favorite = True
+                    library_item = await self.mass.music.tracks.add_item_to_library(prov_item)
+                elif library_item.available != prov_item.available:
+                    # existing library item but availability changed
+                    library_item = await self.mass.music.tracks.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
+                elif not library_item.favorite and import_as_favorite:
+                    # existing library item not favorite but should be
+                    await self.mass.music.tracks.set_favorite(library_item.item_id, True)
+                elif not self._check_provider_mappings(library_item, prov_item, True):
+                    # existing library item but provider mapping doesn't match
+                    library_item = await self.mass.music.tracks.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
+                cur_db_ids.add(int(library_item.item_id))
+                await asyncio.sleep(0)  # yield to eventloop
+            except MusicAssistantError as err:
+                self.logger.warning(
+                    "Skipping sync of track %s - error details: %s",
+                    prov_item.uri,
+                    str(err),
+                )
+        return cur_db_ids
+
+    async def _sync_library_podcasts(self, import_as_favorite: bool) -> set[int]:
+        """Sync Library Podcasts to Music Assistant library."""
+        self.logger.debug("Start sync of Podcasts to Music Assistant library.")
+        cur_db_ids: set[int] = set()
+        async for prov_item in self.get_library_podcasts():
+            library_item = await self.mass.music.podcasts.get_library_item_by_prov_mappings(
+                prov_item.provider_mappings,
+            )
+            try:
+                if not library_item:
+                    # add item to the library
+                    if import_as_favorite:
+                        prov_item.favorite = True
+                    library_item = await self.mass.music.podcasts.add_item_to_library(prov_item)
+                elif library_item.available != prov_item.available:
+                    # existing library item but availability changed
+                    library_item = await self.mass.music.podcasts.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
+                elif not library_item.favorite and import_as_favorite:
+                    # existing library item not favorite but should be
+                    await self.mass.music.podcasts.set_favorite(library_item.item_id, True)
+                elif not self._check_provider_mappings(library_item, prov_item, True):
+                    # existing library item but provider mapping doesn't match
+                    library_item = await self.mass.music.podcasts.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
+
+                cur_db_ids.add(int(library_item.item_id))
+                await asyncio.sleep(0)  # yield to eventloop
+
+                # precache podcast episodes
+                async for _ in self.mass.music.podcasts.episodes(
+                    library_item.item_id, library_item.provider
+                ):
+                    await asyncio.sleep(0)  # yield to eventloop
+            except MusicAssistantError as err:
+                self.logger.warning(
+                    "Skipping sync of podcast %s - error details: %s",
+                    prov_item.uri,
+                    str(err),
+                )
+        return cur_db_ids
+
+    async def _sync_library_radios(self, import_as_favorite: bool) -> set[int]:
+        """Sync Library Radios to Music Assistant library."""
+        self.logger.debug("Start sync of Radios to Music Assistant library.")
+        cur_db_ids: set[int] = set()
+        async for prov_item in self.get_library_radios():
+            library_item = await self.mass.music.radio.get_library_item_by_prov_mappings(
+                prov_item.provider_mappings,
+            )
+            try:
+                if not library_item:
+                    # add item to the library
+                    if import_as_favorite:
+                        prov_item.favorite = True
+                    library_item = await self.mass.music.radio.add_item_to_library(prov_item)
+                elif not library_item.favorite and import_as_favorite:
+                    # existing library item not favorite but should be
+                    await self.mass.music.radio.set_favorite(library_item.item_id, True)
+                elif not self._check_provider_mappings(library_item, prov_item, True):
+                    # existing library item but provider mapping doesn't match
+                    library_item = await self.mass.music.radio.update_item_in_library(
+                        library_item.item_id, prov_item
+                    )
+
+                cur_db_ids.add(int(library_item.item_id))
+                await asyncio.sleep(0)  # yield to eventloop
+
+            except MusicAssistantError as err:
+                self.logger.warning(
+                    "Skipping sync of Radio %s - error details: %s",
+                    prov_item.uri,
+                    str(err),
+                )
+        return cur_db_ids
+
     # DO NOT OVERRIDE BELOW
 
     def library_supported(self, media_type: MediaType) -> bool:
@@ -880,6 +1117,23 @@ class MusicProvider(Provider):
             return ProviderFeature.LIBRARY_PODCASTS_EDIT in self.supported_features
         return False
 
+    async def iter_playlist_tracks(
+        self,
+        prov_playlist_id: str,
+    ) -> AsyncGenerator[Track, None]:
+        """Iterate playlist tracks for the given provider playlist id."""
+        page = 0
+        while True:
+            tracks = await self.get_playlist_tracks(
+                prov_playlist_id,
+                page=page,
+            )
+            if not tracks:
+                break
+            for track in tracks:
+                yield track
+            page += 1
+
     def _get_library_gen(self, media_type: MediaType) -> AsyncGenerator[MediaItemType, None]:
         """Return library generator for given media_type."""
         if media_type == MediaType.ARTIST:
@@ -897,3 +1151,29 @@ class MusicProvider(Provider):
         if media_type == MediaType.PODCAST:
             return self.get_library_podcasts()
         raise NotImplementedError
+
+    def _check_provider_mappings(
+        self, library_item: MediaItemType, provider_item: MediaItemType, in_library: bool
+    ) -> bool:
+        """Check if provider mapping(s) are consistent between library and provider items."""
+        for provider_mapping in provider_item.provider_mappings:
+            if provider_mapping.item_id != provider_item.item_id:
+                raise MusicAssistantError("Inconsistent provider mapping item_id's found")
+            if provider_mapping.provider_instance != self.instance_id:
+                raise MusicAssistantError("Inconsistent provider mapping instance_id's found")
+            provider_mapping.in_library = in_library
+            library_mapping = next(
+                (
+                    x
+                    for x in library_item.provider_mappings
+                    if x.provider_instance == provider_mapping.provider_instance
+                    and x.item_id == provider_mapping.item_id
+                ),
+                None,
+            )
+            if not library_mapping:
+                return False
+            if provider_mapping.in_library != library_mapping.in_library:
+                return False
+            return provider_mapping.available == library_mapping.available
+        return False
index fb6c24b3e9a11eb4e4596e22b65e8eba9e5c615c..10685e28050b090a0f39dbb65913203c509cdd15 100644 (file)
@@ -23,12 +23,17 @@ class Provider:
     """Base representation of a Provider implementation within Music Assistant."""
 
     def __init__(
-        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+        self,
+        mass: MusicAssistant,
+        manifest: ProviderManifest,
+        config: ProviderConfig,
+        supported_features: set[ProviderFeature] | None = None,
     ) -> None:
         """Initialize MusicProvider."""
         self.mass = mass
         self.manifest = manifest
         self.config = config
+        self._supported_features = supported_features or set()
         mass_logger = logging.getLogger(MASS_LOGGER_NAME)
         self.logger = mass_logger.getChild(self.domain)
         log_level = str(config.get_value(CONF_LOG_LEVEL))
@@ -46,7 +51,8 @@ class Provider:
     @property
     def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        return set()
+        # should not be overridden in normal circumstances
+        return self._supported_features
 
     @property
     def lookup_key(self) -> str:
index 36fb44114c5f8f52afdaa624ee6a8fc6a15844ec..e1bbb853fb3068388fc128cc2acfeab1a1167410 100644 (file)
@@ -67,6 +67,29 @@ if TYPE_CHECKING:
     from music_assistant.models import ProviderInstanceType
 
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.BROWSE,
+    ProviderFeature.SEARCH,
+    ProviderFeature.RECOMMENDATIONS,
+    ProviderFeature.LIBRARY_ARTISTS,
+    ProviderFeature.LIBRARY_ALBUMS,
+    ProviderFeature.LIBRARY_TRACKS,
+    ProviderFeature.LIBRARY_PLAYLISTS,
+    ProviderFeature.ARTIST_ALBUMS,
+    ProviderFeature.ARTIST_TOPTRACKS,
+    ProviderFeature.LIBRARY_ARTISTS_EDIT,
+    ProviderFeature.LIBRARY_ALBUMS_EDIT,
+    ProviderFeature.LIBRARY_TRACKS_EDIT,
+    ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
+    ProviderFeature.SIMILAR_TRACKS,
+    # MANDATORY
+    # this constant should contain a set of provider-level features
+    # that your music provider supports or an empty set if none.
+    # for example 'ProviderFeature.BROWSE' if you can browse the provider's items.
+    # see the ProviderFeature enum for all available features
+}
+
+
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
@@ -74,7 +97,7 @@ async def setup(
     # setup is called when the user wants to setup a new provider instance.
     # you are free to do any preflight checks here and but you must return
     #  an instance of the provider.
-    return MyDemoMusicprovider(mass, manifest, config)
+    return MyDemoMusicprovider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -128,31 +151,6 @@ class MyDemoMusicprovider(MusicProvider):
     implement the abc methods with your actual implementation.
     """
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        # MANDATORY
-        # you should return a tuple of provider-level features
-        # here that your music provider supports or an empty tuple if none.
-        # for example 'ProviderFeature.BROWSE' if you can browse the provider's items.
-        return {
-            ProviderFeature.BROWSE,
-            ProviderFeature.SEARCH,
-            ProviderFeature.RECOMMENDATIONS,
-            ProviderFeature.LIBRARY_ARTISTS,
-            ProviderFeature.LIBRARY_ALBUMS,
-            ProviderFeature.LIBRARY_TRACKS,
-            ProviderFeature.LIBRARY_PLAYLISTS,
-            ProviderFeature.ARTIST_ALBUMS,
-            ProviderFeature.ARTIST_TOPTRACKS,
-            ProviderFeature.LIBRARY_ARTISTS_EDIT,
-            ProviderFeature.LIBRARY_ALBUMS_EDIT,
-            ProviderFeature.LIBRARY_TRACKS_EDIT,
-            ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
-            ProviderFeature.SIMILAR_TRACKS,
-            # see the ProviderFeature enum for all available features
-        }
-
     async def loaded_in_mass(self) -> None:
         """Call after the provider has been loaded."""
         # OPTIONAL
@@ -516,7 +514,7 @@ class MyDemoMusicprovider(MusicProvider):
         # This is only called if you reported the RECOMMENDATIONS feature in the supported_features.
         return []
 
-    async def sync_library(self, media_type: MediaType) -> None:
+    async def sync_library(self, media_type: MediaType, import_as_favorite: bool) -> None:
         """Run library sync for this provider."""
         # Run a full sync of the library for the given media type.
         # This is called by the music controller to sync items from your provider to the library.
index 8ea2674e73574a2b8e769456a6963c960df78986..286bf0f5f179546645f3df5f186263aa7f4311a8 100644 (file)
@@ -35,7 +35,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 from music_assistant_models.config_entries import ConfigEntry
-from music_assistant_models.enums import ConfigEntryType
+from music_assistant_models.enums import ConfigEntryType, ProviderFeature
 
 from .constants import CONF_NUMBER_OF_PLAYERS
 from .provider import DemoPlayerprovider
@@ -47,6 +47,16 @@ if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    # MANDATORY
+    # this constant should contain a set of provider-level features
+    # that your provider supports or an empty set if none.
+    # see the ProviderFeature enum for all available features
+    ProviderFeature.SYNC_PLAYERS,
+    ProviderFeature.CREATE_GROUP_PLAYER,
+    ProviderFeature.REMOVE_GROUP_PLAYER,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
@@ -55,7 +65,7 @@ async def setup(
     # setup is called when the user wants to setup a new provider instance.
     # you are free to do any preflight checks here and but you must return
     # an instance of your provider.
-    return DemoPlayerprovider(mass, manifest, config)
+    return DemoPlayerprovider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index 719af2c5f5441d506bb4722c6969c5e2fb89eef8..be7643ae1cd9a9bee9c286cc8f674cc29ab34772 100644 (file)
@@ -4,7 +4,6 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING, cast
 
-from music_assistant_models.enums import ProviderFeature
 from zeroconf import ServiceStateChange
 
 from music_assistant.helpers.util import get_primary_ip_address_from_zeroconf
@@ -34,19 +33,6 @@ class DemoPlayerprovider(PlayerProvider):
     implement the abc methods with your actual implementation.
     """
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        # MANDATORY
-        # you should return a set of provider-level (optional) features
-        # here that your player provider supports or an empty set if none.
-        # for example 'ProviderFeature.SYNC_PLAYERS' if you can sync players.
-        return {
-            ProviderFeature.SYNC_PLAYERS,
-            ProviderFeature.CREATE_GROUP_PLAYER,
-            ProviderFeature.REMOVE_GROUP_PLAYER,
-        }
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         # OPTIONAL
index 27d9f67075117528b25325e395c5e0d44cc5ca3d..26669075f50e87731a5ceeeb4c4dd758c227b6b4 100644 (file)
@@ -50,6 +50,18 @@ if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    # MANDATORY
+    # this constant should contain a set of provider-level features
+    # that your provider supports or an empty set if none.
+    # see the ProviderFeature enum for all available features
+    # at time of writing the only plugin-specific feature is the
+    # 'AUDIO_SOURCE' feature which indicates that this provider can
+    # provide a (single) audio source to Music Assistant, such as a live stream.
+    # we add this feature here to demonstrate the concept.
+    ProviderFeature.AUDIO_SOURCE
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
@@ -58,7 +70,7 @@ async def setup(
     # setup is called when the user wants to setup a new provider instance.
     # you are free to do any preflight checks here and but you must return
     # an instance of the provider.
-    return MyDemoPluginprovider(mass, manifest, config)
+    return MyDemoPluginprovider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -101,17 +113,6 @@ class MyDemoPluginprovider(PluginProvider):
     implement the abc methods with your actual implementation.
     """
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        # you should return a set of provider-level features
-        # here that your plugin provider supports or empty set if none.
-        # at time of writing the only plugin-specific feature is the
-        # 'AUDIO_SOURCE' feature which indicates that this provider can
-        # provide a (single) audio source to Music Assistant, such as a live stream.
-        # we add this feature here to demonstrate the concept.
-        return {ProviderFeature.AUDIO_SOURCE}
-
     async def loaded_in_mass(self) -> None:
         """Call after the provider has been loaded."""
         # OPTIONAL
index 9f4d8bea05ad9b06094aacc54d38f450929b8ca0..6d42ffebf1fd04408543eb5667605b43c8eb18f9 100644 (file)
@@ -5,6 +5,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 from music_assistant_models.config_entries import ProviderConfig
+from music_assistant_models.enums import ProviderFeature
 from music_assistant_models.provider import ProviderManifest
 
 from music_assistant.mass import MusicAssistant
@@ -17,6 +18,13 @@ if TYPE_CHECKING:
 
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.SYNC_PLAYERS,
+    # support sync groups by reporting create/remove player group support
+    ProviderFeature.CREATE_GROUP_PLAYER,
+    ProviderFeature.REMOVE_GROUP_PLAYER,
+}
+
 
 async def get_config_entries(
     mass: MusicAssistant,
@@ -39,4 +47,4 @@ async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return AirPlayProvider(mass, manifest, config)
+    return AirPlayProvider(mass, manifest, config, SUPPORTED_FEATURES)
index 176a367400835296e1e39207a414e33ef33b0639..d7c7f4bbaf20e10e18abfb0635b40747c046c1a1 100644 (file)
@@ -7,7 +7,7 @@ import socket
 from random import randrange
 from typing import cast
 
-from music_assistant_models.enums import PlaybackState, ProviderFeature
+from music_assistant_models.enums import PlaybackState
 from zeroconf import ServiceStateChange
 from zeroconf.asyncio import AsyncServiceInfo
 
@@ -40,16 +40,6 @@ class AirPlayProvider(PlayerProvider):
     _dacp_server: asyncio.Server
     _dacp_info: AsyncServiceInfo
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.SYNC_PLAYERS,
-            # support sync groups by reporting create/remove player group support
-            ProviderFeature.CREATE_GROUP_PLAYER,
-            ProviderFeature.REMOVE_GROUP_PLAYER,
-        }
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         # we locate the cliraop binary here, so we can fail early if it is not available
index 86c7eeccb3c10f6d82966af19eed396a2450ea76..154ae6361a18c37ca5a9ed84b7be558b5a79ff77 100644 (file)
@@ -49,14 +49,14 @@ CONF_API_BASIC_AUTH_USERNAME = "api_username"
 CONF_API_BASIC_AUTH_PASSWORD = "api_password"
 CONF_API_URL = "api_url"
 
-SUPPORTED_FEATURES: set[ProviderFeature] = set()
+SUPPORTED_FEATURES: set[ProviderFeature] = set()  # no special features supported (yet)
 
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return AlexaProvider(mass, manifest, config)
+    return AlexaProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -353,16 +353,8 @@ class AlexaProvider(PlayerProvider):
     login: AlexaLogin
     devices: dict[str, AlexaDevice]
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
-    def __init__(
-        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
-    ) -> None:
-        """Initialize AlexaProvider and its device mapping."""
-        super().__init__(mass, manifest, config)
+    async def handle_async_init(self) -> None:
+        """Handle async initialization of the provider."""
         self.devices = {}
 
     async def loaded_in_mass(self) -> None:
index 2c89c740f6c84b21b115f7732c51faf57baad070..d1505fe4f70712934e37c24be24ec7d720c7f0a2 100644 (file)
@@ -103,7 +103,7 @@ async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return AppleMusicProvider(mass, manifest, config)
+    return AppleMusicProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -277,11 +277,6 @@ class AppleMusicProvider(MusicProvider):
         ) as _file:
             self._decrypt_private_key = await _file.read()
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     async def search(
         self, search_query: str, media_types=list[MediaType] | None, limit: int = 5
     ) -> SearchResults:
index 53a1d1c701b8c1ecab58848e30b2017b5d97316a..5bd739d6125b546a6dc7dbea023879adc290c92d 100644 (file)
@@ -80,12 +80,19 @@ IDENTITY_TOOLKIT_TOKEN = "AIzaSyCEvA_fVGNMRcS9F-Ubaaa0y0qBDUMlh90"
 ARD_ACCOUNTS_URL = "https://accounts.ard.de"
 ARD_AUDIOTHEK_GRAPHQL = "https://api.ardaudiothek.de/graphql"
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.BROWSE,
+    ProviderFeature.SEARCH,
+    ProviderFeature.LIBRARY_RADIOS,
+    ProviderFeature.LIBRARY_PODCASTS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return ARDAudiothek(mass, manifest, config)
+    return ARDAudiothek(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def _login(session: ClientSession, email: str, password: str) -> tuple[str, str, str]:
@@ -233,16 +240,6 @@ async def get_config_entries(
 class ARDAudiothek(MusicProvider):
     """ARD Audiothek Music provider."""
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.BROWSE,
-            ProviderFeature.SEARCH,
-            ProviderFeature.LIBRARY_RADIOS,
-            ProviderFeature.LIBRARY_PODCASTS,
-        }
-
     async def get_client(self) -> Client:
         """Wrap the client creation procedure to recreate client.
 
index 2c9a74307ffc02d51916ff8017ca7d64da256048..2bb611b841766e26f30c25238ce91583bf5d5d87 100644 (file)
@@ -49,12 +49,17 @@ CONF_SERIAL = "serial"
 CONF_LOGIN_URL = "login_url"
 CONF_LOCALE = "locale"
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.BROWSE,
+    ProviderFeature.LIBRARY_AUDIOBOOKS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return Audibleprovider(mass, manifest, config)
+    return Audibleprovider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -215,18 +220,16 @@ async def get_config_entries(
 class Audibleprovider(MusicProvider):
     """Implementation of a Audible Audiobook Provider."""
 
-    def __init__(
-        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
-    ) -> None:
-        """Initialize the Audible Audiobook Provider."""
-        super().__init__(mass, manifest, config)
+    locale: str
+    auth_file: str
+    _client: audible.AsyncClient | None = None
+
+    async def handle_async_init(self) -> None:
+        """Handle asynchronous initialization of the provider."""
         self.locale = cast("str", self.config.get_value(CONF_LOCALE) or "us")
         self.auth_file = cast("str", self.config.get_value(CONF_AUTH_FILE))
         self._client: audible.AsyncClient | None = None
         audible.log_helper.set_level(getLevelName(self.logger.level))
-
-    async def handle_async_init(self) -> None:
-        """Handle asynchronous initialization of the provider."""
         await self._login()
 
     # Cache for authenticators to avoid repeated file I/O
@@ -266,11 +269,6 @@ class Audibleprovider(MusicProvider):
             self.logger.error(f"Failed to authenticate with Audible: {e}")
             raise LoginFailed("Failed to authenticate with Audible.")
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {ProviderFeature.BROWSE, ProviderFeature.LIBRARY_AUDIOBOOKS}
-
     @property
     def is_streaming_provider(self) -> bool:
         """Return True if the provider is a streaming provider."""
index 294c20a53be7ec827ad64bee2d866add6facd8be..1af46cc247193f6dfa7939790140d688061f10a2 100644 (file)
@@ -37,9 +37,7 @@ from aioaudiobookshelf.schema.shelf import (
     ShelfPodcast,
     ShelfSeries,
 )
-from aioaudiobookshelf.schema.shelf import (
-    ShelfId as AbsShelfId,
-)
+from aioaudiobookshelf.schema.shelf import ShelfId as AbsShelfId
 from aioaudiobookshelf.schema.shelf import ShelfType as AbsShelfType
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
 from music_assistant_models.enums import (
@@ -99,12 +97,19 @@ if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.LIBRARY_PODCASTS,
+    ProviderFeature.LIBRARY_AUDIOBOOKS,
+    ProviderFeature.BROWSE,
+    ProviderFeature.RECOMMENDATIONS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return Audiobookshelf(mass, manifest, config)
+    return Audiobookshelf(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -214,16 +219,6 @@ class Audiobookshelf(MusicProvider):
 
         return wrapper
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Features supported by this Provider."""
-        return {
-            ProviderFeature.LIBRARY_PODCASTS,
-            ProviderFeature.LIBRARY_AUDIOBOOKS,
-            ProviderFeature.BROWSE,
-            ProviderFeature.RECOMMENDATIONS,
-        }
-
     async def handle_async_init(self) -> None:
         """Pass config values to client and initialize."""
         base_url = str(self.config.get_value(CONF_URL))
@@ -351,7 +346,7 @@ for more details.
         return False
 
     @handle_refresh_token
-    async def sync_library(self, media_type: MediaType) -> None:
+    async def sync_library(self, media_type: MediaType, import_as_favorite: bool) -> None:
         """Obtain audiobook library ids and podcast library ids."""
         libraries = await self._client.get_all_libraries()
         if len(libraries) == 0:
@@ -364,7 +359,7 @@ for more details.
                 and media_type == MediaType.PODCAST
             ):
                 self.libraries.podcasts[library.id_] = LibraryHelper(name=library.name)
-        await super().sync_library(media_type=media_type)
+        await super().sync_library(media_type, import_as_favorite)
         await self._cache_set_helper_libraries()
 
         # update playlog
index 4bb20631ebffd220e79d18b1b78926fa45f3122d..737abe8e385ddaee5d0a0a512afb531eec20f737 100644 (file)
@@ -4,6 +4,8 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
+from music_assistant_models.enums import ProviderFeature
+
 from .provider import BluesoundPlayerProvider
 
 if TYPE_CHECKING:
@@ -13,12 +15,18 @@ if TYPE_CHECKING:
     from music_assistant import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.SYNC_PLAYERS,
+    ProviderFeature.CREATE_GROUP_PLAYER,
+    ProviderFeature.REMOVE_GROUP_PLAYER,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize BluOS instance with given configuration."""
-    return BluesoundPlayerProvider(mass, manifest, config)
+    return BluesoundPlayerProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index 026b8aa5e96f9e7724604933b90144a841b27b9d..00abdf098f1b936e51002c09bc2de6f18147ea74 100644 (file)
@@ -4,7 +4,6 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING, TypedDict
 
-from music_assistant_models.enums import ProviderFeature
 from zeroconf import ServiceStateChange
 
 from music_assistant.helpers.util import (
@@ -35,15 +34,6 @@ class BluesoundPlayerProvider(PlayerProvider):
 
     player_map: dict[(str, str), str] = {}
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.SYNC_PLAYERS,
-            ProviderFeature.CREATE_GROUP_PLAYER,
-            ProviderFeature.REMOVE_GROUP_PLAYER,
-        }
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
 
index c6512299576ccadf43b31829240ebfee1443b45e..108f6bb8b7db1f3511fff16304be25ffd38d967b 100644 (file)
@@ -6,13 +6,11 @@ import asyncio
 import os
 import time
 from collections.abc import AsyncGenerator
-from typing import TYPE_CHECKING, NotRequired, TypedDict, cast
+from typing import TYPE_CHECKING, cast
 
 import aiofiles
 import shortuuid
-from music_assistant_models.config_entries import ConfigEntry
 from music_assistant_models.enums import (
-    ConfigEntryType,
     ContentType,
     ImageType,
     MediaType,
@@ -43,64 +41,56 @@ from music_assistant.helpers.tags import AudioTags, async_parse_tags
 from music_assistant.helpers.uri import parse_uri
 from music_assistant.models.music_provider import MusicProvider
 
+from .constants import (
+    ALL_FAVORITE_TRACKS,
+    BUILTIN_PLAYLISTS,
+    BUILTIN_PLAYLISTS_ENTRIES,
+    COLLAGE_IMAGE_PLAYLISTS,
+    CONF_ENTRY_LIBRARY_EXPORT_ADD_HIDDEN,
+    CONF_ENTRY_LIBRARY_EXPORT_REMOVE_HIDDEN,
+    CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS_HIDDEN,
+    CONF_ENTRY_LIBRARY_IMPORT_RADIOS_HIDDEN,
+    CONF_ENTRY_LIBRARY_IMPORT_TRACKS_HIDDEN,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PLAYLISTS_MOD,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS_HIDDEN,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_TRACKS_HIDDEN,
+    CONF_KEY_PLAYLISTS,
+    CONF_KEY_RADIOS,
+    CONF_KEY_TRACKS,
+    DEFAULT_FANART,
+    DEFAULT_THUMB,
+    RANDOM_ALBUM,
+    RANDOM_ARTIST,
+    RANDOM_TRACKS,
+    RECENTLY_PLAYED,
+    StoredItem,
+)
+
 if TYPE_CHECKING:
-    from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
+    from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
     from music_assistant_models.provider import ProviderManifest
 
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
 
-class StoredItem(TypedDict):
-    """Definition of an media item (for the builtin provider) stored in persistent storage."""
-
-    item_id: str  # url or (locally accessible) file path (or id in case of playlist)
-    name: str
-    image_url: NotRequired[str]
-    last_updated: NotRequired[int]
-
-
-CONF_KEY_RADIOS = "stored_radios"
-CONF_KEY_TRACKS = "stored_tracks"
-CONF_KEY_PLAYLISTS = "stored_playlists"
-
-
-ALL_FAVORITE_TRACKS = "all_favorite_tracks"
-RANDOM_ARTIST = "random_artist"
-RANDOM_ALBUM = "random_album"
-RANDOM_TRACKS = "random_tracks"
-RECENTLY_PLAYED = "recently_played"
-
-BUILTIN_PLAYLISTS = {
-    ALL_FAVORITE_TRACKS: "All favorited tracks",
-    RANDOM_ARTIST: "Random Artist (from library)",
-    RANDOM_ALBUM: "Random Album (from library)",
-    RANDOM_TRACKS: "500 Random tracks (from library)",
-    RECENTLY_PLAYED: "Recently played tracks",
+SUPPORTED_FEATURES = {
+    ProviderFeature.BROWSE,
+    ProviderFeature.LIBRARY_TRACKS,
+    ProviderFeature.LIBRARY_RADIOS,
+    ProviderFeature.LIBRARY_PLAYLISTS,
+    ProviderFeature.LIBRARY_TRACKS_EDIT,
+    ProviderFeature.LIBRARY_RADIOS_EDIT,
+    ProviderFeature.PLAYLIST_CREATE,
+    ProviderFeature.PLAYLIST_TRACKS_EDIT,
 }
 
-COLLAGE_IMAGE_PLAYLISTS = (ALL_FAVORITE_TRACKS, RANDOM_TRACKS)
-
-DEFAULT_THUMB = MediaItemImage(
-    type=ImageType.THUMB,
-    path="logo.png",
-    provider="builtin",
-    remotely_accessible=False,
-)
-
-DEFAULT_FANART = MediaItemImage(
-    type=ImageType.FANART,
-    path="fanart.jpg",
-    provider="builtin",
-    remotely_accessible=False,
-)
-
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return BuiltinProvider(mass, manifest, config)
+    return BuiltinProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -116,15 +106,17 @@ async def get_config_entries(
     action: [optional] action key called from config entries UI.
     values: the (intermediate) raw values for config entries sent with the action.
     """
-    return tuple(
-        ConfigEntry(
-            key=key,
-            type=ConfigEntryType.BOOLEAN,
-            label=name,
-            default_value=True,
-            category="builtin_playlists",
-        )
-        for key, name in BUILTIN_PLAYLISTS.items()
+    return (
+        *BUILTIN_PLAYLISTS_ENTRIES,
+        # hide some of the default (dynamic) entries for library management
+        CONF_ENTRY_LIBRARY_IMPORT_TRACKS_HIDDEN,
+        CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS_HIDDEN,
+        CONF_ENTRY_LIBRARY_IMPORT_RADIOS_HIDDEN,
+        CONF_ENTRY_PROVIDER_SYNC_INTERVAL_TRACKS_HIDDEN,
+        CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS_HIDDEN,
+        CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PLAYLISTS_MOD,
+        CONF_ENTRY_LIBRARY_EXPORT_ADD_HIDDEN,
+        CONF_ENTRY_LIBRARY_EXPORT_REMOVE_HIDDEN,
     )
 
 
@@ -163,20 +155,6 @@ class BuiltinProvider(MusicProvider):
         """Return True if the provider is a streaming provider."""
         return False
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.BROWSE,
-            ProviderFeature.LIBRARY_TRACKS,
-            ProviderFeature.LIBRARY_RADIOS,
-            ProviderFeature.LIBRARY_PLAYLISTS,
-            ProviderFeature.LIBRARY_TRACKS_EDIT,
-            ProviderFeature.LIBRARY_RADIOS_EDIT,
-            ProviderFeature.PLAYLIST_CREATE,
-            ProviderFeature.PLAYLIST_TRACKS_EDIT,
-        }
-
     async def get_track(self, prov_track_id: str) -> Track:
         """Get full track details by id."""
         parsed_item = cast("Track", await self.parse_item(prov_track_id))
diff --git a/music_assistant/providers/builtin/constants.py b/music_assistant/providers/builtin/constants.py
new file mode 100644 (file)
index 0000000..95e10b3
--- /dev/null
@@ -0,0 +1,142 @@
+"""Constants for Built-in/generic provider."""
+
+from __future__ import annotations
+
+from typing import NotRequired, TypedDict
+
+from music_assistant_models.config_entries import ConfigEntry
+from music_assistant_models.enums import ConfigEntryType, ImageType
+from music_assistant_models.media_items import MediaItemImage
+
+from music_assistant.constants import (
+    CONF_ENTRY_LIBRARY_EXPORT_ADD,
+    CONF_ENTRY_LIBRARY_EXPORT_REMOVE,
+    CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS,
+    CONF_ENTRY_LIBRARY_IMPORT_RADIOS,
+    CONF_ENTRY_LIBRARY_IMPORT_TRACKS,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PLAYLISTS,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_TRACKS,
+)
+
+
+class StoredItem(TypedDict):
+    """Definition of an media item (for the builtin provider) stored in persistent storage."""
+
+    item_id: str  # url or (locally accessible) file path (or id in case of playlist)
+    name: str
+    image_url: NotRequired[str]
+    last_updated: NotRequired[int]
+
+
+CONF_KEY_RADIOS = "stored_radios"
+CONF_KEY_TRACKS = "stored_tracks"
+CONF_KEY_PLAYLISTS = "stored_playlists"
+
+
+ALL_FAVORITE_TRACKS = "all_favorite_tracks"
+RANDOM_ARTIST = "random_artist"
+RANDOM_ALBUM = "random_album"
+RANDOM_TRACKS = "random_tracks"
+RECENTLY_PLAYED = "recently_played"
+
+BUILTIN_PLAYLISTS = {
+    ALL_FAVORITE_TRACKS: "All favorited tracks",
+    RANDOM_ARTIST: "Random Artist (from library)",
+    RANDOM_ALBUM: "Random Album (from library)",
+    RANDOM_TRACKS: "500 Random tracks (from library)",
+    RECENTLY_PLAYED: "Recently played tracks",
+}
+BUILTIN_PLAYLISTS_ENTRIES = [
+    ConfigEntry(
+        key=key,
+        type=ConfigEntryType.BOOLEAN,
+        label=name,
+        default_value=True,
+        category="generic",
+    )
+    for key, name in BUILTIN_PLAYLISTS.items()
+]
+
+COLLAGE_IMAGE_PLAYLISTS = (ALL_FAVORITE_TRACKS, RANDOM_TRACKS)
+
+DEFAULT_THUMB = MediaItemImage(
+    type=ImageType.THUMB,
+    path="logo.png",
+    provider="builtin",
+    remotely_accessible=False,
+)
+
+DEFAULT_FANART = MediaItemImage(
+    type=ImageType.FANART,
+    path="fanart.jpg",
+    provider="builtin",
+    remotely_accessible=False,
+)
+
+CONF_ENTRY_LIBRARY_IMPORT_TRACKS_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_LIBRARY_IMPORT_TRACKS.to_dict(),
+        "hidden": True,
+        "default_value": "import_only",
+    }
+)
+CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS.to_dict(),
+        "hidden": True,
+        "default_value": "import_only",
+    }
+)
+CONF_ENTRY_LIBRARY_IMPORT_TRACKS_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_LIBRARY_IMPORT_TRACKS.to_dict(),
+        "hidden": True,
+        "default_value": "import_only",
+    }
+)
+CONF_ENTRY_LIBRARY_IMPORT_RADIOS_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_LIBRARY_IMPORT_RADIOS.to_dict(),
+        "hidden": True,
+        "default_value": "import_only",
+    }
+)
+CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PLAYLISTS_MOD = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PLAYLISTS.to_dict(),
+        "default_value": 180,
+        "label": "Playlists refresh interval",
+        "description": "The interval at which the builtin generated playlists are refreshed.",
+    }
+)
+
+
+CONF_ENTRY_PROVIDER_SYNC_INTERVAL_TRACKS_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_PROVIDER_SYNC_INTERVAL_TRACKS.to_dict(),
+        "hidden": True,
+        "default_value": 180,
+    }
+)
+CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS.to_dict(),
+        "hidden": True,
+        "default_value": 180,
+    }
+)
+CONF_ENTRY_LIBRARY_EXPORT_ADD_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_LIBRARY_EXPORT_ADD.to_dict(),
+        "hidden": True,
+        "default_value": "export_library",
+    }
+)
+CONF_ENTRY_LIBRARY_EXPORT_REMOVE_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_LIBRARY_EXPORT_REMOVE.to_dict(),
+        "hidden": True,
+        "default_value": "export_library",
+    }
+)
index 22c67b48ce9b6b5131128623d4ae1ba0532ff52c..4c5d6be3da40a5214e4c0e3157bc0e2e371f55eb 100644 (file)
@@ -18,6 +18,8 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
+from music_assistant_models.enums import ProviderFeature
+
 from music_assistant.mass import MusicAssistant
 from music_assistant.models import ProviderInstanceType
 
@@ -27,12 +29,14 @@ if TYPE_CHECKING:
     from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
     from music_assistant_models.provider import ProviderManifest
 
+SUPPORTED_FEATURES = {ProviderFeature.REMOVE_PLAYER}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return BuiltinPlayerProvider(mass, manifest, config)
+    return BuiltinPlayerProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index 923493249b2cbdfb75961038d86fa9a57113ecec..e5ac6efb3834fa3f5485cd10cc8995cb0392f94d 100644 (file)
@@ -3,51 +3,31 @@
 from __future__ import annotations
 
 from collections.abc import Callable
-from typing import TYPE_CHECKING, cast, override
+from typing import TYPE_CHECKING, cast
 
 import shortuuid
 from music_assistant_models.builtin_player import BuiltinPlayerEvent, BuiltinPlayerState
-from music_assistant_models.enums import (
-    BuiltinPlayerEventType,
-    EventType,
-    PlayerFeature,
-    ProviderFeature,
-)
-
-from music_assistant.mass import MusicAssistant
+from music_assistant_models.enums import BuiltinPlayerEventType, EventType, PlayerFeature
+
 from music_assistant.models.player import Player
 from music_assistant.models.player_provider import PlayerProvider
 
 from .player import BuiltinPlayer
 
-if TYPE_CHECKING:
-    from music_assistant_models.config_entries import ProviderConfig
-    from music_assistant_models.provider import ProviderManifest
-
 
 class BuiltinPlayerProvider(PlayerProvider):
     """Builtin Player Provider for playing to the Music Assistant Web Interface."""
 
-    _unregister_cbs: list[Callable[[], None]] = []
+    _unregister_cbs: list[Callable[[], None]]
 
-    def __init__(
-        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
-    ) -> None:
-        """Initialize the provider."""
-        super().__init__(mass, manifest, config)
+    async def handle_async_init(self) -> None:
+        """Handle asynchronous initialization of the provider."""
         self._unregister_cbs = [
             self.mass.register_api_command("builtin_player/register", self.register_player),
             self.mass.register_api_command("builtin_player/unregister", self.unregister_player),
             self.mass.register_api_command("builtin_player/update_state", self.update_player_state),
         ]
 
-    @property
-    @override
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {ProviderFeature.REMOVE_PLAYER}
-
-    @override
     async def unload(self, is_removed: bool = False) -> None:
         """
         Handle unload/close of the provider.
@@ -58,7 +38,6 @@ class BuiltinPlayerProvider(PlayerProvider):
         for unload_cb in self._unregister_cbs:
             unload_cb()
 
-    @override
     async def remove_player(self, player_id: str) -> None:
         """Remove a player."""
         self.mass.signal_event(
index f463d083ea1a5e59098326c53fdcff519db7ca91..4a40fb5177c42b5a19bd06ccd33739dcc9d6cb1c 100644 (file)
@@ -12,11 +12,15 @@ from .provider import ChromecastProvider
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
+    from music_assistant_models.enums import ProviderFeature
     from music_assistant_models.provider import ProviderManifest
 
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES: set[ProviderFeature] = (
+    set()
+)  # we don't have any special supported features (yet)
 
 # Monkey patch the Media controller here to store the queue items
 _patched_process_media_status_org = MediaController._process_media_status
@@ -39,7 +43,7 @@ async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return ChromecastProvider(mass, manifest, config)
+    return ChromecastProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index 5ce218693b2a607321d3ad6362540735bf58cebd..7255b8fed69c853471e6d4b6bcd4eaea4057df39 100644 (file)
@@ -20,6 +20,7 @@ from .player import ChromecastPlayer
 
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import ProviderConfig
+    from music_assistant_models.enums import ProviderFeature
     from music_assistant_models.provider import ProviderManifest
     from pychromecast.models import CastInfo
 
@@ -34,10 +35,14 @@ class ChromecastProvider(PlayerProvider):
     _discover_lock: threading.Lock
 
     def __init__(
-        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+        self,
+        mass: MusicAssistant,
+        manifest: ProviderManifest,
+        config: ProviderConfig,
+        supported_features: set[ProviderFeature],
     ) -> None:
         """Handle async initialization of the provider."""
-        super().__init__(mass, manifest, config)
+        super().__init__(mass, manifest, config, supported_features)
         self._discover_lock = threading.Lock()
         self.mz_mgr = MultizoneManager()
         # Handle config option for manual IP's
index 264342785ac75898fa5d5ff8fd8ae55ff39173ad..330ca97ac95b5a7e01f4fe4617bd8aea4803e4df 100644 (file)
@@ -116,7 +116,7 @@ async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return DeezerProvider(mass, manifest, config)
+    return DeezerProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -190,11 +190,6 @@ class DeezerProvider(MusicProvider):
         )
         await self.gw_client.setup()
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     async def search(
         self, search_query: str, media_types=list[MediaType], limit: int = 5
     ) -> SearchResults:
index 0681ab9c96ff40514991634f6682b46e733bf25b..29cb9f7118bb95bec942bf8da83c4b6dfd8fdf43 100644 (file)
@@ -11,7 +11,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType
+from music_assistant_models.enums import ConfigEntryType, ProviderFeature
 
 from .constants import CONF_NETWORK_SCAN
 from .provider import DLNAPlayerProvider
@@ -23,12 +23,16 @@ if TYPE_CHECKING:
     from music_assistant import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES: set[ProviderFeature] = (
+    set()
+)  # we don't have any special supported features (yet)
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return DLNAPlayerProvider(mass, manifest, config)
+    return DLNAPlayerProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index adceb5d2f9f6c4314af5372e9c37d06bd2ddb4c3..9dd16db44b5b13be56738214cf8802b70d567b41 100644 (file)
@@ -44,7 +44,7 @@ async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return FanartTvMetadataProvider(mass, manifest, config)
+    return FanartTvMetadataProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -100,11 +100,6 @@ class FanartTvMetadataProvider(MetadataProvider):
         else:
             self.throttler = Throttler(rate_limit=1, period=30)
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     async def get_artist_metadata(self, artist: Artist) -> MediaItemMetadata | None:
         """Retrieve metadata for artist on fanart.tv."""
         if not artist.mbid:
index 29ddd852b9fea3c576b148bb50b771be22d3a825..86495aa046e75ad71eff09a6aee47118d3b1a1a1 100644 (file)
@@ -76,6 +76,10 @@ from .constants import (
     CONF_ENTRY_CONTENT_TYPE,
     CONF_ENTRY_CONTENT_TYPE_READ_ONLY,
     CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
+    CONF_ENTRY_LIBRARY_IMPORT_AUDIOBOOKS,
+    CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS,
+    CONF_ENTRY_LIBRARY_IMPORT_PODCASTS,
+    CONF_ENTRY_LIBRARY_IMPORT_TRACKS,
     CONF_ENTRY_MISSING_ALBUM_ARTIST,
     CONF_ENTRY_PATH,
     IMAGE_EXTENSIONS,
@@ -109,6 +113,11 @@ exists = wrap(os.path.exists)
 makedirs = wrap(os.makedirs)
 scandir = wrap(os.scandir)
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.BROWSE,
+    ProviderFeature.SEARCH,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
@@ -132,19 +141,18 @@ async def get_config_entries(
     values: the (intermediate) raw values for config entries sent with the action.
     """
     # ruff: noqa: ARG001
-    if instance_id is None or values is None:
-        return (
-            CONF_ENTRY_CONTENT_TYPE,
-            CONF_ENTRY_PATH,
-            CONF_ENTRY_MISSING_ALBUM_ARTIST,
-            CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
-        )
-    return (
+    base_entries = [
         CONF_ENTRY_PATH,
-        CONF_ENTRY_CONTENT_TYPE_READ_ONLY,
         CONF_ENTRY_MISSING_ALBUM_ARTIST,
         CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
-    )
+        CONF_ENTRY_LIBRARY_IMPORT_TRACKS,
+        CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS,
+        CONF_ENTRY_LIBRARY_IMPORT_PODCASTS,
+        CONF_ENTRY_LIBRARY_IMPORT_AUDIOBOOKS,
+    ]
+    if instance_id is None or values is None:
+        return (CONF_ENTRY_CONTENT_TYPE, *base_entries)
+    return (CONF_ENTRY_CONTENT_TYPE_READ_ONLY, *base_entries)
 
 
 class LocalFileSystemProvider(MusicProvider):
@@ -164,7 +172,7 @@ class LocalFileSystemProvider(MusicProvider):
         base_path: str,
     ) -> None:
         """Initialize MusicProvider."""
-        super().__init__(mass, manifest, config)
+        super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
         self.base_path: str = base_path
         self.write_access: bool = False
         self.sync_running: bool = False
@@ -173,19 +181,13 @@ class LocalFileSystemProvider(MusicProvider):
     @property
     def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        base_features = {
-            ProviderFeature.BROWSE,
-            ProviderFeature.SEARCH,
-        }
+        base_features = {*SUPPORTED_FEATURES}
         if self.media_content_type == "audiobooks":
             return {ProviderFeature.LIBRARY_AUDIOBOOKS, *base_features}
         if self.media_content_type == "podcasts":
             return {ProviderFeature.LIBRARY_PODCASTS, *base_features}
         music_features = {
-            ProviderFeature.LIBRARY_ARTISTS,
-            ProviderFeature.LIBRARY_ALBUMS,
             ProviderFeature.LIBRARY_TRACKS,
-            # for now, only support playlists for music files and not for podcasts or audiobooks
             ProviderFeature.LIBRARY_PLAYLISTS,
             *base_features,
         }
@@ -310,7 +312,7 @@ class LocalFileSystemProvider(MusicProvider):
                 )
         return items
 
-    async def sync_library(self, media_type: MediaType) -> None:
+    async def sync_library(self, media_type: MediaType, import_as_favorite: bool) -> None:
         """Run library sync for this provider."""
         assert self.mass.music.database
         start_time = time.time()
@@ -362,7 +364,7 @@ class LocalFileSystemProvider(MusicProvider):
             try:
                 for item in listdir(self.base_path):
                     prev_checksum = file_checksums.get(item.relative_path)
-                    if self._process_item(item, prev_checksum):
+                    if self._process_item(item, prev_checksum, import_as_favorite):
                         cur_filenames.add(item.relative_path)
             finally:
                 self.sync_running = False
@@ -382,7 +384,9 @@ class LocalFileSystemProvider(MusicProvider):
         # process orphaned albums and artists
         await self._process_orphaned_albums_and_artists()
 
-    def _process_item(self, item: FileSystemItem, prev_checksum: str | None) -> bool:
+    def _process_item(
+        self, item: FileSystemItem, prev_checksum: str | None, import_as_favorite: bool
+    ) -> bool:
         """Process a single item. NOT async friendly."""
         try:
             self.logger.log(VERBOSE_LOG_LEVEL, "Processing: %s", item.relative_path)
@@ -412,6 +416,7 @@ class LocalFileSystemProvider(MusicProvider):
                     # add/update track to db
                     # note that filesystem items are always overwriting existing info
                     # when they are detected as changed
+                    track.favorite = import_as_favorite
                     await self.mass.music.tracks.add_item_to_library(
                         track, overwrite_existing=prev_checksum is not None
                     )
@@ -431,6 +436,7 @@ class LocalFileSystemProvider(MusicProvider):
                     # add/update audiobook to db
                     # note that filesystem items are always overwriting existing info
                     # when they are detected as changed
+                    audiobook.favorite = import_as_favorite
                     await self.mass.music.audiobooks.add_item_to_library(
                         audiobook, overwrite_existing=prev_checksum is not None
                     )
@@ -448,6 +454,7 @@ class LocalFileSystemProvider(MusicProvider):
                     # add/update episode to db
                     # note that filesystem items are always overwriting existing info
                     # when they are detected as changed
+                    episode.favorite = import_as_favorite
                     await self.mass.music.podcasts.add_item_to_library(
                         episode.podcast, overwrite_existing=prev_checksum is not None
                     )
@@ -462,6 +469,7 @@ class LocalFileSystemProvider(MusicProvider):
                     playlist = await self.get_playlist(item.relative_path)
                     # add/update] playlist to db
                     playlist.cache_checksum = item.checksum
+                    playlist.favorite = import_as_favorite
                     await self.mass.music.playlists.add_item_to_library(
                         playlist,
                         overwrite_existing=prev_checksum is not None,
@@ -890,6 +898,7 @@ class LocalFileSystemProvider(MusicProvider):
                         bit_rate=tags.bit_rate,
                     ),
                     details=file_item.checksum,
+                    in_library=True,
                 )
             },
             disc_number=tags.disc or 0,
@@ -1044,6 +1053,7 @@ class LocalFileSystemProvider(MusicProvider):
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     url=artist_path,
+                    in_library=True,
                 )
             },
         )
@@ -1123,6 +1133,7 @@ class LocalFileSystemProvider(MusicProvider):
                         bit_rate=tags.bit_rate,
                     ),
                     details=file_item.checksum,
+                    in_library=True,
                 )
             },
         )
@@ -1221,6 +1232,7 @@ class LocalFileSystemProvider(MusicProvider):
                         bit_rate=tags.bit_rate,
                     ),
                     details=file_item.checksum,
+                    in_library=True,
                 )
             },
             position=tags.track or 0,
@@ -1408,6 +1420,7 @@ class LocalFileSystemProvider(MusicProvider):
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     url=album_dir,
+                    in_library=True,
                 )
             },
         )
index 1a553b207220cd7b5758376ab51aadea9acaddd1..55fe86c728bc0c9271e79571b0e34dc74163695f 100644 (file)
@@ -5,6 +5,8 @@ from __future__ import annotations
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
 from music_assistant_models.enums import ConfigEntryType, ProviderFeature
 
+from music_assistant.constants import CONF_LIBRARY_IMPORT_OPTIONS
+
 CONF_MISSING_ALBUM_ARTIST_ACTION = "missing_album_artist_action"
 CONF_CONTENT_TYPE = "content_type"
 
@@ -46,11 +48,62 @@ CONF_ENTRY_CONTENT_TYPE = ConfigEntry(
     ],
 )
 CONF_ENTRY_CONTENT_TYPE_READ_ONLY = ConfigEntry.from_dict(
-    {
-        **CONF_ENTRY_CONTENT_TYPE.to_dict(),
-        "depends_on": CONF_ENTRY_PATH.key,
-        "depends_on_value": "thisdoesnotexist",
-    }
+    {**CONF_ENTRY_CONTENT_TYPE.to_dict(), "read_only": True}
+)
+
+CONF_ENTRY_LIBRARY_IMPORT_TRACKS = ConfigEntry(
+    key="library_import_tracks",
+    type=ConfigEntryType.STRING,
+    label="Import tracks/files into the Music Assistant library",
+    description="Define how/if you want to import tracks/files from the filesystem "
+    "into the Music Assistant Library. \nWhen not importing into the library, "
+    "they can still be manually browsed using the Browse feature. \n\n"
+    "Please note that by adding a Track into the Music Assistant library, "
+    "the track artists and album will always be imported as well (not as favorites though).",
+    options=CONF_LIBRARY_IMPORT_OPTIONS,
+    default_value="import_only",
+    category="sync_options",
+    depends_on=CONF_CONTENT_TYPE,
+    depends_on_value="music",
+)
+CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS = ConfigEntry(
+    key="library_import_playlists",
+    type=ConfigEntryType.STRING,
+    label="Import playlists (m3u files) into the Music Assistant library",
+    description="Define how/if you want to import playlists (m3u files) from the filesystem "
+    "into the Music Assistant Library. \nWhen not importing into the library, "
+    "they can still be manually browsed using the Browse feature.",
+    options=CONF_LIBRARY_IMPORT_OPTIONS,
+    default_value="import_only",
+    category="sync_options",
+    depends_on=CONF_CONTENT_TYPE,
+    depends_on_value="music",
+)
+CONF_ENTRY_LIBRARY_IMPORT_PODCASTS = ConfigEntry(
+    key="library_import_podcasts",
+    type=ConfigEntryType.STRING,
+    label="Import Podcasts(files) into the Music Assistant library",
+    description="Define how/if you want to import Podcasts(files) from the filesystem "
+    "into the Music Assistant Library. \nWhen not importing into the library, "
+    "they can still be manually browsed using the Browse feature.",
+    options=CONF_LIBRARY_IMPORT_OPTIONS,
+    default_value="import_only",
+    category="sync_options",
+    depends_on=CONF_CONTENT_TYPE,
+    depends_on_value="podcasts",
+)
+CONF_ENTRY_LIBRARY_IMPORT_AUDIOBOOKS = ConfigEntry(
+    key="library_import_audiobooks",
+    type=ConfigEntryType.STRING,
+    label="Import Audiobooks(files) into the Music Assistant library",
+    description="Define how/if you want to import Audiobooks(files) from the filesystem "
+    "into the Music Assistant Library. \nWhen not importing into the library, "
+    "they can still be manually browsed using the Browse feature.",
+    options=CONF_LIBRARY_IMPORT_OPTIONS,
+    default_value="import_only",
+    category="sync_options",
+    depends_on=CONF_CONTENT_TYPE,
+    depends_on_value="audiobooks",
 )
 
 CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS = ConfigEntry(
@@ -58,12 +111,14 @@ CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS = ConfigEntry(
     type=ConfigEntryType.BOOLEAN,
     label="Ignore playlists with album tracks within album folders",
     description="A digital album often comes with a playlist file (.m3u) "
-    "that contains the tracks of the album. Adding all these playlists to the library, "
+    "that contains the tracks of the album. \nAdding all these playlists to the library, "
     "is not very practical so it's better to just ignore them.\n\n"
     "If this option is enabled, all playlists will be ignored which are more than "
     "1 level deep anywhere in the folder structure. E.g. /music/artistname/albumname/playlist.m3u",
     default_value=True,
     required=False,
+    depends_on=CONF_CONTENT_TYPE,
+    depends_on_value="music",
 )
 
 TRACK_EXTENSIONS = {
index 130aeb6b744531c16c64998303db27b0ae9ed5ee..62c95bdbae7dd5ff1c57b8c392b9f54d4ffcd68d 100644 (file)
@@ -18,6 +18,10 @@ from music_assistant.providers.filesystem_local.constants import (
     CONF_ENTRY_CONTENT_TYPE,
     CONF_ENTRY_CONTENT_TYPE_READ_ONLY,
     CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
+    CONF_ENTRY_LIBRARY_IMPORT_AUDIOBOOKS,
+    CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS,
+    CONF_ENTRY_LIBRARY_IMPORT_PODCASTS,
+    CONF_ENTRY_LIBRARY_IMPORT_TRACKS,
     CONF_ENTRY_MISSING_ALBUM_ARTIST,
 )
 
@@ -123,6 +127,10 @@ async def get_config_entries(
         ),
         CONF_ENTRY_MISSING_ALBUM_ARTIST,
         CONF_ENTRY_IGNORE_ALBUM_PLAYLISTS,
+        CONF_ENTRY_LIBRARY_IMPORT_TRACKS,
+        CONF_ENTRY_LIBRARY_IMPORT_PLAYLISTS,
+        CONF_ENTRY_LIBRARY_IMPORT_PODCASTS,
+        CONF_ENTRY_LIBRARY_IMPORT_AUDIOBOOKS,
     )
 
     if instance_id is None or values is None:
index a5f7d5cb2403b4a8f47a10986fcde41bdeed8204..afad31d17c725fb4090adaa036d8cd5ca27938f8 100644 (file)
@@ -5,7 +5,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType
+from music_assistant_models.enums import ConfigEntryType, ProviderFeature
 
 from music_assistant.constants import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT
 
@@ -18,12 +18,16 @@ if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES: set[ProviderFeature] = (
+    set()
+)  # we don't have any special supported features (yet)
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return FullyKioskProvider(mass, manifest, config)
+    return FullyKioskProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index 853567c98f40fd82a858757d4cbdd244bdc8bf60..97a3ce24a1882c5d7066ae15f32ffd083390cbcf 100644 (file)
@@ -34,7 +34,7 @@ async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return GeniusProvider(mass, manifest, config)
+    return GeniusProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -61,11 +61,6 @@ class GeniusProvider(MetadataProvider):
         """Handle async initialization of the provider."""
         self._genius = Genius("public", skip_non_songs=True, remove_section_headers=True)
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     async def get_track_metadata(self, track: Track) -> MediaItemMetadata | None:
         """Retrieve synchronized lyrics for a track."""
         if track.metadata and (track.metadata.lyrics or track.metadata.lrc_lyrics):
index 8cb361cacb149aa22374f770f2365cc7bea8dfdd..35e807abf4ad124fa59a03b99779d841b76c76f0 100644 (file)
@@ -20,11 +20,7 @@ from io import BytesIO
 from typing import TYPE_CHECKING, Any
 
 import podcastparser
-from music_assistant_models.config_entries import (
-    ConfigEntry,
-    ConfigValueType,
-    ProviderConfig,
-)
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
 from music_assistant_models.enums import (
     ConfigEntryType,
     ContentType,
@@ -38,12 +34,7 @@ from music_assistant_models.errors import (
     MediaNotFoundError,
     ResourceTemporarilyUnavailable,
 )
-from music_assistant_models.media_items import (
-    AudioFormat,
-    MediaItemType,
-    Podcast,
-    PodcastEpisode,
-)
+from music_assistant_models.media_items import AudioFormat, MediaItemType, Podcast, PodcastEpisode
 from music_assistant_models.streamdetails import StreamDetails
 
 from music_assistant.helpers.podcast_parsers import (
@@ -53,12 +44,7 @@ from music_assistant.helpers.podcast_parsers import (
 )
 from music_assistant.models.music_provider import MusicProvider
 
-from .client import (
-    EpisodeActionDelete,
-    EpisodeActionNew,
-    EpisodeActionPlay,
-    GPodderClient,
-)
+from .client import EpisodeActionDelete, EpisodeActionNew, EpisodeActionPlay, GPodderClient
 
 if TYPE_CHECKING:
     from music_assistant_models.provider import ProviderManifest
@@ -90,12 +76,17 @@ CACHE_KEY_TIMESTAMP = (
 )
 CACHE_KEY_FEEDS = "feeds"  # list[str] : all available rss feed urls
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.LIBRARY_PODCASTS,
+    ProviderFeature.BROWSE,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return GPodder(mass, manifest, config)
+    return GPodder(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -262,14 +253,6 @@ async def get_config_entries(
 class GPodder(MusicProvider):
     """gPodder MusicProvider."""
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Features supported by this Provider."""
-        return {
-            ProviderFeature.LIBRARY_PODCASTS,
-            ProviderFeature.BROWSE,
-        }
-
     async def handle_async_init(self) -> None:
         """Pass config values to client and initialize."""
         base_url = str(self.config.get_value(CONF_URL))
index 125c950a7a3adf7e1d847ced362b5420859ac605..3314ff5fd0fea772c1c2e071925c4b674ff8caf7 100644 (file)
@@ -25,7 +25,7 @@ from hass_client.utils import (
     get_websocket_url,
 )
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType
+from music_assistant_models.enums import ConfigEntryType, ProviderFeature
 from music_assistant_models.errors import LoginFailed, SetupFailedError
 from music_assistant_models.player_control import PlayerControl
 
@@ -53,12 +53,16 @@ CONF_POWER_CONTROLS = "power_controls"
 CONF_MUTE_CONTROLS = "mute_controls"
 CONF_VOLUME_CONTROLS = "volume_controls"
 
+SUPPORTED_FEATURES: set[ProviderFeature] = (
+    set()
+)  # we don't have any special supported features (yet)
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return HomeAssistantProvider(mass, manifest, config)
+    return HomeAssistantProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index 855ab03fb218009ab8f4f775187809a7ee02c074..1783f175a321e4ef476337eaf9c9af72df04bc44 100644 (file)
@@ -67,7 +67,7 @@ async def setup(
     if not config.get_value(CONF_USERNAME) or not config.get_value(CONF_PASSWORD):
         msg = "Invalid login credentials"
         raise LoginFailed(msg)
-    return IBroadcastProvider(mass, manifest, config)
+    return IBroadcastProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -121,11 +121,6 @@ class IBroadcastProvider(MusicProvider):
             # temporary call to refresh library until ibroadcast provides a detailed api
             await self._client.refresh_library()
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     async def get_library_albums(self) -> AsyncGenerator[Album, None]:
         """Retrieve library albums from ibroadcast."""
         for album in (await self._client.get_albums()).values():
index c25eb568cea212276407b7818436db7651e90cf2..dd345c941041afa6275e8b5159dc7fede333843c 100644 (file)
@@ -58,12 +58,14 @@ CACHE_CATEGORY_PODCASTS = 0
 CACHE_CATEGORY_RECOMMENDATIONS = 1
 CACHE_KEY_TOP_PODCASTS = "top-podcasts"
 
+SUPPORTED_FEATURES = {ProviderFeature.SEARCH, ProviderFeature.RECOMMENDATIONS}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return ITunesPodcastsProvider(mass, manifest, config)
+    return ITunesPodcastsProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -117,11 +119,6 @@ class ITunesPodcastsProvider(MusicProvider):
 
     throttler: ThrottlerManager
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {ProviderFeature.SEARCH, ProviderFeature.RECOMMENDATIONS}
-
     @property
     def is_streaming_provider(self) -> bool:
         """Return True if the provider is a streaming provider."""
index a9b3a3547b59d9117751cfb777962f42e9daee97..c4b7b3ea82b01c4f657678693dba66aa457c568a 100644 (file)
@@ -59,12 +59,23 @@ CONF_PASSWORD = "password"
 CONF_VERIFY_SSL = "verify_ssl"
 FAKE_ARTIST_PREFIX = "_fake://"
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.LIBRARY_ARTISTS,
+    ProviderFeature.LIBRARY_ALBUMS,
+    ProviderFeature.LIBRARY_TRACKS,
+    ProviderFeature.LIBRARY_PLAYLISTS,
+    ProviderFeature.BROWSE,
+    ProviderFeature.SEARCH,
+    ProviderFeature.ARTIST_ALBUMS,
+    ProviderFeature.SIMILAR_TRACKS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return JellyfinProvider(mass, manifest, config)
+    return JellyfinProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -161,20 +172,6 @@ class JellyfinProvider(MusicProvider):
         except Exception as err:
             raise LoginFailed(f"Authentication failed: {err}") from err
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return a list of supported features."""
-        return {
-            ProviderFeature.LIBRARY_ARTISTS,
-            ProviderFeature.LIBRARY_ALBUMS,
-            ProviderFeature.LIBRARY_TRACKS,
-            ProviderFeature.LIBRARY_PLAYLISTS,
-            ProviderFeature.BROWSE,
-            ProviderFeature.SEARCH,
-            ProviderFeature.ARTIST_ALBUMS,
-            ProviderFeature.SIMILAR_TRACKS,
-        }
-
     @property
     def is_streaming_provider(self) -> bool:
         """Return True if the provider is a streaming provider."""
index e871b57536f3d9277ae094c5b96dcb93b3d5541c..1f4dc5a16867266ade7280bc43aa68c5b0d12a7f 100644 (file)
@@ -14,7 +14,7 @@ from music_assistant_models.config_entries import (
     ProviderConfig,
 )
 from music_assistant_models.constants import SECURE_STRING_SUBSTITUTE
-from music_assistant_models.enums import ConfigEntryType, EventType
+from music_assistant_models.enums import ConfigEntryType, EventType, ProviderFeature
 from music_assistant_models.errors import LoginFailed, SetupFailedError
 from music_assistant_models.playback_progress_report import MediaItemPlaybackProgressReport
 from music_assistant_models.provider import ProviderManifest
@@ -26,12 +26,16 @@ from music_assistant.mass import MusicAssistant
 from music_assistant.models import ProviderInstanceType
 from music_assistant.models.plugin import PluginProvider
 
+SUPPORTED_FEATURES: set[ProviderFeature] = (
+    set()
+)  # we don't have any special supported features (yet)
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    provider = LastFMScrobbleProvider(mass, manifest, config)
+    provider = LastFMScrobbleProvider(mass, manifest, config, SUPPORTED_FEATURES)
     pylast.logger.setLevel(provider.logger.level)
 
     # httpcore is very spammy on debug without providing useful information 99% of the time
@@ -40,8 +44,6 @@ async def setup(
     else:
         logging.getLogger("httpcore").setLevel(logging.WARNING)
 
-    # run async setup of provider to catch any login issues early
-    await provider.async_setup()
     return provider
 
 
@@ -51,7 +53,7 @@ class LastFMScrobbleProvider(PluginProvider):
     network: pylast._Network
     _on_unload: list[Callable[[], None]]
 
-    async def async_setup(self) -> None:
+    async def handle_async_init(self) -> None:
         """Handle async setup."""
         self._on_unload: list[Callable[[], None]] = []
 
index 467afcb07b1283e8527b4234496421e1a07833aa..8f4e3c52d11fc762b6894ae31bddc857315548c8 100644 (file)
@@ -12,7 +12,7 @@ from collections.abc import Callable
 from liblistenbrainz import Listen, ListenBrainz
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
 from music_assistant_models.constants import SECURE_STRING_SUBSTITUTE
-from music_assistant_models.enums import ConfigEntryType, EventType
+from music_assistant_models.enums import ConfigEntryType, EventType, ProviderFeature
 from music_assistant_models.errors import SetupFailedError
 from music_assistant_models.playback_progress_report import MediaItemPlaybackProgressReport
 from music_assistant_models.provider import ProviderManifest
@@ -23,6 +23,9 @@ from music_assistant.models import ProviderInstanceType
 from music_assistant.models.plugin import PluginProvider
 
 CONF_USER_TOKEN = "_user_token"
+SUPPORTED_FEATURES: set[ProviderFeature] = (
+    set()
+)  # we don't have any special supported features (yet)
 
 
 async def setup(
@@ -52,7 +55,7 @@ class ListenBrainzScrobbleProvider(PluginProvider):
         client: ListenBrainz,
     ) -> None:
         """Initialize MusicProvider."""
-        super().__init__(mass, manifest, config)
+        super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
         self._client = client
         self._on_unload: list[Callable[[], None]] = []
 
index 42c3a201b395ad9254922bccf3ba98d8732acd4f..167f9c9594f4c031b32546b79f1acf5ef1ab6f0d 100644 (file)
@@ -37,7 +37,7 @@ async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return LrclibProvider(mass, manifest, config)
+    return LrclibProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -77,11 +77,6 @@ class LrclibProvider(MetadataProvider):
             self.throttler = ThrottlerManager(rate_limit=1, period=1)
             self.logger.debug("Using custom API endpoint: %s (throttling disabled)", self.api_url)
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     @throttle_with_retries
     async def _get_data(self, **params: Any) -> dict[str, Any] | None:
         """Get data from LRCLib API with throttling and retries."""
index 8659fa16cd6bf0e7e435679afbb7b14e13523252..44621755f849737af7fc78e178acef986a2db454 100644 (file)
@@ -33,12 +33,16 @@ if TYPE_CHECKING:
 
 LUCENE_SPECIAL = r'([+\-&|!(){}\[\]\^"~*?:\\\/])'
 
+SUPPORTED_FEATURES: set[ProviderFeature] = (
+    set()
+)  # we don't have any special supported features (yet)
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return MusicbrainzProvider(mass, manifest, config)
+    return MusicbrainzProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -236,11 +240,6 @@ class MusicbrainzProvider(MetadataProvider):
         """Handle async initialization of the provider."""
         self.cache = self.mass.cache
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return set()
-
     async def search(
         self, artistname: str, albumname: str, trackname: str, trackversion: str | None = None
     ) -> tuple[MusicBrainzArtist, MusicBrainzReleaseGroup, MusicBrainzRecording] | None:
index cd5407f3b0a9188b0dd33eacdb5d8cadde570d0b..eab00b68c8d713c5d293d5406abc2ca783f0a248 100644 (file)
@@ -1,10 +1,7 @@
 """MusicCast for MusicAssistant."""
 
-from music_assistant_models.config_entries import (
-    ConfigEntry,
-    ConfigValueType,
-    ProviderConfig,
-)
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
+from music_assistant_models.enums import ProviderFeature
 from music_assistant_models.provider import ProviderManifest
 
 from music_assistant.mass import MusicAssistant
@@ -12,12 +9,19 @@ from music_assistant.models import ProviderInstanceType
 
 from .provider import MusicCastProvider
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.SYNC_PLAYERS,
+    # support sync groups by reporting create/remove player group support
+    ProviderFeature.CREATE_GROUP_PLAYER,
+    ProviderFeature.REMOVE_GROUP_PLAYER,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return MusicCastProvider(mass, manifest, config)
+    return MusicCastProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index 05bb0c40572859c75dd605d7321030d6eb97557b..a2416d1e6464490e9c522a9e4687109e4ffa2f5e 100644 (file)
@@ -83,10 +83,14 @@ class MusicCastProvider(PlayerProvider):
     update_player_locks: dict[str, asyncio.Lock] = {}
 
     def __init__(
-        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+        self,
+        mass: MusicAssistant,
+        manifest: ProviderManifest,
+        config: ProviderConfig,
+        supported_features: set[ProviderFeature],
     ) -> None:
         """Init."""
-        super().__init__(mass, manifest, config)
+        super().__init__(mass, manifest, config, supported_features)
         # str is device_id here:
         self.musiccast_player_helpers: dict[str, MusicCastPlayerHelper] = {}
 
@@ -96,16 +100,6 @@ class MusicCastProvider(PlayerProvider):
             assert isinstance(mc_player, MusicCastPlayer)  # for type checking
             mc_player.physical_device.remove()
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.SYNC_PLAYERS,
-            # support sync groups by reporting create/remove player group support
-            ProviderFeature.CREATE_GROUP_PLAYER,
-            ProviderFeature.REMOVE_GROUP_PLAYER,
-        }
-
     async def handle_async_init(self) -> None:
         """Async init."""
         self.mc_controller = MusicCastController(logger=self.logger)
index 51a59fc46dd774b9214a21c0ee711eba7d098519..414441fa14d0a89dc11d76dae849f0a17ff337a0 100644 (file)
@@ -48,14 +48,20 @@ if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.BROWSE,
+    ProviderFeature.LIBRARY_ARTISTS,
+    ProviderFeature.LIBRARY_ALBUMS,
+    ProviderFeature.LIBRARY_PLAYLISTS,
+    ProviderFeature.ARTIST_ALBUMS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    prov = NugsProvider(mass, manifest, config)
-    await prov.handle_async_init()
-    return prov
+    return NugsProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -94,17 +100,6 @@ class NugsProvider(MusicProvider):
     _auth_token: str | None = None
     _token_expiry: float = 0
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.BROWSE,
-            ProviderFeature.LIBRARY_ARTISTS,
-            ProviderFeature.LIBRARY_ALBUMS,
-            ProviderFeature.LIBRARY_PLAYLISTS,
-            ProviderFeature.ARTIST_ALBUMS,
-        }
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         await self.login()
index faa4f362f62161fa52eaa72815f4eca4bfb46ed3..f75ba457f3cab3739e592da09461ee10d13f2414 100644 (file)
@@ -5,7 +5,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
-from music_assistant_models.enums import ConfigEntryType
+from music_assistant_models.enums import ConfigEntryType, ProviderFeature
 
 from music_assistant.constants import CONF_PASSWORD, CONF_PATH, CONF_PORT, CONF_USERNAME
 
@@ -27,12 +27,30 @@ if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.LIBRARY_ARTISTS,
+    ProviderFeature.LIBRARY_ALBUMS,
+    ProviderFeature.LIBRARY_TRACKS,
+    ProviderFeature.LIBRARY_PLAYLISTS,
+    ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
+    ProviderFeature.BROWSE,
+    ProviderFeature.SEARCH,
+    ProviderFeature.RECOMMENDATIONS,
+    ProviderFeature.ARTIST_ALBUMS,
+    ProviderFeature.ARTIST_TOPTRACKS,
+    ProviderFeature.SIMILAR_TRACKS,
+    ProviderFeature.PLAYLIST_TRACKS_EDIT,
+    ProviderFeature.PLAYLIST_CREATE,
+    ProviderFeature.LIBRARY_PODCASTS,
+    ProviderFeature.LIBRARY_PODCASTS_EDIT,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return OpenSonicProvider(mass, manifest, config)
+    return OpenSonicProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index 64bd7546ca41b78c05120792b5702a009caa7d4e..162f0368cf48bb9e092872e4e7ac30f9fdbc737b 100644 (file)
@@ -14,7 +14,7 @@ from libopensonic.errors import (
     ParameterError,
     SonicError,
 )
-from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType
+from music_assistant_models.enums import ContentType, MediaType, StreamType
 from music_assistant_models.errors import (
     ActionUnavailable,
     LoginFailed,
@@ -141,27 +141,6 @@ class OpenSonicProvider(MusicProvider):
         self._reco_limit = int(str(self.config.get_value(CONF_RECO_SIZE)))
         self._cache_base_key = f"{self.instance_id}/"
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return a list of supported features."""
-        return {
-            ProviderFeature.LIBRARY_ARTISTS,
-            ProviderFeature.LIBRARY_ALBUMS,
-            ProviderFeature.LIBRARY_TRACKS,
-            ProviderFeature.LIBRARY_PLAYLISTS,
-            ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
-            ProviderFeature.BROWSE,
-            ProviderFeature.SEARCH,
-            ProviderFeature.RECOMMENDATIONS,
-            ProviderFeature.ARTIST_ALBUMS,
-            ProviderFeature.ARTIST_TOPTRACKS,
-            ProviderFeature.SIMILAR_TRACKS,
-            ProviderFeature.PLAYLIST_TRACKS_EDIT,
-            ProviderFeature.PLAYLIST_CREATE,
-            ProviderFeature.LIBRARY_PODCASTS,
-            ProviderFeature.LIBRARY_PODCASTS_EDIT,
-        }
-
     @property
     def is_streaming_provider(self) -> bool:
         """
index 3af9d08d358679cb71b3110fef3707f5bef484f0..bef07edb0e612ae0f64415fa1b2ab34812ef1aa6 100644 (file)
@@ -89,6 +89,16 @@ FAKE_ARTIST_PREFIX = "_fake://"
 
 AUTH_TOKEN_UNAUTH = "local_auth"
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.LIBRARY_ARTISTS,
+    ProviderFeature.LIBRARY_ALBUMS,
+    ProviderFeature.LIBRARY_TRACKS,
+    ProviderFeature.LIBRARY_PLAYLISTS,
+    ProviderFeature.BROWSE,
+    ProviderFeature.SEARCH,
+    ProviderFeature.ARTIST_ALBUMS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
@@ -98,7 +108,7 @@ async def setup(
         msg = "Invalid login credentials"
         raise LoginFailed(msg)
 
-    return PlexProvider(mass, manifest, config)
+    return PlexProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(  # noqa: PLR0915
@@ -382,19 +392,6 @@ class PlexProvider(MusicProvider):
         except requests.exceptions.ConnectionError as err:
             raise SetupFailedError from err
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return a list of supported features."""
-        return {
-            ProviderFeature.LIBRARY_ARTISTS,
-            ProviderFeature.LIBRARY_ALBUMS,
-            ProviderFeature.LIBRARY_TRACKS,
-            ProviderFeature.LIBRARY_PLAYLISTS,
-            ProviderFeature.BROWSE,
-            ProviderFeature.SEARCH,
-            ProviderFeature.ARTIST_ALBUMS,
-        }
-
     @property
     def is_streaming_provider(self) -> bool:
         """
index 45d99fbb96fb1636bc8408b20a0285668fe9261f..c5e556547e99d275bfc7e1d9653876e70b22fe8a 100644 (file)
@@ -5,9 +5,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType
-
-from music_assistant.constants import DEFAULT_PROVIDER_CONFIG_ENTRIES
+from music_assistant_models.enums import ConfigEntryType, ProviderFeature
 
 from .constants import CONF_API_KEY, CONF_API_SECRET, CONF_STORED_PODCASTS
 from .provider import PodcastIndexProvider
@@ -19,12 +17,19 @@ if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.SEARCH,
+    ProviderFeature.BROWSE,
+    ProviderFeature.LIBRARY_PODCASTS,
+    ProviderFeature.LIBRARY_PODCASTS_EDIT,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return PodcastIndexProvider(mass, manifest, config)
+    return PodcastIndexProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -65,5 +70,4 @@ async def get_config_entries(
             required=False,
             hidden=True,
         ),
-        *DEFAULT_PROVIDER_CONFIG_ENTRIES,
     )
index f2f029668f417b9d98266b7cd6947ca0fbd35933..3685e7b4985f427b1f1ed6d7ffc4f1cac4f6046e 100644 (file)
@@ -4,15 +4,10 @@ from __future__ import annotations
 
 import asyncio
 from collections.abc import AsyncGenerator, Sequence
-from typing import TYPE_CHECKING, Any, cast
+from typing import Any, cast
 
 import aiohttp
-from music_assistant_models.enums import (
-    ContentType,
-    MediaType,
-    ProviderFeature,
-    StreamType,
-)
+from music_assistant_models.enums import ContentType, MediaType, StreamType
 from music_assistant_models.errors import (
     InvalidDataError,
     LoginFailed,
@@ -44,36 +39,12 @@ from .constants import (
 )
 from .helpers import make_api_request, parse_episode_from_data, parse_podcast_from_feed
 
-if TYPE_CHECKING:
-    from music_assistant_models.config_entries import ProviderConfig
-    from music_assistant_models.provider import ProviderManifest
-
-    from music_assistant.mass import MusicAssistant
-
 
 class PodcastIndexProvider(MusicProvider):
     """Podcast Index provider for Music Assistant."""
 
-    def __init__(
-        self,
-        mass: MusicAssistant,
-        manifest: ProviderManifest,
-        config: ProviderConfig,
-    ) -> None:
-        """Initialize the provider."""
-        super().__init__(mass, manifest, config)
-        self.api_key: str = ""
-        self.api_secret: str = ""
-
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.SEARCH,
-            ProviderFeature.BROWSE,
-            ProviderFeature.LIBRARY_PODCASTS,
-            ProviderFeature.LIBRARY_PODCASTS_EDIT,
-        }
+    api_key: str = ""
+    api_secret: str = ""
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
index 49319081ce6069c25a4ffd7faf3308a9c8e8f5db..c10c76789b6d2932e4ee43143b93e7d14287b575 100644 (file)
@@ -41,6 +41,11 @@ CONF_FEED_URL = "feed_url"
 
 CACHE_CATEGORY_PODCASTS = 0
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.BROWSE,
+    ProviderFeature.LIBRARY_PODCASTS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
@@ -49,7 +54,7 @@ async def setup(
     if not config.get_value(CONF_FEED_URL):
         msg = "No podcast feed set"
         raise InvalidProviderURI(msg)
-    return PodcastMusicprovider(mass, manifest, config)
+    return PodcastMusicprovider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -79,14 +84,6 @@ async def get_config_entries(
 class PodcastMusicprovider(MusicProvider):
     """Podcast RSS Feed Music Provider."""
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.BROWSE,
-            ProviderFeature.LIBRARY_PODCASTS,
-        }
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         self.feed_url = podcastparser.normalize_feed_url(str(self.config.get_value(CONF_FEED_URL)))
index 0549e45412b22d191c4da96b1931bcb782128130..8342cbce8f65fafc045d1aa2c7c6d2bc6d975274 100644 (file)
@@ -84,7 +84,7 @@ async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return QobuzProvider(mass, manifest, config)
+    return QobuzProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -136,11 +136,6 @@ class QobuzProvider(MusicProvider):
             msg = f"Login failed for user {self.config.get_value(CONF_USERNAME)}"
             raise LoginFailed(msg)
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     async def search(
         self, search_query: str, media_types=list[MediaType], limit: int = 5
     ) -> SearchResults:
index ee9078ffa59d71358c6f74e9f123c4ecdcba4628..b7b29ed2280ed9735360749b6fdfe3ede99468c1 100644 (file)
@@ -30,6 +30,12 @@ from music_assistant_models.media_items import (
 from music_assistant_models.streamdetails import StreamDetails
 from radios import FilterBy, Order, RadioBrowser, RadioBrowserError, Station
 
+from music_assistant.constants import (
+    CONF_ENTRY_LIBRARY_EXPORT_ADD,
+    CONF_ENTRY_LIBRARY_EXPORT_REMOVE,
+    CONF_ENTRY_LIBRARY_IMPORT_RADIOS,
+    CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS,
+)
 from music_assistant.controllers.cache import use_cache
 from music_assistant.models.music_provider import MusicProvider
 
@@ -49,12 +55,41 @@ if TYPE_CHECKING:
 
 CONF_STORED_RADIOS = "stored_radios"
 
+CONF_ENTRY_LIBRARY_IMPORT_RADIOS_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_LIBRARY_IMPORT_RADIOS.to_dict(),
+        "hidden": True,
+        "default_value": "import_only",
+    }
+)
+CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS.to_dict(),
+        "hidden": True,
+        "default_value": 180,
+    }
+)
+CONF_ENTRY_LIBRARY_EXPORT_ADD_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_LIBRARY_EXPORT_ADD.to_dict(),
+        "hidden": True,
+        "default_value": "export_library",
+    }
+)
+CONF_ENTRY_LIBRARY_EXPORT_REMOVE_HIDDEN = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_LIBRARY_EXPORT_REMOVE.to_dict(),
+        "hidden": True,
+        "default_value": "export_library",
+    }
+)
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return RadioBrowserProvider(mass, manifest, config)
+    return RadioBrowserProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -83,17 +118,17 @@ async def get_config_entries(
             required=False,
             hidden=True,
         ),
+        # hide some of the default (dynamic) entries for library management
+        CONF_ENTRY_LIBRARY_IMPORT_RADIOS_HIDDEN,
+        CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS_HIDDEN,
+        CONF_ENTRY_LIBRARY_EXPORT_ADD_HIDDEN,
+        CONF_ENTRY_LIBRARY_EXPORT_REMOVE_HIDDEN,
     )
 
 
 class RadioBrowserProvider(MusicProvider):
     """Provider implementation for RadioBrowser."""
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         self.radios = RadioBrowser(
index f3d2d9d71343c41d7e93368ce1cfee9b8472ee97..934ea6f21e78fd18924b530bc433fe94b70c3243 100644 (file)
@@ -4,6 +4,8 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
+from music_assistant_models.enums import ProviderFeature
+
 if TYPE_CHECKING:
     from music_assistant_models.config_entries import (
         ConfigEntry,
@@ -17,12 +19,17 @@ if TYPE_CHECKING:
 
 from .provider import RadioParadiseProvider
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.BROWSE,
+    ProviderFeature.LIBRARY_RADIOS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return RadioParadiseProvider(mass, manifest, config)
+    return RadioParadiseProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -32,4 +39,6 @@ async def get_config_entries(
     values: dict[str, ConfigValueType] | None = None,  # noqa: ARG001
 ) -> tuple[ConfigEntry, ...]:
     """Return Config entries to setup this provider."""
-    return ()
+    return (
+        # we (currently) don't have any config entries to set up
+    )
index 5e209a68b66b631888a9dc656c497f8cf674e230..b5d60d3657cb595abc9103072d2100c7f377c5c7 100644 (file)
@@ -5,14 +5,10 @@ from __future__ import annotations
 import asyncio
 import contextlib
 from collections.abc import AsyncGenerator, Sequence
-from typing import TYPE_CHECKING, Any
+from typing import Any
 
 import aiohttp
-from music_assistant_models.enums import (
-    MediaType,
-    ProviderFeature,
-    StreamType,
-)
+from music_assistant_models.enums import MediaType, StreamType
 from music_assistant_models.errors import MediaNotFoundError, UnplayableMediaError
 from music_assistant_models.media_items import (
     AudioFormat,
@@ -29,28 +25,10 @@ from . import parsers
 from .constants import RADIO_PARADISE_CHANNELS
 from .helpers import build_stream_url, find_current_song, get_current_block_position, get_next_song
 
-if TYPE_CHECKING:
-    from music_assistant_models.config_entries import ProviderConfig
-    from music_assistant_models.provider import ProviderManifest
-
-    from music_assistant import MusicAssistant
-
 
 class RadioParadiseProvider(MusicProvider):
     """Radio Paradise Music Provider for Music Assistant."""
 
-    def __init__(self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig):
-        """Initialize the provider."""
-        super().__init__(mass, manifest, config)
-
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.BROWSE,
-            ProviderFeature.LIBRARY_RADIOS,
-        }
-
     @property
     def is_streaming_provider(self) -> bool:
         """Return True if the provider is a streaming provider."""
index b172a4841ff08c81c0b21cee70c4e89f1c737d58..b0929574f29c6520b8662994b8104b97d741aa5b 100644 (file)
@@ -48,12 +48,17 @@ CONF_SXM_USERNAME = "sxm_email_address"
 CONF_SXM_PASSWORD = "sxm_password"
 CONF_SXM_REGION = "sxm_region"
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.BROWSE,
+    ProviderFeature.LIBRARY_RADIOS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return SiriusXMProvider(mass, manifest, config)
+    return SiriusXMProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -112,14 +117,6 @@ class SiriusXMProvider(MusicProvider):
 
     _current_stream_details: StreamDetails | None = None
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.BROWSE,
-            ProviderFeature.LIBRARY_RADIOS,
-        }
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         username: str = self.config.get_value(CONF_SXM_USERNAME)
index c674c5c16c80761bba16a32790ff01a575068cf1..9deef506bc847e014cc7fad190d7fc1f4f1ac3c4 100644 (file)
@@ -6,7 +6,7 @@ from music_assistant_models.config_entries import (
     ConfigValueType,
     ProviderConfig,
 )
-from music_assistant_models.enums import ConfigEntryType
+from music_assistant_models.enums import ConfigEntryType, ProviderFeature
 from music_assistant_models.errors import SetupFailedError
 from music_assistant_models.provider import ProviderManifest
 
@@ -33,12 +33,20 @@ from music_assistant.providers.snapcast.constants import (
 )
 from music_assistant.providers.snapcast.provider import SnapCastProvider
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.SYNC_PLAYERS,
+    ProviderFeature.REMOVE_PLAYER,
+    # support sync groups by reporting create/remove player group support
+    ProviderFeature.CREATE_GROUP_PLAYER,
+    ProviderFeature.REMOVE_GROUP_PLAYER,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return SnapCastProvider(mass, manifest, config)
+    return SnapCastProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index 714bdeae2c28c2429a0d691c2c88ed308eca2cfd..39189cebb7206ed112157e4363b1b5f67a752f15 100644 (file)
@@ -7,7 +7,7 @@ import socket
 from typing import cast
 
 from bidict import bidict
-from music_assistant_models.enums import PlaybackState, ProviderFeature
+from music_assistant_models.enums import PlaybackState
 from music_assistant_models.errors import SetupFailedError
 from snapcast.control import create_server
 from snapcast.control.client import Snapclient
@@ -47,17 +47,6 @@ class SnapCastProvider(PlayerProvider):
     _use_builtin_server: bool
     _stop_called: bool
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.SYNC_PLAYERS,
-            ProviderFeature.REMOVE_PLAYER,
-            # support sync groups by reporting create/remove player group support
-            ProviderFeature.CREATE_GROUP_PLAYER,
-            ProviderFeature.REMOVE_GROUP_PLAYER,
-        }
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         # set snapcast logging
index 31eed7c6311f287feebd80860846a6c021eb73ce..796ac880b32309849dc4f0f613bb52b8476827e2 100644 (file)
@@ -10,6 +10,8 @@ from __future__ import annotations
 import logging
 from typing import TYPE_CHECKING
 
+from music_assistant_models.enums import ProviderFeature
+
 from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS, VERBOSE_LOG_LEVEL
 
 from .provider import SonosPlayerProvider
@@ -21,12 +23,19 @@ if TYPE_CHECKING:
     from music_assistant import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.SYNC_PLAYERS,
+    # support sync groups by reporting create/remove player group support
+    ProviderFeature.CREATE_GROUP_PLAYER,
+    ProviderFeature.REMOVE_GROUP_PLAYER,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    prov = SonosPlayerProvider(mass, manifest, config)
+    prov = SonosPlayerProvider(mass, manifest, config, SUPPORTED_FEATURES)
     # set-up aiosonos logging
     if prov.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
         logging.getLogger("aiosonos").setLevel(logging.DEBUG)
index d3ada68a14309714f12a8a161d2e6d0c0e952a70..13409f8b7525d45d9abeea9454ed2e97dfafd05e 100644 (file)
@@ -13,7 +13,7 @@ from aiohttp import web
 from aiohttp.client_exceptions import ClientError
 from aiosonos.api.models import SonosCapability
 from aiosonos.utils import get_discovery_info
-from music_assistant_models.enums import PlaybackState, ProviderFeature
+from music_assistant_models.enums import PlaybackState
 from zeroconf import ServiceStateChange
 
 from music_assistant.constants import (
@@ -35,16 +35,6 @@ if TYPE_CHECKING:
 class SonosPlayerProvider(PlayerProvider):
     """Sonos Player provider."""
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.SYNC_PLAYERS,
-            # support sync groups by reporting create/remove player group support
-            ProviderFeature.CREATE_GROUP_PLAYER,
-            ProviderFeature.REMOVE_GROUP_PLAYER,
-        }
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         self.mass.streams.register_dynamic_route(
index 0c96581cd2094937461b0fcf8125e83d33f7a160..42ef940423e453ce149c52319e4894c567e5f272 100644 (file)
@@ -12,7 +12,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType
+from music_assistant_models.enums import ConfigEntryType, ProviderFeature
 
 from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS
 
@@ -25,12 +25,19 @@ if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.SYNC_PLAYERS,
+    # support sync groups by reporting create/remove player group support
+    ProviderFeature.CREATE_GROUP_PLAYER,
+    ProviderFeature.REMOVE_GROUP_PLAYER,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return SonosPlayerProvider(mass, manifest, config)
+    return SonosPlayerProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index bf687bbd9d5a14b83ae4536e27f33dfc987ea166..64296cb9864805c4da73657326d24c8789d0af0b 100644 (file)
@@ -8,7 +8,6 @@ from contextlib import suppress
 from dataclasses import dataclass
 from typing import Any
 
-from music_assistant_models.enums import ProviderFeature
 from soco import SoCo
 from soco import config as soco_config
 from soco.discovery import discover, scan_network
@@ -36,16 +35,6 @@ class SonosPlayerProvider(PlayerProvider):
         self.sonosplayers: dict[str, SonosPlayer] = {}
         self._discovered_players: dict[str, DiscoveredPlayer] = {}
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.SYNC_PLAYERS,
-            # support sync groups by reporting create/remove player group support
-            ProviderFeature.CREATE_GROUP_PLAYER,
-            ProviderFeature.REMOVE_GROUP_PLAYER,
-        }
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         # Set up SoCo logging
index baaf2399944bc691648fef97153ef5df220f5295..d662e5aecd5d1a4460eeecc172d2f19d79e7cd9b 100644 (file)
@@ -65,7 +65,7 @@ async def setup(
     if not config.get_value(CONF_CLIENT_ID) or not config.get_value(CONF_AUTHORIZATION):
         msg = "Invalid login credentials"
         raise LoginFailed(msg)
-    return SoundcloudMusicProvider(mass, manifest, config)
+    return SoundcloudMusicProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -114,11 +114,6 @@ class SoundcloudMusicProvider(MusicProvider):
         self._me = await self._soundcloud.get_account_details()
         self._user_id = self._me["id"]
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     async def search(
         self, search_query: str, media_types: list[MediaType], limit: int = 10
     ) -> SearchResults:
index cbba828e3b1aa008c3db77333dd5590fce4e6e7c..9010021d77ca72eca768874922c1049888fb1744 100644 (file)
@@ -7,7 +7,7 @@ from urllib.parse import urlencode
 
 import pkce
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType
+from music_assistant_models.enums import ConfigEntryType, ProviderFeature
 from music_assistant_models.errors import InvalidDataError, SetupFailedError
 
 from music_assistant.helpers.app_vars import app_var  # type: ignore[attr-defined]
@@ -32,6 +32,25 @@ if TYPE_CHECKING:
     from music_assistant import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.LIBRARY_ARTISTS,
+    ProviderFeature.LIBRARY_ALBUMS,
+    ProviderFeature.LIBRARY_TRACKS,
+    ProviderFeature.LIBRARY_PLAYLISTS,
+    ProviderFeature.LIBRARY_ARTISTS_EDIT,
+    ProviderFeature.LIBRARY_ALBUMS_EDIT,
+    ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
+    ProviderFeature.LIBRARY_TRACKS_EDIT,
+    ProviderFeature.PLAYLIST_TRACKS_EDIT,
+    ProviderFeature.BROWSE,
+    ProviderFeature.SEARCH,
+    ProviderFeature.ARTIST_ALBUMS,
+    ProviderFeature.ARTIST_TOPTRACKS,
+    ProviderFeature.SIMILAR_TRACKS,
+    ProviderFeature.LIBRARY_PODCASTS,
+    ProviderFeature.LIBRARY_PODCASTS_EDIT,
+}
+
 
 async def get_config_entries(
     mass: MusicAssistant,
@@ -133,6 +152,24 @@ async def get_config_entries(
             value=values.get(CONF_CLIENT_ID) if values else None,
             hidden=not auth_required,
         ),
+        ConfigEntry(
+            key=CONF_ACTION_AUTH,
+            type=ConfigEntryType.ACTION,
+            label="Authenticate with Spotify",
+            description="This button will redirect you to Spotify to authenticate.",
+            action=CONF_ACTION_AUTH,
+            hidden=not auth_required,
+        ),
+        ConfigEntry(
+            key=CONF_ACTION_CLEAR_AUTH,
+            type=ConfigEntryType.ACTION,
+            label="Clear authentication",
+            description="Clear the current authentication details.",
+            action=CONF_ACTION_CLEAR_AUTH,
+            action_label="Clear authentication",
+            required=False,
+            hidden=auth_required,
+        ),
         ConfigEntry(
             key=CONF_SYNC_PLAYED_STATUS,
             type=ConfigEntryType.BOOLEAN,
@@ -143,6 +180,7 @@ async def get_config_entries(
             "for podcast playback.",
             default_value=False,
             value=values.get(CONF_SYNC_PLAYED_STATUS, True) if values else True,
+            category="sync_options",
         ),
         ConfigEntry(
             key=CONF_PLAYED_THRESHOLD,
@@ -153,24 +191,8 @@ async def get_config_entries(
             default_value=90,
             value=values.get(CONF_PLAYED_THRESHOLD, 90) if values else 90,
             range=(1, 100),
-        ),
-        ConfigEntry(
-            key=CONF_ACTION_AUTH,
-            type=ConfigEntryType.ACTION,
-            label="Authenticate with Spotify",
-            description="This button will redirect you to Spotify to authenticate.",
-            action=CONF_ACTION_AUTH,
-            hidden=not auth_required,
-        ),
-        ConfigEntry(
-            key=CONF_ACTION_CLEAR_AUTH,
-            type=ConfigEntryType.ACTION,
-            label="Clear authentication",
-            description="Clear the current authentication details.",
-            action=CONF_ACTION_CLEAR_AUTH,
-            action_label="Clear authentication",
-            required=False,
-            hidden=auth_required,
+            depends_on=CONF_SYNC_PLAYED_STATUS,
+            category="sync_options",
         ),
     )
 
@@ -182,4 +204,4 @@ async def setup(
     if config.get_value(CONF_REFRESH_TOKEN) in (None, ""):
         msg = "Re-Authentication required"
         raise SetupFailedError(msg)
-    return SpotifyProvider(mass, manifest, config)
+    return SpotifyProvider(mass, manifest, config, SUPPORTED_FEATURES)
index dcfe2d4649f8a0d88b64e77d23201dfaaee873fa..00a6d07604313ec996efbbbf1448f33d98fb6373 100644 (file)
@@ -2,8 +2,6 @@
 
 from __future__ import annotations
 
-from music_assistant_models.enums import ProviderFeature
-
 # Configuration Keys
 CONF_CLIENT_ID = "client_id"
 CONF_ACTION_AUTH = "auth"
@@ -42,23 +40,3 @@ CALLBACK_REDIRECT_URL = "https://music-assistant.io/callback"
 
 # Other Constants
 LIKED_SONGS_FAKE_PLAYLIST_ID_PREFIX = "liked_songs"
-
-# Base Features
-SUPPORTED_FEATURES = {
-    ProviderFeature.LIBRARY_ARTISTS,
-    ProviderFeature.LIBRARY_ALBUMS,
-    ProviderFeature.LIBRARY_TRACKS,
-    ProviderFeature.LIBRARY_PLAYLISTS,
-    ProviderFeature.LIBRARY_ARTISTS_EDIT,
-    ProviderFeature.LIBRARY_ALBUMS_EDIT,
-    ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
-    ProviderFeature.LIBRARY_TRACKS_EDIT,
-    ProviderFeature.PLAYLIST_TRACKS_EDIT,
-    ProviderFeature.BROWSE,
-    ProviderFeature.SEARCH,
-    ProviderFeature.ARTIST_ALBUMS,
-    ProviderFeature.ARTIST_TOPTRACKS,
-    ProviderFeature.SIMILAR_TRACKS,
-    ProviderFeature.LIBRARY_PODCASTS,
-    ProviderFeature.LIBRARY_PODCASTS_EDIT,
-}
index c811a46047d7b4f33434eb59de10b49b795d4298..21a94fdb390a622d6ca9e504fe492088ba8472d6 100644 (file)
@@ -6,7 +6,7 @@ import asyncio
 import os
 import time
 from collections.abc import AsyncGenerator
-from typing import TYPE_CHECKING, Any
+from typing import Any
 
 import aiohttp
 from music_assistant_models.enums import (
@@ -51,7 +51,6 @@ from .constants import (
     CONF_REFRESH_TOKEN,
     CONF_SYNC_PLAYED_STATUS,
     LIKED_SONGS_FAKE_PLAYLIST_ID_PREFIX,
-    SUPPORTED_FEATURES,
 )
 from .helpers import get_librespot_binary
 from .parsers import (
@@ -64,12 +63,6 @@ from .parsers import (
 )
 from .streaming import LibrespotStreamer
 
-if TYPE_CHECKING:
-    from music_assistant_models.config_entries import ProviderConfig
-    from music_assistant_models.provider import ProviderManifest
-
-    from music_assistant import MusicAssistant
-
 
 class SpotifyProvider(MusicProvider):
     """Implementation of a Spotify MusicProvider."""
@@ -80,12 +73,6 @@ class SpotifyProvider(MusicProvider):
     custom_client_id_active: bool = False
     throttler: ThrottlerManager
 
-    def __init__(
-        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
-    ) -> None:
-        """Initialize the provider."""
-        super().__init__(mass, manifest, config)
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         self.cache_dir = os.path.join(self.mass.cache_path, self.instance_id)
@@ -124,12 +111,12 @@ class SpotifyProvider(MusicProvider):
     @property
     def supported_features(self) -> set[ProviderFeature]:
         """Return the features supported by this Provider."""
-        base = SUPPORTED_FEATURES.copy()
+        features = self._supported_features
         if not self.custom_client_id_active:
             # Spotify has killed the similar tracks api for developers
             # https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api
-            base.add(ProviderFeature.SIMILAR_TRACKS)
-        return base
+            return {*features, ProviderFeature.SIMILAR_TRACKS}
+        return features
 
     @property
     def instance_name_postfix(self) -> str | None:
index 97f73c1dc98c016e7ac160d706c28db64af06127..900966f5eda1a5acab96bbe8bd2472a5e984b02a 100644 (file)
@@ -49,6 +49,8 @@ CONNECT_ITEM_ID = "spotify_connect"
 
 EVENTS_SCRIPT = pathlib.Path(__file__).parent.resolve().joinpath("events.py")
 
+SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
@@ -113,7 +115,7 @@ class SpotifyConnectProvider(PluginProvider):
         self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
     ) -> None:
         """Initialize MusicProvider."""
-        super().__init__(mass, manifest, config)
+        super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
         self.mass_player_id = cast("str", self.config.get_value(CONF_MASS_PLAYER_ID))
         self.cache_dir = os.path.join(self.mass.cache_path, self.instance_id)
         self._librespot_bin: str | None = None
@@ -158,11 +160,6 @@ class SpotifyConnectProvider(PluginProvider):
             ),
         ]
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {ProviderFeature.AUDIO_SOURCE}
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         self._librespot_bin = await get_librespot_binary()
index 0eb2191388e90c0f7bbea6fcc87661d6c209e00b..0da037989e91d268296ada60579cf81117e3498e 100644 (file)
@@ -5,7 +5,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType
+from music_assistant_models.enums import ConfigEntryType, ProviderFeature
 
 from music_assistant.constants import CONF_PORT
 
@@ -24,12 +24,19 @@ if TYPE_CHECKING:
     from music_assistant import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.SYNC_PLAYERS,
+    # support sync groups by reporting create/remove player group support
+    ProviderFeature.CREATE_GROUP_PLAYER,
+    ProviderFeature.REMOVE_GROUP_PLAYER,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return SqueezelitePlayerProvider(mass, manifest, config)
+    return SqueezelitePlayerProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index e0778490ce2fc878f94007c901a9fe56b432d8ae..fe3d0d058a564cfde2d74e1c9f4b6214600de3fb 100644 (file)
@@ -9,7 +9,7 @@ from aiohttp import web
 from aioslimproto.models import EventType as SlimEventType
 from aioslimproto.models import SlimEvent
 from aioslimproto.server import SlimServer
-from music_assistant_models.enums import ContentType, ProviderFeature
+from music_assistant_models.enums import ContentType
 from music_assistant_models.errors import SetupFailedError
 from music_assistant_models.media_items import AudioFormat
 
@@ -23,35 +23,12 @@ from .player import SqueezelitePlayer
 
 if TYPE_CHECKING:
     from aioslimproto.client import SlimClient
-    from music_assistant_models.config_entries import ProviderConfig
-    from music_assistant_models.provider import ProviderManifest
-
-    from music_assistant import MusicAssistant
 
 
 class SqueezelitePlayerProvider(PlayerProvider):
     """Player provider for players using slimproto (like Squeezelite)."""
 
-    def __init__(
-        self,
-        mass: MusicAssistant,
-        manifest: ProviderManifest,
-        config: ProviderConfig,
-    ) -> None:
-        """Initialize the provider."""
-        super().__init__(mass, manifest, config)
-        self.slimproto: SlimServer | None = None
-        self._players: dict[str, SqueezelitePlayer] = {}
-
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.SYNC_PLAYERS,
-            # support sync groups by reporting create/remove player group support
-            ProviderFeature.CREATE_GROUP_PLAYER,
-            ProviderFeature.REMOVE_GROUP_PLAYER,
-        }
+    slimproto: SlimServer | None = None
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
@@ -121,10 +98,6 @@ class SqueezelitePlayerProvider(PlayerProvider):
             finally:
                 self.slimproto = None
 
-        # Clear any associated state that might have been created
-        self._players.clear()
-        self._multi_client_streams.clear()
-
     async def loaded_in_mass(self) -> None:
         """Call after the provider has been loaded."""
         await super().loaded_in_mass()
index a5687fb13256735f8daf3b3fa9c11df69dd3755b..b006ad048d4e1605221b18449fea8d4996c2e22d 100644 (file)
@@ -62,12 +62,21 @@ CONF_KEY_NUM_TRACKS = "num_tracks"
 CONF_KEY_NUM_PODCASTS = "num_podcasts"
 CONF_KEY_NUM_AUDIOBOOKS = "num_audiobooks"
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.BROWSE,
+    ProviderFeature.LIBRARY_ARTISTS,
+    ProviderFeature.LIBRARY_ALBUMS,
+    ProviderFeature.LIBRARY_TRACKS,
+    ProviderFeature.LIBRARY_PODCASTS,
+    ProviderFeature.LIBRARY_AUDIOBOOKS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return TestProvider(mass, manifest, config)
+    return TestProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -135,22 +144,6 @@ class TestProvider(MusicProvider):
         """Return True if the provider is a streaming provider."""
         return False
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        sup_features = {ProviderFeature.BROWSE}
-        if self.config.get_value(CONF_KEY_NUM_ARTISTS):
-            sup_features.add(ProviderFeature.LIBRARY_ARTISTS)
-        if self.config.get_value(CONF_KEY_NUM_ALBUMS):
-            sup_features.add(ProviderFeature.LIBRARY_ALBUMS)
-        if self.config.get_value(CONF_KEY_NUM_TRACKS):
-            sup_features.add(ProviderFeature.LIBRARY_TRACKS)
-        if self.config.get_value(CONF_KEY_NUM_PODCASTS):
-            sup_features.add(ProviderFeature.LIBRARY_PODCASTS)
-        if self.config.get_value(CONF_KEY_NUM_AUDIOBOOKS):
-            sup_features.add(ProviderFeature.LIBRARY_AUDIOBOOKS)
-        return sup_features
-
     async def get_track(self, prov_track_id: str) -> Track:
         """Get full track details by id."""
         artist_idx, album_idx, track_idx = prov_track_id.split("_", 3)
index 6729d05e9e154f3f35479f7887a64f54a0e213fe..f1c62f0f22bc538a39ea721b347ef13eaf91f9d3 100644 (file)
@@ -88,7 +88,7 @@ async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return AudioDbMetadataProvider(mass, manifest, config)
+    return AudioDbMetadataProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -143,11 +143,6 @@ class AudioDbMetadataProvider(MetadataProvider):
         self.cache = self.mass.cache
         self.throttler = Throttler(rate_limit=1, period=1)
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     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):
index 5b4097281a29b04360e72ac372927a24a9510b88..1cd48d9b3c35eb51503469c3a3c27f6aae9c05c3 100644 (file)
@@ -99,6 +99,25 @@ DEFAULT_LIMIT = 50
 
 T = TypeVar("T")
 
+SUPPORTED_FEATURES = {
+    ProviderFeature.LIBRARY_ARTISTS,
+    ProviderFeature.LIBRARY_ALBUMS,
+    ProviderFeature.LIBRARY_TRACKS,
+    ProviderFeature.LIBRARY_PLAYLISTS,
+    ProviderFeature.ARTIST_ALBUMS,
+    ProviderFeature.ARTIST_TOPTRACKS,
+    ProviderFeature.SEARCH,
+    ProviderFeature.LIBRARY_ARTISTS_EDIT,
+    ProviderFeature.LIBRARY_ALBUMS_EDIT,
+    ProviderFeature.LIBRARY_TRACKS_EDIT,
+    ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
+    ProviderFeature.PLAYLIST_CREATE,
+    ProviderFeature.SIMILAR_TRACKS,
+    ProviderFeature.BROWSE,
+    ProviderFeature.PLAYLIST_TRACKS_EDIT,
+    ProviderFeature.RECOMMENDATIONS,
+}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
@@ -316,7 +335,7 @@ class TidalProvider(MusicProvider):
 
     def __init__(self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig):
         """Initialize Tidal provider."""
-        super().__init__(mass, manifest, config)
+        super().__init__(mass, manifest, config, SUPPORTED_FEATURES)
         self.auth = TidalAuthManager(
             http_session=mass.http_session,
             config_updater=self._update_auth_config,
@@ -375,28 +394,6 @@ class TidalProvider(MusicProvider):
         logged_in_user = await self.get_user(str(user_info.get("userId")))
         await self.auth.update_user_info(logged_in_user, str(user_info.get("sessionId")))
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {
-            ProviderFeature.LIBRARY_ARTISTS,
-            ProviderFeature.LIBRARY_ALBUMS,
-            ProviderFeature.LIBRARY_TRACKS,
-            ProviderFeature.LIBRARY_PLAYLISTS,
-            ProviderFeature.ARTIST_ALBUMS,
-            ProviderFeature.ARTIST_TOPTRACKS,
-            ProviderFeature.SEARCH,
-            ProviderFeature.LIBRARY_ARTISTS_EDIT,
-            ProviderFeature.LIBRARY_ALBUMS_EDIT,
-            ProviderFeature.LIBRARY_TRACKS_EDIT,
-            ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
-            ProviderFeature.PLAYLIST_CREATE,
-            ProviderFeature.SIMILAR_TRACKS,
-            ProviderFeature.BROWSE,
-            ProviderFeature.PLAYLIST_TRACKS_EDIT,
-            ProviderFeature.RECOMMENDATIONS,
-        }
-
     #
     # API REQUEST HELPERS & DECORATORS
     #
index 1e42229c4a048117a3ba245220dca47df307051a..b3e19ff56670562f8fb2d9e1f43f3e58f11c05e4 100644 (file)
@@ -5,10 +5,7 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from urllib.parse import quote
 
-from music_assistant_models.config_entries import (
-    ConfigEntry,
-    ConfigValueType,
-)
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
 from music_assistant_models.enums import (
     ConfigEntryType,
     ContentType,
@@ -16,11 +13,7 @@ from music_assistant_models.enums import (
     ProviderFeature,
     StreamType,
 )
-from music_assistant_models.errors import (
-    InvalidDataError,
-    LoginFailed,
-    MediaNotFoundError,
-)
+from music_assistant_models.errors import InvalidDataError, LoginFailed, MediaNotFoundError
 from music_assistant_models.media_items import (
     AudioFormat,
     MediaItemImage,
@@ -59,7 +52,7 @@ async def setup(
         msg = "Username is invalid"
         raise LoginFailed(msg)
 
-    return TuneInProvider(mass, manifest, config)
+    return TuneInProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -91,11 +84,6 @@ class TuneInProvider(MusicProvider):
 
     _throttler: Throttler
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         self._throttler = Throttler(rate_limit=1, period=2)
index c16a278cad315b7add0e75113d98dba211e71ed5..a38eb46438fd6cd9fea5842a81760e6ce015f184 100644 (file)
@@ -9,6 +9,8 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
+from music_assistant_models.enums import ProviderFeature
+
 from .player import UniversalGroupPlayer
 from .provider import UniversalGroupProvider
 
@@ -19,12 +21,14 @@ if TYPE_CHECKING:
     from music_assistant import MusicAssistant
     from music_assistant.models import ProviderInstanceType
 
+SUPPORTED_FEATURES = {ProviderFeature.CREATE_GROUP_PLAYER, ProviderFeature.REMOVE_GROUP_PLAYER}
+
 
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return UniversalGroupProvider(mass, manifest, config)
+    return UniversalGroupProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
index d7ea4364b6f3668377b1a56f505e248c3b3e4184..9d22310c5d8ada55fad502433140b0e1f2797dbb 100644 (file)
@@ -5,7 +5,6 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 
 import shortuuid
-from music_assistant_models.enums import ProviderFeature
 
 from music_assistant.constants import CONF_DYNAMIC_GROUP_MEMBERS, CONF_GROUP_MEMBERS
 from music_assistant.models.player_provider import PlayerProvider
@@ -20,11 +19,6 @@ if TYPE_CHECKING:
 class UniversalGroupProvider(PlayerProvider):
     """Universal Group Player Provider."""
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return {ProviderFeature.CREATE_GROUP_PLAYER, ProviderFeature.REMOVE_GROUP_PLAYER}
-
     async def create_group_player(
         self, name: str, members: list[str], dynamic: bool = True
     ) -> Player:
index 04aab48275bfc2e41edb1127be92cf15e2428e68..7e19836636e1374718358a98640d7077c5d791d5 100644 (file)
@@ -142,7 +142,7 @@ async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    return YoutubeMusicProvider(mass, manifest, config)
+    return YoutubeMusicProvider(mass, manifest, config, SUPPORTED_FEATURES)
 
 
 async def get_config_entries(
@@ -226,11 +226,6 @@ class YoutubeMusicProvider(MusicProvider):
         if not await self._user_has_ytm_premium():
             raise LoginFailed("User does not have Youtube Music Premium")
 
-    @property
-    def supported_features(self) -> set[ProviderFeature]:
-        """Return the features supported by this Provider."""
-        return SUPPORTED_FEATURES
-
     async def search(
         self, search_query: str, media_types=list[MediaType], limit: int = 5
     ) -> SearchResults:
index b08e8b6e053b4e5380cea15f2cca31809e2fab5f..dd31590d4adfc81a411d1171130522c415e716af 100644 (file)
@@ -25,7 +25,7 @@ dependencies = [
   "ifaddr==0.2.0",
   "mashumaro==3.16",
   "music-assistant-frontend==2.15.4",
-  "music-assistant-models==1.1.56",
+  "music-assistant-models==1.1.58",
   "mutagen==1.47.0",
   "orjson==3.11.3",
   "pillow==11.3.0",
index 8150f9413ebecbd866c59b47e1ac9a4bb5270d91..767b0faedd70a3b53f8be5f3af8f057df62104e1 100644 (file)
@@ -32,7 +32,7 @@ liblistenbrainz==0.6.0
 lyricsgenius==3.6.5
 mashumaro==3.16
 music-assistant-frontend==2.15.4
-music-assistant-models==1.1.56
+music-assistant-models==1.1.58
 mutagen==1.47.0
 orjson==3.11.3
 pillow==11.3.0
index 5ca5c0f53fc73a1d92e3dbf1bd1685a247b989e3..795059bab1c058405a0368aabb2da4aaf3cec2c3 100644 (file)
@@ -78,6 +78,7 @@
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '70b7288088b42d318f75dbcc41fd0091',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '32ed6a0091733dcff57eae67010f3d4b',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '7c8d0bd55291c7fc0451d17ebef30017',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'dd954bbf54398e247d803186d3585b79',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'da9c458e425584680765ddc3a89cbc0c',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'b5319fb11cde39fca2023184fcfa9862',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '54918f75ee8f6c8b8dc5efd680644f29',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'fb12a77f49616a9fc56a6fab2b94174c',
         'provider_domain': 'jellyfin',
         'provider_instance': 'xx-instance-id-xx',
index 4db3828b6943c28b533cfcc18755d064ae4b0d86..912d49fa9e046bd514a84f333f653aaeaba4f28a 100644 (file)
@@ -38,6 +38,7 @@ async def jellyfin_provider(mass: MusicAssistant) -> AsyncGenerator[ProviderConf
                     "password": "password",
                 },
             )
+            await mass.music.start_sync()
 
         yield config
 
index bee5131ab5c9be3a1d9ece45f20692e8c940cc11..fe2049983d5037a580161d19e78f8b99413d9732 100644 (file)
@@ -48,6 +48,7 @@
             }),
             'available': True,
             'details': None,
+            'in_library': None,
             'item_id': 'fake_artist_unknown',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
             }),
             'available': True,
             'details': None,
+            'in_library': None,
             'item_id': 'fake_artist_unknown',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '37ec820ca7193e17040c98f7da7c4b51',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '37ec820ca7193e17040c98f7da7c4b51',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '37ec820ca7193e17040c98f7da7c4b51',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '37ec820ca7193e17040c98f7da7c4b51',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '100000002',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '100000002',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
           }),
           'available': True,
           'details': None,
+          'in_library': None,
           'item_id': 'pd-5',
           'provider_domain': 'opensubsonic',
           'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'pd-5$!$pe-1860',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
           }),
           'available': True,
           'details': None,
+          'in_library': None,
           'item_id': 'pd-5',
           'provider_domain': 'opensubsonic',
           'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'pd-5$!$pe-1860',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
           }),
           'available': True,
           'details': None,
+          'in_library': None,
           'item_id': 'pd-5',
           'provider_domain': 'opensubsonic',
           'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'pd-5$!$pe-1860',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'pd-5',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': 'pd-5',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
             }),
             'available': True,
             'details': None,
+            'in_library': None,
             'item_id': 'fake_artist_unknown',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
             }),
             'available': True,
             'details': None,
+            'in_library': None,
             'item_id': 'fake_artist_unknown',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
             }),
             'available': True,
             'details': None,
+            'in_library': None,
             'item_id': 'MA-NAVIDROME-The New Deal',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
             }),
             'available': True,
             'details': None,
+            'in_library': None,
             'item_id': 'MA-NAVIDROME-The New Deal',
             'provider_domain': 'opensubsonic',
             'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',
         }),
         'available': True,
         'details': None,
+        'in_library': None,
         'item_id': '082f435a363c32c57d5edb6a678a28d4',
         'provider_domain': 'opensubsonic',
         'provider_instance': 'xx-instance-id-xx',