From: Marcel van der Veldt Date: Mon, 22 Sep 2025 11:42:23 +0000 (+0200) Subject: Add config options to control how library items are synced to MA (#2405) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=ca3db7b9b6996d89c4aa24ff045828ef2ddea44e;p=music-assistant-server.git Add config options to control how library items are synced to MA (#2405) --- diff --git a/music_assistant/constants.py b/music_assistant/constants.py index d482ec7a..ec471dbd 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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, diff --git a/music_assistant/controllers/config.py b/music_assistant/controllers/config.py index 2d49e91a..e87f4197 100644 --- a/music_assistant/controllers/config.py +++ b/music_assistant/controllers/config.py @@ -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 diff --git a/music_assistant/controllers/media/albums.py b/music_assistant/controllers/media/albums.py index 7f9ee196..0e005456 100644 --- a/music_assistant/controllers/media/albums.py +++ b/music_assistant/controllers/media/albums.py @@ -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, + } + ], + } + ) diff --git a/music_assistant/controllers/media/artists.py b/music_assistant/controllers/media/artists.py index f13aa040..4b7d91fc 100644 --- a/music_assistant/controllers/media/artists.py +++ b/music_assistant/controllers/media/artists.py @@ -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( diff --git a/music_assistant/controllers/media/audiobooks.py b/music_assistant/controllers/media/audiobooks.py index 5663db23..8ac8a8c4 100644 --- a/music_assistant/controllers/media/audiobooks.py +++ b/music_assistant/controllers/media/audiobooks.py @@ -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) diff --git a/music_assistant/controllers/media/base.py b/music_assistant/controllers/media/base.py index af33381e..5372fd9f 100644 --- a/music_assistant/controllers/media/base.py +++ b/music_assistant/controllers/media/base.py @@ -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.""" diff --git a/music_assistant/controllers/media/playlists.py b/music_assistant/controllers/media/playlists.py index d961625f..35d3a178 100644 --- a/music_assistant/controllers/media/playlists.py +++ b/music_assistant/controllers/media/playlists.py @@ -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( diff --git a/music_assistant/controllers/media/podcasts.py b/music_assistant/controllers/media/podcasts.py index c7708536..4a7aea7d 100644 --- a/music_assistant/controllers/media/podcasts.py +++ b/music_assistant/controllers/media/podcasts.py @@ -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( diff --git a/music_assistant/controllers/media/radio.py b/music_assistant/controllers/media/radio.py index c9ae1151..3ceb02ec 100644 --- a/music_assistant/controllers/media/radio.py +++ b/music_assistant/controllers/media/radio.py @@ -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( diff --git a/music_assistant/controllers/media/tracks.py b/music_assistant/controllers/media/tracks.py index 5ad4d615..3c24674c 100644 --- a/music_assistant/controllers/media/tracks.py +++ b/music_assistant/controllers/media/tracks.py @@ -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, diff --git a/music_assistant/controllers/metadata.py b/music_assistant/controllers/metadata.py index c14a6d40..255f3087 100644 --- a/music_assistant/controllers/metadata.py +++ b/music_assistant/controllers/metadata.py @@ -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) diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index 3a8ee257..0dd420d0 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -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( diff --git a/music_assistant/helpers/database.py b/music_assistant/helpers/database.py index 3dd534ed..95fb17e6 100644 --- a/music_assistant/helpers/database.py +++ b/music_assistant/helpers/database.py @@ -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, diff --git a/music_assistant/mass.py b/music_assistant/mass.py index c93caa40..76ee6b17 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -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.""" diff --git a/music_assistant/models/__init__.py b/music_assistant/models/__init__.py index 468f8d71..5eb0a982 100644 --- a/music_assistant/models/__init__.py +++ b/music_assistant/models/__init__.py @@ -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 diff --git a/music_assistant/models/metadata_provider.py b/music_assistant/models/metadata_provider.py index 8e4ee60a..a5b6d311 100644 --- a/music_assistant/models/metadata_provider.py +++ b/music_assistant/models/metadata_provider.py @@ -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: diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index f489bcc6..5961df4d 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -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 diff --git a/music_assistant/models/provider.py b/music_assistant/models/provider.py index fb6c24b3..10685e28 100644 --- a/music_assistant/models/provider.py +++ b/music_assistant/models/provider.py @@ -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: diff --git a/music_assistant/providers/_demo_music_provider/__init__.py b/music_assistant/providers/_demo_music_provider/__init__.py index 36fb4411..e1bbb853 100644 --- a/music_assistant/providers/_demo_music_provider/__init__.py +++ b/music_assistant/providers/_demo_music_provider/__init__.py @@ -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. diff --git a/music_assistant/providers/_demo_player_provider/__init__.py b/music_assistant/providers/_demo_player_provider/__init__.py index 8ea2674e..286bf0f5 100644 --- a/music_assistant/providers/_demo_player_provider/__init__.py +++ b/music_assistant/providers/_demo_player_provider/__init__.py @@ -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( diff --git a/music_assistant/providers/_demo_player_provider/provider.py b/music_assistant/providers/_demo_player_provider/provider.py index 719af2c5..be7643ae 100644 --- a/music_assistant/providers/_demo_player_provider/provider.py +++ b/music_assistant/providers/_demo_player_provider/provider.py @@ -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 diff --git a/music_assistant/providers/_demo_plugin_provider/__init__.py b/music_assistant/providers/_demo_plugin_provider/__init__.py index 27d9f670..26669075 100644 --- a/music_assistant/providers/_demo_plugin_provider/__init__.py +++ b/music_assistant/providers/_demo_plugin_provider/__init__.py @@ -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 diff --git a/music_assistant/providers/airplay/__init__.py b/music_assistant/providers/airplay/__init__.py index 9f4d8bea..6d42ffeb 100644 --- a/music_assistant/providers/airplay/__init__.py +++ b/music_assistant/providers/airplay/__init__.py @@ -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) diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index 176a3674..d7c7f4bb 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -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 diff --git a/music_assistant/providers/alexa/__init__.py b/music_assistant/providers/alexa/__init__.py index 86c7eecc..154ae636 100644 --- a/music_assistant/providers/alexa/__init__.py +++ b/music_assistant/providers/alexa/__init__.py @@ -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: diff --git a/music_assistant/providers/apple_music/__init__.py b/music_assistant/providers/apple_music/__init__.py index 2c89c740..d1505fe4 100644 --- a/music_assistant/providers/apple_music/__init__.py +++ b/music_assistant/providers/apple_music/__init__.py @@ -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: diff --git a/music_assistant/providers/ard_audiothek/__init__.py b/music_assistant/providers/ard_audiothek/__init__.py index 53a1d1c7..5bd739d6 100644 --- a/music_assistant/providers/ard_audiothek/__init__.py +++ b/music_assistant/providers/ard_audiothek/__init__.py @@ -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. diff --git a/music_assistant/providers/audible/__init__.py b/music_assistant/providers/audible/__init__.py index 2c9a7430..2bb611b8 100644 --- a/music_assistant/providers/audible/__init__.py +++ b/music_assistant/providers/audible/__init__.py @@ -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.""" diff --git a/music_assistant/providers/audiobookshelf/__init__.py b/music_assistant/providers/audiobookshelf/__init__.py index 294c20a5..1af46cc2 100644 --- a/music_assistant/providers/audiobookshelf/__init__.py +++ b/music_assistant/providers/audiobookshelf/__init__.py @@ -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 diff --git a/music_assistant/providers/bluesound/__init__.py b/music_assistant/providers/bluesound/__init__.py index 4bb20631..737abe8e 100644 --- a/music_assistant/providers/bluesound/__init__.py +++ b/music_assistant/providers/bluesound/__init__.py @@ -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( diff --git a/music_assistant/providers/bluesound/provider.py b/music_assistant/providers/bluesound/provider.py index 026b8aa5..00abdf09 100644 --- a/music_assistant/providers/bluesound/provider.py +++ b/music_assistant/providers/bluesound/provider.py @@ -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.""" diff --git a/music_assistant/providers/builtin/__init__.py b/music_assistant/providers/builtin/__init__.py index c6512299..108f6bb8 100644 --- a/music_assistant/providers/builtin/__init__.py +++ b/music_assistant/providers/builtin/__init__.py @@ -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 index 00000000..95e10b30 --- /dev/null +++ b/music_assistant/providers/builtin/constants.py @@ -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", + } +) diff --git a/music_assistant/providers/builtin_player/__init__.py b/music_assistant/providers/builtin_player/__init__.py index 22c67b48..4c5d6be3 100644 --- a/music_assistant/providers/builtin_player/__init__.py +++ b/music_assistant/providers/builtin_player/__init__.py @@ -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( diff --git a/music_assistant/providers/builtin_player/provider.py b/music_assistant/providers/builtin_player/provider.py index 92349324..e5ac6efb 100644 --- a/music_assistant/providers/builtin_player/provider.py +++ b/music_assistant/providers/builtin_player/provider.py @@ -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( diff --git a/music_assistant/providers/chromecast/__init__.py b/music_assistant/providers/chromecast/__init__.py index f463d083..4a40fb51 100644 --- a/music_assistant/providers/chromecast/__init__.py +++ b/music_assistant/providers/chromecast/__init__.py @@ -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( diff --git a/music_assistant/providers/chromecast/provider.py b/music_assistant/providers/chromecast/provider.py index 5ce21869..7255b8fe 100644 --- a/music_assistant/providers/chromecast/provider.py +++ b/music_assistant/providers/chromecast/provider.py @@ -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 diff --git a/music_assistant/providers/deezer/__init__.py b/music_assistant/providers/deezer/__init__.py index 26434278..330ca97a 100644 --- a/music_assistant/providers/deezer/__init__.py +++ b/music_assistant/providers/deezer/__init__.py @@ -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: diff --git a/music_assistant/providers/dlna/__init__.py b/music_assistant/providers/dlna/__init__.py index 0681ab9c..29cb9f71 100644 --- a/music_assistant/providers/dlna/__init__.py +++ b/music_assistant/providers/dlna/__init__.py @@ -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( diff --git a/music_assistant/providers/fanarttv/__init__.py b/music_assistant/providers/fanarttv/__init__.py index adceb5d2..9dd16db4 100644 --- a/music_assistant/providers/fanarttv/__init__.py +++ b/music_assistant/providers/fanarttv/__init__.py @@ -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: diff --git a/music_assistant/providers/filesystem_local/__init__.py b/music_assistant/providers/filesystem_local/__init__.py index 29ddd852..86495aa0 100644 --- a/music_assistant/providers/filesystem_local/__init__.py +++ b/music_assistant/providers/filesystem_local/__init__.py @@ -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, ) }, ) diff --git a/music_assistant/providers/filesystem_local/constants.py b/music_assistant/providers/filesystem_local/constants.py index 1a553b20..55fe86c7 100644 --- a/music_assistant/providers/filesystem_local/constants.py +++ b/music_assistant/providers/filesystem_local/constants.py @@ -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 = { diff --git a/music_assistant/providers/filesystem_smb/__init__.py b/music_assistant/providers/filesystem_smb/__init__.py index 130aeb6b..62c95bdb 100644 --- a/music_assistant/providers/filesystem_smb/__init__.py +++ b/music_assistant/providers/filesystem_smb/__init__.py @@ -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: diff --git a/music_assistant/providers/fully_kiosk/__init__.py b/music_assistant/providers/fully_kiosk/__init__.py index a5f7d5cb..afad31d1 100644 --- a/music_assistant/providers/fully_kiosk/__init__.py +++ b/music_assistant/providers/fully_kiosk/__init__.py @@ -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( diff --git a/music_assistant/providers/genius_lyrics/__init__.py b/music_assistant/providers/genius_lyrics/__init__.py index 853567c9..97a3ce24 100644 --- a/music_assistant/providers/genius_lyrics/__init__.py +++ b/music_assistant/providers/genius_lyrics/__init__.py @@ -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): diff --git a/music_assistant/providers/gpodder/__init__.py b/music_assistant/providers/gpodder/__init__.py index 8cb361ca..35e807ab 100644 --- a/music_assistant/providers/gpodder/__init__.py +++ b/music_assistant/providers/gpodder/__init__.py @@ -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)) diff --git a/music_assistant/providers/hass/__init__.py b/music_assistant/providers/hass/__init__.py index 125c950a..3314ff5f 100644 --- a/music_assistant/providers/hass/__init__.py +++ b/music_assistant/providers/hass/__init__.py @@ -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( diff --git a/music_assistant/providers/ibroadcast/__init__.py b/music_assistant/providers/ibroadcast/__init__.py index 855ab03f..1783f175 100644 --- a/music_assistant/providers/ibroadcast/__init__.py +++ b/music_assistant/providers/ibroadcast/__init__.py @@ -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(): diff --git a/music_assistant/providers/itunes_podcasts/__init__.py b/music_assistant/providers/itunes_podcasts/__init__.py index c25eb568..dd345c94 100644 --- a/music_assistant/providers/itunes_podcasts/__init__.py +++ b/music_assistant/providers/itunes_podcasts/__init__.py @@ -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.""" diff --git a/music_assistant/providers/jellyfin/__init__.py b/music_assistant/providers/jellyfin/__init__.py index a9b3a354..c4b7b3ea 100644 --- a/music_assistant/providers/jellyfin/__init__.py +++ b/music_assistant/providers/jellyfin/__init__.py @@ -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.""" diff --git a/music_assistant/providers/lastfm_scrobble/__init__.py b/music_assistant/providers/lastfm_scrobble/__init__.py index e871b575..1f4dc5a1 100644 --- a/music_assistant/providers/lastfm_scrobble/__init__.py +++ b/music_assistant/providers/lastfm_scrobble/__init__.py @@ -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]] = [] diff --git a/music_assistant/providers/listenbrainz_scrobble/__init__.py b/music_assistant/providers/listenbrainz_scrobble/__init__.py index 467afcb0..8f4e3c52 100644 --- a/music_assistant/providers/listenbrainz_scrobble/__init__.py +++ b/music_assistant/providers/listenbrainz_scrobble/__init__.py @@ -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]] = [] diff --git a/music_assistant/providers/lrclib/__init__.py b/music_assistant/providers/lrclib/__init__.py index 42c3a201..167f9c95 100644 --- a/music_assistant/providers/lrclib/__init__.py +++ b/music_assistant/providers/lrclib/__init__.py @@ -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.""" diff --git a/music_assistant/providers/musicbrainz/__init__.py b/music_assistant/providers/musicbrainz/__init__.py index 8659fa16..44621755 100644 --- a/music_assistant/providers/musicbrainz/__init__.py +++ b/music_assistant/providers/musicbrainz/__init__.py @@ -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: diff --git a/music_assistant/providers/musiccast/__init__.py b/music_assistant/providers/musiccast/__init__.py index cd5407f3..eab00b68 100644 --- a/music_assistant/providers/musiccast/__init__.py +++ b/music_assistant/providers/musiccast/__init__.py @@ -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( diff --git a/music_assistant/providers/musiccast/provider.py b/music_assistant/providers/musiccast/provider.py index 05bb0c40..a2416d1e 100644 --- a/music_assistant/providers/musiccast/provider.py +++ b/music_assistant/providers/musiccast/provider.py @@ -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) diff --git a/music_assistant/providers/nugs/__init__.py b/music_assistant/providers/nugs/__init__.py index 51a59fc4..414441fa 100644 --- a/music_assistant/providers/nugs/__init__.py +++ b/music_assistant/providers/nugs/__init__.py @@ -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() diff --git a/music_assistant/providers/opensubsonic/__init__.py b/music_assistant/providers/opensubsonic/__init__.py index faa4f362..f75ba457 100644 --- a/music_assistant/providers/opensubsonic/__init__.py +++ b/music_assistant/providers/opensubsonic/__init__.py @@ -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( diff --git a/music_assistant/providers/opensubsonic/sonic_provider.py b/music_assistant/providers/opensubsonic/sonic_provider.py index 64bd7546..162f0368 100644 --- a/music_assistant/providers/opensubsonic/sonic_provider.py +++ b/music_assistant/providers/opensubsonic/sonic_provider.py @@ -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: """ diff --git a/music_assistant/providers/plex/__init__.py b/music_assistant/providers/plex/__init__.py index 3af9d08d..bef07edb 100644 --- a/music_assistant/providers/plex/__init__.py +++ b/music_assistant/providers/plex/__init__.py @@ -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: """ diff --git a/music_assistant/providers/podcast-index/__init__.py b/music_assistant/providers/podcast-index/__init__.py index 45d99fbb..c5e55654 100644 --- a/music_assistant/providers/podcast-index/__init__.py +++ b/music_assistant/providers/podcast-index/__init__.py @@ -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, ) diff --git a/music_assistant/providers/podcast-index/provider.py b/music_assistant/providers/podcast-index/provider.py index f2f02966..3685e7b4 100644 --- a/music_assistant/providers/podcast-index/provider.py +++ b/music_assistant/providers/podcast-index/provider.py @@ -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.""" diff --git a/music_assistant/providers/podcastfeed/__init__.py b/music_assistant/providers/podcastfeed/__init__.py index 49319081..c10c7678 100644 --- a/music_assistant/providers/podcastfeed/__init__.py +++ b/music_assistant/providers/podcastfeed/__init__.py @@ -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))) diff --git a/music_assistant/providers/qobuz/__init__.py b/music_assistant/providers/qobuz/__init__.py index 0549e454..8342cbce 100644 --- a/music_assistant/providers/qobuz/__init__.py +++ b/music_assistant/providers/qobuz/__init__.py @@ -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: diff --git a/music_assistant/providers/radiobrowser/__init__.py b/music_assistant/providers/radiobrowser/__init__.py index ee9078ff..b7b29ed2 100644 --- a/music_assistant/providers/radiobrowser/__init__.py +++ b/music_assistant/providers/radiobrowser/__init__.py @@ -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( diff --git a/music_assistant/providers/radioparadise/__init__.py b/music_assistant/providers/radioparadise/__init__.py index f3d2d9d7..934ea6f2 100644 --- a/music_assistant/providers/radioparadise/__init__.py +++ b/music_assistant/providers/radioparadise/__init__.py @@ -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 + ) diff --git a/music_assistant/providers/radioparadise/provider.py b/music_assistant/providers/radioparadise/provider.py index 5e209a68..b5d60d36 100644 --- a/music_assistant/providers/radioparadise/provider.py +++ b/music_assistant/providers/radioparadise/provider.py @@ -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.""" diff --git a/music_assistant/providers/siriusxm/__init__.py b/music_assistant/providers/siriusxm/__init__.py index b172a484..b0929574 100644 --- a/music_assistant/providers/siriusxm/__init__.py +++ b/music_assistant/providers/siriusxm/__init__.py @@ -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) diff --git a/music_assistant/providers/snapcast/__init__.py b/music_assistant/providers/snapcast/__init__.py index c674c5c1..9deef506 100644 --- a/music_assistant/providers/snapcast/__init__.py +++ b/music_assistant/providers/snapcast/__init__.py @@ -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( diff --git a/music_assistant/providers/snapcast/provider.py b/music_assistant/providers/snapcast/provider.py index 714bdeae..39189ceb 100644 --- a/music_assistant/providers/snapcast/provider.py +++ b/music_assistant/providers/snapcast/provider.py @@ -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 diff --git a/music_assistant/providers/sonos/__init__.py b/music_assistant/providers/sonos/__init__.py index 31eed7c6..796ac880 100644 --- a/music_assistant/providers/sonos/__init__.py +++ b/music_assistant/providers/sonos/__init__.py @@ -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) diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py index d3ada68a..13409f8b 100644 --- a/music_assistant/providers/sonos/provider.py +++ b/music_assistant/providers/sonos/provider.py @@ -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( diff --git a/music_assistant/providers/sonos_s1/__init__.py b/music_assistant/providers/sonos_s1/__init__.py index 0c96581c..42ef9404 100644 --- a/music_assistant/providers/sonos_s1/__init__.py +++ b/music_assistant/providers/sonos_s1/__init__.py @@ -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( diff --git a/music_assistant/providers/sonos_s1/provider.py b/music_assistant/providers/sonos_s1/provider.py index bf687bbd..64296cb9 100644 --- a/music_assistant/providers/sonos_s1/provider.py +++ b/music_assistant/providers/sonos_s1/provider.py @@ -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 diff --git a/music_assistant/providers/soundcloud/__init__.py b/music_assistant/providers/soundcloud/__init__.py index baaf2399..d662e5ae 100644 --- a/music_assistant/providers/soundcloud/__init__.py +++ b/music_assistant/providers/soundcloud/__init__.py @@ -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: diff --git a/music_assistant/providers/spotify/__init__.py b/music_assistant/providers/spotify/__init__.py index cbba828e..9010021d 100644 --- a/music_assistant/providers/spotify/__init__.py +++ b/music_assistant/providers/spotify/__init__.py @@ -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) diff --git a/music_assistant/providers/spotify/constants.py b/music_assistant/providers/spotify/constants.py index dcfe2d46..00a6d076 100644 --- a/music_assistant/providers/spotify/constants.py +++ b/music_assistant/providers/spotify/constants.py @@ -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, -} diff --git a/music_assistant/providers/spotify/provider.py b/music_assistant/providers/spotify/provider.py index c811a460..21a94fdb 100644 --- a/music_assistant/providers/spotify/provider.py +++ b/music_assistant/providers/spotify/provider.py @@ -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: diff --git a/music_assistant/providers/spotify_connect/__init__.py b/music_assistant/providers/spotify_connect/__init__.py index 97f73c1d..900966f5 100644 --- a/music_assistant/providers/spotify_connect/__init__.py +++ b/music_assistant/providers/spotify_connect/__init__.py @@ -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() diff --git a/music_assistant/providers/squeezelite/__init__.py b/music_assistant/providers/squeezelite/__init__.py index 0eb21913..0da03798 100644 --- a/music_assistant/providers/squeezelite/__init__.py +++ b/music_assistant/providers/squeezelite/__init__.py @@ -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( diff --git a/music_assistant/providers/squeezelite/provider.py b/music_assistant/providers/squeezelite/provider.py index e0778490..fe3d0d05 100644 --- a/music_assistant/providers/squeezelite/provider.py +++ b/music_assistant/providers/squeezelite/provider.py @@ -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() diff --git a/music_assistant/providers/test/__init__.py b/music_assistant/providers/test/__init__.py index a5687fb1..b006ad04 100644 --- a/music_assistant/providers/test/__init__.py +++ b/music_assistant/providers/test/__init__.py @@ -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) diff --git a/music_assistant/providers/theaudiodb/__init__.py b/music_assistant/providers/theaudiodb/__init__.py index 6729d05e..f1c62f0f 100644 --- a/music_assistant/providers/theaudiodb/__init__.py +++ b/music_assistant/providers/theaudiodb/__init__.py @@ -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): diff --git a/music_assistant/providers/tidal/__init__.py b/music_assistant/providers/tidal/__init__.py index 5b409728..1cd48d9b 100644 --- a/music_assistant/providers/tidal/__init__.py +++ b/music_assistant/providers/tidal/__init__.py @@ -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 # diff --git a/music_assistant/providers/tunein/__init__.py b/music_assistant/providers/tunein/__init__.py index 1e42229c..b3e19ff5 100644 --- a/music_assistant/providers/tunein/__init__.py +++ b/music_assistant/providers/tunein/__init__.py @@ -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) diff --git a/music_assistant/providers/universal_group/__init__.py b/music_assistant/providers/universal_group/__init__.py index c16a278c..a38eb464 100644 --- a/music_assistant/providers/universal_group/__init__.py +++ b/music_assistant/providers/universal_group/__init__.py @@ -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( diff --git a/music_assistant/providers/universal_group/provider.py b/music_assistant/providers/universal_group/provider.py index d7ea4364..9d22310c 100644 --- a/music_assistant/providers/universal_group/provider.py +++ b/music_assistant/providers/universal_group/provider.py @@ -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: diff --git a/music_assistant/providers/ytmusic/__init__.py b/music_assistant/providers/ytmusic/__init__.py index 04aab482..7e198366 100644 --- a/music_assistant/providers/ytmusic/__init__.py +++ b/music_assistant/providers/ytmusic/__init__.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index b08e8b6e..dd31590d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index 8150f941..767b0fae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/tests/providers/jellyfin/__snapshots__/test_parsers.ambr b/tests/providers/jellyfin/__snapshots__/test_parsers.ambr index 5ca5c0f5..795059ba 100644 --- a/tests/providers/jellyfin/__snapshots__/test_parsers.ambr +++ b/tests/providers/jellyfin/__snapshots__/test_parsers.ambr @@ -78,6 +78,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '70b7288088b42d318f75dbcc41fd0091', 'provider_domain': 'jellyfin', 'provider_instance': 'xx-instance-id-xx', @@ -170,6 +171,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '32ed6a0091733dcff57eae67010f3d4b', 'provider_domain': 'jellyfin', 'provider_instance': 'xx-instance-id-xx', @@ -248,6 +250,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '7c8d0bd55291c7fc0451d17ebef30017', 'provider_domain': 'jellyfin', 'provider_instance': 'xx-instance-id-xx', @@ -330,6 +333,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'dd954bbf54398e247d803186d3585b79', 'provider_domain': 'jellyfin', 'provider_instance': 'xx-instance-id-xx', @@ -430,6 +434,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'da9c458e425584680765ddc3a89cbc0c', 'provider_domain': 'jellyfin', 'provider_instance': 'xx-instance-id-xx', @@ -525,6 +530,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'b5319fb11cde39fca2023184fcfa9862', 'provider_domain': 'jellyfin', 'provider_instance': 'xx-instance-id-xx', @@ -612,6 +618,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '54918f75ee8f6c8b8dc5efd680644f29', 'provider_domain': 'jellyfin', 'provider_instance': 'xx-instance-id-xx', @@ -732,6 +739,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'fb12a77f49616a9fc56a6fab2b94174c', 'provider_domain': 'jellyfin', 'provider_instance': 'xx-instance-id-xx', diff --git a/tests/providers/jellyfin/test_init.py b/tests/providers/jellyfin/test_init.py index 4db3828b..912d49fa 100644 --- a/tests/providers/jellyfin/test_init.py +++ b/tests/providers/jellyfin/test_init.py @@ -38,6 +38,7 @@ async def jellyfin_provider(mass: MusicAssistant) -> AsyncGenerator[ProviderConf "password": "password", }, ) + await mass.music.start_sync() yield config diff --git a/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr b/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr index bee5131a..fe204998 100644 --- a/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr +++ b/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr @@ -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', @@ -118,6 +119,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -180,6 +182,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'fake_artist_unknown', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -256,6 +259,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -377,6 +381,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -504,6 +509,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -580,6 +586,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '37ec820ca7193e17040c98f7da7c4b51', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -661,6 +668,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '37ec820ca7193e17040c98f7da7c4b51', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -736,6 +744,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '37ec820ca7193e17040c98f7da7c4b51', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -817,6 +826,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '37ec820ca7193e17040c98f7da7c4b51', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -882,6 +892,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '100000002', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -953,6 +964,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '100000002', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1057,6 +1069,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'pd-5', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1085,6 +1098,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'pd-5$!$pe-1860', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1190,6 +1204,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'pd-5', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1218,6 +1233,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'pd-5$!$pe-1860', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1323,6 +1339,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'pd-5', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1351,6 +1368,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'pd-5$!$pe-1860', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1420,6 +1438,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1485,6 +1504,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'pd-5', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1552,6 +1572,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'pd-5', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1629,6 +1650,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'fake_artist_unknown', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1699,6 +1721,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '082f435a363c32c57d5edb6a678a28d4', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1828,6 +1851,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'fake_artist_unknown', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1898,6 +1922,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '082f435a363c32c57d5edb6a678a28d4', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -1974,6 +1999,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'MA-NAVIDROME-The New Deal', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -2044,6 +2070,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '082f435a363c32c57d5edb6a678a28d4', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -2173,6 +2200,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': 'MA-NAVIDROME-The New Deal', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -2243,6 +2271,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '082f435a363c32c57d5edb6a678a28d4', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -2378,6 +2407,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '082f435a363c32c57d5edb6a678a28d4', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx', @@ -2566,6 +2596,7 @@ }), 'available': True, 'details': None, + 'in_library': None, 'item_id': '082f435a363c32c57d5edb6a678a28d4', 'provider_domain': 'opensubsonic', 'provider_instance': 'xx-instance-id-xx',