diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 0a9d25918e..d5eccd19ca 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -163,6 +163,7 @@ DB_TABLE_ALBUM_TRACKS: Final[str] = "album_tracks" DB_TABLE_TRACK_ARTISTS: Final[str] = "track_artists" DB_TABLE_ALBUM_ARTISTS: Final[str] = "album_artists" +DB_TABLE_AUDIOBOOK_ARTISTS: Final[str] = "audiobook_artists" DB_TABLE_LOUDNESS_MEASUREMENTS: Final[str] = "loudness_measurements" DB_TABLE_AUDIO_ANALYSIS: Final[str] = "audio_analysis" DB_TABLE_GENRES: Final[str] = "genres" diff --git a/music_assistant/controllers/media/artists.py b/music_assistant/controllers/media/artists.py index 62a93514e3..20a16cf38d 100644 --- a/music_assistant/controllers/media/artists.py +++ b/music_assistant/controllers/media/artists.py @@ -6,17 +6,25 @@ import contextlib from typing import TYPE_CHECKING, Any, cast -from music_assistant_models.enums import AlbumType, MediaType, ProviderFeature +from music_assistant_models.enums import AlbumType, ArtistType, MediaType, ProviderFeature from music_assistant_models.errors import ( MediaNotFoundError, MusicAssistantError, ProviderUnavailableError, ) -from music_assistant_models.media_items import Album, Artist, ItemMapping, ProviderMapping, Track +from music_assistant_models.media_items import ( + Album, + Artist, + Audiobook, + ItemMapping, + ProviderMapping, + Track, +) from music_assistant.constants import ( DB_TABLE_ALBUM_ARTISTS, DB_TABLE_ARTISTS, + DB_TABLE_AUDIOBOOK_ARTISTS, DB_TABLE_TRACK_ARTISTS, VARIOUS_ARTISTS_MBID, VARIOUS_ARTISTS_NAME, @@ -46,13 +54,17 @@ def __init__(self, mass: MusicAssistant) -> None: api_base = self.api_base self.mass.register_api_command(f"music/{api_base}/artist_albums", self.albums) self.mass.register_api_command(f"music/{api_base}/artist_tracks", self.tracks) + self.mass.register_api_command(f"music/{api_base}/artist_audiobooks", self.audiobooks) async def library_count( - self, favorite_only: bool = False, album_artists_only: bool = False + self, + favorite_only: bool = False, + album_artists_only: bool = False, + artist_type: ArtistType = ArtistType.SINGER, ) -> int: """Return the total number of items in the library.""" sql_query = f"SELECT item_id FROM {self.db_table}" - query_parts: list[str] = [] + query_parts = [f"artist_type = '{artist_type}'"] if favorite_only: query_parts.append("favorite = 1") if album_artists_only: @@ -74,6 +86,7 @@ async def library_items( provider: str | list[str] | None = None, genre: int | list[int] | None = None, album_artists_only: bool = False, + artist_type: ArtistType = ArtistType.SINGER, **kwargs: Any, ) -> list[Artist]: """Get in-database (album) artists. @@ -86,10 +99,11 @@ async def library_items( :param provider: Filter by provider instance ID (single string or list). :param album_artists_only: Only return artists that have albums. :param genre: Filter by genre id(s). + :param artist_type: The artist's type """ extra_query_params: dict[str, Any] = {} - extra_query_parts: list[str] = [] - if album_artists_only: + extra_query_parts = [f"artist_type = '{artist_type}'"] + if album_artists_only and artist_type == ArtistType.SINGER: extra_query_parts.append( f"artists.item_id in (select {DB_TABLE_ALBUM_ARTISTS}.artist_id " f"from {DB_TABLE_ALBUM_ARTISTS})" @@ -114,7 +128,7 @@ async def tracks( in_library_only: bool = False, provider_filter: str | list[str] | None = None, ) -> list[Track]: - """Return all/top tracks for an artist.""" + """Return all/top tracks for a music artist.""" if provider_filter and provider_instance_id_or_domain != "library": raise MusicAssistantError("Cannot use provider_filter with specific provider request") if isinstance(provider_filter, str): @@ -123,6 +137,11 @@ async def tracks( library_artist = await self.get_library_item_by_prov_id( item_id, provider_instance_id_or_domain ) + if library_artist and library_artist.artist_type != ArtistType.SINGER: + self.logger.debug( + "Ignoring tracks request for artist of type %s", library_artist.artist_type + ) + return [] if not library_artist: return await self.get_provider_artist_toptracks(item_id, provider_instance_id_or_domain) db_items = await self.get_library_artist_tracks(library_artist.item_id) @@ -167,6 +186,11 @@ async def albums( library_artist = await self.get_library_item_by_prov_id( item_id, provider_instance_id_or_domain ) + if library_artist and library_artist.artist_type != ArtistType.SINGER: + self.logger.debug( + "Ignoring album request for artist of type %s", library_artist.artist_type + ) + return [] if not library_artist: return await self.get_provider_artist_albums(item_id, provider_instance_id_or_domain) db_items = await self.get_library_artist_albums(library_artist.item_id) @@ -201,7 +225,20 @@ async def albums( async def remove_item_from_library(self, item_id: str | int, recursive: bool = True) -> None: """Delete record from the database.""" db_id = int(item_id) # ensure integer + library_item = await self.get_library_item(db_id) + if library_item.artist_type == ArtistType.SINGER: + await self._remove_music_artist_from_library(db_id=db_id, recursive=recursive) + elif library_item.artist_type in (ArtistType.AUTHOR, ArtistType.NARRATOR): + await self._remove_author_narrator_from_library(db_id=db_id, recursive=recursive) + else: + raise MusicAssistantError(f"Unknown artist_type {library_item.artist_type}.") + + # delete the artist itself from db + # this will raise if the item still has references and recursive is false + await super().remove_item_from_library(db_id) + + async def _remove_music_artist_from_library(self, db_id: int, recursive: bool) -> None: # recursively also remove artist albums for db_row in await self.mass.music.database.get_rows_from_query( f"SELECT album_id FROM {DB_TABLE_ALBUM_ARTISTS} WHERE artist_id = :artist_id", @@ -223,9 +260,17 @@ async def remove_item_from_library(self, item_id: str | int, recursive: bool = T with contextlib.suppress(MediaNotFoundError): await self.mass.music.tracks.remove_item_from_library(db_row["track_id"]) - # delete the artist itself from db - # this will raise if the item still has references and recursive is false - await super().remove_item_from_library(db_id) + async def _remove_author_narrator_from_library(self, db_id: int, recursive: bool) -> None: + # recursively also remove author/ narrator audiobooks + for db_row in await self.mass.music.database.get_rows_from_query( + f"SELECT album_id FROM {DB_TABLE_AUDIOBOOK_ARTISTS} WHERE artist_id = :artist_id", + {"artist_id": db_id}, + limit=5000, + ): + if not recursive: + raise MusicAssistantError("Artist still has albums linked") + with contextlib.suppress(MediaNotFoundError): + await self.mass.music.audiobooks.remove_item_from_library(db_row["album_id"]) async def get_provider_artist_toptracks( self, @@ -244,6 +289,9 @@ async def get_provider_artist_toptracks( item_id, provider_instance_id_or_domain, ): + if db_artist.artist_type != ArtistType.SINGER: + self.logger.debug("Top tracks only available for artists of type ARTIST") + return [] db_artist_id = int(db_artist.item_id) # ensure integer subquery = f"SELECT track_id FROM {DB_TABLE_TRACK_ARTISTS} WHERE artist_id = :artist_id" query = f"tracks.item_id in ({subquery})" @@ -260,6 +308,10 @@ async def get_library_artist_tracks( ) -> list[Track]: """Return all tracks for an artist in the library/db.""" db_id = int(item_id) # ensure integer + library_item = await self.get_library_item(db_id) + if library_item.artist_type != ArtistType.SINGER: + self.logger.debug("Tracks only available for artists of type ARTIST") + return [] subquery = f"SELECT track_id FROM {DB_TABLE_TRACK_ARTISTS} WHERE artist_id = :artist_id" query = f"tracks.item_id in ({subquery})" return await self.mass.music.tracks.get_library_items_by_query( @@ -284,6 +336,9 @@ async def get_provider_artist_albums( item_id, provider_instance_id_or_domain, ): + if db_artist.artist_type != ArtistType.SINGER: + self.logger.debug("Albums only available for artists of type ARTIST") + return [] db_artist_id = int(db_artist.item_id) # ensure integer subquery = f"SELECT album_id FROM {DB_TABLE_ALBUM_ARTISTS} WHERE artist_id = :artist_id" query = f"albums.item_id in ({subquery})" @@ -300,6 +355,10 @@ async def get_library_artist_albums( ) -> list[Album]: """Return all in-library albums for an artist.""" db_id = int(item_id) # ensure integer + library_item = await self.get_library_item(db_id) + if library_item.artist_type != ArtistType.SINGER: + self.logger.debug("Albums only available for artists of type ARTIST") + return [] subquery = f"SELECT album_id FROM {DB_TABLE_ALBUM_ARTISTS} WHERE artist_id = :artist_id" query = f"albums.item_id in ({subquery})" return await self.mass.music.albums.get_library_items_by_query( @@ -331,6 +390,7 @@ async def _add_library_item( "search_name": create_safe_string(item.name, True, True), "search_sort_name": create_safe_string(item.sort_name or "", True, True), "timestamp_added": int(item.date_added.timestamp()) if item.date_added else UNSET, + "artist_type": item.artist_type, }, ) # update/set provider_mappings table @@ -377,6 +437,7 @@ async def _update_library_item( "timestamp_added": int(update.date_added.timestamp()) if update.date_added else UNSET, + "artist_type": update.artist_type, }, ) self.logger.debug("updated %s in database: %s", update.name, db_id) @@ -400,6 +461,8 @@ async def radio_mode_base_tracks( :param item: The Artist to get base tracks for. :param preferred_provider_instances: List of preferred provider instance IDs to use. """ + if item.artist_type != ArtistType.SINGER: + raise MusicAssistantError("Radio mode tracks only exists for artists of type ARTIST.") return await self.tracks( item.item_id, item.provider, @@ -535,3 +598,146 @@ def artist_from_item_mapping(self, item: ItemMapping) -> Artist: ], } ) + + async def audiobooks( + self, + item_id: str, + provider_instance_id_or_domain: str, + artist_type: ArtistType = ArtistType.AUTHOR, + in_library_only: bool = False, + ) -> list[Audiobook]: + """Return audiobooks for an artist. + + Artist_type can be omitted for in-library artists. + """ + if artist_type == ArtistType.SINGER: + self.logger.warning("Audiobooks not supported for artist_type ARTIST.") + return [] + # always check if we have a library item for this artist + library_artist = await self.get_library_item_by_prov_id( + item_id, provider_instance_id_or_domain + ) + if library_artist and library_artist.artist_type == ArtistType.SINGER: + self.logger.debug( + "Ignoring audiobook request for artist of type %s", library_artist.artist_type + ) + return [] + if not library_artist: + if artist_type == ArtistType.AUTHOR: + return await self.get_provider_author_audiobooks( + item_id, provider_instance_id_or_domain + ) + if artist_type == ArtistType.NARRATOR: + return await self.get_provider_narrator_audiobooks( + item_id, provider_instance_id_or_domain + ) + return [] + + db_items = await self.get_library_author_narrator_audiobooks( + library_artist.item_id, artist_type=library_artist.artist_type + ) + result: list[Audiobook] = db_items + if in_library_only: + # return in-library items only + return result + # return all (unique) items from all providers + # initialize unique_ids with db_items to prevent duplicates + unique_ids: set[str] = {f"{item.name}.{item.version}" for item in db_items} + unique_providers = self.mass.music.get_unique_providers() + for provider_mapping in library_artist.provider_mappings: + if provider_mapping.provider_instance not in unique_providers: + continue + provider_audiobooks = await self.get_provider_author_audiobooks( + provider_mapping.item_id, provider_mapping.provider_instance + ) + for provider_audiobook in provider_audiobooks: + unique_id = f"{provider_audiobook.name}.{provider_audiobook.version}" + if unique_id in unique_ids: + continue + unique_ids.add(unique_id) + # prefer db item + if db_item := await self.mass.music.audiobooks.get_library_item_by_prov_id( + provider_audiobook.item_id, provider_audiobook.provider + ): + result.append(db_item) + elif not in_library_only: + result.append(provider_audiobook) + return result + + async def get_provider_author_audiobooks( + self, + item_id: str, + provider_instance_id_or_domain: str, + ) -> list[Audiobook]: + """Return audiobooks for an author on given provider.""" + assert provider_instance_id_or_domain != "library" + if not (prov := self.mass.get_provider(provider_instance_id_or_domain)): + return [] + prov = cast("MusicProvider", prov) + if ProviderFeature.AUTHOR_AUDIOBOOKS in prov.supported_features: + return await prov.get_author_audiobooks(item_id) + # fallback implementation using the db + return await self._get_db_author_narrator_audiobooks( + item_id=item_id, + provider_instance_id_or_domain=provider_instance_id_or_domain, + artist_type=ArtistType.AUTHOR, + ) + + async def get_provider_narrator_audiobooks( + self, + item_id: str, + provider_instance_id_or_domain: str, + ) -> list[Audiobook]: + """Return audiobooks for an author on given provider.""" + assert provider_instance_id_or_domain != "library" + if not (prov := self.mass.get_provider(provider_instance_id_or_domain)): + return [] + prov = cast("MusicProvider", prov) + if ProviderFeature.NARRATOR_AUDIOBOOKS in prov.supported_features: + return await prov.get_author_audiobooks(item_id) + # fallback implementation using the db + return await self._get_db_author_narrator_audiobooks( + item_id=item_id, + provider_instance_id_or_domain=provider_instance_id_or_domain, + artist_type=ArtistType.NARRATOR, + ) + + async def _get_db_author_narrator_audiobooks( + self, item_id: str, provider_instance_id_or_domain: str, artist_type: ArtistType + ) -> list[Audiobook]: + if db_author_narrator := await self.mass.music.artists.get_library_item_by_prov_id( + item_id, + provider_instance_id_or_domain, + ): + if db_author_narrator.artist_type != artist_type: + self.logger.debug("Artist type must be %s.", artist_type) + return [] + db_artist_id = int(db_author_narrator.item_id) # ensure integer + subquery = f"SELECT audiobook_id FROM {DB_TABLE_AUDIOBOOK_ARTISTS} WHERE artist_id = :artist_id" + query = f"audiobooks.item_id in ({subquery})" + return await self.mass.music.audiobooks.get_library_items_by_query( + extra_query_parts=[query], + extra_query_params={"artist_id": db_artist_id}, + provider_filter=[provider_instance_id_or_domain], + ) + return [] + + async def get_library_author_narrator_audiobooks( + self, + item_id: str | int, + artist_type: ArtistType, + ) -> list[Audiobook]: + """Return all in-library audiobooks for an author/ narrator.""" + db_id = int(item_id) # ensure integer + library_item = await self.get_library_item(db_id) + if library_item.artist_type != artist_type: + self.logger.debug("Audiobooks only available for artists of type %s", artist_type) + return [] + subquery = ( + f"SELECT audiobook_id FROM {DB_TABLE_AUDIOBOOK_ARTISTS} WHERE artist_id = :artist_id" + ) + query = f"audiobooks.item_id in ({subquery})" + return await self.mass.music.audiobooks.get_library_items_by_query( + extra_query_parts=[query], + extra_query_params={"artist_id": db_id}, + ) diff --git a/music_assistant/controllers/media/audiobooks.py b/music_assistant/controllers/media/audiobooks.py index 8aff8e927b..56b2fb6671 100644 --- a/music_assistant/controllers/media/audiobooks.py +++ b/music_assistant/controllers/media/audiobooks.py @@ -2,12 +2,24 @@ from __future__ import annotations +from collections.abc import Iterable +from json import loads as json_loads from typing import TYPE_CHECKING, Any -from music_assistant_models.enums import MediaType, ProviderFeature -from music_assistant_models.media_items import Audiobook, ProviderMapping, UniqueList +from music_assistant_models.enums import ArtistType, MediaType, ProviderFeature +from music_assistant_models.media_items import ( + Artist, + Audiobook, + ItemMapping, + ProviderMapping, + UniqueList, +) -from music_assistant.constants import DB_TABLE_AUDIOBOOKS, DB_TABLE_PLAYLOG +from music_assistant.constants import ( + DB_TABLE_AUDIOBOOK_ARTISTS, + DB_TABLE_AUDIOBOOKS, + DB_TABLE_PLAYLOG, +) from music_assistant.controllers.media.base import MediaControllerBase from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user from music_assistant.helpers.compare import ( @@ -123,6 +135,17 @@ async def library_items( ) return result + async def _authors_narrators(self, column: str) -> UniqueList[str]: + """Return all available authors.""" + assert self.mass.music.database is not None # for type checking + rows = await self.mass.music.database.get_rows_from_query( + query=f"SELECT DISTINCT {column} FROM {DB_TABLE_AUDIOBOOKS}" + ) + result: set[str] = set() + for row in rows: + result.update(json_loads(row[column])) + return UniqueList(sorted(result)) + async def versions( self, item_id: str, @@ -147,8 +170,20 @@ async def versions( ) return result + async def remove_item_from_library(self, item_id: str | int, recursive: bool = True) -> None: + """Delete item from the library(database).""" + db_id = int(item_id) # ensure integer + # delete entry(s) from album artists table + await self.mass.music.database.delete(DB_TABLE_AUDIOBOOK_ARTISTS, {"audiobook_id": db_id}) + # delete the album itself from db + # this will raise if the item still has references and recursive is false + await super().remove_item_from_library(item_id) + async def _add_library_item(self, item: Audiobook, overwrite_existing: bool = False) -> int: """Add a new record to the database.""" + # only serialize str narrators/ authors to db + _authors = [author for author in item.authors if isinstance(author, str)] + _narrators = [narrator for narrator in item.narrators if isinstance(narrator, str)] db_id = await self.mass.music.database.insert( self.db_table, { @@ -159,8 +194,8 @@ async def _add_library_item(self, item: Audiobook, overwrite_existing: bool = Fa "metadata": serialize_to_json(item.metadata), "external_ids": serialize_to_json(item.external_ids), "publisher": item.publisher, - "authors": serialize_to_json(item.authors), - "narrators": serialize_to_json(item.narrators), + "authors": serialize_to_json(_authors), + "narrators": serialize_to_json(_narrators), "duration": item.duration, "search_name": create_safe_string(item.name, True, True), "search_sort_name": create_safe_string(item.sort_name or "", True, True), @@ -171,8 +206,75 @@ async def _add_library_item(self, item: Audiobook, overwrite_existing: bool = Fa 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) + + # update artist mappings - the sync method in the provider model raises an exception + # if not all entries are either of type str or Artist + if item.authors and isinstance(item.authors[0], Artist): + # only for type checking + authors = [author for author in item.authors if isinstance(author, Artist)] + for author in authors: + # just to be sure + author.artist_type = ArtistType.AUTHOR + await self._set_audiobook_authors_narrators(db_id, authors) + if item.narrators and isinstance(item.narrators[0], Artist): + # only for type checking + narrators = [narrator for narrator in item.narrators if isinstance(narrator, Artist)] + for narrator in narrators: + # just to be sure + narrator.artist_type = ArtistType.NARRATOR + await self._set_audiobook_authors_narrators(db_id, narrators) return db_id + async def _set_audiobook_authors_narrators( + self, + db_id: int, + artists: Iterable[Artist | ItemMapping], + overwrite: bool = False, + ) -> None: + """Write audiobook id and author/ narrator id to DB_TABLE_ALBUM_ARTISTS.""" + if overwrite: + # on overwrite, clear the album_artists table first + await self.mass.music.database.delete( + DB_TABLE_AUDIOBOOK_ARTISTS, + { + "audiobook_id": db_id, + }, + ) + for artist in artists: + await self._set_audiobook_author_narrator(db_id, artist=artist, overwrite=overwrite) + + async def _set_audiobook_author_narrator( + self, db_id: int, artist: Artist | ItemMapping, overwrite: bool = False + ) -> ItemMapping: + """Store Album Artist info.""" + db_artist: Artist | ItemMapping | None = None + if artist.provider == "library": + db_artist = artist + elif existing := await self.mass.music.artists.get_library_item_by_prov_id( + artist.item_id, artist.provider + ): + db_artist = existing + + if not db_artist or overwrite: + # Convert ItemMapping to Artist if needed + artist_to_add = ( + self.mass.music.artists.artist_from_item_mapping(artist) + if isinstance(artist, ItemMapping) + else artist + ) + db_artist = await self.mass.music.artists.add_item_to_library( + artist_to_add, overwrite_existing=overwrite + ) + # write (or update) record in album_artists table + await self.mass.music.database.insert_or_replace( + DB_TABLE_AUDIOBOOK_ARTISTS, + { + "audiobook_id": db_id, + "artist_id": int(db_artist.item_id), + }, + ) + return ItemMapping.from_item(db_artist) + async def _update_library_item( self, item_id: str | int, update: Audiobook, overwrite: bool = False ) -> None: @@ -183,6 +285,9 @@ async def _update_library_item( cur_item.external_ids.update(update.external_ids) 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 + # only serialize str narrators/ authors to db + _update_authors = [author for author in update.authors if isinstance(author, str)] + _update_narrators = [narrator for narrator in update.narrators if isinstance(narrator, str)] await self.mass.music.database.update( self.db_table, {"item_id": db_id}, @@ -196,10 +301,10 @@ async def _update_library_item( ), "publisher": cur_item.publisher or update.publisher, "authors": serialize_to_json( - update.authors if overwrite else cur_item.authors or update.authors + _update_authors if overwrite else cur_item.authors or _update_authors ), "narrators": serialize_to_json( - update.narrators if overwrite else cur_item.narrators or update.narrators + _update_narrators if overwrite else cur_item.narrators or _update_narrators ), "duration": update.duration if overwrite else cur_item.duration or update.duration, "search_name": create_safe_string(name, True, True), diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index f55a70d2fa..ae8de8ab85 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -54,6 +54,7 @@ DB_TABLE_ALBUMS, DB_TABLE_ARTISTS, DB_TABLE_AUDIO_ANALYSIS, + DB_TABLE_AUDIOBOOK_ARTISTS, DB_TABLE_AUDIOBOOKS, DB_TABLE_GENRE_MEDIA_ITEM_EXCLUSION, DB_TABLE_GENRE_MEDIA_ITEM_MAPPING, @@ -109,7 +110,7 @@ DEFAULT_SYNC_INTERVAL = 12 * 60 # default sync interval in minutes CONF_SYNC_INTERVAL = "sync_interval" CONF_DELETED_PROVIDERS = "deleted_providers" -DB_SCHEMA_VERSION: Final[int] = 40 +DB_SCHEMA_VERSION: Final[int] = 41 CACHE_CATEGORY_SEARCH_RESULTS: Final[int] = 10 DATABASE_CLEANUP_TASK_ID: Final[str] = "music_database_cleanup" @@ -2677,6 +2678,17 @@ async def _get_or_create_genre( if "duplicate column" not in str(err): raise + if prev_version <= 40: + # add artist_type column to artist table, and make + # "artist the default, as this was the only artist type supported + try: + await self._database.execute( + f"ALTER TABLE {DB_TABLE_ARTISTS} ADD COLUMN artist_type TEXT DEFAULT 'singer' NOT NULL" + ) + except Exception as err: + if "duplicate column" not in str(err): + raise + # save changes await self._database.commit() @@ -2750,7 +2762,8 @@ async def __create_database_tables(self) -> None: [timestamp_added] INTEGER DEFAULT (cast(strftime('%s','now') as int)), [timestamp_modified] INTEGER NOT NULL DEFAULT 0, [search_name] TEXT NOT NULL, - [search_sort_name] TEXT NOT NULL + [search_sort_name] TEXT NOT NULL, + [artist_type] TEXT NOT NULL );""" ) await self.database.execute( @@ -2945,6 +2958,15 @@ async def __create_database_tables(self) -> None: UNIQUE(album_id, artist_id) );""" ) + await self.database.execute( + f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_AUDIOBOOK_ARTISTS}( + [audiobook_id] INTEGER NOT NULL, + [artist_id] INTEGER NOT NULL, + FOREIGN KEY([audiobook_id]) REFERENCES [audiobooks]([item_id]), + FOREIGN KEY([artist_id]) REFERENCES [artists]([item_id]), + UNIQUE(audiobook_id, artist_id) + );""" + ) await self.database.execute( f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_AUDIO_ANALYSIS}( diff --git a/music_assistant/helpers/compare.py b/music_assistant/helpers/compare.py index 8af69f7c37..2e7e1b99e1 100644 --- a/music_assistant/helpers/compare.py +++ b/music_assistant/helpers/compare.py @@ -87,6 +87,13 @@ def compare_artist( ) if external_id_match is not None: return external_id_match + # return early if artist_types don't match + if ( + isinstance(base_item, Artist) + and isinstance(compare_item, Artist) + and base_item.artist_type != compare_item.artist_type + ): + return False # finally comparing on (exact) name match return compare_strings(base_item.name, compare_item.name, strict=strict) diff --git a/music_assistant/models/music_provider.py b/music_assistant/models/music_provider.py index 07fd72426c..95156795bf 100644 --- a/music_assistant/models/music_provider.py +++ b/music_assistant/models/music_provider.py @@ -185,6 +185,20 @@ async def get_audiobook(self, prov_audiobook_id: str) -> Audiobook: """ raise NotImplementedError + async def get_author_audiobooks(self, prov_artist_id: str) -> list[Audiobook]: + """Get a list of all audiobooks for the given author. + + Only called if provider supports ProviderFeature.AUTHOR_AUDIOBOOKS. + """ + raise NotImplementedError + + async def get_narrator_audiobooks(self, prov_artist_id: str) -> list[Audiobook]: + """Get a list of all audiobooks for the given narrator. + + Only called if provider supports ProviderFeature.NARRATOR_AUDIOBOOKS. + """ + raise NotImplementedError + async def get_podcast(self, prov_podcast_id: str) -> Podcast: """Get full podcast details by id. @@ -984,6 +998,34 @@ async def _sync_library_audiobooks(self) -> set[int]: prov_item.provider_mappings, ) try: + if ProviderFeature.AUTHOR_AUDIOBOOKS in self.supported_features and not all( + isinstance(author, Artist) for author in prov_item.authors + ): + raise MusicAssistantError( + f"Provider {self.name} supports ProviderFeature.AUTHOR_AUDIOBOOKS, but" + f" item {prov_item.name} does not exclusively provide Artist instances." + ) + if ProviderFeature.NARRATOR_AUDIOBOOKS in self.supported_features and not all( + isinstance(narrator, Artist) for narrator in prov_item.narrators + ): + raise MusicAssistantError( + f"Provider {self.name} supports ProviderFeature.NARRATOR_AUDIOBOOKS, but" + f" item {prov_item.name} does not exclusively provide Artist instances." + ) + if ProviderFeature.AUTHOR_AUDIOBOOKS not in self.supported_features and not all( + isinstance(author, str) for author in prov_item.authors + ): + raise MusicAssistantError( + f"Provider {self.name} does not support ProviderFeature.AUTHOR_AUDIOBOOKS, but" + f" item {prov_item.name} does not exclusively provide strings." + ) + if ProviderFeature.NARRATOR_AUDIOBOOKS not in self.supported_features and not all( + isinstance(narrator, str) for narrator in prov_item.narrators + ): + raise MusicAssistantError( + f"Provider {self.name} does not support ProviderFeature.NARRATOR_AUDIOBOOKS, but" + f" item {prov_item.name} does not exclusively provide strings." + ) if not library_item: # add item to the library for prov_map in prov_item.provider_mappings: @@ -999,6 +1041,27 @@ async def _sync_library_audiobooks(self) -> set[int]: library_item = await self.mass.music.audiobooks.update_item_in_library( library_item.item_id, prov_item ) + else: + # detect a change in ProviderFeature + lib_author: str | Artist | None = None + prov_author: str | Artist | None = None + lib_narrator: str | Artist | None = None + prov_narrator: str | Artist | None = None + if len(library_item.authors) > 0: + lib_author = library_item.authors[0] + if len(prov_item.authors) > 0: + prov_author = prov_item.authors[0] + if len(library_item.narrators) > 0: + lib_narrator = library_item.narrators[0] + if len(prov_item.narrators) > 0: + prov_narrator = prov_item.narrators[0] + if (isinstance(lib_author, Artist) and not isinstance(prov_author, Artist)) or ( + isinstance(lib_narrator, Artist) and not isinstance(prov_narrator, Artist) + ): + library_item = await self.mass.music.audiobooks.update_item_in_library( + library_item.item_id, prov_item + ) + if not library_item.favorite and prov_item.favorite: # existing library item not favorite but should be await self.mass.music.audiobooks.set_favorite(library_item.item_id, True)