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,
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,
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,
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.
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}"
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(
# 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
'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(
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):
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,
{
},
)
# 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)
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(
},
)
# 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)
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,
+ }
+ ],
+ }
+ )
},
)
# 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
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(
'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,
},
)
# 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
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(
},
)
# 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)
}
-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:
'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
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
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,
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."""
},
)
# 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
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(
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)
},
)
# 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
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(
},
)
# 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(
},
)
# 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
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(
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,
)
'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(
},
)
# 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
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)
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,
"""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)
)
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 = (
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)
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)
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,
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):
"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,
) -> 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,
if action == CONF_RESET_DB:
await self._reset_database()
await self.mass.cache.clear()
- self.start_sync()
+ await self.start_sync()
entries = (
*entries,
ConfigEntry(
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()
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,
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]:
"""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(
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(
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(
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")
# 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)
# 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, "
"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 [
)
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
# 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())
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,
# 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...")
):
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()
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."""
[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
);"""
[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
);"""
[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
);"""
[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
);"""
[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
);"""
[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,
[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
);"""
[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
);"""
[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,
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(
"""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,
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:
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."""
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
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
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:
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
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).
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).
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),
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:
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:
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
"""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))
@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:
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:
# 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(
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
# 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.
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
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
# 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(
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
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
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
# 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(
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
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
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,
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)
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
_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
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(
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:
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(
) 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:
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]:
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.
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(
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
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."""
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 (
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(
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))
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:
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
from typing import TYPE_CHECKING
+from music_assistant_models.enums import ProviderFeature
+
from .provider import BluesoundPlayerProvider
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(
from typing import TYPE_CHECKING, TypedDict
-from music_assistant_models.enums import ProviderFeature
from zeroconf import ServiceStateChange
from music_assistant.helpers.util import (
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."""
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,
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(
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,
)
"""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))
--- /dev/null
+"""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",
+ }
+)
from typing import TYPE_CHECKING
+from music_assistant_models.enums import ProviderFeature
+
from music_assistant.mass import MusicAssistant
from music_assistant.models import ProviderInstanceType
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(
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.
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(
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
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(
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
_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
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(
)
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:
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
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(
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(
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:
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,
makedirs = wrap(os.makedirs)
scandir = wrap(os.scandir)
+SUPPORTED_FEATURES = {
+ ProviderFeature.BROWSE,
+ ProviderFeature.SEARCH,
+}
+
async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
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):
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
@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,
}
)
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()
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
# 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)
# 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
)
# 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
)
# 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
)
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,
bit_rate=tags.bit_rate,
),
details=file_item.checksum,
+ in_library=True,
)
},
disc_number=tags.disc or 0,
provider_domain=self.domain,
provider_instance=self.instance_id,
url=artist_path,
+ in_library=True,
)
},
)
bit_rate=tags.bit_rate,
),
details=file_item.checksum,
+ in_library=True,
)
},
)
bit_rate=tags.bit_rate,
),
details=file_item.checksum,
+ in_library=True,
)
},
position=tags.track or 0,
provider_domain=self.domain,
provider_instance=self.instance_id,
url=album_dir,
+ in_library=True,
)
},
)
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"
],
)
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(
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 = {
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_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:
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
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(
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(
"""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):
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,
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 (
)
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
)
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(
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))
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
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(
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(
# 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():
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(
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."""
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(
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."""
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
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
else:
logging.getLogger("httpcore").setLevel(logging.WARNING)
- # run async setup of provider to catch any login issues early
- await provider.async_setup()
return provider
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]] = []
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
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(
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]] = []
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(
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."""
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(
"""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:
"""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
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(
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] = {}
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)
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(
_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()
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
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(
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,
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:
"""
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
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
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:
"""
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
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(
required=False,
hidden=True,
),
- *DEFAULT_PROVIDER_CONFIG_ENTRIES,
)
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,
)
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."""
CACHE_CATEGORY_PODCASTS = 0
+SUPPORTED_FEATURES = {
+ ProviderFeature.BROWSE,
+ ProviderFeature.LIBRARY_PODCASTS,
+}
+
async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
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(
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)))
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(
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:
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
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(
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(
from typing import TYPE_CHECKING
+from music_assistant_models.enums import ProviderFeature
+
if TYPE_CHECKING:
from music_assistant_models.config_entries import (
ConfigEntry,
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(
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
+ )
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,
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."""
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(
_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)
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
)
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(
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
_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
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
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)
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 (
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(
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
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(
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
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
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(
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:
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]
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,
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,
"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,
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",
),
)
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)
from __future__ import annotations
-from music_assistant_models.enums import ProviderFeature
-
# Configuration Keys
CONF_CLIENT_ID = "client_id"
CONF_ACTION_AUTH = "auth"
# 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,
-}
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 (
CONF_REFRESH_TOKEN,
CONF_SYNC_PLAYED_STATUS,
LIKED_SONGS_FAKE_PLAYLIST_ID_PREFIX,
- SUPPORTED_FEATURES,
)
from .helpers import get_librespot_binary
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."""
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)
@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:
EVENTS_SCRIPT = pathlib.Path(__file__).parent.resolve().joinpath("events.py")
+SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE}
+
async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
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
),
]
- @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()
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
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(
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
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."""
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()
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(
"""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)
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(
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):
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
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,
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
#
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,
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,
msg = "Username is invalid"
raise LoginFailed(msg)
- return TuneInProvider(mass, manifest, config)
+ return TuneInProvider(mass, manifest, config, SUPPORTED_FEATURES)
async def get_config_entries(
_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)
from typing import TYPE_CHECKING
+from music_assistant_models.enums import ProviderFeature
+
from .player import UniversalGroupPlayer
from .provider import UniversalGroupProvider
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(
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
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:
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(
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:
"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",
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
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '70b7288088b42d318f75dbcc41fd0091',
'provider_domain': 'jellyfin',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '32ed6a0091733dcff57eae67010f3d4b',
'provider_domain': 'jellyfin',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '7c8d0bd55291c7fc0451d17ebef30017',
'provider_domain': 'jellyfin',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'dd954bbf54398e247d803186d3585b79',
'provider_domain': 'jellyfin',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'da9c458e425584680765ddc3a89cbc0c',
'provider_domain': 'jellyfin',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'b5319fb11cde39fca2023184fcfa9862',
'provider_domain': 'jellyfin',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '54918f75ee8f6c8b8dc5efd680644f29',
'provider_domain': 'jellyfin',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'fb12a77f49616a9fc56a6fab2b94174c',
'provider_domain': 'jellyfin',
'provider_instance': 'xx-instance-id-xx',
"password": "password",
},
)
+ await mass.music.start_sync()
yield config
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'fake_artist_unknown',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'fake_artist_unknown',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'ad0f112b6dcf83de5e9cae85d07f0d35',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '37ec820ca7193e17040c98f7da7c4b51',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '37ec820ca7193e17040c98f7da7c4b51',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '37ec820ca7193e17040c98f7da7c4b51',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '37ec820ca7193e17040c98f7da7c4b51',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '100000002',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '100000002',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'pd-5',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'pd-5$!$pe-1860',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'pd-5',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'pd-5$!$pe-1860',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'pd-5',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'pd-5$!$pe-1860',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'pd-5',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'pd-5',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'fake_artist_unknown',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '082f435a363c32c57d5edb6a678a28d4',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'fake_artist_unknown',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '082f435a363c32c57d5edb6a678a28d4',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'MA-NAVIDROME-The New Deal',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '082f435a363c32c57d5edb6a678a28d4',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': 'MA-NAVIDROME-The New Deal',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '082f435a363c32c57d5edb6a678a28d4',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '082f435a363c32c57d5edb6a678a28d4',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',
}),
'available': True,
'details': None,
+ 'in_library': None,
'item_id': '082f435a363c32c57d5edb6a678a28d4',
'provider_domain': 'opensubsonic',
'provider_instance': 'xx-instance-id-xx',