From: Marcel van der Veldt Date: Wed, 19 Jul 2023 18:43:02 +0000 (+0200) Subject: Refactor library storage (#781) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=3f1122ed3c4191eb084a29b3917ad3f0dc806945;p=music-assistant-server.git Refactor library storage (#781) * refactor in_library to favorites and db to library * fix missing parts * adjust library * change is_unique -> is_streaming_provider * finishing touches --- diff --git a/music_assistant/__main__.py b/music_assistant/__main__.py index 41dd76fa..5e1f3c4c 100644 --- a/music_assistant/__main__.py +++ b/music_assistant/__main__.py @@ -54,10 +54,6 @@ def get_arguments(): def setup_logger(data_path: str, level: str = "DEBUG"): """Initialize logger.""" - logs_dir = os.path.join(data_path, "logs") - if not os.path.isdir(logs_dir): - os.mkdir(logs_dir) - # define log formatter log_fmt = "%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s" @@ -86,7 +82,7 @@ def setup_logger(data_path: str, level: str = "DEBUG"): logging.captureWarnings(True) # setup file handler - log_filename = os.path.join(logs_dir, "musicassistant.log") + log_filename = os.path.join(data_path, "musicassistant.log") file_handler = RotatingFileHandler(log_filename, maxBytes=MAX_LOG_FILESIZE, backupCount=1) # rotate log at each start with suppress(OSError): diff --git a/music_assistant/client/music.py b/music_assistant/client/music.py index f5b3dca7..281e9867 100644 --- a/music_assistant/client/music.py +++ b/music_assistant/client/music.py @@ -1,6 +1,7 @@ """Handle Music/library related endpoints for Music Assistant.""" from __future__ import annotations +import urllib.parse from typing import TYPE_CHECKING from music_assistant.common.models.enums import MediaType @@ -31,9 +32,9 @@ class Music: # Tracks related endpoints/commands - async def get_tracks( + async def get_library_tracks( self, - in_library: bool | None = None, + favorite: bool | None = None, search: str | None = None, limit: int | None = None, offset: int | None = None, @@ -42,8 +43,8 @@ class Music: """Get Track listing from the server.""" return PagedItems.parse( await self.client.send_command( - "music/tracks", - in_library=in_library, + "music/tracks/library_items", + favorite=favorite, search=search, limit=limit, offset=offset, @@ -56,19 +57,15 @@ class Music: self, item_id: str, provider_instance_id_or_domain: str, - force_refresh: bool | None = None, - lazy: bool | None = None, - album: str | None = None, + album_uri: str | None = None, ) -> Track: """Get single Track from the server.""" return Track.from_dict( await self.client.send_command( - "music/track", + "music/tracks/get_track", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, - force_refresh=force_refresh, - lazy=lazy, - album=album, + album_uri=album_uri, ), ) @@ -81,7 +78,7 @@ class Music: return [ Track.from_dict(item) for item in await self.client.send_command( - "music/track/versions", + "music/tracks/track_versions", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, ) @@ -96,29 +93,26 @@ class Music: return [ Album.from_dict(item) for item in await self.client.send_command( - "music/track/albums", + "music/tracks/track_albums", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, ) ] - async def get_track_preview_url( + def get_track_preview_url( self, item_id: str, provider_instance_id_or_domain: str, ) -> str: """Get URL to preview clip of given track.""" - return await self.client.send_command( - "music/track/preview", - item_id=item_id, - provider_instance_id_or_domain=provider_instance_id_or_domain, - ) + encoded_url = urllib.parse.quote(urllib.parse.quote(item_id)) + return f"{self.client.server_info.base_url}/preview?path={encoded_url}&provider={provider_instance_id_or_domain}" # noqa: E501 # Albums related endpoints/commands - async def get_albums( + async def get_library_albums( self, - in_library: bool | None = None, + favorite: bool | None = None, search: str | None = None, limit: int | None = None, offset: int | None = None, @@ -127,8 +121,8 @@ class Music: """Get Albums listing from the server.""" return PagedItems.parse( await self.client.send_command( - "music/albums", - in_library=in_library, + "music/albums/library_items", + favorite=favorite, search=search, limit=limit, offset=offset, @@ -141,17 +135,13 @@ class Music: self, item_id: str, provider_instance_id_or_domain: str, - force_refresh: bool | None = None, - lazy: bool | None = None, ) -> Album: """Get single Album from the server.""" return Album.from_dict( await self.client.send_command( - "music/album", + "music/albums/get_album", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, - force_refresh=force_refresh, - lazy=lazy, ), ) @@ -179,7 +169,7 @@ class Music: return [ Album.from_dict(item) for item in await self.client.send_command( - "music/album/versions", + "music/albums/album_versions", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, ) @@ -187,44 +177,25 @@ class Music: # Artist related endpoints/commands - async def get_artists( + async def get_library_artists( self, - in_library: bool | None = None, + favorite: bool | None = None, search: str | None = None, limit: int | None = None, offset: int | None = None, order_by: str | None = None, + album_artists_only: bool = False, ) -> PagedItems: """Get Artists listing from the server.""" return PagedItems.parse( await self.client.send_command( - "music/artists", - in_library=in_library, - search=search, - limit=limit, - offset=offset, - order_by=order_by, - ), - Artist, - ) - - async def get_album_artists( - self, - in_library: bool | None = None, - search: str | None = None, - limit: int | None = None, - offset: int | None = None, - order_by: str | None = None, - ) -> PagedItems: - """Get AlbumArtists listing from the server.""" - return PagedItems.parse( - await self.client.send_command( - "music/albumartists", - in_library=in_library, + "music/artists/library_items", + favorite=favorite, search=search, limit=limit, offset=offset, order_by=order_by, + album_artists_only=album_artists_only, ), Artist, ) @@ -233,17 +204,13 @@ class Music: self, item_id: str, provider_instance_id_or_domain: str, - force_refresh: bool | None = None, - lazy: bool | None = None, ) -> Artist: """Get single Artist from the server.""" return Artist.from_dict( await self.client.send_command( - "music/artist", + "music/artists/get_artist", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, - force_refresh=force_refresh, - lazy=lazy, ), ) @@ -256,7 +223,7 @@ class Music: return [ Artist.from_dict(item) for item in await self.client.send_command( - "music/artist/tracks", + "music/artists/artist_tracks", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, ) @@ -271,7 +238,7 @@ class Music: return [ Album.from_dict(item) for item in await self.client.send_command( - "music/artist/albums", + "music/artists/artist_albums", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, ) @@ -279,9 +246,9 @@ class Music: # Playlist related endpoints/commands - async def get_playlists( + async def get_library_playlists( self, - in_library: bool | None = None, + favorite: bool | None = None, search: str | None = None, limit: int | None = None, offset: int | None = None, @@ -290,8 +257,8 @@ class Music: """Get Playlists listing from the server.""" return PagedItems.parse( await self.client.send_command( - "music/playlists", - in_library=in_library, + "music/playlists/library_items", + favorite=favorite, search=search, limit=limit, offset=offset, @@ -304,17 +271,13 @@ class Music: self, item_id: str, provider_instance_id_or_domain: str, - force_refresh: bool | None = None, - lazy: bool | None = None, ) -> Playlist: """Get single Playlist from the server.""" return Playlist.from_dict( await self.client.send_command( - "music/playlist", + "music/playlists/get_playlist", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, - force_refresh=force_refresh, - lazy=lazy, ), ) @@ -327,7 +290,7 @@ class Music: return [ Track.from_dict(item) for item in await self.client.send_command( - "music/playlist/tracks", + "music/playlists/playlist_tracks", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, ) @@ -336,7 +299,7 @@ class Music: async def add_playlist_tracks(self, db_playlist_id: str | int, uris: list[str]) -> None: """Add multiple tracks to playlist. Creates background tasks to process the action.""" await self.client.send_command( - "music/playlist/tracks/add", + "music/playlists/add_playlist_tracks", db_playlist_id=db_playlist_id, uris=uris, ) @@ -346,7 +309,7 @@ class Music: ) -> None: """Remove multiple tracks from playlist.""" await self.client.send_command( - "music/playlist/tracks/add", + "music/playlists/remove_playlist_tracks", db_playlist_id=db_playlist_id, positions_to_remove=positions_to_remove, ) @@ -357,7 +320,7 @@ class Music: """Create new playlist.""" return Playlist.from_dict( await self.client.send_command( - "music/playlist/create", + "music/playlists/create_playlist", name=name, provider_instance_or_domain=provider_instance_or_domain, ) @@ -365,9 +328,9 @@ class Music: # Radio related endpoints/commands - async def get_radios( + async def get_library_radios( self, - in_library: bool | None = None, + favorite: bool | None = None, search: str | None = None, limit: int | None = None, offset: int | None = None, @@ -376,8 +339,8 @@ class Music: """Get Radio listing from the server.""" return PagedItems.parse( await self.client.send_command( - "music/radios", - in_library=in_library, + "music/radio/library_items", + favorite=favorite, search=search, limit=limit, offset=offset, @@ -390,17 +353,13 @@ class Music: self, item_id: str, provider_instance_id_or_domain: str, - force_refresh: bool | None = None, - lazy: bool | None = None, ) -> Radio: """Get single Radio from the server.""" return Radio.from_dict( await self.client.send_command( - "music/radio", + "music/radio/get_item", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, - force_refresh=force_refresh, - lazy=lazy, ), ) @@ -413,7 +372,7 @@ class Music: return [ Radio.from_dict(item) for item in await self.client.send_command( - "music/radio/versions", + "music/radio/radio_versions", item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, ) @@ -424,15 +383,9 @@ class Music: async def get_item_by_uri( self, uri: str, - force_refresh: bool | None = None, - lazy: bool | None = None, ) -> MediaItemType: """Get single music item providing a mediaitem uri.""" - return media_from_dict( - await self.client.send_command( - "music/item_by_uri", uri=uri, force_refresh=force_refresh, lazy=lazy - ) - ) + return media_from_dict(await self.client.send_command("music/item_by_uri", uri=uri)) async def refresh_item( self, @@ -448,8 +401,6 @@ class Music: media_type: MediaType, item_id: str, provider_instance_id_or_domain: str, - force_refresh: bool | None = None, - lazy: bool | None = None, ) -> MediaItemType: """Get single music item by id and media type.""" return media_from_dict( @@ -458,45 +409,49 @@ class Music: media_type=media_type, item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, - force_refresh=force_refresh, - lazy=lazy, ) ) - async def add_to_library( - self, - media_type: MediaType, - item_id: str, - provider_instance_id_or_domain: str, + async def add_item_to_library(self, item: str | MediaItemType) -> MediaItemType: + """Add item (uri or mediaitem) to the library.""" + await self.client.send_command("music/library/add_item", item=item) + + async def remove_item_from_library( + self, media_type: MediaType, library_item_id: str | int ) -> None: - """Add an item to the library.""" + """ + Remove item from the library. + + Destructive! Will remove the item and all dependants. + """ await self.client.send_command( - "music/library/add", - media_type=media_type, - item_id=item_id, - provider_instance_id_or_domain=provider_instance_id_or_domain, + "music/library/remove", media_type=media_type, library_item_id=library_item_id ) - async def remove_from_library( + async def add_item_to_favorites( self, media_type: MediaType, item_id: str, provider_instance_id_or_domain: str, ) -> None: - """Remove an item from the library.""" + """Add an item to the favorites.""" await self.client.send_command( - "music/library/remove", + "music/favorites/add_item", media_type=media_type, item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, ) - async def delete_db_item( - self, media_type: MediaType, db_item_id: str | int, recursive: bool = False + async def remove_item_from_favorites( + self, + media_type: MediaType, + item_id: str | int, ) -> None: - """Remove item from the database.""" + """Remove (library) item from the favorites.""" await self.client.send_command( - "music/delete", media_type=media_type, db_item_id=db_item_id, recursive=recursive + "music/favorites/remove_item", + media_type=media_type, + item_id=item_id, ) async def browse( diff --git a/music_assistant/common/helpers/util.py b/music_assistant/common/helpers/util.py index 55ad431a..343cbd4d 100755 --- a/music_assistant/common/helpers/util.py +++ b/music_assistant/common/helpers/util.py @@ -47,7 +47,7 @@ def try_parse_bool(possible_bool: Any) -> str: def create_sort_name(input_str: str) -> str: """Create sort name/title from string.""" input_str = input_str.lower().strip() - for item in ["the ", "de ", "les "]: + for item in ["the ", "de ", "les ", "dj "]: if input_str.startswith(item): input_str = input_str.replace(item, "") return input_str.strip() diff --git a/music_assistant/common/models/media_items.py b/music_assistant/common/models/media_items.py index b8e7a489..f8a3d377 100755 --- a/music_assistant/common/models/media_items.py +++ b/music_assistant/common/models/media_items.py @@ -21,8 +21,7 @@ from music_assistant.common.models.enums import ( MetadataTypes = int | bool | str | list[str] -JSON_KEYS = ("artists", "artist", "albums", "metadata", "provider_mappings") -JOINED_KEYS = ("barcode", "isrc") +JSON_KEYS = ("artists", "metadata", "provider_mappings") @dataclass @@ -69,10 +68,14 @@ class ProviderMapping(DataClassDictMixin): available: bool = True # quality/audio details (streamable content only) audio_format: AudioFormat = field(default_factory=AudioFormat) - # optional details to store provider specific details - details: str | None = None # url = link to provider details page if exists url: str | None = None + # isrc (tracks only) - isrc identifier if known + isrc: str | None = None + # barcode (albums only) - barcode identifier if known + barcode: str | None = None + # optional details to store provider specific details + details: str | None = None @property def quality(self) -> int: @@ -206,7 +209,7 @@ class MediaItem(DataClassDictMixin): # optional fields below metadata: MediaItemMetadata = field(default_factory=MediaItemMetadata) - in_library: bool = False + favorite: bool = False media_type: MediaType = MediaType.UNKNOWN # sort_name and uri are auto generated, do not override unless really needed sort_name: str | None = None @@ -226,21 +229,12 @@ class MediaItem(DataClassDictMixin): def from_db_row(cls, db_row: Mapping): """Create MediaItem object from database row.""" db_row = dict(db_row) - db_row["provider"] = "database" + db_row["provider"] = "library" for key in JSON_KEYS: if key in db_row and db_row[key] is not None: db_row[key] = json_loads(db_row[key]) - for key in JOINED_KEYS: - if key not in db_row: - continue - db_row[key] = db_row[key].strip() - db_row[key] = db_row[key].split(";") if db_row[key] else [] - if "in_library" in db_row: - db_row["in_library"] = bool(db_row["in_library"]) - if db_row.get("albums"): - db_row["album"] = db_row["albums"][0] - db_row["disc_number"] = db_row["albums"][0]["disc_number"] - db_row["track_number"] = db_row["albums"][0]["track_number"] + if "favorite" in db_row: + db_row["favorite"] = bool(db_row["favorite"]) db_row["item_id"] = str(db_row["item_id"]) return cls.from_dict(db_row) @@ -251,8 +245,6 @@ class MediaItem(DataClassDictMixin): """Transform value for db storage.""" if key in JSON_KEYS: return json_dumps(value) - if key in JOINED_KEYS: - return ";".join(value) return value return { @@ -304,9 +296,9 @@ class ItemMapping(DataClassDictMixin): item_id: str provider: str # provider instance id or provider domain name: str + version: str = "" sort_name: str | None = None uri: str | None = None - version: str = "" available: bool = True @classmethod @@ -327,13 +319,17 @@ class ItemMapping(DataClassDictMixin): """Return custom hash.""" return hash((self.media_type.value, self.provider, self.item_id)) + def __eq__(self, other: ProviderMapping) -> bool: + """Check equality of two items.""" + return self.__hash__() == other.__hash__() + @dataclass class Artist(MediaItem): """Model for an artist.""" media_type: MediaType = MediaType.ARTIST - musicbrainz_id: str | None = None + mbid: str | None = None @dataclass @@ -345,40 +341,7 @@ class Album(MediaItem): year: int | None = None artists: list[Artist | ItemMapping] = field(default_factory=list) album_type: AlbumType = AlbumType.UNKNOWN - barcode: set[str] = field(default_factory=set) - musicbrainz_id: str | None = None # release group id - - -@dataclass -class DbAlbum(Album): - """Model for an album when retrieved from the db.""" - - artists: list[ItemMapping] = field(default_factory=list) - - -@dataclass -class TrackAlbumMapping(ItemMapping): - """Model for a track that is mapped to an album.""" - - disc_number: int | None = None - track_number: int | None = None - - def __hash__(self): - """Return custom hash.""" - return hash((self.media_type, self.provider, self.item_id)) - - @classmethod - def from_item( - cls, - item: MediaItemType | ItemMapping, - disc_number: int | None = None, - track_number: int | None = None, - ) -> TrackAlbumMapping: - """Create TrackAlbumMapping object from regular item.""" - result = super().from_item(item) - result.disc_number = disc_number - result.track_number = track_number - return result + mbid: str | None = None # release group id @dataclass @@ -388,16 +351,9 @@ class Track(MediaItem): media_type: MediaType = MediaType.TRACK duration: int = 0 version: str = "" - isrc: set[str] = field(default_factory=set) - musicbrainz_id: str | None = None # Recording ID + mbid: str | None = None # Recording ID artists: list[Artist | ItemMapping] = field(default_factory=list) - # album track only - album: Album | ItemMapping | None = None - albums: list[TrackAlbumMapping] = field(default_factory=list) - disc_number: int | None = None - track_number: int | None = None - # playlist track only - position: int | None = None + album: Album | ItemMapping | None = None # optional def __hash__(self): """Return custom hash.""" @@ -424,14 +380,20 @@ class Track(MediaItem): return self.metadata and self.metadata.chapters and len(self.metadata.chapters) > 1 -@dataclass -class DbTrack(Track): - """Model for a track when retrieved from the db.""" +@dataclass(kw_only=True) +class AlbumTrack(Track): + """Model for a track on an album.""" + + album: Album | ItemMapping # required + disc_number: int = 0 + track_number: int = 0 + + +@dataclass(kw_only=True) +class PlaylistTrack(Track): + """Model for a track on a playlist.""" - artists: list[ItemMapping] = field(default_factory=list) - # album track only - album: ItemMapping | None = None - albums: list[TrackAlbumMapping] = field(default_factory=list) + position: int # required @dataclass diff --git a/music_assistant/constants.py b/music_assistant/constants.py index e0bbf0b2..8ed067ac 100755 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -3,14 +3,15 @@ import pathlib from typing import Final -API_SCHEMA_VERSION: Final[int] = 22 -MIN_SCHEMA_VERSION = 22 +API_SCHEMA_VERSION: Final[int] = 23 +MIN_SCHEMA_VERSION: Final[int] = 23 +DB_SCHEMA_VERSION: Final[int] = 24 ROOT_LOGGER_NAME: Final[str] = "music_assistant" UNKNOWN_ARTIST: Final[str] = "Unknown Artist" -VARIOUS_ARTISTS: Final[str] = "Various Artists" -VARIOUS_ARTISTS_ID: Final[str] = "89ad4ac3-39f7-470e-963a-56509c546377" +VARIOUS_ARTISTS_NAME: Final[str] = "Various Artists" +VARIOUS_ARTISTS_ID_MBID: Final[str] = "89ad4ac3-39f7-470e-963a-56509c546377" RESOURCES_DIR: Final[pathlib.Path] = ( @@ -61,6 +62,7 @@ DB_TABLE_PLAYLOG: Final[str] = "playlog" DB_TABLE_ARTISTS: Final[str] = "artists" DB_TABLE_ALBUMS: Final[str] = "albums" DB_TABLE_TRACKS: Final[str] = "tracks" +DB_TABLE_ALBUM_TRACKS: Final[str] = "albumtracks" DB_TABLE_PLAYLISTS: Final[str] = "playlists" DB_TABLE_RADIOS: Final[str] = "radios" DB_TABLE_CACHE: Final[str] = "cache" diff --git a/music_assistant/server/controllers/cache.py b/music_assistant/server/controllers/cache.py index e1b12653..7e84dce2 100644 --- a/music_assistant/server/controllers/cache.py +++ b/music_assistant/server/controllers/cache.py @@ -8,12 +8,17 @@ import os import time from collections import OrderedDict from collections.abc import Iterator, MutableMapping -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any from music_assistant.common.helpers.json import json_dumps, json_loads from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType from music_assistant.common.models.enums import ConfigEntryType -from music_assistant.constants import DB_TABLE_CACHE, DB_TABLE_SETTINGS, ROOT_LOGGER_NAME +from music_assistant.constants import ( + DB_SCHEMA_VERSION, + DB_TABLE_CACHE, + DB_TABLE_SETTINGS, + ROOT_LOGGER_NAME, +) from music_assistant.server.helpers.database import DatabaseConnection from music_assistant.server.models.core_controller import CoreController @@ -22,7 +27,6 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.cache") CONF_CLEAR_CACHE = "clear_cache" -DB_SCHEMA_VERSION: Final[int] = 22 class CacheController(CoreController): diff --git a/music_assistant/server/controllers/media/albums.py b/music_assistant/server/controllers/media/albums.py index 3fcfb325..2b064090 100644 --- a/music_assistant/server/controllers/media/albums.py +++ b/music_assistant/server/controllers/media/albums.py @@ -16,15 +16,19 @@ from music_assistant.common.models.errors import ( ) from music_assistant.common.models.media_items import ( Album, + AlbumTrack, AlbumType, - DbAlbum, ItemMapping, MediaType, Track, ) -from music_assistant.constants import DB_TABLE_ALBUMS, DB_TABLE_TRACKS +from music_assistant.constants import DB_TABLE_ALBUM_TRACKS, DB_TABLE_ALBUMS, DB_TABLE_TRACKS from music_assistant.server.controllers.media.base import MediaControllerBase -from music_assistant.server.helpers.compare import compare_album, loose_compare_strings +from music_assistant.server.helpers.compare import ( + compare_album, + compare_artists, + loose_compare_strings, +) if TYPE_CHECKING: from music_assistant.server.models.music_provider import MusicProvider @@ -35,19 +39,23 @@ class AlbumsController(MediaControllerBase[Album]): db_table = DB_TABLE_ALBUMS media_type = MediaType.ALBUM - item_cls = DbAlbum - _db_add_lock = asyncio.Lock() + item_cls = Album def __init__(self, *args, **kwargs): """Initialize class.""" super().__init__(*args, **kwargs) + self._db_add_lock = asyncio.Lock() # register api handlers - self.mass.register_api_command("music/albums", self.db_items) - self.mass.register_api_command("music/album", self.get) - self.mass.register_api_command("music/album/tracks", self.tracks) - self.mass.register_api_command("music/album/versions", self.versions) - self.mass.register_api_command("music/album/update", self._update_db_item) - self.mass.register_api_command("music/album/delete", self.delete) + self.mass.register_api_command("music/albums/library_items", self.library_items) + self.mass.register_api_command( + "music/albums/update_item_in_library", self.update_item_in_library + ) + self.mass.register_api_command( + "music/albums/remove_item_from_library", self.remove_item_from_library + ) + self.mass.register_api_command("music/albums/get_album", self.get) + self.mass.register_api_command("music/albums/album_tracks", self.tracks) + self.mass.register_api_command("music/albums/album_versions", self.versions) async def get( self, @@ -56,7 +64,7 @@ class AlbumsController(MediaControllerBase[Album]): force_refresh: bool = False, lazy: bool = True, details: Album | ItemMapping = None, - add_to_db: bool = True, + add_to_library: bool = False, ) -> Album: """Return (full) details for a single media item.""" album = await super().get( @@ -65,25 +73,27 @@ class AlbumsController(MediaControllerBase[Album]): force_refresh=force_refresh, lazy=lazy, details=details, - add_to_db=add_to_db, + add_to_library=add_to_library, ) # append full artist details to full album item album.artists = [ await self.mass.music.artists.get( item.item_id, item.provider, - lazy=True, + lazy=lazy, details=item, - add_to_db=add_to_db, + add_to_library=add_to_library, ) for item in album.artists ] return album - async def add(self, item: Album, skip_metadata_lookup: bool = False) -> Album: - """Add album to local db and return the database item.""" + async def add_item_to_library(self, item: Album, skip_metadata_lookup: bool = False) -> Album: + """Add album to library and return the database item.""" if not isinstance(item, Album): raise InvalidDataError("Not a valid Album object (ItemMapping can not be added to db)") + if not item.provider_mappings: + raise InvalidDataError("Album is missing provider mapping(s)") # resolve any ItemMapping artists item.artists = [ await self.mass.music.artists.get_provider_item( @@ -93,48 +103,91 @@ class AlbumsController(MediaControllerBase[Album]): else artist for artist in item.artists ] + if not item.artists: + raise InvalidDataError("Album is missing artist(s)") # grab additional metadata if not skip_metadata_lookup: await self.mass.metadata.get_album_metadata(item) - if item.provider == "database": - db_item = await self._update_db_item(item.item_id, item) - else: - # use the lock to prevent a race condition of the same item being added twice - async with self._db_add_lock: - db_item = await self._add_db_item(item) + # actually add (or update) the item in the library db + # use the lock to prevent a race condition of the same item being added twice + async with self._db_add_lock: + library_item = await self._add_library_item(item) # also fetch the same album on all providers if not skip_metadata_lookup: - await self._match(db_item) - # preload album tracks listing (do not load them in the db) - for prov_mapping in db_item.provider_mappings: - if not prov_mapping.available: - continue - await self._get_provider_album_tracks( - prov_mapping.item_id, prov_mapping.provider_instance - ) - # return final db_item after all match/metadata actions - return await self.get_db_item(db_item.item_id) + await self._match(library_item) + library_item = await self.get_library_item(library_item.item_id) + # also add album tracks + if not skip_metadata_lookup and item.provider != "library": + async with asyncio.TaskGroup() as tg: + for track in await self._get_provider_album_tracks(item.item_id, item.provider): + track.album = library_item + tg.create_task( + self.mass.music.tracks.add_item_to_library( + track, skip_metadata_lookup=skip_metadata_lookup + ) + ) + self.mass.signal_event( + EventType.MEDIA_ITEM_ADDED, + library_item.uri, + library_item, + ) + return library_item - async def update(self, item_id: str | int, update: Album, overwrite: bool = False) -> Album: + async def update_item_in_library( + self, item_id: str | int, update: Album, overwrite: bool = False + ) -> Album: """Update existing record in the database.""" db_id = int(item_id) # ensure integer - return await self._update_db_item(item_id=db_id, item=update, overwrite=overwrite) + cur_item = await self.get_library_item(db_id) + metadata = cur_item.metadata.update(getattr(update, "metadata", None), overwrite) + provider_mappings = self._get_provider_mappings(cur_item, update, overwrite) + album_artists = await self._get_artist_mappings(cur_item, update, overwrite) + if getattr(update, "album_type", AlbumType.UNKNOWN) != AlbumType.UNKNOWN: + album_type = update.album_type + else: + album_type = cur_item.album_type + sort_artist = album_artists[0].sort_name + await self.mass.music.database.update( + self.db_table, + {"item_id": db_id}, + { + "name": update.name if overwrite else cur_item.name, + "sort_name": update.sort_name if overwrite else cur_item.sort_name, + "sort_artist": sort_artist, + "version": update.version if overwrite else cur_item.version, + "year": update.year if overwrite else cur_item.year or update.year, + "album_type": album_type.value, + "artists": serialize_to_json(album_artists), + "metadata": serialize_to_json(metadata), + "provider_mappings": serialize_to_json(provider_mappings), + "mbid": update.mbid or cur_item.mbid, + "timestamp_modified": int(utc_timestamp()), + }, + ) + # update/set provider_mappings table + await self._set_provider_mappings(db_id, provider_mappings) + self.logger.debug("updated %s in database: %s", update.name, db_id) + # get full created object + library_item = await self.get_library_item(db_id) + self.mass.signal_event( + EventType.MEDIA_ITEM_UPDATED, + library_item.uri, + library_item, + ) + # return the full item we just updated + return library_item - async def delete(self, item_id: str | int, recursive: bool = False) -> None: + async def remove_item_from_library(self, item_id: str | int) -> None: """Delete record from the database.""" db_id = int(item_id) # ensure integer - # check album tracks - db_rows = await self.mass.music.database.get_rows_from_query( - f"SELECT item_id FROM {DB_TABLE_TRACKS} WHERE albums LIKE '%\"{db_id}\"%'", - limit=5000, - ) - assert not (db_rows and not recursive), "Tracks attached to album" - for db_row in db_rows: + # recursively also remove album tracks + for db_track in await self._get_db_album_tracks(db_id): with contextlib.suppress(MediaNotFoundError): - await self.mass.music.tracks.delete(db_row["item_id"], recursive) - + await self.mass.music.tracks.remove_item_from_library(db_track.item_id) + # delete entry(s) from albumtracks table + await self.mass.music.database.delete(DB_TABLE_ALBUM_TRACKS, {"album_id": db_id}) # delete the album itself from db - await super().delete(item_id) + await super().remove_item_from_library(item_id) async def tracks( self, @@ -142,19 +195,8 @@ class AlbumsController(MediaControllerBase[Album]): provider_instance_id_or_domain: str, ) -> list[Track]: """Return album tracks for the given provider album id.""" - if provider_instance_id_or_domain == "database": - if db_result := await self._get_db_album_tracks(item_id): - return db_result - # no results in db (yet), grab provider details - if db_album := await self.get_db_item(item_id): - for prov_mapping in db_album.provider_mappings: - # returns the first provider that is available - if not prov_mapping.available: - continue - return await self._get_provider_album_tracks( - prov_mapping.item_id, prov_mapping.provider_instance - ) - + if provider_instance_id_or_domain == "library": + return await self._get_db_album_tracks(item_id) # return provider album tracks return await self._get_provider_album_tracks(item_id, provider_instance_id_or_domain) @@ -163,55 +205,30 @@ class AlbumsController(MediaControllerBase[Album]): item_id: str, provider_instance_id_or_domain: str, ) -> list[Album]: - """Return all versions of an album we can find on all providers.""" - album = await self.get(item_id, provider_instance_id_or_domain, add_to_db=False) - # perform a search on all provider(types) to collect all versions/variants + """Return all versions of an album we can find on the provider.""" + album = await self.get(item_id, provider_instance_id_or_domain, add_to_library=False) search_query = f"{album.artists[0].name} - {album.name}" - all_versions = { - prov_item.item_id: prov_item - for prov_items in await asyncio.gather( - *[ - self.search(search_query, provider_instance_id) - for provider_instance_id in self.mass.music.get_unique_providers() - ] - ) - for prov_item in prov_items - # title must (partially) match + return [ + prov_item + for prov_item in await self.search(search_query, provider_instance_id_or_domain) if loose_compare_strings(album.name, prov_item.name) - # artist must match - and album.artists[0].sort_name in {x.sort_name for x in prov_item.artists} - } - # make sure that the 'base' version is NOT included - for prov_version in album.provider_mappings: - all_versions.pop(prov_version.item_id, None) - - # return the aggregated result - return all_versions.values() + and compare_artists(prov_item.artists, album.artists, any_match=True) + # make sure that the 'base' version is NOT included + and prov_item.item_id != item_id + ] - async def _add_db_item(self, item: Album) -> Album: + async def _add_library_item(self, item: Album) -> Album: """Add a new record to the database.""" - assert item.provider_mappings, "Item is missing provider mapping(s)" - assert item.artists, f"Album {item.name} is missing artists" - # safety guard: check for existing item first - if cur_item := await self.get_db_item_by_prov_id(item.item_id, item.provider): + if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider): # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) - if item.musicbrainz_id: - match = {"musicbrainz_id": item.musicbrainz_id} + return await self.update_item_in_library(cur_item.item_id, item) + if item.mbid: + match = {"mbid": item.mbid} if db_row := await self.mass.music.database.get_row(self.db_table, match): cur_item = Album.from_db_row(db_row) # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) - # try barcode/upc - if not cur_item and item.barcode: - for barcode in item.barcode: - if search_result := await self.mass.music.database.search( - self.db_table, barcode, "barcode" - ): - cur_item = Album.from_db_row(search_result[0]) - # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) + return await self.update_item_in_library(cur_item.item_id, item) # fallback to search and match match = {"sort_name": item.sort_name} for row in await self.mass.music.database.get_rows(self.db_table, match): @@ -219,16 +236,16 @@ class AlbumsController(MediaControllerBase[Album]): if compare_album(row_album, item): cur_item = row_album # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) + return await self.update_item_in_library(cur_item.item_id, item) # insert new item album_artists = await self._get_artist_mappings(item, cur_item) - sort_artist = album_artists[0].sort_name if album_artists else "" + sort_artist = album_artists[0].sort_name new_item = await self.mass.music.database.insert( self.db_table, { **item.to_db_row(), - "artists": serialize_to_json(album_artists) or None, + "artists": serialize_to_json(album_artists), "sort_artist": sort_artist, "timestamp_added": int(utc_timestamp()), "timestamp_modified": int(utc_timestamp()), @@ -238,72 +255,14 @@ class AlbumsController(MediaControllerBase[Album]): # update/set provider_mappings table await self._set_provider_mappings(db_id, item.provider_mappings) self.logger.debug("added %s to database", item.name) - # get full created object - db_item = await self.get_db_item(db_id) - # only signal event if we're not running a sync (to prevent a floodstorm of events) - if not self.mass.music.get_running_sync_tasks(): - self.mass.signal_event( - EventType.MEDIA_ITEM_ADDED, - db_item.uri, - db_item, - ) # return the full item we just added - return db_item - - async def _update_db_item( - self, item_id: str | int, item: Album | ItemMapping, overwrite: bool = False - ) -> Album: - """Update Album record in the database.""" - db_id = int(item_id) # ensure integer - cur_item = await self.get_db_item(db_id) - metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite) - provider_mappings = self._get_provider_mappings(cur_item, item, overwrite) - album_artists = await self._get_artist_mappings(cur_item, item, overwrite) - if getattr(item, "barcode", None): - cur_item.barcode.update(item.barcode) - if getattr(item, "album_type", AlbumType.UNKNOWN) != AlbumType.UNKNOWN: - album_type = item.album_type - else: - album_type = cur_item.album_type - sort_artist = album_artists[0].sort_name if album_artists else "" - await self.mass.music.database.update( - self.db_table, - {"item_id": db_id}, - { - "name": item.name if overwrite else cur_item.name, - "sort_name": item.sort_name if overwrite else cur_item.sort_name, - "sort_artist": sort_artist, - "version": item.version if overwrite else cur_item.version, - "year": item.year if overwrite else cur_item.year or item.year, - "barcode": ";".join(cur_item.barcode), - "album_type": album_type.value, - "artists": serialize_to_json(album_artists) or None, - "metadata": serialize_to_json(metadata), - "provider_mappings": serialize_to_json(provider_mappings), - "musicbrainz_id": item.musicbrainz_id or cur_item.musicbrainz_id, - "timestamp_modified": int(utc_timestamp()), - }, - ) - # update/set provider_mappings table - await self._set_provider_mappings(db_id, provider_mappings) - self.logger.debug("updated %s in database: %s", item.name, db_id) - # get full created object - db_item = await self.get_db_item(db_id) - # only signal event if we're not running a sync (to prevent a floodstorm of events) - if not self.mass.music.get_running_sync_tasks(): - self.mass.signal_event( - EventType.MEDIA_ITEM_UPDATED, - db_item.uri, - db_item, - ) - # return the full item we just updated - return db_item + return await self.get_library_item(db_id) async def _get_provider_album_tracks( self, item_id: str, provider_instance_id_or_domain: str - ) -> list[Track]: + ) -> list[AlbumTrack]: """Return album tracks for the given provider album id.""" - assert provider_instance_id_or_domain != "database" + assert provider_instance_id_or_domain != "library" prov = self.mass.get_provider(provider_instance_id_or_domain) if prov is None: return [] @@ -316,10 +275,12 @@ class AlbumsController(MediaControllerBase[Album]): else: cache_checksum = full_album.metadata.checksum if cache := await self.mass.cache.get(cache_key, checksum=cache_checksum): - return [Track.from_dict(x) for x in cache] + return [AlbumTrack.from_dict(x) for x in cache] # no items in cache - get listing from provider items = [] for track in await prov.get_album_tracks(item_id): + assert isinstance(track, AlbumTrack) + assert track.track_number # make sure that the (full) album is stored on the tracks track.album = full_album if not isinstance(full_album, ItemMapping) and full_album.metadata.images: @@ -338,7 +299,7 @@ class AlbumsController(MediaControllerBase[Album]): limit: int = 25, ): """Generate a dynamic list of tracks based on the album content.""" - assert provider_instance_id_or_domain != "database" + assert provider_instance_id_or_domain != "library" prov = self.mass.get_provider(provider_instance_id_or_domain) if prov is None: return [] @@ -377,35 +338,35 @@ class AlbumsController(MediaControllerBase[Album]): async def _get_db_album_tracks( self, item_id: str | int, - ) -> list[Track]: + ) -> list[AlbumTrack]: """Return in-database album tracks for the given database album.""" db_id = int(item_id) # ensure integer - db_album = await self.get_db_item(db_id) - # simply grab all tracks in the db that are linked to this album - # TODO: adjust to json query instead of text search? - query = f'SELECT * FROM {DB_TABLE_TRACKS} WHERE albums LIKE \'%"item_id":"{db_id}","provider":"database"%\'' # noqa: E501 - result = [] - for track in await self.mass.music.tracks.get_db_items_by_query(query): - if album_mapping := next( - (x for x in track.albums if x.item_id == db_album.item_id), None - ): - # make sure that the full album is set on the track and prefer the album's images - track.album = db_album - if db_album.metadata.images: - track.metadata.images = db_album.metadata.images - # apply the disc and track number from the mapping - track.disc_number = album_mapping.disc_number - track.track_number = album_mapping.track_number - result.append(track) - return sorted(result, key=lambda x: (x.disc_number or 0, x.track_number or 0)) + db_album = await self.get_library_item(db_id) + result: list[AlbumTrack] = [] + async for album_track_row in self.mass.music.database.iter_items( + DB_TABLE_ALBUM_TRACKS, {"album_id": db_id} + ): + # TODO: make this a nice join query + track_id = album_track_row["track_id"] + track_row = await self.mass.music.database.get_row( + DB_TABLE_TRACKS, {"item_id": track_id} + ) + album_track = AlbumTrack.from_db_row( + {**track_row, **album_track_row, "album": db_album.to_dict()} + ) + if db_album.metadata.images: + album_track.metadata.images = db_album.metadata.images + result.append(album_track) + return sorted(result, key=lambda x: (x.disc_number, x.track_number)) async def _match(self, db_album: Album) -> None: - """Try to find matching album on all providers for the provided (database) album. + """Try to find match on all (streaming) providers for the provided (database) album. This is used to link objects of different providers/qualities together. """ - if db_album.provider != "database": + if db_album.provider != "library": return # Matching only supported for database items + artist_name = db_album.artists[0].name async def find_prov_match(provider: MusicProvider): self.logger.debug( @@ -414,8 +375,8 @@ class AlbumsController(MediaControllerBase[Album]): match_found = False for search_str in ( db_album.name, - f"{db_album.artists[0].name} - {db_album.name}", - f"{db_album.artists[0].name} {db_album.name}", + f"{artist_name} - {db_album.name}", + f"{artist_name} {db_album.name}", ): if match_found: break @@ -432,9 +393,10 @@ class AlbumsController(MediaControllerBase[Album]): fallback=search_result_item, ) if compare_album(prov_album, db_album): - # 100% match, we can simply update the db with additional provider ids - await self._update_db_item(db_album.item_id, prov_album) + # 100% match, we update the db with the additional provider mapping(s) match_found = True + for provider_mapping in search_result_item.provider_mappings: + await self.add_provider_mapping(db_album.item_id, provider_mapping) return match_found # try to find match on all providers @@ -444,8 +406,10 @@ class AlbumsController(MediaControllerBase[Album]): continue if ProviderFeature.SEARCH not in provider.supported_features: continue - if provider.is_unique: - # matching on unique provider sis pointless as they push (all) their content to MA + if not provider.library_supported(MediaType.ALBUM): + continue + if not provider.is_streaming_provider: + # matching on unique providers is pointless as they push (all) their content to MA continue if await find_prov_match(provider): cur_provider_domains.add(provider.domain) diff --git a/music_assistant/server/controllers/media/artists.py b/music_assistant/server/controllers/media/artists.py index ed8939a3..530dae78 100644 --- a/music_assistant/server/controllers/media/artists.py +++ b/music_assistant/server/controllers/media/artists.py @@ -3,9 +3,8 @@ from __future__ import annotations import asyncio import contextlib -import itertools from random import choice, random -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from music_assistant.common.helpers.datetime import utc_timestamp from music_assistant.common.helpers.json import serialize_to_json @@ -20,7 +19,7 @@ from music_assistant.common.models.media_items import ( PagedItems, Track, ) -from music_assistant.constants import VARIOUS_ARTISTS, VARIOUS_ARTISTS_ID +from music_assistant.constants import VARIOUS_ARTISTS_ID_MBID, VARIOUS_ARTISTS_NAME from music_assistant.server.controllers.media.base import MediaControllerBase from music_assistant.server.controllers.music import ( DB_TABLE_ALBUMS, @@ -39,161 +38,178 @@ class ArtistsController(MediaControllerBase[Artist]): db_table = DB_TABLE_ARTISTS media_type = MediaType.ARTIST item_cls = Artist - _db_add_lock = asyncio.Lock() def __init__(self, *args, **kwargs): """Initialize class.""" super().__init__(*args, **kwargs) + self._db_add_lock = asyncio.Lock() # register api handlers - self.mass.register_api_command("music/artists", self.db_items) - self.mass.register_api_command("music/albumartists", self.album_artists) - self.mass.register_api_command("music/artist", self.get) - self.mass.register_api_command("music/artist/albums", self.albums) - self.mass.register_api_command("music/artist/tracks", self.tracks) - self.mass.register_api_command("music/artist/update", self._update_db_item) - self.mass.register_api_command("music/artist/delete", self.delete) + self.mass.register_api_command("music/artists/library_items", self.library_items) + self.mass.register_api_command( + "music/artists/update_item_in_library", self.update_item_in_library + ) + self.mass.register_api_command( + "music/artists/remove_item_from_library", self.remove_item_from_library + ) + self.mass.register_api_command("music/artists/get_artist", self.get) + self.mass.register_api_command("music/artists/artist_albums", self.albums) + self.mass.register_api_command("music/artists/artist_tracks", self.tracks) - async def add(self, item: Artist | ItemMapping, skip_metadata_lookup: bool = False) -> Artist: - """Add artist to local db and return the database item.""" + async def add_item_to_library( + self, item: Artist | ItemMapping, skip_metadata_lookup: bool = False + ) -> Artist: + """Add artist to library and return the database item.""" if isinstance(item, ItemMapping): skip_metadata_lookup = True # grab musicbrainz id and additional metadata if not skip_metadata_lookup: await self.mass.metadata.get_artist_metadata(item) - if item.provider == "database": - db_item = await self._update_db_item(item.item_id, item) - else: - # use the lock to prevent a race condition of the same item being added twice - async with self._db_add_lock: - db_item = await self._add_db_item(item) + # actually add (or update) the item in the library db + # use the lock to prevent a race condition of the same item being added twice + async with self._db_add_lock: + library_item = await self._add_library_item(item) # also fetch same artist on all providers if not skip_metadata_lookup: - await self.match_artist(db_item) - # return final db_item after all match/metadata actions - return await self.get_db_item(db_item.item_id) + await self.match_artist(library_item) + library_item = await self.get_library_item(library_item.item_id) + self.mass.signal_event( + EventType.MEDIA_ITEM_ADDED, + library_item.uri, + library_item, + ) + # return final library_item after all match/metadata actions + return library_item - async def update(self, item_id: str | int, update: Artist, overwrite: bool = False) -> Artist: + async def update_item_in_library( + self, item_id: str | int, update: Artist, overwrite: bool = False + ) -> Artist: """Update existing record in the database.""" - return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite) + db_id = int(item_id) # ensure integer + cur_item = await self.get_library_item(db_id) + metadata = cur_item.metadata.update(getattr(update, "metadata", None), overwrite) + provider_mappings = self._get_provider_mappings(cur_item, update, overwrite) - async def album_artists( + # enforce various artists name + id + mbid = cur_item.mbid + if (not mbid or overwrite) and getattr(update, "mbid", None): + if compare_strings(update.name, VARIOUS_ARTISTS_NAME): + update.mbid = VARIOUS_ARTISTS_ID_MBID + if update.mbid == VARIOUS_ARTISTS_ID_MBID: + update.name = VARIOUS_ARTISTS_NAME + await self.mass.music.database.update( + self.db_table, + {"item_id": db_id}, + { + "name": update.name if overwrite else cur_item.name, + "sort_name": update.sort_name if overwrite else cur_item.sort_name, + "mbid": mbid, + "metadata": serialize_to_json(metadata), + "provider_mappings": serialize_to_json(provider_mappings), + "timestamp_modified": int(utc_timestamp()), + }, + ) + # update/set provider_mappings table + await self._set_provider_mappings(db_id, provider_mappings) + self.logger.debug("updated %s in database: %s", update.name, db_id) + # get full created object + library_item = await self.get_library_item(db_id) + self.mass.signal_event( + EventType.MEDIA_ITEM_UPDATED, + library_item.uri, + library_item, + ) + # return the full item we just updated + return library_item + + async def library_items( self, - in_library: bool | None = None, + favorite: bool | None = None, search: str | None = None, limit: int = 500, offset: int = 0, order_by: str = "sort_name", + album_artists_only: bool = False, ) -> PagedItems: """Get in-database album artists.""" - return await self.db_items( - in_library=in_library, + return await super().library_items( + favorite=favorite, search=search, limit=limit, offset=offset, order_by=order_by, - query_parts=["artists.sort_name in (select albums.sort_artist from albums)"], + query_parts=["artists.sort_name in (select albums.sort_artist from albums)"] + if album_artists_only + else None, ) async def tracks( self, - item_id: str | None = None, - provider_instance_id_or_domain: str | None = None, - artist: Artist | None = None, + item_id: str, + provider_instance_id_or_domain: str, ) -> list[Track]: - """Return top tracks for an artist.""" - if not artist: - artist = await self.get(item_id, provider_instance_id_or_domain, add_to_db=False) - # get results from all providers - coros = [ - self.get_provider_artist_toptracks( - prov_mapping.item_id, - prov_mapping.provider_instance, - cache_checksum=artist.metadata.checksum, + """Return all/top tracks for an artist.""" + if provider_instance_id_or_domain == "library": + return await self.get_library_artist_tracks( + item_id, ) - for prov_mapping in artist.provider_mappings - ] - tracks = itertools.chain.from_iterable(await asyncio.gather(*coros)) - # merge duplicates using a dict - final_items: dict[str, Track] = {} - for track in tracks: - key = f".{track.name}.{track.version}" - if key in final_items: - final_items[key].provider_mappings.update(track.provider_mappings) - else: - final_items[key] = track - return list(final_items.values()) + return await self.get_provider_artist_toptracks( + item_id, + provider_instance_id_or_domain, + ) async def albums( self, - item_id: str | None = None, - provider_instance_id_or_domain: str | None = None, - artist: Artist | None = None, + item_id: str, + provider_instance_id_or_domain: str, ) -> list[Album]: """Return (all/most popular) albums for an artist.""" - if not artist: - artist = await self.get(item_id, provider_instance_id_or_domain, add_to_db=False) - # get results from all providers - coros = [ - self.get_provider_artist_albums( - item.item_id, - item.provider_domain, - cache_checksum=artist.metadata.checksum, + if provider_instance_id_or_domain == "library": + return await self.get_library_artist_albums( + item_id, ) - for item in artist.provider_mappings - ] - albums: list[Album] = itertools.chain.from_iterable(await asyncio.gather(*coros)) - # merge duplicates using a dict - final_items: dict[str, Album] = {} - for album in albums: - key = f".{album.name}.{album.version}.{album.metadata.explicit}" - if key in final_items: - final_items[key].provider_mappings.update(album.provider_mappings) - else: - final_items[key] = album - if album.in_library: - final_items[key].in_library = True - return list(final_items.values()) + return await self.get_provider_artist_albums( + item_id, + provider_instance_id_or_domain, + ) - async def delete(self, item_id: str | int, recursive: bool = False) -> None: + async def remove_item_from_library(self, item_id: str | int) -> None: """Delete record from the database.""" db_id = int(item_id) # ensure integer - # check artist albums - db_rows = await self.mass.music.database.get_rows_from_query( + # recursively also remove artist albums + for db_row in await self.mass.music.database.get_rows_from_query( f"SELECT item_id FROM {DB_TABLE_ALBUMS} WHERE artists LIKE '%\"{db_id}\"%'", limit=5000, - ) - assert not (db_rows and not recursive), "Albums attached to artist" - for db_row in db_rows: + ): with contextlib.suppress(MediaNotFoundError): - await self.mass.music.albums.delete(db_row["item_id"], recursive) + await self.mass.music.albums.remove_item_from_library(db_row["item_id"]) - # check artist tracks - db_rows = await self.mass.music.database.get_rows_from_query( + # recursively also remove artist tracks + for db_row in await self.mass.music.database.get_rows_from_query( f"SELECT item_id FROM {DB_TABLE_TRACKS} WHERE artists LIKE '%\"{db_id}\"%'", limit=5000, - ) - assert not (db_rows and not recursive), "Tracks attached to artist" - for db_row in db_rows: + ): with contextlib.suppress(MediaNotFoundError): - await self.mass.music.albums.delete(db_row["item_id"], recursive) + await self.mass.music.tracks.remove_item_from_library(db_row["item_id"]) # delete the artist itself from db - await super().delete(db_id) + await super().remove_item_from_library(db_id) async def match_artist(self, db_artist: Artist): """Try to find matching artists on all providers for the provided (database) item_id. This is used to link objects of different providers together. """ - assert db_artist.provider == "database", "Matching only supported for database items!" + assert db_artist.provider == "library", "Matching only supported for database items!" cur_provider_domains = {x.provider_domain for x in db_artist.provider_mappings} for provider in self.mass.music.providers: if provider.domain in cur_provider_domains: continue if ProviderFeature.SEARCH not in provider.supported_features: continue - if provider.is_unique: - # matching on unique provider sis pointless as they push (all) their content to MA + if not provider.library_supported(MediaType.ARTIST): + continue + if not provider.is_streaming_provider: + # matching on unique providers is pointless as they push (all) their content to MA continue if await self._match(db_artist, provider): cur_provider_domains.add(provider.domain) @@ -207,11 +223,12 @@ class ArtistsController(MediaControllerBase[Artist]): async def get_provider_artist_toptracks( self, item_id: str, - provider_instance_id_or_domain: str | None = None, - cache_checksum: Any = None, + provider_instance_id_or_domain: str, ) -> list[Track]: """Return top tracks for an artist on given provider.""" - assert provider_instance_id_or_domain != "database" + assert provider_instance_id_or_domain != "library" + artist = await self.get(item_id, provider_instance_id_or_domain, add_to_library=False) + cache_checksum = artist.metadata.checksum prov = self.mass.get_provider(provider_instance_id_or_domain) if prov is None: return [] @@ -225,28 +242,38 @@ class ArtistsController(MediaControllerBase[Artist]): else: # fallback implementation using the db items = [] - if db_artist := await self.mass.music.artists.get_db_item_by_prov_id( + if db_artist := await self.mass.music.artists.get_library_item_by_prov_id( item_id, provider_instance_id_or_domain, ): # TODO: adjust to json query instead of text search? query = f"SELECT * FROM tracks WHERE artists LIKE '%\"{db_artist.item_id}\"%'" query += f" AND provider_mappings LIKE '%\"{provider_instance_id_or_domain}\"%'" - items = await self.mass.music.tracks.get_db_items_by_query(query) + items = await self.mass.music.tracks.get_library_items_by_query(query) # store (serializable items) in cache self.mass.create_task( self.mass.cache.set(cache_key, [x.to_dict() for x in items], checksum=cache_checksum) ) return items + async def get_library_artist_tracks( + self, + item_id: str | int, + ) -> list[Track]: + """Return all tracks for an artist in the library.""" + # TODO: adjust to json query instead of text search? + query = f"SELECT * FROM tracks WHERE artists LIKE '%\"{item_id}\"%'" + return await self.mass.music.tracks.get_library_items_by_query(query) + async def get_provider_artist_albums( self, item_id: str, - provider_instance_id_or_domain: str | None = None, - cache_checksum: Any = None, + provider_instance_id_or_domain: str, ) -> list[Album]: """Return albums for an artist on given provider.""" - assert provider_instance_id_or_domain != "database" + assert provider_instance_id_or_domain != "library" + artist = await self.get_provider_item(item_id, provider_instance_id_or_domain) + cache_checksum = artist.metadata.checksum prov = self.mass.get_provider(provider_instance_id_or_domain) if prov is None: return [] @@ -259,14 +286,15 @@ class ArtistsController(MediaControllerBase[Artist]): items = await prov.get_artist_albums(item_id) else: # fallback implementation using the db - if db_artist := await self.mass.music.artists.get_db_item_by_prov_id( # noqa: PLR5501 + # ruff: noqa: PLR5501 + if db_artist := await self.mass.music.artists.get_library_item_by_prov_id( item_id, provider_instance_id_or_domain, ): # TODO: adjust to json query instead of text search? query = f"SELECT * FROM albums WHERE artists LIKE '%\"{db_artist.item_id}\"%'" query += f" AND provider_mappings LIKE '%\"{provider_instance_id_or_domain}\"%'" - items = await self.mass.music.albums.get_db_items_by_query(query) + items = await self.mass.music.albums.get_library_items_by_query(query) else: # edge case items = [] @@ -276,28 +304,39 @@ class ArtistsController(MediaControllerBase[Artist]): ) return items - async def _add_db_item(self, item: Artist | ItemMapping) -> Artist: + async def get_library_artist_albums( + self, + item_id: str | int, + ) -> list[Album]: + """Return all in-library albums for an artist.""" + # TODO: adjust to json query instead of text search? + query = f"SELECT * FROM albums WHERE artists LIKE '%\"{item_id}\"%'" + return await self.mass.music.albums.get_library_items_by_query(query) + + async def _add_library_item(self, item: Artist | ItemMapping) -> Artist: """Add a new item record to the database.""" # enforce various artists name + id if not isinstance(item, ItemMapping): - if compare_strings(item.name, VARIOUS_ARTISTS): - item.musicbrainz_id = VARIOUS_ARTISTS_ID - if item.musicbrainz_id == VARIOUS_ARTISTS_ID: - item.name = VARIOUS_ARTISTS + if compare_strings(item.name, VARIOUS_ARTISTS_NAME): + item.mbid = VARIOUS_ARTISTS_ID_MBID + if item.mbid == VARIOUS_ARTISTS_ID_MBID: + item.name = VARIOUS_ARTISTS_NAME # safety guard: check for existing item first if isinstance(item, ItemMapping) and ( - cur_item := await self.get_db_item_by_prov_id(item.item_id, item.provider) + cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider) ): # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) - if cur_item := await self.get_db_item_by_prov_mappings(item.provider_mappings): - return await self._update_db_item(cur_item.item_id, item) - if musicbrainz_id := getattr(item, "musicbrainz_id", None): - match = {"musicbrainz_id": musicbrainz_id} + return await self.update_item_in_library(cur_item.item_id, item) + if not isinstance(item, ItemMapping) and ( + cur_item := await self.get_library_item_by_prov_mappings(item.provider_mappings) + ): + return await self.update_item_in_library(cur_item.item_id, item) + if mbid := getattr(item, "mbid", None): + match = {"mbid": mbid} if db_row := await self.mass.music.database.get_row(self.db_table, match): # existing item found: update it cur_item = Artist.from_db_row(db_row) - return await self._update_db_item(cur_item.item_id, item) + return await self.update_item_in_library(cur_item.item_id, item) # fallback to exact name match # NOTE: we match an artist by name which could theoretically lead to collisions # but the chance is so small it is not worth the additional overhead of grabbing @@ -308,7 +347,7 @@ class ArtistsController(MediaControllerBase[Artist]): if row_artist.sort_name == item.sort_name: cur_item = row_artist # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) + return await self.update_item_in_library(cur_item.item_id, item) # no existing item matched: insert item item.timestamp_added = int(utc_timestamp()) @@ -322,60 +361,8 @@ class ArtistsController(MediaControllerBase[Artist]): # update/set provider_mappings table await self._set_provider_mappings(db_id, item.provider_mappings) self.logger.debug("added %s to database", item.name) - # get full created object - db_item = await self.get_db_item(db_id) - # only signal event if we're not running a sync (to prevent a floodstorm of events) - if not self.mass.music.get_running_sync_tasks(): - self.mass.signal_event( - EventType.MEDIA_ITEM_ADDED, - db_item.uri, - db_item, - ) # return the full item we just added - return db_item - - async def _update_db_item( - self, item_id: str | int, item: Artist | ItemMapping, overwrite: bool = False - ) -> Artist: - """Update Artist record in the database.""" - db_id = int(item_id) # ensure integer - cur_item = await self.get_db_item(db_id) - metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite) - provider_mappings = self._get_provider_mappings(cur_item, item, overwrite) - - # enforce various artists name + id - musicbrainz_id = cur_item.musicbrainz_id - if (not musicbrainz_id or overwrite) and getattr(item, "musicbrainz_id", None): - if compare_strings(item.name, VARIOUS_ARTISTS): - item.musicbrainz_id = VARIOUS_ARTISTS_ID - if item.musicbrainz_id == VARIOUS_ARTISTS_ID: - item.name = VARIOUS_ARTISTS - await self.mass.music.database.update( - self.db_table, - {"item_id": db_id}, - { - "name": item.name if overwrite else cur_item.name, - "sort_name": item.sort_name if overwrite else cur_item.sort_name, - "musicbrainz_id": musicbrainz_id, - "metadata": serialize_to_json(metadata), - "provider_mappings": serialize_to_json(provider_mappings), - "timestamp_modified": int(utc_timestamp()), - }, - ) - # update/set provider_mappings table - await self._set_provider_mappings(db_id, provider_mappings) - self.logger.debug("updated %s in database: %s", item.name, db_id) - # get full created object - db_item = await self.get_db_item(db_id) - # only signal event if we're not running a sync (to prevent a floodstorm of events) - if not self.mass.music.get_running_sync_tasks(): - self.mass.signal_event( - EventType.MEDIA_ITEM_UPDATED, - db_item.uri, - db_item, - ) - # return the full item we just updated - return db_item + return await self.get_library_item(db_id) async def _get_provider_dynamic_tracks( self, @@ -384,7 +371,7 @@ class ArtistsController(MediaControllerBase[Artist]): limit: int = 25, ): """Generate a dynamic list of tracks based on the artist's top tracks.""" - assert provider_instance_id_or_domain != "database" + assert provider_instance_id_or_domain != "library" prov = self.mass.get_provider(provider_instance_id_or_domain) if prov is None: return [] @@ -424,15 +411,26 @@ class ArtistsController(MediaControllerBase[Artist]): """Try to find matching artists on given provider for the provided (database) artist.""" self.logger.debug("Trying to match artist %s on provider %s", db_artist.name, provider.name) # try to get a match with some reference tracks of this artist - for ref_track in await self.tracks(db_artist.item_id, db_artist.provider, artist=db_artist): + ref_tracks = await self.mass.music.artists.tracks(db_artist.item_id, db_artist.provider) + if len(ref_tracks) < 10: + # fetch reference tracks from provider(s) attached to the artist + for provider_mapping in db_artist.provider_mappings: + ref_tracks += await self.mass.music.artists.tracks( + provider_mapping.item_id, provider_mapping.provider_instance + ) + for ref_track in ref_tracks: # make sure we have a full track if isinstance(ref_track.album, ItemMapping): - ref_track = await self.mass.music.tracks.get( # noqa: PLW2901 - ref_track.item_id, ref_track.provider, add_to_db=False - ) + try: + ref_track = await self.mass.music.tracks.get_provider_item( # noqa: PLW2901 + ref_track.item_id, ref_track.provider + ) + except MediaNotFoundError: + continue for search_str in ( f"{db_artist.name} - {ref_track.name}", f"{db_artist.name} {ref_track.name}", + f"{db_artist.sort_name} {ref_track.sort_name}", ref_track.name, ): search_results = await self.mass.music.tracks.search(search_str, provider.domain) @@ -450,11 +448,19 @@ class ArtistsController(MediaControllerBase[Artist]): search_item_artist.provider, fallback=search_result_item, ) - await self._update_db_item(db_artist.item_id, prov_artist) + # 100% match, we update the db with the additional provider mapping(s) + for provider_mapping in search_result_item.provider_mappings: + await self.add_provider_mapping(db_artist.item_id, provider_mapping) return True # try to get a match with some reference albums of this artist - artist_albums = await self.albums(db_artist.item_id, db_artist.provider, artist=db_artist) - for ref_album in artist_albums: + ref_albums = await self.mass.music.artists.albums(db_artist.item_id, db_artist.provider) + if len(ref_albums) < 10: + # fetch reference albums from provider(s) attached to the artist + for provider_mapping in db_artist.provider_mappings: + ref_albums += await self.mass.music.artists.albums( + provider_mapping.item_id, provider_mapping.provider_instance + ) + for ref_album in ref_albums: if ref_album.album_type == AlbumType.COMPILATION: continue if not ref_album.artists: @@ -463,6 +469,7 @@ class ArtistsController(MediaControllerBase[Artist]): ref_album.name, f"{db_artist.name} - {ref_album.name}", f"{db_artist.name} {ref_album.name}", + f"{db_artist.sort_name} {ref_album.sort_name}", ): search_result = await self.mass.music.albums.search(search_str, provider.domain) for search_result_item in search_result: @@ -480,6 +487,6 @@ class ArtistsController(MediaControllerBase[Artist]): search_result_item.artists[0].provider, fallback=search_result_item, ) - await self._update_db_item(db_artist.item_id, prov_artist) + await self.update_item_in_library(db_artist.item_id, prov_artist) return True return False diff --git a/music_assistant/server/controllers/media/base.py b/music_assistant/server/controllers/media/base.py index e29bb3d0..216a344c 100644 --- a/music_assistant/server/controllers/media/base.py +++ b/music_assistant/server/controllers/media/base.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging from abc import ABCMeta, abstractmethod -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Iterable from contextlib import suppress from time import time from typing import TYPE_CHECKING, Generic, TypeVar @@ -18,7 +18,7 @@ from music_assistant.common.models.media_items import ( ProviderMapping, media_from_dict, ) -from music_assistant.constants import DB_TABLE_PROVIDER_MAPPINGS, ROOT_LOGGER_NAME, VARIOUS_ARTISTS +from music_assistant.constants import DB_TABLE_PROVIDER_MAPPINGS, ROOT_LOGGER_NAME if TYPE_CHECKING: from music_assistant.common.models.media_items import Album, Artist, Track @@ -42,19 +42,23 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): self.logger = logging.getLogger(f"{ROOT_LOGGER_NAME}.music.{self.media_type.value}") @abstractmethod - async def add(self, item: ItemCls, skip_metadata_lookup: bool = False) -> ItemCls: - """Add item to local db and return the database item.""" + async def add_item_to_library( + self, item: ItemCls, skip_metadata_lookup: bool = False + ) -> ItemCls: + """Add item to library and return the database item.""" raise NotImplementedError @abstractmethod - async def update(self, item_id: str | int, update: ItemCls, overwrite: bool = False) -> ItemCls: - """Update existing record in the database.""" + async def update_item_in_library( + self, item_id: str | int, update: ItemCls, overwrite: bool = False + ) -> ItemCls: + """Update existing library record in the database.""" - async def delete(self, item_id: str | int, recursive: bool = False) -> None: # noqa: ARG002 - """Delete record from the database.""" + async def remove_item_from_library(self, item_id: str | int) -> None: + """Delete library record from the database.""" db_id = int(item_id) # ensure integer - db_item = await self.get_db_item(db_id) - assert db_item, f"Item does not exist: {db_id}" + library_item = await self.get_library_item(db_id) + assert library_item, f"Item does not exist: {db_id}" # delete item await self.mass.music.database.delete( self.db_table, @@ -67,12 +71,12 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): ) # NOTE: this does not delete any references to this item in other records, # this is handled/overridden in the mediatype specific controllers - self.mass.signal_event(EventType.MEDIA_ITEM_DELETED, db_item.uri, db_item) + self.mass.signal_event(EventType.MEDIA_ITEM_DELETED, library_item.uri, library_item) self.logger.debug("deleted item with id %s from database", db_id) - async def db_items( + async def library_items( self, - in_library: bool | None = None, + favorite: bool | None = None, search: str | None = None, limit: int = 500, offset: int = 0, @@ -89,13 +93,13 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): query_parts.append("(name LIKE :search or artists LIKE :search)") else: query_parts.append("name LIKE :search") - if in_library is not None: - query_parts.append("in_library = :in_library") - params["in_library"] = in_library + if favorite is not None: + query_parts.append("favorite = :favorite") + params["favorite"] = favorite if query_parts: sql_query += " WHERE " + " AND ".join(query_parts) sql_query += f" ORDER BY {order_by}" - items = await self.get_db_items_by_query(sql_query, params, limit=limit, offset=offset) + items = await self.get_library_items_by_query(sql_query, params, limit=limit, offset=offset) count = len(items) if 0 < count < limit: total = offset + count @@ -103,9 +107,9 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): total = await self.mass.music.database.get_count_from_query(sql_query, params) return PagedItems(items, count, limit, offset, total) - async def iter_db_items( + async def iter_library_items( self, - in_library: bool | None = None, + favorite: bool | None = None, search: str | None = None, order_by: str = "sort_name", ) -> AsyncGenerator[ItemCls, None]: @@ -113,8 +117,8 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): limit: int = 500 offset: int = 0 while True: - next_items = await self.db_items( - in_library=in_library, + next_items = await self.library_items( + favorite=favorite, search=search, limit=limit, offset=offset, @@ -133,33 +137,34 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): force_refresh: bool = False, lazy: bool = True, details: ItemCls = None, - add_to_db: bool = True, + add_to_library: bool = False, ) -> ItemCls: """Return (full) details for a single media item.""" - if not add_to_db and provider_instance_id_or_domain == "database": - return await self.get_db_item(item_id) - if details and not add_to_db and details.provider == "database": - return details - db_item = await self.get_db_item_by_prov_id( + if provider_instance_id_or_domain == "database": + # backwards compatibility - to remove when 2.0 stable is released + provider_instance_id_or_domain = "library" + # always prefer the full library item if we have it + library_item = await self.get_library_item_by_prov_id( item_id, provider_instance_id_or_domain, ) - if db_item and (time() - (db_item.metadata.last_refresh or 0)) > REFRESH_INTERVAL: + if library_item and (time() - (library_item.metadata.last_refresh or 0)) > REFRESH_INTERVAL: # it's been too long since the full metadata was last retrieved (or never at all) force_refresh = True - if db_item and force_refresh and add_to_db: - # get (first) provider item id belonging to this db item - provider_instance_id_or_domain, item_id = await self.get_provider_mapping(db_item) - elif db_item: - # we have a db item and no refreshing is needed, return the results! - return db_item + add_to_library = True + if library_item and force_refresh: + # get (first) provider item id belonging to this library item + provider_instance_id_or_domain, item_id = await self.get_provider_mapping(library_item) + elif library_item: + # we have a library item and no refreshing is needed, return the results! + return library_item if ( provider_instance_id_or_domain and item_id and ( not details or isinstance(details, ItemMapping) - or (add_to_db and details.provider == "database") + or (add_to_library and details.provider == "library") ) ): # grab full details from the provider @@ -172,19 +177,21 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): if not details: # we couldn't get a match from any of the providers, raise error raise MediaNotFoundError(f"Item not found: {provider_instance_id_or_domain}/{item_id}") - if not add_to_db: + if not add_to_library: + # return the provider item as-is return details - # create task to add the item to the db, including matching metadata etc. takes some time + # create task to add the item to the library, + # including matching metadata etc. takes some time # in 99% of the cases we just return lazy because we want the details as fast as possible - # only if we really need to wait for the result (e.g. to prevent race conditions), we - # can set lazy to false and we await the job to complete. + # only if we really need to wait for the result (e.g. to prevent race conditions), + # we can set lazy to false and we await the job to complete. task_id = f"add_{self.media_type.value}.{details.provider}.{details.item_id}" - add_task = self.mass.create_task(self.add, details, task_id=task_id) + add_task = self.mass.create_task(self.add_item_to_library, item=details, task_id=task_id) if not lazy: await add_task return add_task.result() - return details + return library_item or details async def search( self, @@ -195,7 +202,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): """Search database or provider with given query.""" # create safe search string search_query = search_query.replace("/", " ").replace("'", "") - if provider_instance_id_or_domain == "database": + if provider_instance_id_or_domain == "library": return [ self.item_cls.from_db_row(db_row) for db_row in await self.mass.music.database.search(self.db_table, search_query) @@ -236,69 +243,23 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): ) return items - async def add_to_library( - self, - item_id: str, - provider_instance_id_or_domain: str, - ) -> None: - """Add an item to the library.""" - prov_item = await self.get_db_item_by_prov_id( - item_id, - provider_instance_id_or_domain, - ) - if prov_item is None: - prov_item = await self.get_provider_item(item_id, provider_instance_id_or_domain) - if prov_item.in_library is True: - return - # mark as favorite/library item on provider(s) - for prov_mapping in prov_item.provider_mappings: - if prov := self.mass.get_provider(prov_mapping.provider_instance): - if not prov.library_edit_supported(self.media_type): - continue - await prov.library_add(prov_mapping.item_id, self.media_type) - # mark as library item in internal db if db item - if prov_item.provider == "database" and not prov_item.in_library: - prov_item.in_library = True - await self.set_db_library(prov_item.item_id, True) - - async def remove_from_library(self, item_id: str, provider_instance_id_or_domain: str) -> None: - """Remove item from the library.""" - prov_item = await self.get_db_item_by_prov_id( - item_id, - provider_instance_id_or_domain, - ) - if prov_item is None: - prov_item = await self.get_provider_item(item_id, provider_instance_id_or_domain) - if prov_item.in_library is False: - return - # unmark as favorite/library item on provider(s) - for prov_mapping in prov_item.provider_mappings: - if prov := self.mass.get_provider(prov_mapping.provider_instance): - if not prov.library_edit_supported(self.media_type): - continue - await prov.library_remove(prov_mapping.item_id, self.media_type) - # unmark as library item in internal db if db item - if prov_item.provider == "database": - prov_item.in_library = False - await self.set_db_library(prov_item.item_id, False) - async def get_provider_mapping(self, item: ItemCls) -> tuple[str, str]: """Return (first) provider and item id.""" if not getattr(item, "provider_mappings", None): # make sure we have a full object - item = await self.get_db_item(item.item_id) + item = await self.get_library_item(item.item_id) for prefer_unique in (True, False): for prov_mapping in item.provider_mappings: # returns the first provider that is available if not prov_mapping.available: continue if provider := self.mass.get_provider(prov_mapping.provider_instance): - if prefer_unique and not provider.is_unique: + if prefer_unique and provider.is_streaming_provider: continue return (prov_mapping.provider_instance, prov_mapping.item_id) return (None, None) - async def get_db_items_by_query( + async def get_library_items_by_query( self, custom_query: str | None = None, query_params: dict | None = None, @@ -313,62 +274,62 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): ) ] - async def get_db_item(self, item_id: int | str) -> ItemCls: + async def get_library_item(self, item_id: int | str) -> ItemCls: """Get record by id.""" db_id = int(item_id) # ensure integer match = {"item_id": db_id} if db_row := await self.mass.music.database.get_row(self.db_table, match): return self.item_cls.from_db_row(db_row) - raise MediaNotFoundError(f"Album not found in database: {db_id}") + raise MediaNotFoundError(f"{self.media_type.value} not found in library: {db_id}") - async def get_db_item_by_prov_id( + async def get_library_item_by_prov_id( self, item_id: str, provider_instance_id_or_domain: str, ) -> ItemCls | None: - """Get the database item for the given provider_instance.""" + """Get the library item for the given provider_instance.""" assert item_id assert provider_instance_id_or_domain - if provider_instance_id_or_domain == "database": - return await self.get_db_item(item_id) - for item in await self.get_db_items_by_prov_id( + if provider_instance_id_or_domain == "library": + return await self.get_library_item(item_id) + for item in await self.get_library_items_by_prov_id( provider_instance_id_or_domain, provider_item_ids=(item_id,), ): return item return None - async def get_db_item_by_prov_mappings( + async def get_library_item_by_prov_mappings( self, provider_mappings: list[ProviderMapping], ) -> ItemCls | None: - """Get the database item for the given provider_instance.""" + """Get the library item for the given provider_instance.""" # always prefer provider instance first for mapping in provider_mappings: - for item in await self.get_db_items_by_prov_id( + for item in await self.get_library_items_by_prov_id( mapping.provider_instance, provider_item_ids=(mapping.item_id,), ): return item # check by domain too for mapping in provider_mappings: - for item in await self.get_db_items_by_prov_id( + for item in await self.get_library_items_by_prov_id( mapping.provider_domain, provider_item_ids=(mapping.item_id,), ): return item return None - async def get_db_items_by_prov_id( + async def get_library_items_by_prov_id( self, provider_instance_id_or_domain: str, provider_item_ids: tuple[str, ...] | None = None, limit: int = 500, offset: int = 0, ) -> list[ItemCls]: - """Fetch all records from database for given provider.""" - if provider_instance_id_or_domain == "database": - return await self.get_db_items_by_query(limit=limit, offset=offset) + """Fetch all records from library for given provider.""" + if provider_instance_id_or_domain == "library": + return await self.get_library_items_by_query(limit=limit, offset=offset) # we use the separate provider_mappings table to perform quick lookups # from provider id's to database id's because this is faster @@ -382,9 +343,9 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): prov_ids = prov_ids.replace(",)", ")") subquery += f" AND provider_item_id in {prov_ids}" query = f"SELECT * FROM {self.db_table} WHERE item_id in ({subquery})" - return await self.get_db_items_by_query(query, limit=limit, offset=offset) + return await self.get_library_items_by_query(query, limit=limit, offset=offset) - async def iter_db_items_by_prov_id( + async def iter_library_items_by_prov_id( self, provider_instance_id_or_domain: str, provider_item_ids: tuple[str, ...] | None = None, @@ -395,7 +356,7 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): limit: int = 500 offset: int = 0 while True: - next_items = await self.get_db_items_by_prov_id( + next_items = await self.get_library_items_by_prov_id( provider_instance_id_or_domain, provider_item_ids=provider_item_ids, limit=limit, @@ -407,13 +368,16 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): break offset += limit - async def set_db_library(self, item_id: str | int, in_library: bool) -> None: - """Set the in-library bool on a database item.""" + async def set_favorite(self, item_id: str | int, favorite: bool) -> None: + """Set the favorite bool on a database item.""" db_id = int(item_id) # ensure integer + library_item = await self.get_library_item(db_id) + if library_item.favorite == favorite: + return match = {"item_id": db_id} - await self.mass.music.database.update(self.db_table, match, {"in_library": in_library}) - db_item = await self.get_db_item(db_id) - self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item) + await self.mass.music.database.update(self.db_table, match, {"favorite": favorite}) + library_item = await self.get_library_item(db_id) + self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, library_item.uri, library_item) async def get_provider_item( self, @@ -426,8 +390,8 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): cache_key = ( f"provider_item.{self.media_type.value}.{provider_instance_id_or_domain}.{item_id}" ) - if provider_instance_id_or_domain == "database": - return await self.get_db_item(item_id) + if provider_instance_id_or_domain == "library": + return await self.get_library_item(item_id) if not force_refresh and (cache := await self.mass.cache.get(cache_key)): return self.item_cls.from_dict(cache) if provider := self.mass.get_provider(provider_instance_id_or_domain): @@ -440,7 +404,9 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): # so we return the previous details (if we have any) marked as unavailable, so # at least we have the possibility to sort out the new id through matching logic. if not fallback: - fallback = await self.get_db_item_by_prov_id(item_id, provider_instance_id_or_domain) + fallback = await self.get_library_item_by_prov_id( + item_id, provider_instance_id_or_domain + ) if fallback: fallback_item = ItemMapping.from_item(fallback) fallback_item.available = False @@ -450,11 +416,36 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): f"found on provider {provider_instance_id_or_domain}" ) - async def remove_prov_mapping(self, item_id: str | int, provider_instance_id: str) -> None: - """Remove provider id(s) from item.""" + async def add_provider_mapping( + self, item_id: str | int, provider_mapping: ProviderMapping + ) -> None: + """Add provider mapping to existing library item.""" + db_id = int(item_id) # ensure integer + library_item = await self.get_library_item(db_id) + # ignore if the mapping is already present + if provider_mapping in library_item.provider_mappings: + return + # update item's db record + library_item.provider_mappings.add(provider_mapping) + await self.mass.music.database.update( + self.db_table, + {"item_id": db_id}, + { + "provider_mappings": serialize_to_json(library_item.provider_mappings), + }, + ) + # update provider_mappings table + await self._set_provider_mappings( + item_id=item_id, provider_mappings=library_item.provider_mappings + ) + + async def remove_provider_mapping( + self, item_id: str | int, provider_instance_id: str, provider_item_id: str + ) -> None: + """Remove provider mapping(s) from item.""" db_id = int(item_id) # ensure integer try: - db_item = await self.get_db_item(db_id) + library_item = await self.get_library_item(db_id) except MediaNotFoundError: # edge case: already deleted / race condition return @@ -466,26 +457,75 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): "media_type": self.media_type.value, "item_id": db_id, "provider_instance": provider_instance_id, + "provider_item_id": provider_item_id, }, ) # update the item in db (provider_mappings column only) - db_item.provider_mappings = { - x for x in db_item.provider_mappings if x.provider_instance != provider_instance_id + library_item.provider_mappings = { + x + for x in library_item.provider_mappings + if x.provider_instance != provider_instance_id and x.item_id != provider_item_id } match = {"item_id": db_id} - if db_item.provider_mappings: + if library_item.provider_mappings: await self.mass.music.database.update( self.db_table, match, - {"provider_mappings": serialize_to_json(db_item.provider_mappings)}, + {"provider_mappings": serialize_to_json(library_item.provider_mappings)}, + ) + self.logger.debug( + "removed provider_mapping %s/%s from item id %s", + provider_instance_id, + provider_item_id, + db_id, ) - self.logger.debug("removed provider %s from item id %s", provider_instance_id, db_id) - self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, db_item.uri, db_item) + self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, library_item.uri, library_item) else: - # delete item if it has no more providers + # remove item if it has no more providers with suppress(AssertionError): - await self.delete(db_id) + await self.remove_item_from_library(db_id) + + async def remove_provider_mappings(self, item_id: str | int, provider_instance_id: str) -> None: + """Remove all provider mappings from an item.""" + db_id = int(item_id) # ensure integer + try: + library_item = await self.get_library_item(db_id) + except MediaNotFoundError: + # edge case: already deleted / race condition + return + + # update provider_mappings table + await self.mass.music.database.delete( + DB_TABLE_PROVIDER_MAPPINGS, + { + "media_type": self.media_type.value, + "item_id": db_id, + "provider_instance": provider_instance_id, + }, + ) + + # update the item in db (provider_mappings column only) + library_item.provider_mappings = { + x for x in library_item.provider_mappings if x.provider_instance != provider_instance_id + } + match = {"item_id": db_id} + if library_item.provider_mappings: + await self.mass.music.database.update( + self.db_table, + match, + {"provider_mappings": serialize_to_json(library_item.provider_mappings)}, + ) + self.logger.debug( + "removed all provider mappings for provider %s from item id %s", + provider_instance_id, + db_id, + ) + self.mass.signal_event(EventType.MEDIA_ITEM_UPDATED, library_item.uri, library_item) + else: + # remove item if it has no more providers + with suppress(AssertionError): + await self.remove_item_from_library(db_id) async def dynamic_tracks( self, @@ -523,12 +563,12 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): """Get dynamic list of tracks for given item, fallback/default implementation.""" async def _set_provider_mappings( - self, item_id: str | int, provider_mappings: list[ProviderMapping] + self, item_id: str | int, provider_mappings: Iterable[ProviderMapping] ) -> None: """Update the provider_items table for the media item.""" db_id = int(item_id) # ensure integer # get current mappings (if any) - cur_mappings = set() + cur_mappings: set[ProviderMapping] = set() match = {"media_type": self.media_type.value, "item_id": db_id} for db_row in await self.mass.music.database.get_rows(DB_TABLE_PROVIDER_MAPPINGS, match): cur_mappings.add( @@ -582,40 +622,42 @@ class MediaControllerBase(Generic[ItemCls], metaclass=ABCMeta): overwrite: bool = False, ) -> list[ItemMapping]: """Extract (database) album/track artist(s) as ItemMapping.""" + artist_mappings: list[ItemMapping] = [] if update_item is None or isinstance(update_item, ItemMapping): source_artists = org_item.artists elif overwrite and update_item.artists: source_artists = update_item.artists else: source_artists = org_item.artists + update_item.artists - item_artists = {await self._get_artist_mapping(artist) for artist in source_artists} - # use intermediate set to prevent duplicates - # filter various artists if multiple artists - if len(item_artists) > 1: - item_artists = {x for x in item_artists if (x.name != VARIOUS_ARTISTS)} - return list(item_artists) + for artist in source_artists: + artist_mapping = await self._get_artist_mapping(artist) + if artist_mapping not in artist_mappings: + artist_mappings.append(artist_mapping) + return artist_mappings async def _get_artist_mapping(self, artist: Artist | ItemMapping) -> ItemMapping: """Extract (database) track artist as ItemMapping.""" - if artist.provider == "database": + if artist.provider == "library": if isinstance(artist, ItemMapping): return artist return ItemMapping.from_item(artist) - if db_artist := await self.mass.music.artists.get_db_item_by_prov_id( + if db_artist := await self.mass.music.artists.get_library_item_by_prov_id( artist.item_id, artist.provider ): return ItemMapping.from_item(db_artist) # try to request the full item with suppress(MediaNotFoundError, AssertionError, InvalidDataError): - db_artist = await self.mass.music.artists.add(artist, skip_metadata_lookup=True) + db_artist = await self.mass.music.artists.add_item_to_library( + artist, skip_metadata_lookup=True + ) return ItemMapping.from_item(db_artist) # fallback to just the provider item - album = await self.mass.music.albums.get_provider_item( + artist = await self.mass.music.artists.get_provider_item( artist.item_id, artist.provider, fallback=artist ) - if isinstance(album, ItemMapping): + if isinstance(artist, ItemMapping): # this can happen for unavailable items return artist - return ItemMapping.from_item(album) + return ItemMapping.from_item(artist) diff --git a/music_assistant/server/controllers/media/playlists.py b/music_assistant/server/controllers/media/playlists.py index 376f3d3d..5b741a58 100644 --- a/music_assistant/server/controllers/media/playlists.py +++ b/music_assistant/server/controllers/media/playlists.py @@ -28,41 +28,93 @@ class PlaylistController(MediaControllerBase[Playlist]): db_table = DB_TABLE_PLAYLISTS media_type = MediaType.PLAYLIST item_cls = Playlist - _db_add_lock = asyncio.Lock() def __init__(self, *args, **kwargs): """Initialize class.""" super().__init__(*args, **kwargs) + self._db_add_lock = asyncio.Lock() # register api handlers - self.mass.register_api_command("music/playlists", self.db_items) - self.mass.register_api_command("music/playlist", self.get) - self.mass.register_api_command("music/playlist/tracks", self.tracks) - self.mass.register_api_command("music/playlist/tracks/add", self.add_playlist_tracks) - self.mass.register_api_command("music/playlist/tracks/remove", self.remove_playlist_tracks) - self.mass.register_api_command("music/playlist/update", self._update_db_item) - self.mass.register_api_command("music/playlist/delete", self.delete) - self.mass.register_api_command("music/playlist/create", self.create) + self.mass.register_api_command("music/playlists/library_items", self.library_items) + self.mass.register_api_command( + "music/playlists/update_item_in_library", self.update_item_in_library + ) + self.mass.register_api_command( + "music/playlists/remove_item_from_library", self.remove_item_from_library + ) + self.mass.register_api_command("music/playlists/create_playlist", self.create_playlist) - async def add(self, item: Playlist, skip_metadata_lookup: bool = False) -> Playlist: - """Add playlist to local db and return the new database item.""" - if item.provider == "database": - db_item = await self._update_db_item(item.item_id, item) - else: - # use the lock to prevent a race condition of the same item being added twice - async with self._db_add_lock: - db_item = await self._add_db_item(item) + self.mass.register_api_command("music/playlists/get_playlist", self.get) + self.mass.register_api_command("music/playlists/playlist_tracks", self.tracks) + self.mass.register_api_command( + "music/playlists/add_playlist_tracks", self.add_playlist_tracks + ) + self.mass.register_api_command( + "music/playlists/remove_playlist_tracks", self.remove_playlist_tracks + ) + + async def add_item_to_library( + self, item: Playlist, skip_metadata_lookup: bool = False + ) -> Playlist: + """Add playlist to library and return the new database item.""" + if not isinstance(item, Playlist): + raise InvalidDataError( + "Not a valid Playlist object (ItemMapping can not be added to db)" + ) + if not item.provider_mappings: + raise InvalidDataError("Playlist is missing provider mapping(s)") + + # actually add (or update) the item in the library db + # use the lock to prevent a race condition of the same item being added twice + async with self._db_add_lock: + library_item = await self._add_library_item(item) # preload playlist tracks listing (do not load them in the db) async for _ in self.tracks(item.item_id, item.provider): pass # metadata lookup we need to do after adding it to the db if not skip_metadata_lookup: - await self.mass.metadata.get_playlist_metadata(db_item) - db_item = await self._update_db_item(db_item.item_id, db_item) - return db_item + await self.mass.metadata.get_playlist_metadata(library_item) + library_item = await self.update_item_in_library(library_item.item_id, library_item) + self.mass.signal_event( + EventType.MEDIA_ITEM_ADDED, + library_item.uri, + library_item, + ) + return library_item - async def update(self, item_id: int, update: Playlist, overwrite: bool = False) -> Playlist: + async def update_item_in_library( + self, item_id: int, update: Playlist, overwrite: bool = False + ) -> Playlist: """Update existing record in the database.""" - return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite) + db_id = int(item_id) # ensure integer + cur_item = await self.get_library_item(db_id) + metadata = cur_item.metadata.update(getattr(update, "metadata", None), overwrite) + provider_mappings = self._get_provider_mappings(cur_item, update, overwrite) + await self.mass.music.database.update( + self.db_table, + {"item_id": db_id}, + { + # always prefer name/owner from updated item here + "name": update.name or cur_item.name, + "sort_name": update.sort_name or cur_item.sort_name, + "owner": update.owner or cur_item.sort_name, + "is_editable": update.is_editable, + "metadata": serialize_to_json(metadata), + "provider_mappings": serialize_to_json(provider_mappings), + "timestamp_modified": int(utc_timestamp()), + }, + ) + # update/set provider_mappings table + await self._set_provider_mappings(db_id, provider_mappings) + self.logger.debug("updated %s in database: %s", update.name, db_id) + # get full created object + library_item = await self.get_library_item(db_id) + self.mass.signal_event( + EventType.MEDIA_ITEM_UPDATED, + library_item.uri, + library_item, + ) + # return the full item we just updated + return library_item async def tracks( self, @@ -79,7 +131,9 @@ class PlaylistController(MediaControllerBase[Playlist]): ): yield track - async def create(self, name: str, provider_instance_or_domain: str | None = None) -> Playlist: + async def create_playlist( + self, name: str, provider_instance_or_domain: str | None = None + ) -> Playlist: """Create new playlist.""" # if provider is omitted, just pick first provider if provider_instance_or_domain: @@ -97,15 +151,12 @@ class PlaylistController(MediaControllerBase[Playlist]): raise ProviderUnavailableError("No provider available which allows playlists creation.") # create playlist on the provider - prov_playlist = await provider.create_playlist(name) - prov_playlist.in_library = True - # return db playlist - return await self.add(prov_playlist, True) + return await provider.create_playlist(name) async def add_playlist_tracks(self, db_playlist_id: str | int, uris: list[str]) -> None: """Add multiple tracks to playlist. Creates background tasks to process the action.""" db_id = int(db_playlist_id) # ensure integer - playlist = await self.get_db_item(db_id) + playlist = await self.get_library_item(db_id) if not playlist: raise MediaNotFoundError(f"Playlist with id {db_id} not found") if not playlist.is_editable: @@ -117,13 +168,13 @@ class PlaylistController(MediaControllerBase[Playlist]): """Add track to playlist - make sure we dont add duplicates.""" db_id = int(db_playlist_id) # ensure integer # we can only edit playlists that are in the database (marked as editable) - playlist = await self.get_db_item(db_id) + playlist = await self.get_library_item(db_id) if not playlist: raise MediaNotFoundError(f"Playlist with id {db_id} not found") if not playlist.is_editable: raise InvalidDataError(f"Playlist {playlist.name} is not editable") # make sure we have recent full track details - track = await self.mass.music.get_item_by_uri(track_uri, lazy=False) + track = await self.mass.music.get_item_by_uri(track_uri) assert track.media_type == MediaType.TRACK # a playlist can only have one provider (for now) playlist_prov = next(iter(playlist.provider_mappings)) @@ -173,14 +224,14 @@ class PlaylistController(MediaControllerBase[Playlist]): provider = self.mass.get_provider(playlist_prov.provider_instance) await provider.add_playlist_tracks(playlist_prov.item_id, [track_id_to_add]) # invalidate cache by updating the checksum - await self.get(db_id, "database", force_refresh=True) + await self.get(db_id, "library", force_refresh=True) async def remove_playlist_tracks( self, db_playlist_id: str | int, positions_to_remove: tuple[int, ...] ) -> None: """Remove multiple tracks from playlist.""" db_id = int(db_playlist_id) # ensure integer - playlist = await self.get_db_item(db_id) + playlist = await self.get_library_item(db_id) if not playlist: raise MediaNotFoundError(f"Playlist with id {db_id} not found") if not playlist.is_editable: @@ -195,21 +246,20 @@ class PlaylistController(MediaControllerBase[Playlist]): continue await provider.remove_playlist_tracks(prov_mapping.item_id, positions_to_remove) # invalidate cache by updating the checksum - await self.get(db_id, "database", force_refresh=True) + await self.get(db_id, "library", force_refresh=True) - async def _add_db_item(self, item: Playlist) -> Playlist: + async def _add_library_item(self, item: Playlist) -> Playlist: """Add a new record to the database.""" - assert item.provider_mappings, "Item is missing provider mapping(s)" # safety guard: check for existing item first - if cur_item := await self.get_db_item_by_prov_mappings(item.provider_mappings): + if cur_item := await self.get_library_item_by_prov_mappings(item.provider_mappings): # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) + return await self.update_item_in_library(cur_item.item_id, item) # try name matching match = {"name": item.name, "owner": item.owner} if db_row := await self.mass.music.database.get_row(self.db_table, match): cur_item = Playlist.from_db_row(db_row) # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) + return await self.update_item_in_library(cur_item.item_id, item) # insert new item item.timestamp_added = int(utc_timestamp()) item.timestamp_modified = int(utc_timestamp()) @@ -218,54 +268,8 @@ class PlaylistController(MediaControllerBase[Playlist]): # update/set provider_mappings table await self._set_provider_mappings(db_id, item.provider_mappings) self.logger.debug("added %s to database", item.name) - # get full created object - db_item = await self.get_db_item(db_id) - # only signal event if we're not running a sync (to prevent a floodstorm of events) - if not self.mass.music.get_running_sync_tasks(): - self.mass.signal_event( - EventType.MEDIA_ITEM_ADDED, - db_item.uri, - db_item, - ) # return the full item we just added - return db_item - - async def _update_db_item( - self, item_id: str | int, item: Playlist, overwrite: bool = False - ) -> Playlist: - """Update Playlist record in the database.""" - db_id = int(item_id) # ensure integer - cur_item = await self.get_db_item(db_id) - metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite) - provider_mappings = self._get_provider_mappings(cur_item, item, overwrite) - await self.mass.music.database.update( - self.db_table, - {"item_id": db_id}, - { - # always prefer name/owner from updated item here - "name": item.name or cur_item.name, - "sort_name": item.sort_name or cur_item.sort_name, - "owner": item.owner or cur_item.sort_name, - "is_editable": item.is_editable, - "metadata": serialize_to_json(metadata), - "provider_mappings": serialize_to_json(provider_mappings), - "timestamp_modified": int(utc_timestamp()), - }, - ) - # update/set provider_mappings table - await self._set_provider_mappings(db_id, provider_mappings) - self.logger.debug("updated %s in database: %s", item.name, db_id) - # get full created object - db_item = await self.get_db_item(db_id) - # only signal event if we're not running a sync (to prevent a floodstorm of events) - if not self.mass.music.get_running_sync_tasks(): - self.mass.signal_event( - EventType.MEDIA_ITEM_UPDATED, - db_item.uri, - db_item, - ) - # return the full item we just updated - return db_item + return await self.get_library_item(db_id) async def _get_provider_playlist_tracks( self, @@ -274,7 +278,7 @@ class PlaylistController(MediaControllerBase[Playlist]): cache_checksum: Any = None, ) -> AsyncGenerator[Track, None]: """Return album tracks for the given provider album id.""" - assert provider_instance_id_or_domain != "database" + assert provider_instance_id_or_domain != "library" provider = self.mass.get_provider(provider_instance_id_or_domain) if not provider: return @@ -305,7 +309,7 @@ class PlaylistController(MediaControllerBase[Playlist]): limit: int = 25, ): """Generate a dynamic list of tracks based on the playlist content.""" - assert provider_instance_id_or_domain != "database" + assert provider_instance_id_or_domain != "library" provider = self.mass.get_provider(provider_instance_id_or_domain) if not provider or ProviderFeature.SIMILAR_TRACKS not in provider.supported_features: return [] diff --git a/music_assistant/server/controllers/media/radio.py b/music_assistant/server/controllers/media/radio.py index 8adc68b0..79c554c4 100644 --- a/music_assistant/server/controllers/media/radio.py +++ b/music_assistant/server/controllers/media/radio.py @@ -6,6 +6,7 @@ import asyncio from music_assistant.common.helpers.datetime import utc_timestamp from music_assistant.common.helpers.json import serialize_to_json from music_assistant.common.models.enums import EventType, MediaType +from music_assistant.common.models.errors import InvalidDataError from music_assistant.common.models.media_items import Radio, Track from music_assistant.constants import DB_TABLE_RADIOS from music_assistant.server.helpers.compare import loose_compare_strings @@ -19,17 +20,21 @@ class RadioController(MediaControllerBase[Radio]): db_table = DB_TABLE_RADIOS media_type = MediaType.RADIO item_cls = Radio - _db_add_lock = asyncio.Lock() def __init__(self, *args, **kwargs): """Initialize class.""" super().__init__(*args, **kwargs) + self._db_add_lock = asyncio.Lock() # register api handlers - self.mass.register_api_command("music/radios", self.db_items) - self.mass.register_api_command("music/radio", self.get) - self.mass.register_api_command("music/radio/versions", self.versions) - self.mass.register_api_command("music/radio/update", self._update_db_item) - self.mass.register_api_command("music/radio/delete", self.delete) + self.mass.register_api_command("music/radio/library_items", self.library_items) + self.mass.register_api_command("music/radio/get_radio", self.get) + self.mass.register_api_command( + "music/radio/update_item_in_library", self.update_item_in_library + ) + self.mass.register_api_command( + "music/radio/remove_item_from_library", self.remove_item_from_library + ) + self.mass.register_api_command("music/radio/radio_versions", self.versions) async def versions( self, @@ -57,36 +62,72 @@ class RadioController(MediaControllerBase[Radio]): # return the aggregated result return all_versions.values() - async def add(self, item: Radio, skip_metadata_lookup: bool = False) -> Radio: - """Add radio to local db and return the new database item.""" + async def add_item_to_library(self, item: Radio, skip_metadata_lookup: bool = False) -> Radio: + """Add radio to library and return the new database item.""" + if not isinstance(item, Radio): + raise InvalidDataError("Not a valid Radio object (ItemMapping can not be added to db)") + if not item.provider_mappings: + raise InvalidDataError("Radio is missing provider mapping(s)") if not skip_metadata_lookup: await self.mass.metadata.get_radio_metadata(item) - if item.provider == "database": - db_item = await self._update_db_item(item.item_id, item) - else: - # use the lock to prevent a race condition of the same item being added twice - async with self._db_add_lock: - db_item = await self._add_db_item(item) - return db_item + # actually add (or update) the item in the library db + # use the lock to prevent a race condition of the same item being added twice + async with self._db_add_lock: + library_item = await self._add_library_item(item) + self.mass.signal_event( + EventType.MEDIA_ITEM_ADDED, + library_item.uri, + library_item, + ) + return library_item - async def update(self, item_id: str | int, update: Radio, overwrite: bool = False) -> Radio: + async def update_item_in_library( + self, item_id: str | int, update: Radio, overwrite: bool = False + ) -> Radio: """Update existing record in the database.""" - return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite) + db_id = int(item_id) # ensure integer + cur_item = await self.get_library_item(db_id) + metadata = cur_item.metadata.update(getattr(update, "metadata", None), overwrite) + provider_mappings = self._get_provider_mappings(cur_item, update, overwrite) + match = {"item_id": db_id} + await self.mass.music.database.update( + self.db_table, + match, + { + # always prefer name from updated item here + "name": update.name or cur_item.name, + "sort_name": update.sort_name or cur_item.sort_name, + "metadata": serialize_to_json(metadata), + "provider_mappings": serialize_to_json(provider_mappings), + "timestamp_modified": int(utc_timestamp()), + }, + ) + # update/set provider_mappings table + await self._set_provider_mappings(db_id, provider_mappings) + self.logger.debug("updated %s in database: %s", update.name, db_id) + # get full created object + library_item = await self.get_library_item(db_id) + self.mass.signal_event( + EventType.MEDIA_ITEM_UPDATED, + library_item.uri, + library_item, + ) + # return the full item we just updated + return library_item - async def _add_db_item(self, item: Radio) -> Radio: + async def _add_library_item(self, item: Radio) -> Radio: """Add a new item record to the database.""" - assert item.provider_mappings, "Item is missing provider mapping(s)" cur_item = None # safety guard: check for existing item first - if cur_item := await self.get_db_item_by_prov_id(item.item_id, item.provider): + if cur_item := await self.get_library_item_by_prov_id(item.item_id, item.provider): # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) + return await self.update_item_in_library(cur_item.item_id, item) # try name matching match = {"name": item.name} if db_row := await self.mass.music.database.get_row(self.db_table, match): cur_item = Radio.from_db_row(db_row) # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) + return await self.update_item_in_library(cur_item.item_id, item) # insert new item item.timestamp_added = int(utc_timestamp()) item.timestamp_modified = int(utc_timestamp()) @@ -95,53 +136,8 @@ class RadioController(MediaControllerBase[Radio]): # update/set provider_mappings table await self._set_provider_mappings(db_id, item.provider_mappings) self.logger.debug("added %s to database", item.name) - # get full created object - db_item = await self.get_db_item(db_id) - # only signal event if we're not running a sync (to prevent a floodstorm of events) - if not self.mass.music.get_running_sync_tasks(): - self.mass.signal_event( - EventType.MEDIA_ITEM_ADDED, - db_item.uri, - db_item, - ) # return the full item we just added - return db_item - - async def _update_db_item( - self, item_id: str | int, item: Radio, overwrite: bool = False - ) -> Radio: - """Update Radio record in the database.""" - db_id = int(item_id) # ensure integer - cur_item = await self.get_db_item(db_id) - metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite) - provider_mappings = self._get_provider_mappings(cur_item, item, overwrite) - match = {"item_id": db_id} - await self.mass.music.database.update( - self.db_table, - match, - { - # always prefer name from updated item here - "name": item.name or cur_item.name, - "sort_name": item.sort_name or cur_item.sort_name, - "metadata": serialize_to_json(metadata), - "provider_mappings": serialize_to_json(provider_mappings), - "timestamp_modified": int(utc_timestamp()), - }, - ) - # update/set provider_mappings table - await self._set_provider_mappings(db_id, provider_mappings) - self.logger.debug("updated %s in database: %s", item.name, db_id) - # get full created object - db_item = await self.get_db_item(db_id) - # only signal event if we're not running a sync (to prevent a floodstorm of events) - if not self.mass.music.get_running_sync_tasks(): - self.mass.signal_event( - EventType.MEDIA_ITEM_UPDATED, - db_item.uri, - db_item, - ) - # return the full item we just updated - return db_item + return await self.get_library_item(db_id) async def _get_provider_dynamic_tracks( self, diff --git a/music_assistant/server/controllers/media/tracks.py b/music_assistant/server/controllers/media/tracks.py index 267d3afb..26f5ab54 100644 --- a/music_assistant/server/controllers/media/tracks.py +++ b/music_assistant/server/controllers/media/tracks.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio import urllib.parse -from contextlib import suppress from music_assistant.common.helpers.datetime import utc_timestamp from music_assistant.common.helpers.json import serialize_to_json @@ -13,14 +12,8 @@ from music_assistant.common.models.errors import ( MediaNotFoundError, UnsupportedFeaturedException, ) -from music_assistant.common.models.media_items import ( - Album, - DbTrack, - ItemMapping, - Track, - TrackAlbumMapping, -) -from music_assistant.constants import DB_TABLE_TRACKS +from music_assistant.common.models.media_items import Album, ItemMapping, Track +from music_assistant.constants import DB_TABLE_ALBUM_TRACKS, DB_TABLE_TRACKS from music_assistant.server.helpers.compare import ( compare_artists, compare_track, @@ -35,20 +28,24 @@ class TracksController(MediaControllerBase[Track]): db_table = DB_TABLE_TRACKS media_type = MediaType.TRACK - item_cls = DbTrack - _db_add_lock = asyncio.Lock() + item_cls = Track def __init__(self, *args, **kwargs): """Initialize class.""" super().__init__(*args, **kwargs) + self._db_add_lock = asyncio.Lock() # register api handlers - self.mass.register_api_command("music/tracks", self.db_items) - self.mass.register_api_command("music/track", self.get) - self.mass.register_api_command("music/track/versions", self.versions) - self.mass.register_api_command("music/track/albums", self.albums) - self.mass.register_api_command("music/track/update", self._update_db_item) - self.mass.register_api_command("music/track/delete", self.delete) - self.mass.register_api_command("music/track/preview", self.get_preview_url) + self.mass.register_api_command("music/tracks/library_items", self.library_items) + self.mass.register_api_command("music/tracks/get_track", self.get) + self.mass.register_api_command("music/tracks/track_versions", self.versions) + self.mass.register_api_command("music/tracks/track_albums", self.albums) + self.mass.register_api_command( + "music/tracks/update_item_in_library", self.update_item_in_library + ) + self.mass.register_api_command( + "music/tracks/remove_item_from_library", self.remove_item_from_library + ) + self.mass.register_api_command("music/tracks/preview", self.get_preview_url) async def get( self, @@ -58,7 +55,7 @@ class TracksController(MediaControllerBase[Track]): lazy: bool = True, details: Track = None, album_uri: str | None = None, - add_to_db: bool = True, + add_to_library: bool = False, ) -> Track: """Return (full) details for a single media item.""" track = await super().get( @@ -67,24 +64,34 @@ class TracksController(MediaControllerBase[Track]): force_refresh=force_refresh, lazy=lazy, details=details, - add_to_db=add_to_db, + add_to_library=add_to_library, ) # append full album details to full track item try: if album_uri and (album := await self.mass.music.get_item_by_uri(album_uri)): track.album = album - track.metadata.images = [album.image] + track.metadata.images elif track.album: track.album = await self.mass.music.albums.get( track.album.item_id, track.album.provider, - lazy=True, + lazy=lazy, details=None if isinstance(track.album, ItemMapping) else track.album, - add_to_db=add_to_db, + add_to_library=add_to_library, ) + elif provider_instance_id_or_domain == "library": + # grab the first album this track is attached to + for album_track_row in await self.mass.music.database.get_rows( + DB_TABLE_ALBUM_TRACKS, {"track_id": int(item_id)}, limit=1 + ): + track.album = await self.mass.music.albums.get_library_item( + album_track_row["album_id"] + ) except MediaNotFoundError: # edge case where playlist track has invalid albumdetails self.logger.warning("Unable to fetch album details %s", track.album.uri) + # prefer album image (otherwise it may look weird) + if track.album and track.album.image: + track.metadata.images = [track.album.image] + track.metadata.images # append full artist details to full track item full_artists = [] for artist in track.artists: @@ -92,20 +99,22 @@ class TracksController(MediaControllerBase[Track]): await self.mass.music.artists.get( artist.item_id, artist.provider, - lazy=True, + lazy=lazy, details=None if isinstance(artist, ItemMapping) else artist, - add_to_db=add_to_db, + add_to_library=add_to_library, ) ) track.artists = full_artists return track - async def add(self, item: Track, skip_metadata_lookup: bool = False) -> Track: - """Add track to local db and return the new database item.""" + async def add_item_to_library(self, item: Track, skip_metadata_lookup: bool = False) -> Track: + """Add track to library and return the new database item.""" if not isinstance(item, Track): raise InvalidDataError("Not a valid Track object (ItemMapping can not be added to db)") if not item.artists: raise InvalidDataError("Track is missing artist(s)") + if not item.provider_mappings: + raise InvalidDataError("Track is missing provider mapping(s)") # resolve any ItemMapping artists item.artists = [ await self.mass.music.artists.get_provider_item( @@ -132,49 +141,84 @@ class TracksController(MediaControllerBase[Track]): # grab additional metadata if not skip_metadata_lookup: await self.mass.metadata.get_track_metadata(item) - if item.provider == "database": - db_item = await self._update_db_item(item.item_id, item) - else: - # use the lock to prevent a race condition of the same item being added twice - async with self._db_add_lock: - db_item = await self._add_db_item(item) + # actually add (or update) the item in the library db + # use the lock to prevent a race condition of the same item being added twice + async with self._db_add_lock: + library_item = await self._add_library_item(item) # also fetch same track on all providers (will also get other quality versions) if not skip_metadata_lookup: - await self._match(db_item) - # return final db_item after all match/metadata actions - return await self.get_db_item(db_item.item_id) + await self._match(library_item) + library_item = await self.get_library_item(library_item.item_id) + self.mass.signal_event( + EventType.MEDIA_ITEM_ADDED, + library_item.uri, + library_item, + ) + # return final library_item after all match/metadata actions + return library_item - async def update(self, item_id: str | int, update: Track, overwrite: bool = False) -> Track: - """Update existing record in the database.""" - return await self._update_db_item(item_id=item_id, item=update, overwrite=overwrite) + async def update_item_in_library( + self, item_id: str | int, update: Track, overwrite: bool = False + ) -> Track: + """Update Track record in the database, merging data.""" + db_id = int(item_id) # ensure integer + cur_item = await self.get_library_item(db_id) + metadata = cur_item.metadata.update(getattr(update, "metadata", None), overwrite) + provider_mappings = self._get_provider_mappings(cur_item, update, overwrite) + track_artists = await self._get_artist_mappings(cur_item, update, overwrite=overwrite) + await self.mass.music.database.update( + self.db_table, + {"item_id": db_id}, + { + "name": update.name or cur_item.name, + "sort_name": update.sort_name or cur_item.sort_name, + "version": update.version or cur_item.version, + "duration": getattr(update, "duration", None) or cur_item.duration, + "artists": serialize_to_json(track_artists), + "metadata": serialize_to_json(metadata), + "provider_mappings": serialize_to_json(provider_mappings), + "timestamp_modified": int(utc_timestamp()), + }, + ) + # update/set provider_mappings table + await self._set_provider_mappings(db_id, provider_mappings) + # handle track album + if update.album: + await self._set_track_album( + db_id=db_id, + album=update.album, + disc_number=getattr(update, "disc_number", None) or 0, + track_number=getattr(update, "track_number", None) or 0, + ) + # get full created object + library_item = await self.get_library_item(db_id) + # only signal event if we're not running a sync (to prevent a floodstorm of events) + if not self.mass.music.get_running_sync_tasks(): + self.mass.signal_event( + EventType.MEDIA_ITEM_UPDATED, + library_item.uri, + library_item, + ) + self.logger.debug("updated %s in database: %s", update.name, db_id) + # return the full item we just updated + return library_item async def versions( self, item_id: str, provider_instance_id_or_domain: str, ) -> list[Track]: - """Return all versions of a track we can find on all providers.""" - track = await self.get(item_id, provider_instance_id_or_domain, add_to_db=False) - # perform a search on all provider(types) to collect all versions/variants + """Return all versions of a track we can find on the provider.""" + track = await self.get(item_id, provider_instance_id_or_domain, add_to_library=False) search_query = f"{track.artists[0].name} - {track.name}" - all_versions = { - prov_item.item_id: prov_item - for prov_items in await asyncio.gather( - *[ - self.search(search_query, provider_domain) - for provider_domain in self.mass.music.get_unique_providers() - ] - ) - for prov_item in prov_items + return [ + prov_item + for prov_item in await self.search(search_query, provider_instance_id_or_domain) if loose_compare_strings(track.name, prov_item.name) and compare_artists(prov_item.artists, track.artists, any_match=True) - } - # make sure that the 'base' version is NOT included - for prov_version in track.provider_mappings: - all_versions.pop(prov_version.item_id, None) - - # return the aggregated result - return all_versions.values() + # make sure that the 'base' version is NOT included + and prov_item.item_id != item_id + ] async def albums( self, @@ -182,13 +226,32 @@ class TracksController(MediaControllerBase[Track]): provider_instance_id_or_domain: str, ) -> list[Album]: """Return all albums the track appears on.""" - track = await self.get(item_id, provider_instance_id_or_domain, add_to_db=False) - return await asyncio.gather( - *[ - self.mass.music.albums.get(album.item_id, album.provider, add_to_db=False) - for album in track.albums + if provider_instance_id_or_domain == "library": + return [ + await self.mass.music.albums.get_library_item(album_track_row["album_id"]) + async for album_track_row in self.mass.music.database.iter_items( + DB_TABLE_ALBUM_TRACKS, {"track_id": int(item_id)} + ) ] - ) + # use search to get all items on the provider + # TODO: we could use musicbrainz info here to get a list of all releases known + track = await self.get(item_id, provider_instance_id_or_domain, add_to_library=False) + search_query = f"{track.artists[0].name} - {track.name}" + return [ + prov_item.album + for prov_item in await self.search(search_query, provider_instance_id_or_domain) + if loose_compare_strings(track.name, prov_item.name) + and prov_item.album + and compare_artists(prov_item.artists, track.artists, any_match=True) + ] + + async def remove_item_from_library(self, item_id: str | int) -> None: + """Delete record from the database.""" + db_id = int(item_id) # ensure integer + # delete entry(s) from albumtracks table + await self.mass.music.database.delete(DB_TABLE_ALBUM_TRACKS, {"track_id": db_id}) + # delete the track itself from db + await super().remove_item_from_library(db_id) async def get_preview_url(self, provider_instance_id_or_domain: str, item_id: str) -> str: """Return url to short preview sample.""" @@ -208,13 +271,16 @@ class TracksController(MediaControllerBase[Track]): This is used to link objects of different providers/qualities together. """ - if db_track.provider != "database": + if db_track.provider != "library": return # Matching only supported for database items + track_albums = await self.albums(db_track.item_id, db_track.provider) for provider in self.mass.music.providers: if ProviderFeature.SEARCH not in provider.supported_features: continue - if provider.is_unique: - # matching on unique provider sis pointless as they push (all) their content to MA + if not provider.is_streaming_provider: + # matching on unique providers is pointless as they push (all) their content to MA + continue + if not provider.library_supported(MediaType.TRACK): continue self.logger.debug( "Trying to match track %s on provider %s", db_track.name, provider.name @@ -232,7 +298,7 @@ class TracksController(MediaControllerBase[Track]): if not search_result_item.available: continue # do a basic compare first - if not compare_track(search_result_item, db_track, False): + if not compare_track(search_result_item, db_track, strict=False): continue # we must fetch the full version, search results are simplified objects prov_track = await self.get_provider_item( @@ -240,10 +306,11 @@ class TracksController(MediaControllerBase[Track]): search_result_item.provider, fallback=search_result_item, ) - if compare_track(prov_track, db_track): - # 100% match, we can simply update the db with additional provider ids + if compare_track(prov_track, db_track, strict=True, track_albums=track_albums): + # 100% match, we update the db with the additional provider mapping(s) match_found = True - await self._update_db_item(db_track.item_id, search_result_item) + for provider_mapping in search_result_item.provider_mappings: + await self.add_provider_mapping(db_track.item_id, provider_mapping) if not match_found: self.logger.debug( @@ -259,7 +326,7 @@ class TracksController(MediaControllerBase[Track]): limit: int = 25, ): """Generate a dynamic list of tracks based on the track.""" - assert provider_instance_id_or_domain != "database" + assert provider_instance_id_or_domain != "library" prov = self.mass.get_provider(provider_instance_id_or_domain) if prov is None: return [] @@ -278,50 +345,33 @@ class TracksController(MediaControllerBase[Track]): "No Music Provider found that supports requesting similar tracks." ) - async def _add_db_item(self, item: Track) -> Track: + async def _add_library_item(self, item: Track) -> Track: """Add a new item record to the database.""" - assert isinstance(item, Track), "Not a full Track object" - assert item.artists, "Track is missing artist(s)" - assert item.provider_mappings, "Track is missing provider mapping(s)" - # safety guard: check for existing item first - if cur_item := await self.get_db_item_by_prov_mappings(item.provider_mappings): - # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) - # try matching on musicbrainz_id - if item.musicbrainz_id: - match = {"musicbrainz_id": item.musicbrainz_id} + # check for existing item first + if item.provider == "library": + return await self.update_item_in_library(item.item_id, item) + if cur_item := await self.get_library_item_by_prov_mappings(item.provider_mappings): + return await self.update_item_in_library(cur_item.item_id, item) + if item.mbid: + match = {"mbid": item.mbid} if db_row := await self.mass.music.database.get_row(self.db_table, match): cur_item = Track.from_db_row(db_row) - # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) - # try matching on isrc - for isrc in item.isrc: - if search_result := await self.mass.music.database.search(self.db_table, isrc, "isrc"): - cur_item = Track.from_db_row(search_result[0]) - # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) - # fallback to compare matching + return await self.update_item_in_library(cur_item.item_id, item) match = {"sort_name": item.sort_name} for row in await self.mass.music.database.get_rows(self.db_table, match): row_track = Track.from_db_row(row) - if compare_track(row_track, item): + track_albums = await self.albums(row_track.item_id, row_track.provider) + if compare_track(row_track, item, strict=True, track_albums=track_albums): cur_item = row_track - # existing item found: update it - return await self._update_db_item(cur_item.item_id, item) - - # no existing match found: insert new item + return await self.update_item_in_library(cur_item.item_id, item) track_artists = await self._get_artist_mappings(item) - track_albums = await self._get_track_albums(item) - sort_artist = track_artists[0].sort_name if track_artists else "" - sort_album = track_albums[0].sort_name if track_albums else "" + sort_artist = track_artists[0].sort_name new_item = await self.mass.music.database.insert( self.db_table, { **item.to_db_row(), "artists": serialize_to_json(track_artists), - "albums": serialize_to_json(track_albums), "sort_artist": sort_artist, - "sort_album": sort_album, "timestamp_added": int(utc_timestamp()), "timestamp_modified": int(utc_timestamp()), }, @@ -329,126 +379,48 @@ class TracksController(MediaControllerBase[Track]): db_id = new_item["item_id"] # update/set provider_mappings table await self._set_provider_mappings(db_id, item.provider_mappings) - # return created object - self.logger.debug("added %s to database: %s", item.name, db_id) - # get full created object - db_item = await self.get_db_item(db_id) - # only signal event if we're not running a sync (to prevent a floodstorm of events) - if not self.mass.music.get_running_sync_tasks(): - self.mass.signal_event( - EventType.MEDIA_ITEM_ADDED, - db_item.uri, - db_item, + # handle track album + if item.album: + await self._set_track_album( + db_id=db_id, + album=item.album, + disc_number=getattr(item, "disc_number", None) or 0, + track_number=getattr(item, "track_number", None) or 0, ) + self.logger.debug("added %s to database: %s", item.name, db_id) # return the full item we just added - return db_item - - async def _update_db_item( - self, item_id: str | int, item: Track | ItemMapping, overwrite: bool = False - ) -> Track: - """Update Track record in the database, merging data.""" - db_id = int(item_id) # ensure integer - cur_item = await self.get_db_item(db_id) - metadata = cur_item.metadata.update(getattr(item, "metadata", None), overwrite) - provider_mappings = self._get_provider_mappings(cur_item, item, overwrite) - if getattr(item, "isrc", None): - cur_item.isrc.update(item.isrc) - track_artists = await self._get_artist_mappings(cur_item, item, overwrite=overwrite) - track_albums = await self._get_track_albums(cur_item, item, overwrite=overwrite) - await self.mass.music.database.update( - self.db_table, - {"item_id": db_id}, - { - "name": item.name or cur_item.name, - "sort_name": item.sort_name or cur_item.sort_name, - "version": item.version or cur_item.version, - "duration": getattr(item, "duration", None) or cur_item.duration, - "artists": serialize_to_json(track_artists), - "albums": serialize_to_json(track_albums), - "metadata": serialize_to_json(metadata), - "provider_mappings": serialize_to_json(provider_mappings), - "isrc": ";".join(cur_item.isrc), - "timestamp_modified": int(utc_timestamp()), - }, - ) - # update/set provider_mappings table - await self._set_provider_mappings(db_id, provider_mappings) - self.logger.debug("updated %s in database: %s", item.name, db_id) - # get full created object - db_item = await self.get_db_item(db_id) - # only signal event if we're not running a sync (to prevent a floodstorm of events) - if not self.mass.music.get_running_sync_tasks(): - self.mass.signal_event( - EventType.MEDIA_ITEM_UPDATED, - db_item.uri, - db_item, - ) - # return the full item we just updated - return db_item - - async def _get_track_albums( - self, - org_item: DbTrack, - update_item: Track | ItemMapping | None = None, - overwrite: bool = False, - ) -> list[TrackAlbumMapping]: - """Extract all (unique) albums of track as TrackAlbumMapping.""" - if (update_item is None or isinstance(update_item, ItemMapping)) and org_item.albums: - # already TrackAlbumMappings - return org_item.albums - track_albums: set[TrackAlbumMapping] = set() - # add base albums (only if not overwriting) - if ( - not overwrite - or update_item is None - or isinstance(update_item, ItemMapping) - or not (update_item.album or update_item.albums) - ): - track_albums.update(org_item.albums) - if org_item.album: - track_albums.add( - await self._get_album_mapping( - org_item.album, org_item.disc_number, org_item.track_number - ) - ) - - # album(s) from update item - if update_item and not isinstance(update_item, ItemMapping): - if update_item.albums: - track_albums.update(update_item.albums) - if update_item.album: - track_albums.add( - await self._get_album_mapping( - update_item.album, update_item.disc_number, update_item.track_number - ) - ) - # use intermediate set to prevent duplicates - return list(track_albums) - - async def _get_album_mapping( - self, - album: Album | TrackAlbumMapping | ItemMapping, - disc_number: int | None = None, - track_number: int | None = None, - ) -> TrackAlbumMapping: - """Extract (database) album as TrackAlbumMapping.""" - if album.provider == "database": - if isinstance(album, TrackAlbumMapping): - return album - return TrackAlbumMapping.from_item(album, disc_number, track_number) + return await self.get_library_item(db_id) - if db_album := await self.mass.music.albums.get_db_item_by_prov_id( - album.item_id, album.provider + async def _set_track_album(self, db_id: int, album: Album, disc_number: int, track_number: int): + """Store AlbumTrack info.""" + if album.provider == "library": + db_album = album + elif match := await self.mass.music.albums.get_library_item_by_prov_mappings( + album.provider_mappings ): - return TrackAlbumMapping.from_item(db_album, disc_number, track_number) - - # try to request the full item - with suppress(MediaNotFoundError, AssertionError, InvalidDataError): - db_album = await self.mass.music.albums.add(album, skip_metadata_lookup=True) - return TrackAlbumMapping.from_item(db_album, disc_number, track_number) - - # fallback to just the provider item - album = await self.mass.music.albums.get_provider_item( - album.item_id, album.provider, fallback=album - ) - return TrackAlbumMapping.from_item(album, disc_number, track_number) + db_album = match + else: + db_album = await self.mass.music.albums.add_item_to_library( + album, skip_metadata_lookup=True + ) + album_mapping = {"track_id": db_id, "album_id": int(db_album.item_id)} + if db_row := await self.mass.music.database.get_row(DB_TABLE_ALBUM_TRACKS, album_mapping): + # update existing + await self.mass.music.database.update( + DB_TABLE_ALBUM_TRACKS, + album_mapping, + { + "disc_number": disc_number or db_row["disc_number"], + "track_number": track_number or db_row["track_number"], + }, + ) + else: + # create new albumtrack record + await self.mass.music.database.insert_or_replace( + DB_TABLE_ALBUM_TRACKS, + { + **album_mapping, + "disc_number": disc_number, + "track_number": track_number, + }, + ) diff --git a/music_assistant/server/controllers/metadata.py b/music_assistant/server/controllers/metadata.py index ed8181d2..4ab8ce52 100755 --- a/music_assistant/server/controllers/metadata.py +++ b/music_assistant/server/controllers/metadata.py @@ -95,7 +95,7 @@ class MetaDataController(CoreController): LOGGER.debug("Start scan for missing artist metadata") self.scan_busy = True - async for artist in self.mass.music.artists.iter_db_items(): + async for artist in self.mass.music.artists.iter_library_items(): if artist.metadata.last_refresh is not None: continue # most important is to see artist thumb in listings @@ -115,15 +115,10 @@ class MetaDataController(CoreController): async def get_artist_metadata(self, artist: Artist) -> None: """Get/update rich metadata for an artist.""" - # set timestamp, used to determine when this function was last called - artist.metadata.last_refresh = int(time()) - - if not artist.musicbrainz_id: - artist.musicbrainz_id = await self.get_artist_musicbrainz_id(artist) - - if not artist.musicbrainz_id: + if not artist.mbid: + artist.mbid = await self.get_artist_mbid(artist) + if not artist.mbid: return - # collect metadata from all providers for provider in self.providers: if ProviderFeature.ARTIST_METADATA not in provider.supported_features: @@ -135,13 +130,13 @@ class MetaDataController(CoreController): artist.name, provider.name, ) + # set timestamp, used to determine when this function was last called + artist.metadata.last_refresh = int(time()) async def get_album_metadata(self, album: Album) -> None: """Get/update rich metadata for an album.""" - # set timestamp, used to determine when this function was last called - album.metadata.last_refresh = int(time()) # ensure the album has a musicbrainz id or artist - if not (album.musicbrainz_id or album.artists): + if not (album.mbid or album.artists): return # collect metadata from all providers for provider in self.providers: @@ -154,6 +149,8 @@ class MetaDataController(CoreController): album.name, provider.name, ) + # set timestamp, used to determine when this function was last called + album.metadata.last_refresh = int(time()) async def get_track_metadata(self, track: Track) -> None: """Get/update rich metadata for a track.""" @@ -228,12 +225,24 @@ class MetaDataController(CoreController): # NOTE: we do not have any metadata for radio so consider this future proofing ;-) radio.metadata.last_refresh = int(time()) - async def get_artist_musicbrainz_id(self, artist: Artist) -> str | None: + async def get_artist_mbid(self, artist: Artist) -> str | None: """Fetch musicbrainz id by performing search using the artist name, albums and tracks.""" - ref_albums = await self.mass.music.artists.albums(artist=artist) - ref_tracks = await self.mass.music.artists.tracks(artist=artist) + ref_albums = await self.mass.music.artists.albums(artist.item_id, artist.provider) + if len(ref_albums) < 10: + # fetch reference albums from provider(s) attached to the artist + for provider_mapping in artist.provider_mappings: + ref_albums += await self.mass.music.artists.albums( + provider_mapping.item_id, provider_mapping.provider_instance + ) + ref_tracks = await self.mass.music.artists.tracks(artist.item_id, artist.provider) + if len(ref_tracks) < 10: + # fetch reference tracks from provider(s) attached to the artist + for provider_mapping in artist.provider_mappings: + ref_tracks += await self.mass.music.artists.tracks( + provider_mapping.item_id, provider_mapping.provider_instance + ) - # randomize providers so average the load + # randomize providers to average the load providers = self.providers shuffle(providers) @@ -241,7 +250,7 @@ class MetaDataController(CoreController): for provider in providers: if ProviderFeature.GET_ARTIST_MBID not in provider.supported_features: continue - if musicbrainz_id := await provider.get_musicbrainz_artist_id( + if mbid := await provider.get_musicbrainz_artist_id( artist, ref_albums=ref_albums, ref_tracks=ref_tracks ): LOGGER.debug( @@ -249,7 +258,7 @@ class MetaDataController(CoreController): artist.name, provider.name, ) - return musicbrainz_id + return mbid # lookup failed ref_albums_str = "/".join(x.name for x in ref_albums) or "none" diff --git a/music_assistant/server/controllers/music.py b/music_assistant/server/controllers/music.py index c5bc5621..c61c12c2 100755 --- a/music_assistant/server/controllers/music.py +++ b/music_assistant/server/controllers/music.py @@ -2,11 +2,12 @@ from __future__ import annotations import asyncio -import logging import os +import shutil import statistics +from contextlib import suppress from itertools import zip_longest -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING from music_assistant.common.helpers.datetime import utc_timestamp from music_assistant.common.helpers.json import json_dumps, json_loads @@ -23,6 +24,8 @@ from music_assistant.common.models.errors import MusicAssistantError from music_assistant.common.models.media_items import BrowseFolder, MediaItemType, SearchResults from music_assistant.common.models.provider import SyncTask from music_assistant.constants import ( + DB_SCHEMA_VERSION, + DB_TABLE_ALBUM_TRACKS, DB_TABLE_ALBUMS, DB_TABLE_ARTISTS, DB_TABLE_PLAYLISTS, @@ -32,7 +35,6 @@ from music_assistant.constants import ( DB_TABLE_SETTINGS, DB_TABLE_TRACK_LOUDNESS, DB_TABLE_TRACKS, - ROOT_LOGGER_NAME, ) from music_assistant.server.helpers.api import api_command from music_assistant.server.helpers.database import DatabaseConnection @@ -48,10 +50,8 @@ from .media.tracks import TracksController if TYPE_CHECKING: from music_assistant.common.models.config_entries import CoreConfig -LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.music") DEFAULT_SYNC_INTERVAL = 3 * 60 # default sync interval in minutes CONF_SYNC_INTERVAL = "sync_interval" -DB_SCHEMA_VERSION: Final[int] = 23 class MusicController(CoreController): @@ -260,7 +260,7 @@ class MusicController(CoreController): if not path or path == "root": return BrowseFolder( item_id="root", - provider="database", + provider="library", path="root", label="browse", name="", @@ -282,17 +282,13 @@ class MusicController(CoreController): return await prov.browse(path) @api_command("music/item_by_uri") - async def get_item_by_uri( - self, uri: str, force_refresh: bool = False, lazy: bool = True - ) -> MediaItemType: + async def get_item_by_uri(self, uri: str) -> MediaItemType: """Fetch MediaItem by uri.""" media_type, provider_instance_id_or_domain, item_id = parse_uri(uri) return await self.get_item( media_type=media_type, item_id=item_id, provider_instance_id_or_domain=provider_instance_id_or_domain, - force_refresh=force_refresh, - lazy=lazy, ) @api_command("music/item") @@ -303,9 +299,12 @@ class MusicController(CoreController): provider_instance_id_or_domain: str, force_refresh: bool = False, lazy: bool = True, - add_to_db: bool = False, + add_to_library: bool = False, ) -> MediaItemType: """Get single music item by id and media type.""" + if provider_instance_id_or_domain == "database": + # backwards compatibility - to remove when 2.0 stable is released + provider_instance_id_or_domain = "library" if provider_instance_id_or_domain == "url": # handle special case of 'URL' MusicProvider which allows us to play regular url's return await self.mass.get_provider("url").parse_item(item_id) @@ -315,88 +314,80 @@ class MusicController(CoreController): provider_instance_id_or_domain=provider_instance_id_or_domain, force_refresh=force_refresh, lazy=lazy, - add_to_db=add_to_db, + add_to_library=add_to_library, ) - @api_command("music/library/add") - async def add_to_library( + @api_command("music/favorites/add_item") + async def add_item_to_favorites( self, - media_type: MediaType, - item_id: str, - provider_instance_id_or_domain: str, + item: str | MediaItemType, ) -> None: - """Add an item to the library.""" - # make sure we have a full db item + """Add an item to the favorites.""" + if isinstance(item, str): + item = await self.get_item_by_uri(item) + # make sure we have a full library item + # a favorite must always be in the library full_item = await self.get_item( - media_type, - item_id, - provider_instance_id_or_domain, + item.media_type, + item.item_id, + item.provider, lazy=False, - add_to_db=True, + add_to_library=True, ) - ctrl = self.get_controller(media_type) - await ctrl.add_to_library( + # set favorite in library db + ctrl = self.get_controller(item.media_type) + await ctrl.set_favorite( full_item.item_id, - full_item.provider, - ) - - @api_command("music/library/add_items") - async def add_items_to_library(self, items: list[str | MediaItemType]) -> None: - """Add multiple items to the library (provide uri or MediaItem).""" - tasks = [] - for item in items: - if isinstance(item, str): - item = await self.get_item_by_uri(item) # noqa: PLW2901 - tasks.append( - self.mass.create_task( - self.add_to_library( - media_type=item.media_type, - item_id=item.item_id, - provider_instance_id_or_domain=item.provider, - ) - ) - ) - await asyncio.gather(*tasks) + True, + ) - @api_command("music/library/remove") - async def remove_from_library( + @api_command("music/favorites/remove_item") + async def remove_item_from_favorites( self, media_type: MediaType, - item_id: str, - provider_instance_id_or_domain: str, + library_item_id: str | int, ) -> None: - """Remove item from the library.""" + """Remove (library) item from the favorites.""" ctrl = self.get_controller(media_type) - await ctrl.remove_from_library( - item_id, - provider_instance_id_or_domain, - ) - - @api_command("music/library/remove_items") - async def remove_items_from_library(self, items: list[str | MediaItemType]) -> None: - """Remove multiple items from the library (provide uri or MediaItem).""" - tasks = [] - for item in items: - if isinstance(item, str): - item = await self.get_item_by_uri(item) # noqa: PLW2901 - tasks.append( - self.mass.create_task( - self.remove_from_library( - media_type=item.media_type, - item_id=item.item_id, - provider_instance_id_or_domain=item.provider, - ) - ) - ) - await asyncio.gather(*tasks) + await ctrl.set_favorite( + library_item_id, + False, + ) - @api_command("music/delete") - async def delete( - self, media_type: MediaType, db_item_id: str | int, recursive: bool = False + @api_command("music/library/remove_item") + async def remove_item_from_library( + self, media_type: MediaType, library_item_id: str | int ) -> None: - """Remove item from the database.""" + """ + Remove item from the library. + + Destructive! Will remove the item and all dependants. + """ ctrl = self.get_controller(media_type) - await ctrl.delete(db_item_id, recursive) + item = await ctrl.get_library_item(library_item_id) + # remove from all providers + for provider_mapping in item.provider_mappings: + prov_controller = self.mass.get_provider(provider_mapping.provider_instance) + with suppress(NotImplementedError): + await prov_controller.library_remove(provider_mapping.item_id, item.media_type) + await ctrl.remove_item_from_library(library_item_id) + + @api_command("music/library/add_item") + async def add_item_to_library(self, item: str | MediaItemType) -> MediaItemType: + """Add item (uri or mediaitem) to the library.""" + if isinstance(item, str): + item = await self.get_item_by_uri(item) + ctrl = self.get_controller(item.media_type) + # add to provider's library first + provider = self.mass.get_provider(item.provider) + if provider.library_edit_supported(item.media_type): + await provider.library_add(item.item_id, item.media_type) + return await ctrl.get( + item_id=item.item_id, + provider_instance_id_or_domain=item.provider, + details=item, + add_to_library=True, + ) async def refresh_items(self, items: list[MediaItemType]) -> None: """Refresh MediaItems to force retrieval of full info and matches. @@ -419,7 +410,7 @@ class MusicController(CoreController): media_item.provider, force_refresh=True, lazy=False, - add_to_db=True, + add_to_library=True, ) except MusicAssistantError: pass @@ -438,7 +429,7 @@ class MusicController(CoreController): for item in result: if item.available: return await self.get_item( - item.media_type, item.item_id, item.provider, lazy=False, add_to_db=True + item.media_type, item.item_id, item.provider, lazy=False, add_to_library=True ) return None @@ -496,34 +487,6 @@ class MusicController(CoreController): allow_replace=True, ) - async def library_add_items(self, items: list[MediaItemType]) -> None: - """Add media item(s) to the library. - - Creates background tasks to process the action. - """ - for media_item in items: - self.mass.create_task( - self.add_to_library( - media_item.media_type, - media_item.item_id, - media_item.provider, - ) - ) - - async def library_remove_items(self, items: list[MediaItemType]) -> None: - """Remove media item(s) from the library. - - Creates background tasks to process the action. - """ - for media_item in items: - self.mass.create_task( - self.remove_from_library( - media_item.media_type, - media_item.item_id, - media_item.provider, - ) - ) - def get_controller( self, media_type: MediaType ) -> ( @@ -556,7 +519,7 @@ class MusicController(CoreController): instances = set() domains = set() for provider in self.providers: - if provider.domain not in domains or provider.is_unique: + if provider.domain not in domains or not provider.is_streaming_provider: instances.add(provider.instance_id) domains.add(provider.domain) return instances @@ -569,7 +532,7 @@ class MusicController(CoreController): continue for media_type in media_types: if media_type in sync_task.media_types: - LOGGER.debug( + self.logger.debug( "Skip sync task for %s because another task is already in progress", provider_instance, ) @@ -579,7 +542,7 @@ class MusicController(CoreController): async def run_sync() -> None: # Wrap the provider sync into a lock to prevent - # race conditions when multiple propviders are syncing at the same time. + # race conditions when multiple providers are syncing at the same time. async with self._sync_lock: await provider.sync_library(media_types) @@ -597,9 +560,16 @@ class MusicController(CoreController): def on_sync_task_done(task: asyncio.Task): # noqa: ARG001 self.in_progress_syncs.remove(sync_spec) + if task_err := task.exception(): + self.logger.warning( + "Sync task for %s completed with errors", provider.name, exc_info=task_err + ) + else: + self.logger.info("Sync task for %s completed", provider.name) self.mass.signal_event(EventType.SYNC_TASKS_UPDATED, data=self.in_progress_syncs) - # trigger metadata scan after provider sync completed - self.mass.metadata.start_scan() + # trigger metadata scan after all provider syncs completed + if len(self.in_progress_syncs) == 0: + self.mass.metadata.start_scan() task.add_done_callback(on_sync_task_done) @@ -617,9 +587,9 @@ class MusicController(CoreController): self.mass.music.albums, self.mass.music.artists, ): - prov_items = await ctrl.get_db_items_by_prov_id(provider_instance) + prov_items = await ctrl.get_library_items_by_prov_id(provider_instance) for item in prov_items: - await ctrl.remove_prov_mapping(item.item_id, provider_instance) + await ctrl.remove_provider_mappings(item.item_id, provider_instance) async def _setup_database(self): """Initialize database.""" @@ -638,16 +608,57 @@ class MusicController(CoreController): prev_version = 0 if prev_version not in (0, DB_SCHEMA_VERSION): - LOGGER.info( + self.logger.info( "Performing database migration from %s to %s", prev_version, DB_SCHEMA_VERSION, ) + # make a backup of db file + db_path_backup = db_path + ".backup" + await asyncio.to_thread(shutil.copyfile, db_path, db_path_backup) + + if prev_version < 22 or prev_version > DB_SCHEMA_VERSION: + # for now just keep it simple and just recreate the tables + # if the schema is too old or too new + # we allow migrations only for up to 2 schema versions behind + await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_ARTISTS}") + await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_ALBUMS}") + await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_TRACKS}") + await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_PLAYLISTS}") + await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_RADIOS}") + # recreate missing tables + await self.__create_database_tables() + + if prev_version in (22, 23): + # reset albums, artists, tracks, impossible to migrate in a clean way + for table in ( + DB_TABLE_ARTISTS, + DB_TABLE_ALBUMS, + DB_TABLE_TRACKS, + ): + self.logger.warning( + "Resetting %s library/database - a full rescan will be performed!", table + ) + await self.database.execute(f"DROP TABLE IF EXISTS {table}") + # recreate missing tables + await self.__create_database_tables() - if prev_version == 22: - # migrate provider_mapping column (audio_format) - for table in ("tracks", "albums"): + # migrate in_library --> favorite + for table in ( + DB_TABLE_PLAYLISTS, + DB_TABLE_RADIOS, + ): + # rename in_library --> favorite + await self.database.execute( + f"ALTER TABLE {table} RENAME COLUMN in_library TO favorite;" + ) + # clean out all non favorites from library db + item_ids_to_delete = set() async for item in self.database.iter_items(table): + if not (item["favorite"] or '"url' in item["provider_mappings"]): + item_ids_to_delete.add(item["item_id"]) + continue + # migrate provider_mapping column (audio_format) prov_mappings = json_loads(item["provider_mappings"]) needs_update = False for mapping in prov_mappings: @@ -670,18 +681,13 @@ class MusicController(CoreController): "provider_mappings": json_dumps(prov_mappings), }, ) - elif prev_version < 22: - # for now just keep it simple and just recreate the tables if the schema is too old - await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_ARTISTS}") - await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_ALBUMS}") - await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_TRACKS}") - await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_PLAYLISTS}") - await self.database.execute(f"DROP TABLE IF EXISTS {DB_TABLE_RADIOS}") + for item_id in item_ids_to_delete: + await self.database.delete(table, {"item_id": item_id}) - # recreate missing tables - await self.__create_database_tables() - else: - raise RuntimeError("db schema migration missing") + self.logger.info( + "Database migration to version %s completed", + DB_SCHEMA_VERSION, + ) # store current schema version await self.database.insert_or_replace( @@ -722,15 +728,14 @@ class MusicController(CoreController): name TEXT NOT NULL, sort_name TEXT NOT NULL, sort_artist TEXT, - album_type TEXT, + album_type TEXT NOT NULL, year INTEGER, version TEXT, - in_library BOOLEAN DEFAULT 0, - barcode TEXT, - musicbrainz_id TEXT, - artists json, - metadata json, - provider_mappings json, + favorite BOOLEAN DEFAULT 0, + mbid TEXT, + artists json NOT NULL, + metadata json NOT NULL, + provider_mappings json NOT NULL, timestamp_added INTEGER NOT NULL, timestamp_modified INTEGER NOT NULL );""" @@ -740,10 +745,10 @@ class MusicController(CoreController): item_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, sort_name TEXT NOT NULL, - musicbrainz_id TEXT, - in_library BOOLEAN DEFAULT 0, - metadata json, - provider_mappings json, + mbid TEXT, + favorite BOOLEAN DEFAULT 0, + metadata json NOT NULL, + provider_mappings json NOT NULL, timestamp_added INTEGER NOT NULL, timestamp_modified INTEGER NOT NULL );""" @@ -754,20 +759,26 @@ class MusicController(CoreController): name TEXT NOT NULL, sort_name TEXT NOT NULL, sort_artist TEXT, - sort_album TEXT, version TEXT, duration INTEGER, - in_library BOOLEAN DEFAULT 0, - isrc TEXT, - musicbrainz_id TEXT, - artists json, - albums json, - metadata json, - provider_mappings json, + favorite BOOLEAN DEFAULT 0, + mbid TEXT, + artists json NOT NULL, + metadata json NOT NULL, + provider_mappings json NOT NULL, timestamp_added INTEGER NOT NULL, timestamp_modified INTEGER NOT NULL );""" ) + await self.database.execute( + f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_ALBUM_TRACKS}( + track_id INTEGER NOT NULL, + album_id INTEGER NOT NULL, + disc_number INTEGER NOT NULL, + track_number INTEGER NOT NULL, + UNIQUE(track_id, album_id) + );""" + ) await self.database.execute( f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_PLAYLISTS}( item_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -775,7 +786,7 @@ class MusicController(CoreController): sort_name TEXT NOT NULL, owner TEXT NOT NULL, is_editable BOOLEAN NOT NULL, - in_library BOOLEAN DEFAULT 0, + favorite BOOLEAN DEFAULT 0, metadata json, provider_mappings json, timestamp_added INTEGER NOT NULL, @@ -787,7 +798,7 @@ class MusicController(CoreController): item_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, sort_name TEXT NOT NULL, - in_library BOOLEAN DEFAULT 0, + favorite BOOLEAN DEFAULT 0, metadata json, provider_mappings json, timestamp_added INTEGER NOT NULL, @@ -808,19 +819,19 @@ class MusicController(CoreController): async def __create_database_indexes(self) -> None: """Create database indexes.""" await self.database.execute( - "CREATE INDEX IF NOT EXISTS artists_in_library_idx on artists(in_library);" + "CREATE INDEX IF NOT EXISTS artists_in_library_idx on artists(favorite);" ) await self.database.execute( - "CREATE INDEX IF NOT EXISTS albums_in_library_idx on albums(in_library);" + "CREATE INDEX IF NOT EXISTS albums_in_library_idx on albums(favorite);" ) await self.database.execute( - "CREATE INDEX IF NOT EXISTS tracks_in_library_idx on tracks(in_library);" + "CREATE INDEX IF NOT EXISTS tracks_in_library_idx on tracks(favorite);" ) await self.database.execute( - "CREATE INDEX IF NOT EXISTS playlists_in_library_idx on playlists(in_library);" + "CREATE INDEX IF NOT EXISTS playlists_in_library_idx on playlists(favorite);" ) await self.database.execute( - "CREATE INDEX IF NOT EXISTS radios_in_library_idx on radios(in_library);" + "CREATE INDEX IF NOT EXISTS radios_in_library_idx on radios(favorite);" ) await self.database.execute( "CREATE INDEX IF NOT EXISTS artists_sort_name_idx on artists(sort_name);" @@ -837,16 +848,6 @@ class MusicController(CoreController): await self.database.execute( "CREATE INDEX IF NOT EXISTS radios_sort_name_idx on radios(sort_name);" ) - await self.database.execute( - "CREATE INDEX IF NOT EXISTS artists_musicbrainz_id_idx on artists(musicbrainz_id);" - ) - await self.database.execute( - "CREATE INDEX IF NOT EXISTS albums_musicbrainz_id_idx on albums(musicbrainz_id);" - ) - await self.database.execute( - "CREATE INDEX IF NOT EXISTS tracks_musicbrainz_id_idx on tracks(musicbrainz_id);" - ) - await self.database.execute("CREATE INDEX IF NOT EXISTS tracks_isrc_idx on tracks(isrc);") - await self.database.execute( - "CREATE INDEX IF NOT EXISTS albums_barcode_idx on albums(barcode);" - ) + await self.database.execute("CREATE INDEX IF NOT EXISTS artists_mbid_idx on artists(mbid);") + await self.database.execute("CREATE INDEX IF NOT EXISTS albums_mbid_idx on albums(mbid);") + await self.database.execute("CREATE INDEX IF NOT EXISTS tracks_mbid_idx on tracks(mbid);") diff --git a/music_assistant/server/helpers/compare.py b/music_assistant/server/helpers/compare.py index d7c6354d..a981856d 100644 --- a/music_assistant/server/helpers/compare.py +++ b/music_assistant/server/helpers/compare.py @@ -8,13 +8,22 @@ import unidecode from music_assistant.common.helpers.util import create_sort_name from music_assistant.common.models.media_items import ( Album, + AlbumTrack, Artist, ItemMapping, MediaItem, MediaItemMetadata, + ProviderMapping, Track, ) +IGNORE_VERSIONS = ( + "remaster", + "explicit", + "music from and inspired by the motion picture", + "original soundtrack", +) + def create_safe_string(input_str: str) -> str: """Return clean lowered string for compare actions.""" @@ -57,98 +66,100 @@ def compare_strings(str1: str, str2: str, strict: bool = True) -> bool: return create_sort_name(str1) == create_sort_name(str2) -def compare_version(left_version: str, right_version: str) -> bool: +def compare_version(base_version: str, compare_version: str) -> bool: """Compare version string.""" - if not left_version and not right_version: + if not base_version and not compare_version: + return True + if not base_version and compare_version.lower() in IGNORE_VERSIONS: return True - if not left_version and right_version: + if not compare_version and base_version.lower() in IGNORE_VERSIONS: + return True + if not base_version and compare_version: return False - if left_version and not right_version: + if base_version and not compare_version: return False - if " " not in left_version: - return compare_strings(left_version, right_version) + if " " not in base_version: + return compare_strings(base_version, compare_version) # do this the hard way as sometimes the version string is in the wrong order - left_versions = left_version.lower().split(" ").sort() - right_versions = right_version.lower().split(" ").sort() - return left_versions == right_versions + base_versions = base_version.lower().split(" ").sort() + compare_versions = compare_version.lower().split(" ").sort() + return base_versions == compare_versions -def compare_explicit(left: MediaItemMetadata, right: MediaItemMetadata) -> bool: +def compare_explicit(base: MediaItemMetadata, compare: MediaItemMetadata) -> bool: """Compare if explicit is same in metadata.""" - if left.explicit is None or right.explicit is None: + if base.explicit is None or compare.explicit is None: # explicitness info is not always present in metadata # only strict compare them if both have the info set return True - return left == right + return base == compare def compare_artist( - left_artist: Artist | ItemMapping, - right_artist: Artist | ItemMapping, + base_item: Artist | ItemMapping, + compare_item: Artist | ItemMapping, ) -> bool: """Compare two artist items and return True if they match.""" - if left_artist is None or right_artist is None: + if base_item is None or compare_item is None: return False # return early on exact item_id match - if compare_item_ids(left_artist, right_artist): + if compare_item_ids(base_item, compare_item): return True - # prefer match on musicbrainz_id - if getattr(left_artist, "musicbrainz_id", None) and getattr( - right_artist, "musicbrainz_id", None - ): - return left_artist.musicbrainz_id == right_artist.musicbrainz_id + # prefer match on mbid + if getattr(base_item, "mbid", None) and getattr(compare_item, "mbid", None): + return base_item.mbid == compare_item.mbid # fallback to comparing - return compare_strings(left_artist.name, right_artist.name, False) + return compare_strings(base_item.name, compare_item.name, False) def compare_artists( - left_artists: list[Artist | ItemMapping], - right_artists: list[Artist | ItemMapping], - any_match: bool = False, + base_items: list[Artist | ItemMapping], + compare_items: list[Artist | ItemMapping], + any_match: bool = True, ) -> bool: """Compare two lists of artist and return True if both lists match (exactly).""" matches = 0 - for left_artist in left_artists: - for right_artist in right_artists: - if compare_artist(left_artist, right_artist): + for base_item in base_items: + for compare_item in compare_items: + if compare_artist(base_item, compare_item): if any_match: return True matches += 1 - return len(left_artists) == matches + return len(base_items) == matches def compare_item_ids( - left_item: MediaItem | ItemMapping, right_item: MediaItem | ItemMapping + base_item: MediaItem | ItemMapping, compare_item: MediaItem | ItemMapping ) -> bool: """Compare item_id(s) of two media items.""" - if not left_item.provider or not right_item.provider: + if not base_item.provider or not compare_item.provider: return False - if not left_item.item_id or not right_item.item_id: + if not base_item.item_id or not compare_item.item_id: return False - if left_item.provider == right_item.provider and left_item.item_id == right_item.item_id: + if base_item.provider == compare_item.provider and base_item.item_id == compare_item.item_id: return True - left_prov_ids = getattr(left_item, "provider_mappings", None) - right_prov_ids = getattr(right_item, "provider_mappings", None) + base_prov_ids = getattr(base_item, "provider_mappings", None) + compare_prov_ids = getattr(compare_item, "provider_mappings", None) - if left_prov_ids is not None: - for prov_l in left_item.provider_mappings: + if base_prov_ids is not None: + for prov_l in base_item.provider_mappings: if ( - prov_l.provider_domain == right_item.provider - and prov_l.item_id == right_item.item_id + prov_l.provider_domain == compare_item.provider + and prov_l.item_id == compare_item.item_id ): return True - if right_prov_ids is not None: - for prov_r in right_item.provider_mappings: - if prov_r.provider_domain == left_item.provider and prov_r.item_id == left_item.item_id: + if compare_prov_ids is not None: + for prov_r in compare_item.provider_mappings: + if prov_r.provider_domain == base_item.provider and prov_r.item_id == base_item.item_id: return True - if left_prov_ids is not None and right_prov_ids is not None: - for prov_l in left_item.provider_mappings: - for prov_r in right_item.provider_mappings: + if base_prov_ids is not None and compare_prov_ids is not None: + for prov_l in base_item.provider_mappings: + for prov_r in compare_item.provider_mappings: if prov_l.provider_domain != prov_r.provider_domain: continue if prov_l.item_id == prov_r.item_id: @@ -157,152 +168,171 @@ def compare_item_ids( def compare_albums( - left_albums: list[Album | ItemMapping], - right_albums: list[Album | ItemMapping], + base_items: list[Album | ItemMapping], + compare_items: list[Album | ItemMapping], ): """Compare two lists of albums and return True if a match was found.""" - for left_album in left_albums: - for right_album in right_albums: - if compare_album(left_album, right_album): + for base_item in base_items: + for compare_item in compare_items: + if compare_album(base_item, compare_item): return True return False def compare_barcode( - left_barcodes: set[str], - right_barcodes: set[str], + base_mappings: set[ProviderMapping], + compare_mappings: set[ProviderMapping], ): - """Compare two sets of barcodes and return True if a match was found.""" - for left_barcode in left_barcodes: - if not left_barcode.strip(): + """Compare barcode within provider mappings and return True if a match was found.""" + for base_mapping in base_mappings: + if not base_mapping.barcode: continue - for right_barcode in right_barcodes: - if not right_barcode.strip(): + for compare_mapping in compare_mappings: + if not compare_mapping.barcode: continue # convert EAN-13 to UPC-A by stripping off the leading zero - left_upc = left_barcode[1:] if left_barcode.startswith("0") else left_barcode - right_upc = right_barcode[1:] if right_barcode.startswith("0") else right_barcode - if compare_strings(left_upc, right_upc): + base_upc = ( + base_mapping.barcode[1:] + if base_mapping.barcode.startswith("0") + else base_mapping.barcode + ) + compare_upc = ( + compare_mapping.barcode[1:] + if compare_mapping.barcode.startswith("0") + else compare_mapping.barcode + ) + if compare_strings(base_upc, compare_upc): return True return False def compare_isrc( - left_isrcs: set[str], - right_isrcs: set[str], + base_mappings: set[ProviderMapping], + compare_mappings: set[ProviderMapping], ): - """Compare two sets of isrc codes and return True if a match was found.""" - for left_isrc in left_isrcs: - if not left_isrc.strip(): + """Compare isrc within provider mappings and return True if a match was found.""" + for base_mapping in base_mappings: + if not base_mapping.isrc: continue - for right_isrc in right_isrcs: - if not right_isrc.strip(): + for compare_mapping in compare_mappings: + if not compare_mapping.isrc: continue - if compare_strings(left_isrc, right_isrc): + if compare_strings(base_mapping.isrc, compare_mapping.isrc): return True return False def compare_album( - left_album: Album | ItemMapping, - right_album: Album | ItemMapping, + base_item: Album | ItemMapping, + compare_item: Album | ItemMapping, ): """Compare two album items and return True if they match.""" - if left_album is None or right_album is None: + if base_item is None or compare_item is None: return False # return early on exact item_id match - if compare_item_ids(left_album, right_album): + if compare_item_ids(base_item, compare_item): return True + # prefer match on mbid (not present on ItemMapping) + if getattr(base_item, "mbid", None) and getattr(compare_item, "mbid", None): + return compare_strings(base_item.mbid, compare_item.mbid) # prefer match on barcode/upc # not present on ItemMapping if ( - getattr(left_album, "barcode", None) - and getattr(right_album, "barcode", None) - and compare_barcode(left_album.barcode, right_album.barcode) + isinstance(base_item, Album) + and isinstance(compare_item, Album) + and compare_barcode(base_item.provider_mappings, compare_item.provider_mappings) ): return True - # prefer match on musicbrainz_id - # not present on ItemMapping - if getattr(left_album, "musicbrainz_id", None) and getattr(right_album, "musicbrainz_id", None): - return left_album.musicbrainz_id == right_album.musicbrainz_id - # fallback to comparing - if not compare_strings(left_album.name, right_album.name, True): + if not compare_strings(base_item.name, compare_item.name, True): return False - if not compare_version(left_album.version, right_album.version): + if not compare_version(base_item.version, compare_item.version): return False if ( - hasattr(left_album, "metadata") - and hasattr(right_album, "metadata") - and not compare_explicit(left_album.metadata, right_album.metadata) + hasattr(base_item, "metadata") + and hasattr(compare_item, "metadata") + and not compare_explicit(base_item.metadata, compare_item.metadata) ): return False # compare album artist # Note: Not present on ItemMapping if ( - isinstance(left_album, Album) - and isinstance(right_album, Album) - and not compare_artists(left_album.artists, right_album.artists, True) + isinstance(base_item, Album) + and isinstance(compare_item, Album) + and not compare_artists(base_item.artists, compare_item.artists, True) ): return False - return left_album.sort_name == right_album.sort_name + return base_item.sort_name == compare_item.sort_name -def compare_track(left_track: Track, right_track: Track, strict: bool = True): +def compare_track( + base_item: Track | AlbumTrack, + compare_item: Track | AlbumTrack, + strict: bool = True, + track_albums: list[Album | ItemMapping] | None = None, +): """Compare two track items and return True if they match.""" - if left_track is None or right_track is None: + if base_item is None or compare_item is None: return False - assert isinstance(left_track, Track) and isinstance(right_track, Track) + assert isinstance(base_item, Track) and isinstance(compare_item, Track) # return early on exact item_id match - if compare_item_ids(left_track, right_track): + if compare_item_ids(base_item, compare_item): return True - if compare_isrc(left_track.isrc, right_track.isrc): + if compare_isrc(base_item.provider_mappings, compare_item.provider_mappings): return True - if compare_strings(left_track.musicbrainz_id, right_track.musicbrainz_id): + if compare_strings(base_item.mbid, compare_item.mbid): return True - # album is required for track linking - if strict and left_track.album is None or right_track.album is None: - return False # track name must match - if not compare_strings(left_track.name, right_track.name, False): - return False - # track version must match - if not compare_version(left_track.version, right_track.version): + if not compare_strings(base_item.name, compare_item.name, False): return False # track artist(s) must match - if not compare_artists(left_track.artists, right_track.artists): + if not compare_artists(base_item.artists, compare_item.artists): + return False + # track version must match + if strict and not compare_version(base_item.version, compare_item.version): return False # check if both tracks are (not) explicit - if strict and not compare_explicit(left_track.metadata, right_track.metadata): + if base_item.metadata.explicit is None and base_item.album: + base_item.metadata.explicit = base_item.album.metadata.explicit + if compare_item.metadata.explicit is None and compare_item.album: + compare_item.metadata.explicit = compare_item.album.metadata.explicit + if strict and not compare_explicit(base_item.metadata, compare_item.metadata): return False + if not strict and not track_albums: + # in non-strict mode, the album does not have to match + return abs(base_item.duration - compare_item.duration) <= 3 # exact albumtrack match = 100% match if ( - compare_album(left_track.album, right_track.album) - and left_track.track_number - and right_track.track_number - and ((left_track.disc_number or 1) == (right_track.disc_number or 1)) - and left_track.track_number == right_track.track_number + isinstance(base_item, AlbumTrack) + and isinstance(compare_item, AlbumTrack) + and compare_album(base_item.album, compare_item.album) + and base_item.track_number == compare_item.track_number ): return True - # check album match + # fallback: exact album match and (near-exact) track duration match if ( - not (album_match_found := compare_album(left_track.album, right_track.album)) - and left_track.albums - and right_track.albums + base_item.album is not None + and compare_item.album is not None + and compare_album(base_item.album, compare_item.album) + and abs(base_item.duration - compare_item.duration) <= 5 ): - for left_album in left_track.albums: - for right_album in right_track.albums: - if compare_album(left_album, right_album): - album_match_found = True - if ( - (left_album.disc_number or 1) == (right_album.disc_number or 1) - and left_album.track_number - and right_album.track_number - and left_album.track_number == right_album.track_number - ): - # exact albumtrack match = 100% match - return True - # fallback: exact album match and (near-exact) track duration match - if album_match_found and abs(left_track.duration - right_track.duration) <= 3: return True + # fallback: additional compare albums provided for base track + if ( + compare_item.album is not None + and track_albums + and abs(base_item.duration - compare_item.duration) <= 5 + ): + for track_album in track_albums: + if compare_album(track_album, compare_item.album): + return True + # edge case: albumless track + if ( + base_item.album is None + and compare_item.album is None + and abs(base_item.duration - compare_item.duration) <= 3 + ): + return True + + # all efforts failed, this is NOT a match return False diff --git a/music_assistant/server/helpers/tags.py b/music_assistant/server/helpers/tags.py index 5c9adf04..d309173d 100644 --- a/music_assistant/server/helpers/tags.py +++ b/music_assistant/server/helpers/tags.py @@ -216,25 +216,24 @@ class AudioTags: return AlbumType.UNKNOWN @property - def isrc(self) -> tuple[str, ...]: - """Return isrc tag(s).""" - if tag := self.tags.get("isrc"): - return split_items(tag, True) - if tag := self.tags.get("tsrc"): - return split_items(tag, True) - return tuple() + def isrc(self) -> str | None: + """Return isrc tag.""" + for tag in ("isrc", "tsrc"): + if tag := self.tags.get("isrc"): + # sometyimes the field contains multiple values + # we only need one + return split_items(tag, True)[0] + return None @property - def barcode(self) -> tuple[str, ...]: + def barcode(self) -> str | None: """Return barcode (upc/ean) tag(s).""" - # prefer multi-artist tag - if tag := self.tags.get("barcode"): - return split_items(tag, True) - if tag := self.tags.get("upc"): - return split_items(tag, True) - if tag := self.tags.get("ean"): - return split_items(tag, True) - return tuple() + for tag in ("barcode", "upc", "ean"): + if tag := self.tags.get("isrc"): + # sometyimes the field contains multiple values + # we only need one + return split_items(tag, True)[0] + return None @property def chapters(self) -> list[MediaItemChapter]: diff --git a/music_assistant/server/models/music_provider.py b/music_assistant/server/models/music_provider.py index 0ce03220..d9c2b16d 100644 --- a/music_assistant/server/models/music_provider.py +++ b/music_assistant/server/models/music_provider.py @@ -4,12 +4,15 @@ from __future__ import annotations from collections.abc import AsyncGenerator from music_assistant.common.models.enums import MediaType, ProviderFeature +from music_assistant.common.models.errors import MediaNotFoundError from music_assistant.common.models.media_items import ( Album, + AlbumTrack, Artist, BrowseFolder, MediaItemType, Playlist, + PlaylistTrack, Radio, SearchResults, StreamDetails, @@ -28,17 +31,19 @@ class MusicProvider(Provider): """ @property - def is_unique(self) -> bool: + def is_streaming_provider(self) -> bool: """ - Return True if the (non user related) data in this provider instance is unique. + Return True if the provider is a streaming provider. - For example on a global streaming provider (like Spotify), - the data on all instances is the same. - For a file provider each instance has other items. - Setting this to False will only query one instance of the provider for search and lookups. - Setting this to True will query all instances of this provider for search and lookups. + This literally means that the catalog is not the same as the library contents. + For local based providers (files, plex), the catalog is the same as the library content. + It also means that data is if this provider is NOT a streaming provider, + data cross instances is unique, the catalog and library differs per instance. + + Setting this to True will only query one instance of the provider for search and lookups. + Setting this to False will query all instances of this provider for search and lookups. """ - return False + return True async def search( self, @@ -68,7 +73,7 @@ class MusicProvider(Provider): raise NotImplementedError yield # type: ignore - async def get_library_tracks(self) -> AsyncGenerator[Track, None]: + async def get_library_tracks(self) -> AsyncGenerator[Track | AlbumTrack, None]: """Retrieve library tracks from the provider.""" if ProviderFeature.LIBRARY_TRACKS in self.supported_features: raise NotImplementedError @@ -122,14 +127,16 @@ class MusicProvider(Provider): if ProviderFeature.LIBRARY_RADIOS in self.supported_features: raise NotImplementedError - async def get_album_tracks(self, prov_album_id: str) -> list[Track]: # type: ignore[return] + async def get_album_tracks( + self, prov_album_id: str # type: ignore[return] + ) -> list[AlbumTrack]: """Get album tracks for given album id.""" if ProviderFeature.LIBRARY_ALBUMS in self.supported_features: raise NotImplementedError async def get_playlist_tracks( # type: ignore[return] self, prov_playlist_id: str - ) -> AsyncGenerator[Track, None]: + ) -> AsyncGenerator[PlaylistTrack, None]: """Get all playlist tracks for given playlist id.""" if ProviderFeature.LIBRARY_PLAYLISTS in self.supported_features: raise NotImplementedError @@ -399,30 +406,52 @@ class MusicProvider(Provider): controller = self.mass.music.get_controller(media_type) cur_db_ids = set() async for prov_item in self._get_library_gen(media_type): - db_item = await controller.get_db_item_by_prov_mappings( + library_item = await controller.get_library_item_by_prov_mappings( prov_item.provider_mappings, ) - if not db_item: + if not library_item: # create full db item - prov_item.in_library = True - db_item = await controller.add(prov_item, skip_metadata_lookup=True) + # note that we skip the metadata lookup purely to speed up the sync + # the additional metadata is then lazy retrieved afterwards + prov_item.favorite = True + library_item = await controller.add_item_to_library( + prov_item, skip_metadata_lookup=True + ) elif ( - db_item.metadata.checksum and prov_item.metadata.checksum - ) and db_item.metadata.checksum != prov_item.metadata.checksum: + library_item.metadata.checksum and prov_item.metadata.checksum + ) and library_item.metadata.checksum != prov_item.metadata.checksum: # existing dbitem checksum changed - db_item = await controller.update(db_item.item_id, prov_item) - cur_db_ids.add(db_item.item_id) - if not db_item.in_library: - await controller.set_db_library(db_item.item_id, True) + library_item = await controller.update_item_in_library( + library_item.item_id, prov_item + ) + cur_db_ids.add(library_item.item_id) # process deletions (= no longer in library) - cache_key = f"db_items.{media_type}.{self.instance_id}" - prev_db_items: list[int] | None - if prev_db_items := await self.mass.cache.get(cache_key): - for db_id in prev_db_items: + cache_key = f"library_items.{media_type}.{self.instance_id}" + prev_library_items: list[int] | None + if prev_library_items := await self.mass.cache.get(cache_key): + for db_id in prev_library_items: if db_id not in cur_db_ids: - # only mark the item as not in library and leave the metadata in db - await controller.set_db_library(db_id, False) + try: + item = await controller.get_library_item(db_id) + except MediaNotFoundError: + # edge case: the item is already removed + continue + remaining_providers = { + x.provider_domain + for x in item.provider_mappings + if x.provider_domain != self.domain + } + if not remaining_providers and media_type != MediaType.ARTIST: + # 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 we skip artists here to prevent a recursive removal + # of all albums and tracks underneath this artist + await controller.remove_item_from_library(db_id) + else: + # otherwise: just unmark favorite + await controller.set_favorite(db_id, False) await self.mass.cache.set(cache_key, list(cur_db_ids)) # DO NOT OVERRIDE BELOW diff --git a/music_assistant/server/providers/deezer/__init__.py b/music_assistant/server/providers/deezer/__init__.py index c8536299..e74bb338 100644 --- a/music_assistant/server/providers/deezer/__init__.py +++ b/music_assistant/server/providers/deezer/__init__.py @@ -3,6 +3,7 @@ import hashlib from asyncio import TaskGroup from collections.abc import AsyncGenerator from math import ceil +from typing import Any import deezer from aiohttp import ClientTimeout @@ -25,6 +26,7 @@ from music_assistant.common.models.enums import ( from music_assistant.common.models.errors import LoginFailed from music_assistant.common.models.media_items import ( Album, + AlbumTrack, Artist, AudioFormat, BrowseFolder, @@ -32,6 +34,7 @@ from music_assistant.common.models.media_items import ( MediaItemImage, MediaItemMetadata, Playlist, + PlaylistTrack, ProviderMapping, SearchResults, StreamDetails, @@ -237,7 +240,7 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 return self.parse_album(album=await self.client.get_album(album_id=int(prov_album_id))) except deezer.exceptions.DeezerErrorResponse as error: self.logger.warning("Failed getting album: %s", error) - return Album(prov_album_id, self.instance_id, "Not Found") + return Album(itemid=prov_album_id, provider=self.instance_id, name="Not Found") async def get_playlist(self, prov_playlist_id: str) -> Playlist: """Get full playlist details by id.""" @@ -252,22 +255,30 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 user_country=self.gw_client.user_country, ) - async def get_album_tracks(self, prov_album_id: str) -> list[Track]: - """Get all albums in a playlist.""" + async def get_album_tracks(self, prov_album_id: str) -> list[AlbumTrack]: + """Get all tracks in a album.""" album = await self.client.get_album(album_id=int(prov_album_id)) - return [ - self.parse_track(track=track, user_country=self.gw_client.user_country) - for track in album.tracks - ] + result = [] + for count, deezer_track in enumerate(album.tracks, start=1): + result.append( + self.parse_track( + track=deezer_track, + user_country=self.gw_client.user_country, + extra_init_kwargs={"disc_number": 0, "track_number": count}, + ) + ) + return result - async def get_playlist_tracks(self, prov_playlist_id: str) -> AsyncGenerator[Track, None]: + async def get_playlist_tracks( + self, prov_playlist_id: str + ) -> AsyncGenerator[PlaylistTrack, None]: """Get all tracks in a playlist.""" playlist = await self.client.get_playlist(playlist_id=prov_playlist_id) - for count, track in enumerate(playlist.tracks, start=1): - track_parsed = self.parse_track(track=track, user_country=self.gw_client.user_country) - track_parsed.position = count - track_parsed.id = track.id - yield track_parsed + for count, deezer_track in enumerate(playlist.tracks, start=1): + track = self.parse_track(track=deezer_track, user_country=self.gw_client.user_country) + track.position = count + track.id = track.id + yield track async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: """Get albums by an artist.""" @@ -287,7 +298,7 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 ] async def library_add(self, prov_item_id: str, media_type: MediaType) -> bool: - """Add an item to the library.""" + """Add an item to the provider's library/favorites.""" result = False if media_type == MediaType.ARTIST: result = await self.client.add_user_artists( @@ -310,7 +321,7 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 return result async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool: - """Remove an item to the library.""" + """Remove an item from the provider's library/favorites.""" result = False if media_type == MediaType.ARTIST: result = await self.client.remove_user_artists( @@ -512,9 +523,20 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 is_editable=playlist.creator.id == self.client.user.id, ) - def parse_track(self, track: deezer.Track, user_country: str) -> Track: + def parse_track( + self, + track: deezer.Track, + user_country: str, + extra_init_kwargs: dict[str, Any] | None = None, + ) -> Track | PlaylistTrack: """Parse the deezer-python track to a MASS track.""" - return Track( + if "position" in extra_init_kwargs: + track_class = PlaylistTrack + elif "disc_number" in extra_init_kwargs and "track_number" in extra_init_kwargs: + track_class = AlbumTrack + else: + track_class = Track + return track_class( item_id=str(track.id), provider=self.domain, name=track.title, @@ -545,6 +567,7 @@ class DeezerProvider(MusicProvider): # pylint: disable=W0223 ) }, metadata=self.parse_metadata_track(track=track), + **extra_init_kwargs or {}, ) ### SEARCH AND PARSE FUNCTIONS ### diff --git a/music_assistant/server/providers/fanarttv/__init__.py b/music_assistant/server/providers/fanarttv/__init__.py index 09631a84..f3b00077 100644 --- a/music_assistant/server/providers/fanarttv/__init__.py +++ b/music_assistant/server/providers/fanarttv/__init__.py @@ -80,10 +80,10 @@ class FanartTvMetadataProvider(MetadataProvider): async def get_artist_metadata(self, artist: Artist) -> MediaItemMetadata | None: """Retrieve metadata for artist on fanart.tv.""" - if not artist.musicbrainz_id: + if not artist.mbid: return None self.logger.debug("Fetching metadata for Artist %s on Fanart.tv", artist.name) - if data := await self._get_data(f"music/{artist.musicbrainz_id}"): + if data := await self._get_data(f"music/{artist.mbid}"): metadata = MediaItemMetadata() metadata.images = [] for key, img_type in IMG_MAPPING.items(): @@ -97,12 +97,12 @@ class FanartTvMetadataProvider(MetadataProvider): async def get_album_metadata(self, album: Album) -> MediaItemMetadata | None: """Retrieve metadata for album on fanart.tv.""" - if not album.musicbrainz_id: + if not album.mbid: return None self.logger.debug("Fetching metadata for Album %s on Fanart.tv", album.name) - if data := await self._get_data(f"music/albums/{album.musicbrainz_id}"): # noqa: SIM102 + if data := await self._get_data(f"music/albums/{album.mbid}"): # noqa: SIM102 if data and data.get("albums"): - data = data["albums"][album.musicbrainz_id] + data = data["albums"][album.mbid] metadata = MediaItemMetadata() metadata.images = [] for key, img_type in IMG_MAPPING.items(): diff --git a/music_assistant/server/providers/filesystem_local/base.py b/music_assistant/server/providers/filesystem_local/base.py index ae5bbcd9..2984112f 100644 --- a/music_assistant/server/providers/filesystem_local/base.py +++ b/music_assistant/server/providers/filesystem_local/base.py @@ -25,6 +25,7 @@ from music_assistant.common.models.errors import ( ) from music_assistant.common.models.media_items import ( Album, + AlbumTrack, Artist, AudioFormat, BrowseFolder, @@ -33,13 +34,19 @@ from music_assistant.common.models.media_items import ( MediaItemImage, MediaType, Playlist, + PlaylistTrack, ProviderMapping, Radio, SearchResults, StreamDetails, Track, ) -from music_assistant.constants import VARIOUS_ARTISTS, VARIOUS_ARTISTS_ID +from music_assistant.constants import ( + DB_TABLE_ALBUM_TRACKS, + DB_TABLE_TRACKS, + VARIOUS_ARTISTS_ID_MBID, + VARIOUS_ARTISTS_NAME, +) from music_assistant.server.controllers.cache import use_cache from music_assistant.server.controllers.music import DB_SCHEMA_VERSION from music_assistant.server.helpers.compare import compare_strings @@ -177,17 +184,19 @@ class FileSystemProviderBase(MusicProvider): # should normally not be needed to override @property - def is_unique(self) -> bool: + def is_streaming_provider(self) -> bool: """ - Return True if the (non user related) data in this provider instance is unique. + Return True if the provider is a streaming provider. + + This literally means that the catalog is not the same as the library contents. + For local based providers (files, plex), the catalog is the same as the library content. + It also means that data is if this provider is NOT a streaming provider, + data cross instances is unique, the catalog and library differs per instance. - For example on a global streaming provider (like Spotify), - the data on all instances is the same. - For a file provider each instance has other items. - Setting this to False will only query one instance of the provider for search and lookups. - Setting this to True will query all instances of this provider for search and lookups. + Setting this to True will only query one instance of the provider for search and lookups. + Setting this to False will query all instances of this provider for search and lookups. """ - return True + return False async def search( self, search_query: str, media_types=list[MediaType] | None, limit: int = 5 # noqa: ARG002 @@ -203,16 +212,18 @@ class FileSystemProviderBase(MusicProvider): # ruff: noqa: E501 if media_types is None or MediaType.TRACK in media_types: query = "SELECT * FROM tracks WHERE name LIKE :name AND provider_mappings LIKE :provider_instance" - result.tracks = await self.mass.music.tracks.get_db_items_by_query(query, params) + result.tracks = await self.mass.music.tracks.get_library_items_by_query(query, params) if media_types is None or MediaType.ALBUM in media_types: query = "SELECT * FROM albums WHERE name LIKE :name AND provider_mappings LIKE :provider_instance" - result.albums = await self.mass.music.albums.get_db_items_by_query(query, params) + result.albums = await self.mass.music.albums.get_library_items_by_query(query, params) if media_types is None or MediaType.ARTIST in media_types: query = "SELECT * FROM artists WHERE name LIKE :name AND provider_mappings LIKE :provider_instance" - result.artists = await self.mass.music.artists.get_db_items_by_query(query, params) + result.artists = await self.mass.music.artists.get_library_items_by_query(query, params) if media_types is None or MediaType.PLAYLIST in media_types: query = "SELECT * FROM playlists WHERE name LIKE :name AND provider_mappings LIKE :provider_instance" - result.playlists = await self.mass.music.playlists.get_db_items_by_query(query, params) + result.playlists = await self.mass.music.playlists.get_library_items_by_query( + query, params + ) return result async def browse(self, path: str) -> BrowseFolder: @@ -241,28 +252,30 @@ class FileSystemProviderBase(MusicProvider): continue if item.ext in TRACK_EXTENSIONS: - if db_item := await self.mass.music.tracks.get_db_item_by_prov_id( + if library_item := await self.mass.music.tracks.get_library_item_by_prov_id( item.path, self.instance_id ): - subitems.append(db_item) + subitems.append(library_item) elif track := await self.get_track(item.path): # make sure that the item exists # https://github.com/music-assistant/hass-music-assistant/issues/707 - db_item = await self.mass.music.tracks.add(track, skip_metadata_lookup=True) - subitems.append(db_item) + library_item = await self.mass.music.tracks.add_item_to_library( + track, skip_metadata_lookup=True + ) + subitems.append(library_item) continue if item.ext in PLAYLIST_EXTENSIONS: - if db_item := await self.mass.music.playlists.get_db_item_by_prov_id( + if library_item := await self.mass.music.playlists.get_library_item_by_prov_id( item.path, self.instance_id ): - subitems.append(db_item) + subitems.append(library_item) elif playlist := await self.get_playlist(item.path): # make sure that the item exists # https://github.com/music-assistant/hass-music-assistant/issues/707 - db_item = await self.mass.music.playlists.add( + library_item = await self.mass.music.playlists.add( playlist, skip_metadata_lookup=True ) - subitems.append(db_item) + subitems.append(library_item) continue return BrowseFolder( @@ -320,14 +333,18 @@ class FileSystemProviderBase(MusicProvider): if item.ext in TRACK_EXTENSIONS: # add/update track to db track = await self._parse_track(item) - await self.mass.music.tracks.add(track, skip_metadata_lookup=True) + await self.mass.music.tracks.add_item_to_library( + track, skip_metadata_lookup=True + ) elif item.ext in PLAYLIST_EXTENSIONS: playlist = await self.get_playlist(item.path) # add/update] playlist to db playlist.metadata.checksum = item.checksum # playlist is always in-library - playlist.in_library = True - await self.mass.music.playlists.add(playlist, skip_metadata_lookup=True) + playlist.favorite = True + await self.mass.music.playlists.add_item_to_library( + playlist, skip_metadata_lookup=True + ) except Exception as err: # pylint: disable=broad-except # we don't want the whole sync to crash on one file so we catch all exceptions here self.logger.exception("Error processing %s - %s", item.path, str(err)) @@ -349,6 +366,8 @@ class FileSystemProviderBase(MusicProvider): async def _process_deletions(self, deleted_files: set[str]) -> None: """Process all deletions.""" # process deleted tracks/playlists + album_ids = set() + artist_ids = set() for file_path in deleted_files: _, ext = file_path.rsplit(".", 1) if ext not in SUPPORTED_EXTENSIONS: @@ -360,12 +379,28 @@ class FileSystemProviderBase(MusicProvider): else: controller = self.mass.music.get_controller(MediaType.TRACK) - if db_item := await controller.get_db_item_by_prov_id(file_path, self.instance_id): - await controller.delete(db_item.item_id, True) + if library_item := await controller.get_library_item_by_prov_id( + file_path, self.instance_id + ): + if library_item.media_type == MediaType.TRACK: + album_ids.add(library_item.album.item_id) + for artist in library_item.artists + library_item.album.artists: + artist_ids.add(artist.item_id) + await controller.remove_item_from_library(library_item.item_id) + # check if any albums need to be cleaned up + for album_id in album_ids: + if not self.mass.music.albums.tracks(album_id, "library"): + await self.mass.music.albums.remove_item_from_library(album_id) + # check if any artists need to be cleaned up + for artist_id in artist_ids: + if not self.mass.music.artists.albums( + artist_id, "library" + ) and self.mass.music.artists.tracks(artist_id, "library"): + await self.mass.music.artists.remove_item_from_library(album_id) async def get_artist(self, prov_artist_id: str) -> Artist: """Get full artist details by id.""" - db_artist = await self.mass.music.artists.get_db_item_by_prov_id( + db_artist = await self.mass.music.artists.get_library_item_by_prov_id( prov_artist_id, self.instance_id ) if db_artist is None: @@ -401,7 +436,7 @@ class FileSystemProviderBase(MusicProvider): file_item = await self.resolve(prov_playlist_id) playlist = Playlist( - file_item.path, + item_id=file_item.path, provider=self.instance_id, name=file_item.name.replace(f".{file_item.ext}", ""), ) @@ -419,27 +454,30 @@ class FileSystemProviderBase(MusicProvider): playlist.metadata.checksum = checksum return playlist - async def get_album_tracks(self, prov_album_id: str) -> list[Track]: + async def get_album_tracks(self, prov_album_id: str) -> list[AlbumTrack]: """Get album tracks for given album id.""" # filesystem items are always stored in db so we can query the database - db_album = await self.mass.music.albums.get_db_item_by_prov_id( + db_album = await self.mass.music.albums.get_library_item_by_prov_id( prov_album_id, self.instance_id ) if db_album is None: raise MediaNotFoundError(f"Album not found: {prov_album_id}") - # TODO: adjust to json query instead of text search - query = f"SELECT * FROM tracks WHERE albums LIKE '%\"{db_album.item_id}\"%'" - query += f" AND provider_mappings LIKE '%\"{self.instance_id}\"%'" - result = [] - for track in await self.mass.music.tracks.get_db_items_by_query(query): - track.album = db_album - if album_mapping := next( - (x for x in track.albums if x.item_id == db_album.item_id), None - ): - track.disc_number = album_mapping.disc_number - track.track_number = album_mapping.track_number - result.append(track) - return sorted(result, key=lambda x: (x.disc_number or 0, x.track_number or 0)) + result: list[AlbumTrack] = [] + async for album_track_row in self.mass.music.database.iter_items( + DB_TABLE_ALBUM_TRACKS, {"album_id": db_album.item_id} + ): + track_row = await self.mass.music.database.get_row( + DB_TABLE_TRACKS, {"item_id": album_track_row["track_id"]} + ) + if f'"{self.instance_id}"' not in track_row["provider_mappings"]: + continue + album_track = AlbumTrack.from_db_row( + {**track_row, **album_track_row, "album": db_album.to_dict()} + ) + if db_album.metadata.images: + album_track.metadata.images = db_album.metadata.images + result.append(album_track) + return sorted(result, key=lambda x: (x.disc_number, x.track_number)) async def get_playlist_tracks(self, prov_playlist_id: str) -> AsyncGenerator[Track, None]: """Get playlist tracks for given playlist id.""" @@ -460,23 +498,26 @@ class FileSystemProviderBase(MusicProvider): else: playlist_lines = await parse_pls(playlist_data) - for line_no, playlist_line in enumerate(playlist_lines): + for line_no, playlist_line in enumerate(playlist_lines, 1): if media_item := await self._parse_playlist_line( - playlist_line, os.path.dirname(prov_playlist_id) + playlist_line, os.path.dirname(prov_playlist_id), line_no ): - # use the linenumber as position for easier deletions - media_item.position = line_no + 1 yield media_item except Exception as err: # pylint: disable=broad-except self.logger.warning("Error while parsing playlist %s", prov_playlist_id, exc_info=err) - async def _parse_playlist_line(self, line: str, playlist_path: str) -> Track | Radio | None: + async def _parse_playlist_line( + self, line: str, playlist_path: str, position: int + ) -> Track | Radio | None: """Try to parse a track from a playlist line.""" try: if "://" in line: # handle as generic uri - return await self.mass.music.get_item_by_uri(line) + media_item = await self.mass.music.get_item_by_uri(line) + if isinstance(media_item, Track): + return PlaylistTrack.from_dict({**media_item.to_dict(), "position": position}) + return media_item # if a relative path was given in an upper level from the playlist, # try to resolve it @@ -491,7 +532,7 @@ class FileSystemProviderBase(MusicProvider): for filename in (line, os.path.join(playlist_path, line)): with contextlib.suppress(FileNotFoundError): item = await self.resolve(filename) - return await self._parse_track(item) + return await self._parse_track(item, playlist_position=position) except MusicAssistantError as err: self.logger.warning("Could not parse uri/file %s to track: %s", line, str(err)) @@ -552,11 +593,13 @@ class FileSystemProviderBase(MusicProvider): async def get_stream_details(self, item_id: str) -> StreamDetails: """Return the content details for the given track when it will be streamed.""" - db_item = await self.mass.music.tracks.get_db_item_by_prov_id(item_id, self.instance_id) - if db_item is None: + library_item = await self.mass.music.tracks.get_library_item_by_prov_id( + item_id, self.instance_id + ) + if library_item is None: raise MediaNotFoundError(f"Item not found: {item_id}") - prov_mapping = next(x for x in db_item.provider_mappings if x.item_id == item_id) + prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id) file_item = await self.resolve(item_id) return StreamDetails( @@ -564,7 +607,7 @@ class FileSystemProviderBase(MusicProvider): item_id=item_id, audio_format=prov_mapping.audio_format, media_type=MediaType.TRACK, - duration=db_item.duration, + duration=library_item.duration, size=file_item.file_size, direct=file_item.local_path, can_seek=prov_mapping.audio_format.content_type in SEEKABLE_FILES, @@ -594,21 +637,37 @@ class FileSystemProviderBase(MusicProvider): file_item = await self.resolve(path) return file_item.local_path or self.read_file_content(file_item.absolute_path) - async def _parse_track(self, file_item: FileSystemItem) -> Track: + async def _parse_track( + self, file_item: FileSystemItem, playlist_position: int | None = None + ) -> Track | AlbumTrack | PlaylistTrack: """Get full track details by id.""" # ruff: noqa: PLR0915, PLR0912 # parse tags input_file = file_item.local_path or self.read_file_content(file_item.absolute_path) tags = await parse_tags(input_file, file_item.file_size) - name, version = parse_title_and_version(tags.title, tags.version) - track = Track( - item_id=file_item.path, - provider=self.instance_id, - name=name, - version=version, - ) + base_details = { + "item_id": file_item.path, + "provider": self.instance_id, + "name": name, + "version": version, + } + if playlist_position is not None: + track = PlaylistTrack( + **base_details, + position=playlist_position, + ) + elif tags.album and tags.disc and tags.track: + track = AlbumTrack( + **base_details, + disc_number=tags.disc, + track_number=tags.track, + ) + else: + track = Track( + **base_details, + ) # album if tags.album: @@ -626,9 +685,9 @@ class FileSystemProviderBase(MusicProvider): # work out if we have an artist folder artist_dir = get_parentdir(album_dir, album_artist_str, 1) artist = await self._parse_artist(album_artist_str, artist_path=artist_dir) - if not artist.musicbrainz_id: + if not artist.mbid: with contextlib.suppress(IndexError): - artist.musicbrainz_id = tags.musicbrainz_albumartistids[index] + artist.mbid = tags.musicbrainz_albumartistids[index] album_artists.append(artist) else: # album artist tag is missing, determine fallback @@ -637,9 +696,9 @@ class FileSystemProviderBase(MusicProvider): self.logger.warning( "%s is missing ID3 tag [albumartist], using %s as fallback", file_item.path, - VARIOUS_ARTISTS, + VARIOUS_ARTISTS_NAME, ) - album_artists = [await self._parse_artist(name=VARIOUS_ARTISTS)] + album_artists = [await self._parse_artist(name=VARIOUS_ARTISTS_NAME)] elif fallback_action == "track_artist": self.logger.warning( "%s is missing ID3 tag [albumartist], using track artist(s) as fallback", @@ -654,26 +713,23 @@ class FileSystemProviderBase(MusicProvider): raise InvalidDataError("missing ID3 tag [albumartist]") track.album = await self._parse_album( - tags.album, - album_dir, - disc_dir, - artists=album_artists, + tags.album, album_dir, disc_dir, artists=album_artists, barcode=tags.barcode ) - else: - self.logger.warning("%s is missing ID3 tag [album]", file_item.path) # track artist(s) for index, track_artist_str in enumerate(tags.artists): # re-use album artist details if possible if track.album and ( - artist := next((x for x in track.album.artists if x.name == track_artist_str), None) + album_artist := next( + (x for x in track.album.artists if x.name == track_artist_str), None + ) ): - track.artists.append(artist) + artist = album_artist else: artist = await self._parse_artist(track_artist_str) - if not artist.musicbrainz_id: + if not artist.mbid: with contextlib.suppress(IndexError): - artist.musicbrainz_id = tags.musicbrainz_artistids[index] + artist.mbid = tags.musicbrainz_artistids[index] track.artists.append(artist) # cover image - prefer embedded image, fallback to album cover @@ -696,20 +752,18 @@ class FileSystemProviderBase(MusicProvider): track.metadata.genres = set(tags.genres) track.disc_number = tags.disc track.track_number = tags.track - track.isrc.update(tags.isrc) track.metadata.copyright = tags.get("copyright") track.metadata.lyrics = tags.get("lyrics") explicit_tag = tags.get("itunesadvisory") if explicit_tag is not None: track.metadata.explicit = explicit_tag == "1" - track.musicbrainz_id = tags.musicbrainz_trackid + track.mbid = tags.musicbrainz_trackid track.metadata.chapters = tags.chapters if track.album: - if not track.album.musicbrainz_id: - track.album.musicbrainz_id = tags.musicbrainz_releasegroupid + if not track.album.mbid: + track.album.mbid = tags.musicbrainz_releasegroupid if not track.album.year: track.album.year = tags.year - track.album.barcode.update(tags.barcode) track.album.album_type = tags.album_type track.album.metadata.explicit = track.metadata.explicit # set checksum to invalidate any cached listings @@ -731,6 +785,7 @@ class FileSystemProviderBase(MusicProvider): bit_depth=tags.bits_per_sample, bit_rate=tags.bit_rate, ), + isrc=tags.isrc, ) ) return track @@ -749,13 +804,13 @@ class FileSystemProviderBase(MusicProvider): name = artist_path.split(os.sep)[-1] artist = Artist( - artist_path, - self.instance_id, - name, + item_id=artist_path, + provider=self.instance_id, + name=name, provider_mappings={ ProviderMapping(artist_path, self.instance_id, self.instance_id, url=artist_path) }, - musicbrainz_id=VARIOUS_ARTISTS_ID if compare_strings(name, VARIOUS_ARTISTS) else None, + mbid=VARIOUS_ARTISTS_ID_MBID if compare_strings(name, VARIOUS_ARTISTS_NAME) else None, ) if not await self.exists(artist_path): @@ -774,8 +829,8 @@ class FileSystemProviderBase(MusicProvider): artist.name = info.get("title", info.get("name", name)) if sort_name := info.get("sortname"): artist.sort_name = sort_name - if musicbrainz_id := info.get("musicbrainzartistid"): - artist.musicbrainz_id = musicbrainz_id + if mbid := info.get("musicbrainzartistid"): + artist.mbid = mbid if description := info.get("biography"): artist.metadata.description = description if genre := info.get("genre"): @@ -787,7 +842,12 @@ class FileSystemProviderBase(MusicProvider): return artist async def _parse_album( - self, name: str | None, album_path: str | None, disc_path: str | None, artists: list[Artist] + self, + name: str | None, + album_path: str | None, + disc_path: str | None, + artists: list[Artist], + barcode: str | None = None, ) -> Album | None: """Lookup metadata in Album folder.""" assert (name or album_path) and artists @@ -799,12 +859,14 @@ class FileSystemProviderBase(MusicProvider): name = album_path.split(os.sep)[-1] album = Album( - album_path, - self.instance_id, - name, + item_id=album_path, + provider=self.instance_id, + name=name, artists=artists, provider_mappings={ - ProviderMapping(album_path, self.instance_id, self.instance_id, url=album_path) + ProviderMapping( + album_path, self.instance_id, self.instance_id, url=album_path, barcode=barcode + ) }, ) @@ -827,11 +889,11 @@ class FileSystemProviderBase(MusicProvider): album.name = info.get("title", info.get("name", name)) if sort_name := info.get("sortname"): album.sort_name = sort_name - if musicbrainz_id := info.get("musicbrainzreleasegroupid"): - album.musicbrainz_id = musicbrainz_id + if mbid := info.get("musicbrainzreleasegroupid"): + album.mbid = mbid if mb_artist_id := info.get("musicbrainzalbumartistid"): # noqa: SIM102 - if album.artists and not album.artists[0].musicbrainz_id: - album.artists[0].musicbrainz_id = mb_artist_id + if album.artists and not album.artists[0].mbid: + album.artists[0].mbid = mb_artist_id if description := info.get("review"): album.metadata.description = description if year := info.get("year"): diff --git a/music_assistant/server/providers/filesystem_smb/__init__.py b/music_assistant/server/providers/filesystem_smb/__init__.py index bd2c988e..9f0537b4 100644 --- a/music_assistant/server/providers/filesystem_smb/__init__.py +++ b/music_assistant/server/providers/filesystem_smb/__init__.py @@ -136,6 +136,7 @@ class SMBFileSystemProvider(LocalFileSystemProvider): await makedirs(self.base_path) try: + await self.unmount() await self.mount() except Exception as err: raise LoginFailed(f"Connection failed for the given details: {err}") from err diff --git a/music_assistant/server/providers/musicbrainz/__init__.py b/music_assistant/server/providers/musicbrainz/__init__.py index 494394f5..e146b6b8 100644 --- a/music_assistant/server/providers/musicbrainz/__init__.py +++ b/music_assistant/server/providers/musicbrainz/__init__.py @@ -79,35 +79,39 @@ class MusicbrainzProvider(MetadataProvider): """Discover MusicBrainzArtistId for an artist given some reference albums/tracks.""" for ref_album in ref_albums: # try matching on album musicbrainz id - if ref_album.musicbrainz_id: # noqa: SIM102 - if musicbrainz_id := await self._search_artist_by_album_mbid( - artistname=artist.name, album_mbid=ref_album.musicbrainz_id + if ref_album.mbid: # noqa: SIM102 + if mbid := await self._search_artist_by_album_mbid( + artistname=artist.name, album_mbid=ref_album.mbid ): - return musicbrainz_id + return mbid # try matching on album barcode - for barcode in ref_album.barcode: - if musicbrainz_id := await self._search_artist_by_album( + for provider_mapping in ref_album.provider_mappings: + if not provider_mapping.barcode: + continue + if mbid := await self._search_artist_by_album( artistname=artist.name, - album_barcode=barcode, + album_barcode=provider_mapping.barcode, ): - return musicbrainz_id + return mbid # try again with matching on track isrc for ref_track in ref_tracks: - for isrc in ref_track.isrc: - if musicbrainz_id := await self._search_artist_by_track( + for provider_mapping in ref_track.provider_mappings: + if not provider_mapping.isrc: + continue + if mbid := await self._search_artist_by_track( artistname=artist.name, - track_isrc=isrc, + track_isrc=provider_mapping.isrc, ): - return musicbrainz_id + return mbid # last restort: track matching by name for ref_track in ref_tracks: - if musicbrainz_id := await self._search_artist_by_track( + if mbid := await self._search_artist_by_track( artistname=artist.name, trackname=ref_track.name, ): - return musicbrainz_id + return mbid return None diff --git a/music_assistant/server/providers/plex/__init__.py b/music_assistant/server/providers/plex/__init__.py index 82bcf72b..979123b5 100644 --- a/music_assistant/server/providers/plex/__init__.py +++ b/music_assistant/server/providers/plex/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from asyncio import TaskGroup from collections.abc import AsyncGenerator, Callable, Coroutine +from typing import Any import plexapi.exceptions from aiohttp import ClientTimeout @@ -34,6 +35,7 @@ from music_assistant.common.models.enums import ( from music_assistant.common.models.errors import InvalidDataError, LoginFailed, MediaNotFoundError from music_assistant.common.models.media_items import ( Album, + AlbumTrack, Artist, AudioFormat, ItemMapping, @@ -41,6 +43,7 @@ from music_assistant.common.models.media_items import ( MediaItemChapter, MediaItemImage, Playlist, + PlaylistTrack, ProviderMapping, SearchResults, StreamDetails, @@ -182,17 +185,19 @@ class PlexProvider(MusicProvider): ) @property - def is_unique(self) -> bool: + def is_streaming_provider(self) -> bool: """ - Return True if the (non user related) data in this provider instance is unique. + Return True if the provider is a streaming provider. - For example on a global streaming provider (like Spotify), - the data on all instances is the same. - For a file provider each instance has other items. - Setting this to False will only query one instance of the provider for search and lookups. - Setting this to True will query all instances of this provider for search and lookups. + This literally means that the catalog is not the same as the library contents. + For local based providers (files, plex), the catalog is the same as the library content. + It also means that data is if this provider is NOT a streaming provider, + data cross instances is unique, the catalog and library differs per instance. + + Setting this to True will only query one instance of the provider for search and lookups. + Setting this to False will query all instances of this provider for search and lookups. """ - return True + return False async def resolve_image(self, path: str) -> str | bytes | AsyncGenerator[bytes, None]: """Return the full image URL including the auth token.""" @@ -220,7 +225,7 @@ class PlexProvider(MusicProvider): "name": f"%{artist_name}%", "provider_instance": f"%{self.instance_id}%", } - db_artists = await self.mass.music.artists.get_db_items_by_query(query, params) + db_artists = await self.mass.music.artists.get_library_items_by_query(query, params) if db_artists: return ItemMapping.from_item(db_artists[0]) @@ -359,9 +364,26 @@ class PlexProvider(MusicProvider): ) return playlist - async def _parse_track(self, plex_track: PlexTrack) -> Track: + async def _parse_track( + self, plex_track: PlexTrack, extra_init_kwargs: dict[str, Any] | None = None + ) -> Track | AlbumTrack | PlaylistTrack: """Parse a Plex Track response to a Track model object.""" - track = Track(item_id=plex_track.key, provider=self.instance_id, name=plex_track.title) + if extra_init_kwargs and "position" in extra_init_kwargs: + track_class = PlaylistTrack + elif ( + extra_init_kwargs + and "disc_number" in extra_init_kwargs + and "track_number" in extra_init_kwargs + ): + track_class = AlbumTrack + else: + track_class = Track + track = track_class( + item_id=plex_track.key, + provider=self.instance_id, + name=plex_track.title, + **extra_init_kwargs or {}, + ) if plex_track.originalTitle and plex_track.originalTitle != plex_track.grandparentTitle: # The artist of the track if different from the album's artist. @@ -385,10 +407,6 @@ class PlexProvider(MusicProvider): ) if plex_track.duration: track.duration = int(plex_track.duration / 1000) - if plex_track.trackNumber: - track.track_number = plex_track.trackNumber - if plex_track.parentIndex: - track.disc_number = plex_track.parentIndex if plex_track.chapters: track.metadata.chapters = [ MediaItemChapter( @@ -506,13 +524,15 @@ class PlexProvider(MusicProvider): return await self._parse_album(plex_album) raise MediaNotFoundError(f"Item {prov_album_id} not found") - async def get_album_tracks(self, prov_album_id: str) -> list[Track]: + async def get_album_tracks(self, prov_album_id: str) -> list[AlbumTrack]: """Get album tracks for given album id.""" - plex_album = await self._get_data(prov_album_id, PlexAlbum) - + plex_album: PlexAlbum = await self._get_data(prov_album_id, PlexAlbum) tracks = [] for plex_track in await self._run_async(plex_album.tracks): - track = await self._parse_track(plex_track) + track = await self._parse_track( + plex_track, + {"disc_number": plex_track.parentIndex, "track_number": plex_track.trackNumber}, + ) tracks.append(track) return tracks @@ -521,7 +541,7 @@ class PlexProvider(MusicProvider): if prov_artist_id.startswith(FAKE_ARTIST_PREFIX): # This artist does not exist in plex, so we can just load it from DB. - if db_artist := await self.mass.music.artists.get_db_item_by_prov_id( + if db_artist := await self.mass.music.artists.get_library_item_by_prov_id( prov_artist_id, self.instance_id ): return db_artist @@ -547,16 +567,11 @@ class PlexProvider(MusicProvider): self, prov_playlist_id: str ) -> AsyncGenerator[Track, None]: """Get all playlist tracks for given playlist id.""" - plex_playlist = await self._get_data(prov_playlist_id, PlexPlaylist) - + plex_playlist: PlexPlaylist = await self._get_data(prov_playlist_id, PlexPlaylist) playlist_items = await self._run_async(plex_playlist.items) - if not playlist_items: - yield None - for index, plex_track in enumerate(playlist_items): - track = await self._parse_track(plex_track) - if track: - track.position = index + 1 + for index, plex_track in enumerate(playlist_items or []): + if track := await self._parse_track(plex_track, {"position": index + 1}): yield track async def get_artist_albums(self, prov_artist_id) -> list[Album]: diff --git a/music_assistant/server/providers/qobuz/__init__.py b/music_assistant/server/providers/qobuz/__init__.py index feff3ab3..dae1b2f8 100644 --- a/music_assistant/server/providers/qobuz/__init__.py +++ b/music_assistant/server/providers/qobuz/__init__.py @@ -17,6 +17,7 @@ from music_assistant.common.models.enums import ConfigEntryType, ProviderFeature from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError from music_assistant.common.models.media_items import ( Album, + AlbumTrack, AlbumType, Artist, AudioFormat, @@ -25,12 +26,18 @@ from music_assistant.common.models.media_items import ( MediaItemImage, MediaType, Playlist, + PlaylistTrack, ProviderMapping, SearchResults, StreamDetails, Track, ) -from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME +from music_assistant.constants import ( + CONF_PASSWORD, + CONF_USERNAME, + VARIOUS_ARTISTS_ID_MBID, + VARIOUS_ARTISTS_NAME, +) from music_assistant.server.helpers.app_vars import app_var # pylint: disable=no-name-in-module from music_assistant.server.models.music_provider import MusicProvider @@ -57,6 +64,8 @@ SUPPORTED_FEATURES = ( ProviderFeature.ARTIST_TOPTRACKS, ) +VARIOUS_ARTISTS_ID = "145383" + async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig @@ -217,7 +226,7 @@ class QobuzProvider(MusicProvider): return await self._parse_playlist(playlist_obj) raise MediaNotFoundError(f"Item {prov_playlist_id} not found") - async def get_album_tracks(self, prov_album_id) -> list[Track]: + async def get_album_tracks(self, prov_album_id) -> list[AlbumTrack]: """Get all album tracks for given album id.""" params = {"album_id": prov_album_id} return [ @@ -226,31 +235,34 @@ class QobuzProvider(MusicProvider): if (item and item["id"]) ] - async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]: + async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[PlaylistTrack, None]: """Get all playlist tracks for given playlist id.""" count = 1 - for item in await self._get_all_items( + for track_obj in await self._get_all_items( "playlist/get", key="tracks", playlist_id=prov_playlist_id, extra="tracks", ): - if not (item and item["id"]): + if not (track_obj and track_obj["id"]): continue - track = await self._parse_track(item) - # use count as position - track.position = count + track_obj["position"] = count + track = await self._parse_track(track_obj) yield track count += 1 async def get_artist_albums(self, prov_artist_id) -> list[Album]: """Get a list of albums for the given artist.""" - endpoint = "artist/get" + result = await self._get_data( + "artist/get", + artist_id=prov_artist_id, + extra="albums", + offset=0, + limit=100, + ) return [ await self._parse_album(item) - for item in await self._get_all_items( - endpoint, key="albums", artist_id=prov_artist_id, extra="albums" - ) + for item in result["albums"]["items"] if (item and item["id"] and str(item["artist"]["id"]) == prov_artist_id) ] @@ -428,6 +440,9 @@ class QobuzProvider(MusicProvider): artist = Artist( item_id=str(artist_obj["id"]), provider=self.domain, name=artist_obj["name"] ) + if artist.item_id == VARIOUS_ARTISTS_ID: + artist.mbid = VARIOUS_ARTISTS_ID_MBID + artist.name = VARIOUS_ARTISTS_NAME artist.add_provider_mapping( ProviderMapping( item_id=str(artist_obj["id"]), @@ -460,6 +475,7 @@ class QobuzProvider(MusicProvider): provider_domain=self.domain, provider_instance=self.instance_id, available=album_obj["streamable"] and album_obj["displayable"], + barcode=album_obj["upc"], audio_format=AudioFormat( content_type=ContentType.FLAC, sample_rate=album_obj["maximum_sampling_rate"] * 1000, @@ -488,7 +504,6 @@ class QobuzProvider(MusicProvider): album.metadata.genres = {album_obj["genre"]["name"]} if img := self.__get_image(album_obj): album.metadata.images = [MediaItemImage(ImageType.THUMB, img)] - album.barcode.add(album_obj["upc"]) if "label" in album_obj: album.metadata.label = album_obj["label"]["name"] if (released_at := album_obj.get("released_at")) and released_at != 0: @@ -501,19 +516,29 @@ class QobuzProvider(MusicProvider): album.metadata.explicit = True return album - async def _parse_track(self, track_obj: dict): + async def _parse_track(self, track_obj: dict) -> Track | AlbumTrack | PlaylistTrack: """Parse qobuz track object to generic layout.""" # pylint: disable=too-many-branches name, version = parse_title_and_version(track_obj["title"], track_obj.get("version")) - track = Track( + if "position" in track_obj: + track_class = PlaylistTrack + extra_init_kwargs = {"position": track_obj["position"]} + elif "media_number" in track_obj and "track_number" in track_obj: + track_class = AlbumTrack + extra_init_kwargs = { + "disc_number": track_obj["media_number"], + "track_number": track_obj["track_number"], + } + else: + track_class = Track + extra_init_kwargs = {} + track = track_class( item_id=str(track_obj["id"]), provider=self.domain, name=name, version=version, - disc_number=track_obj["media_number"], - track_number=track_obj["track_number"], duration=track_obj["duration"], - position=track_obj.get("position"), + **extra_init_kwargs, ) if track_obj.get("performer") and "Various " not in track_obj["performer"]: artist = await self._parse_artist(track_obj["performer"]) @@ -534,7 +559,7 @@ class QobuzProvider(MusicProvider): role = performer_str.split(", ")[1] name = performer_str.split(", ")[0] if "artist" in role.lower(): - artist = Artist(name, self.domain, name) + artist = Artist(item_id=name, provider=self.domain, name=name) track.artists.append(artist) # TODO: fix grabbing composer from details @@ -542,8 +567,6 @@ class QobuzProvider(MusicProvider): album = await self._parse_album(track_obj["album"]) if album: track.album = album - if track_obj.get("isrc"): - track.isrc.add(track_obj["isrc"]) if track_obj.get("performers"): track.metadata.performers = {x.strip() for x in track_obj["performers"].split("-")} if track_obj.get("copyright"): @@ -567,6 +590,7 @@ class QobuzProvider(MusicProvider): bit_depth=track_obj["maximum_bit_depth"], ), url=track_obj.get("url", f'https://open.qobuz.com/track/{track_obj["id"]}'), + isrc=track_obj.get("isrc"), ) ) return track @@ -632,7 +656,6 @@ class QobuzProvider(MusicProvider): if not result.get(key) or not result[key].get("items"): break for item in result[key]["items"]: - item["position"] = len(all_items) + 1 all_items.append(item) if len(result[key]["items"]) < limit: break diff --git a/music_assistant/server/providers/slimproto/__init__.py b/music_assistant/server/providers/slimproto/__init__.py index 26941e8e..f5443de4 100644 --- a/music_assistant/server/providers/slimproto/__init__.py +++ b/music_assistant/server/providers/slimproto/__init__.py @@ -307,9 +307,9 @@ class SlimprotoProvider(PlayerProvider): preset_entries = tuple() if not (client and client.device_model in self._virtual_providers): presets = [] - async for playlist in self.mass.music.playlists.iter_db_items(True): + async for playlist in self.mass.music.playlists.iter_library_items(True): presets.append(ConfigValueOption(playlist.name, playlist.uri)) - async for radio in self.mass.music.radio.iter_db_items(True): + async for radio in self.mass.music.radio.iter_library_items(True): presets.append(ConfigValueOption(radio.name, radio.uri)) # dynamically extend the amount of presets when needed if self.mass.config.get_raw_player_config_value(player_id, "preset_15"): diff --git a/music_assistant/server/providers/slimproto/cli.py b/music_assistant/server/providers/slimproto/cli.py index 84bec41f..0e172ff3 100644 --- a/music_assistant/server/providers/slimproto/cli.py +++ b/music_assistant/server/providers/slimproto/cli.py @@ -1181,21 +1181,27 @@ class LmsCli: await self.mass.music.artists.album_artists(True, limit=limit, offset=offset) ).items elif mode == "artists": - items = (await self.mass.music.artists.db_items(True, limit=limit, offset=offset)).items + items = ( + await self.mass.music.artists.library_items(True, limit=limit, offset=offset) + ).items elif mode == "artist" and "uri" in kwargs: artist = await self.mass.music.get_item_by_uri(kwargs["uri"]) items = await self.mass.music.artists.tracks(artist.item_id, artist.provider) elif mode == "albums": - items = (await self.mass.music.albums.db_items(True, limit=limit, offset=offset)).items + items = ( + await self.mass.music.albums.library_items(True, limit=limit, offset=offset) + ).items elif mode == "album" and "uri" in kwargs: album = await self.mass.music.get_item_by_uri(kwargs["uri"]) items = await self.mass.music.albums.tracks(album.item_id, album.provider) elif mode == "playlists": items = ( - await self.mass.music.playlists.db_items(True, limit=limit, offset=offset) + await self.mass.music.playlists.library_items(True, limit=limit, offset=offset) ).items elif mode == "radios": - items = (await self.mass.music.radio.db_items(True, limit=limit, offset=offset)).items + items = ( + await self.mass.music.radio.library_items(True, limit=limit, offset=offset) + ).items elif mode == "playlist" and "uri" in kwargs: playlist = await self.mass.music.get_item_by_uri(kwargs["uri"]) items = [ diff --git a/music_assistant/server/providers/soundcloud/__init__.py b/music_assistant/server/providers/soundcloud/__init__.py index ddc5f876..f6199dda 100644 --- a/music_assistant/server/providers/soundcloud/__init__.py +++ b/music_assistant/server/providers/soundcloud/__init__.py @@ -18,6 +18,7 @@ from music_assistant.common.models.media_items import ( MediaItemImage, MediaType, Playlist, + PlaylistTrack, ProviderMapping, SearchResults, StreamDetails, @@ -233,7 +234,7 @@ class SoundcloudMusicProvider(MusicProvider): self.logger.debug("Parse playlist failed: %s", playlist_obj, exc_info=error) return playlist - async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]: + async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[PlaylistTrack, None]: """Get all playlist tracks for given playlist id.""" playlist_obj = await self._soundcloud.get_playlist_details(playlist_id=prov_playlist_id) if "tracks" not in playlist_obj: @@ -241,9 +242,7 @@ class SoundcloudMusicProvider(MusicProvider): for index, item in enumerate(playlist_obj["tracks"]): song = await self._soundcloud.get_track_details(item["id"]) try: - track = await self._parse_track(song[0]) - if track: - track.position = index + 1 + if track := await self._parse_track(song[0], index + 1): yield track except (KeyError, TypeError, InvalidDataError, IndexError) as error: self.logger.debug("Parse track failed: %s", song, exc_info=error) @@ -346,15 +345,19 @@ class SoundcloudMusicProvider(MusicProvider): playlist.metadata.style = playlist_obj["tag_list"] return playlist - async def _parse_track(self, track_obj: dict) -> Track: + async def _parse_track( + self, track_obj: dict, playlist_position: int | None = None + ) -> Track | PlaylistTrack: """Parse a Soundcloud Track response to a Track model object.""" name, version = parse_title_and_version(track_obj["title"]) - track = Track( + track_class = PlaylistTrack if playlist_position is not None else Track + track = track_class( item_id=track_obj["id"], provider=self.domain, name=name, version=version, duration=track_obj["duration"] / 1000, + **{"position": playlist_position} if playlist_position else {}, ) user_id = track_obj["user"]["id"] user = await self._soundcloud.get_user_details(user_id) diff --git a/music_assistant/server/providers/spotify/__init__.py b/music_assistant/server/providers/spotify/__init__.py index 8d350399..cc7a360d 100644 --- a/music_assistant/server/providers/spotify/__init__.py +++ b/music_assistant/server/providers/spotify/__init__.py @@ -10,7 +10,7 @@ import time from collections.abc import AsyncGenerator from json.decoder import JSONDecodeError from tempfile import gettempdir -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import aiohttp from asyncio_throttle import Throttler @@ -21,6 +21,7 @@ from music_assistant.common.models.enums import ConfigEntryType, ProviderFeature from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError from music_assistant.common.models.media_items import ( Album, + AlbumTrack, AlbumType, Artist, AudioFormat, @@ -29,6 +30,7 @@ from music_assistant.common.models.media_items import ( MediaItemImage, MediaType, Playlist, + PlaylistTrack, ProviderMapping, SearchResults, StreamDetails, @@ -245,7 +247,7 @@ class SpotifyProvider(MusicProvider): return await self._parse_playlist(playlist_obj) raise MediaNotFoundError(f"Item {prov_playlist_id} not found") - async def get_album_tracks(self, prov_album_id) -> list[Track]: + async def get_album_tracks(self, prov_album_id) -> list[AlbumTrack]: """Get all album tracks for given album id.""" return [ await self._parse_track(item) @@ -253,7 +255,7 @@ class SpotifyProvider(MusicProvider): if (item and item["id"]) ] - async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]: + async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[PlaylistTrack, None]: """Get all playlist tracks for given playlist id.""" count = 1 for item in await self._get_all_items( @@ -261,9 +263,9 @@ class SpotifyProvider(MusicProvider): ): if not (item and item["track"] and item["track"]["id"]): continue - track = await self._parse_track(item["track"]) # use count as position - track.position = count + item["track"]["position"] = count + track = await self._parse_track(item["track"]) yield track count += 1 @@ -434,10 +436,6 @@ class SpotifyProvider(MusicProvider): album.metadata.genre = set(album_obj["genres"]) if album_obj.get("images"): album.metadata.images = [MediaItemImage(ImageType.THUMB, album_obj["images"][0]["url"])] - if "external_ids" in album_obj and album_obj["external_ids"].get("upc"): - album.barcode.add(album_obj["external_ids"]["upc"]) - if "external_ids" in album_obj and album_obj["external_ids"].get("ean"): - album.barcode.add(album_obj["external_ids"]["ean"]) if "label" in album_obj: album.metadata.label = album_obj["label"] if album_obj.get("release_date"): @@ -446,6 +444,11 @@ class SpotifyProvider(MusicProvider): album.metadata.copyright = album_obj["copyrights"][0]["text"] if album_obj.get("explicit"): album.metadata.explicit = album_obj["explicit"] + barcode = None + if "external_ids" in album_obj and album_obj["external_ids"].get("upc"): + barcode = album_obj["external_ids"]["upc"] + if "external_ids" in album_obj and album_obj["external_ids"].get("ean"): + barcode = album_obj["external_ids"]["ean"] album.add_provider_mapping( ProviderMapping( item_id=album_obj["id"], @@ -453,23 +456,40 @@ class SpotifyProvider(MusicProvider): provider_instance=self.instance_id, audio_format=AudioFormat(content_type=ContentType.OGG, bit_rate=320), url=album_obj["external_urls"]["spotify"], + barcode=barcode, ) ) return album - async def _parse_track(self, track_obj, artist=None): + async def _parse_track( + self, + track_obj: dict[str, Any], + artist=None, + ) -> Track | AlbumTrack | PlaylistTrack: """Parse spotify track object to generic layout.""" name, version = parse_title_and_version(track_obj["name"]) - track = Track( + if "position" in track_obj: + track_class = PlaylistTrack + extra_init_kwargs = {"position": track_obj["position"]} + elif "disc_number" in track_obj and "track_number" in track_obj: + track_class = AlbumTrack + extra_init_kwargs = { + "disc_number": track_obj["disc_number"], + "track_number": track_obj["track_number"], + } + else: + track_class = Track + extra_init_kwargs = {} + + track = track_class( item_id=track_obj["id"], provider=self.domain, name=name, version=version, duration=track_obj["duration_ms"] / 1000, - disc_number=track_obj["disc_number"], - track_number=track_obj["track_number"], - position=track_obj.get("position"), + **extra_init_kwargs, ) + if artist: track.artists.append(artist) for track_artist in track_obj.get("artists", []): @@ -480,8 +500,6 @@ class SpotifyProvider(MusicProvider): track.metadata.explicit = track_obj["explicit"] if "preview_url" in track_obj: track.metadata.preview = track_obj["preview_url"] - if "external_ids" in track_obj and "isrc" in track_obj["external_ids"]: - track.isrc.add(track_obj["external_ids"]["isrc"]) if "album" in track_obj: track.album = await self._parse_album(track_obj["album"]) if track_obj["album"].get("images"): @@ -503,6 +521,7 @@ class SpotifyProvider(MusicProvider): content_type=ContentType.OGG, bit_rate=320, ), + isrc=track_obj.get("external_ids", {}).get("isrc"), url=track_obj["external_urls"]["spotify"], available=not track_obj["is_local"] and track_obj["is_playable"], ) @@ -670,9 +689,7 @@ class SpotifyProvider(MusicProvider): offset += limit if not result or key not in result or not result[key]: break - for item in result[key]: - item["position"] = len(all_items) + 1 - all_items.append(item) + all_items += result[key] if len(result[key]) < limit: break return all_items diff --git a/music_assistant/server/providers/theaudiodb/__init__.py b/music_assistant/server/providers/theaudiodb/__init__.py index 21a159c1..6ef2c547 100644 --- a/music_assistant/server/providers/theaudiodb/__init__.py +++ b/music_assistant/server/providers/theaudiodb/__init__.py @@ -117,7 +117,7 @@ class AudioDbMetadataProvider(MetadataProvider): async def get_artist_metadata(self, artist: Artist) -> MediaItemMetadata | None: """Retrieve metadata for artist on theaudiodb.""" - if data := await self._get_data("artist-mb.php", i=artist.musicbrainz_id): # noqa: SIM102 + if data := await self._get_data("artist-mb.php", i=artist.mbid): # noqa: SIM102 if data.get("artists"): return self.__parse_artist(data["artists"][0]) return None @@ -125,8 +125,8 @@ class AudioDbMetadataProvider(MetadataProvider): async def get_album_metadata(self, album: Album) -> MediaItemMetadata | None: """Retrieve metadata for album on theaudiodb.""" adb_album = None - if album.musicbrainz_id: - result = await self._get_data("album-mb.php", i=album.musicbrainz_id) + if album.mbid: + result = await self._get_data("album-mb.php", i=album.mbid) if result and result.get("album"): adb_album = result["album"][0] elif album.artists: @@ -136,8 +136,8 @@ class AudioDbMetadataProvider(MetadataProvider): if result and result.get("album"): for item in result["album"]: assert isinstance(artist, Artist) - if artist.musicbrainz_id: - if artist.musicbrainz_id != item["strMusicBrainzArtistID"]: + if artist.mbid: + if artist.mbid != item["strMusicBrainzArtistID"]: continue elif not compare_strings(artist.name, item["strArtistStripped"]): continue @@ -147,11 +147,11 @@ class AudioDbMetadataProvider(MetadataProvider): if adb_album: if not album.year: album.year = int(adb_album.get("intYearReleased", "0")) - if not album.musicbrainz_id: - album.musicbrainz_id = adb_album["strMusicBrainzID"] + if not album.mbid: + album.mbid = adb_album["strMusicBrainzID"] assert isinstance(album.artists[0], Artist) - if album.artists and not album.artists[0].musicbrainz_id: - album.artists[0].musicbrainz_id = adb_album["strMusicBrainzArtistID"] + if album.artists and not album.artists[0].mbid: + album.artists[0].mbid = adb_album["strMusicBrainzArtistID"] if album.album_type == AlbumType.UNKNOWN: album.album_type = ALBUMTYPE_MAPPING.get( adb_album.get("strReleaseFormat"), AlbumType.UNKNOWN @@ -162,8 +162,8 @@ class AudioDbMetadataProvider(MetadataProvider): async def get_track_metadata(self, track: Track) -> MediaItemMetadata | None: """Retrieve metadata for track on theaudiodb.""" adb_track = None - if track.musicbrainz_id: - result = await self._get_data("track-mb.php", i=track.musicbrainz_id) + if track.mbid: + result = await self._get_data("track-mb.php", i=track.mbid) if result and result.get("track"): return self.__parse_track(result["track"][0]) @@ -173,8 +173,8 @@ class AudioDbMetadataProvider(MetadataProvider): result = await self._get_data("searchtrack.php?", s=track_artist.name, t=track.name) if result and result.get("track"): for item in result["track"]: - if track_artist.musicbrainz_id: - if track_artist.musicbrainz_id != item["strMusicBrainzArtistID"]: + if track_artist.mbid: + if track_artist.mbid != item["strMusicBrainzArtistID"]: continue elif not compare_strings(track_artist.name, item["strArtist"]): continue @@ -182,13 +182,13 @@ class AudioDbMetadataProvider(MetadataProvider): adb_track = item break if adb_track: - if not track.musicbrainz_id: - track.musicbrainz_id = adb_track["strMusicBrainzID"] + if not track.mbid: + track.mbid = adb_track["strMusicBrainzID"] assert isinstance(track.album, Album) - if track.album and not track.album.musicbrainz_id: - track.album.musicbrainz_id = adb_track["strMusicBrainzAlbumID"] - if not track_artist.musicbrainz_id: - track_artist.musicbrainz_id = adb_track["strMusicBrainzArtistID"] + if track.album and not track.album.mbid: + track.album.mbid = adb_track["strMusicBrainzAlbumID"] + if not track_artist.mbid: + track_artist.mbid = adb_track["strMusicBrainzArtistID"] return self.__parse_track(adb_track) return None @@ -200,7 +200,7 @@ class AudioDbMetadataProvider(MetadataProvider): ref_tracks: Iterable[Track], # noqa: ARG002 ) -> str | None: """Discover MusicBrainzArtistId for an artist given some reference albums/tracks.""" - musicbrainz_id = None + mbid = None if data := await self._get_data("searchalbum.php", s=artist.name): # NOTE: object is 'null' when no records found instead of empty array albums = data.get("album") or [] @@ -211,12 +211,14 @@ class AudioDbMetadataProvider(MetadataProvider): if not compare_strings(item["strAlbumStripped"], ref_album.name): continue # found match - update album metadata too while we're here - if not ref_album.musicbrainz_id: + if ref_album.provider == "library" and not ref_album.mbid: ref_album.metadata = self.__parse_album(item) - await self.mass.music.albums.add(ref_album, skip_metadata_lookup=True) - musicbrainz_id = item["strMusicBrainzArtistID"] + await self.mass.music.albums.update_item_in_library( + ref_album.item_id, ref_album + ) + mbid = item["strMusicBrainzArtistID"] - return musicbrainz_id + return mbid def __parse_artist(self, artist_obj: dict[str, Any]) -> MediaItemMetadata: """Parse audiodb artist object to MediaItemMetadata.""" diff --git a/music_assistant/server/providers/tidal/__init__.py b/music_assistant/server/providers/tidal/__init__.py index 66077c6f..b393cbe1 100644 --- a/music_assistant/server/providers/tidal/__init__.py +++ b/music_assistant/server/providers/tidal/__init__.py @@ -30,12 +30,14 @@ from music_assistant.common.models.enums import ( from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError from music_assistant.common.models.media_items import ( Album, + AlbumTrack, Artist, AudioFormat, ContentType, ItemMapping, MediaItemImage, Playlist, + PlaylistTrack, ProviderMapping, SearchResults, StreamDetails, @@ -266,8 +268,14 @@ class TidalProvider(MusicProvider): tidal_session = await self._get_tidal_session() async with self._throttler: return [ - await self._parse_track(track_obj=track) - for track in await get_album_tracks(tidal_session, prov_album_id) + await self._parse_track( + track_obj=track_obj, + extra_init_kwargs={ + "disc_number": track_obj.volume_num, + "track_number": track_obj.track_num, + }, + ) + for track_obj in await get_album_tracks(tidal_session, prov_album_id) ] async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: @@ -288,7 +296,9 @@ class TidalProvider(MusicProvider): for track in await get_artist_toptracks(tidal_session, prov_artist_id) ] - async def get_playlist_tracks(self, prov_playlist_id: str) -> AsyncGenerator[Track, None]: + async def get_playlist_tracks( + self, prov_playlist_id: str + ) -> AsyncGenerator[PlaylistTrack, None]: """Get all playlist tracks for given playlist id.""" tidal_session = await self._get_tidal_session() total_playlist_tracks = 0 @@ -297,8 +307,9 @@ class TidalProvider(MusicProvider): get_playlist_tracks, tidal_session, prov_playlist_id, limit=DEFAULT_LIMIT ): total_playlist_tracks += 1 - track = await self._parse_track(track_obj=track_obj) - track.position = total_playlist_tracks + track = await self._parse_track( + track_obj=track_obj, extra_init_kwargs={"position": total_playlist_tracks} + ) yield track async def get_similar_tracks(self, prov_track_id: str, limit=25) -> list[Track]: @@ -542,20 +553,30 @@ class TidalProvider(MusicProvider): return album - async def _parse_track(self, track_obj: TidalTrack, full_details: bool = False) -> Track: + async def _parse_track( + self, + track_obj: TidalTrack, + full_details: bool = False, + extra_init_kwargs: dict[str, Any] | None = None, + ) -> Track | AlbumTrack | PlaylistTrack: """Parse tidal track object to generic layout.""" version = track_obj.version if track_obj.version is not None else None track_id = str(track_obj.id) - track = Track( + if "position" in extra_init_kwargs: + track_class = PlaylistTrack + elif "disc_number" in extra_init_kwargs and "track_number" in extra_init_kwargs: + track_class = AlbumTrack + else: + track_class = Track + + track = track_class( item_id=track_id, provider=self.instance_id, name=track_obj.name, version=version, duration=track_obj.duration, - disc_number=track_obj.volume_num, - track_number=track_obj.track_num, + **extra_init_kwargs or {}, ) - track.isrc.add(track_obj.isrc) track.album = self.get_item_mapping( media_type=MediaType.ALBUM, key=track_obj.album.id, @@ -576,6 +597,7 @@ class TidalProvider(MusicProvider): sample_rate=44100, bit_depth=16, ), + isrc=track_obj.isrc, url=f"http://www.tidal.com/tracks/{track_id}", available=available, ) diff --git a/music_assistant/server/providers/url/__init__.py b/music_assistant/server/providers/url/__init__.py index 77cbd38e..9c54e091 100644 --- a/music_assistant/server/providers/url/__init__.py +++ b/music_assistant/server/providers/url/__init__.py @@ -64,7 +64,7 @@ class URLProvider(MusicProvider): Called when provider is registered. """ self._full_url = {} - # self.mass.register_api_command("music/tracks", self.db_items) + # self.mass.register_api_command("music/tracks", self.library_items) async def get_track(self, prov_track_id: str) -> Track: """Get full track details by id.""" @@ -79,9 +79,9 @@ class URLProvider(MusicProvider): artist = prov_artist_id # this is here for compatibility reasons only return Artist( - artist, - self.domain, - artist, + item_id=artist, + provider=self.domain, + name=artist, provider_mappings={ ProviderMapping(artist, self.domain, self.instance_id, available=False) }, diff --git a/music_assistant/server/providers/ytmusic/__init__.py b/music_assistant/server/providers/ytmusic/__init__.py index 274993ab..e5820045 100644 --- a/music_assistant/server/providers/ytmusic/__init__.py +++ b/music_assistant/server/providers/ytmusic/__init__.py @@ -22,6 +22,7 @@ from music_assistant.common.models.errors import ( ) from music_assistant.common.models.media_items import ( Album, + AlbumTrack, AlbumType, Artist, AudioFormat, @@ -31,6 +32,7 @@ from music_assistant.common.models.media_items import ( MediaItemImage, MediaType, Playlist, + PlaylistTrack, ProviderMapping, SearchResults, StreamDetails, @@ -284,7 +286,7 @@ class YoutubeMusicProvider(MusicProvider): return await self._parse_album(album_obj=album_obj, album_id=prov_album_id) raise MediaNotFoundError(f"Item {prov_album_id} not found") - async def get_album_tracks(self, prov_album_id: str) -> list[Track]: + async def get_album_tracks(self, prov_album_id: str) -> list[AlbumTrack]: """Get album tracks for given album id.""" await self._check_oauth_token() album_obj = await get_album(prov_album_id=prov_album_id) @@ -292,12 +294,12 @@ class YoutubeMusicProvider(MusicProvider): return [] tracks = [] for idx, track_obj in enumerate(album_obj["tracks"], 1): + track_obj["disc_number"] = 0 + track_obj["track_number"] = idx try: track = await self._parse_track(track_obj=track_obj) except InvalidDataError: continue - track.disc_number = 0 - track.track_number = idx tracks.append(track) return tracks @@ -331,7 +333,7 @@ class YoutubeMusicProvider(MusicProvider): return await self._parse_playlist(playlist_obj) raise MediaNotFoundError(f"Item {prov_playlist_id} not found") - async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]: + async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[PlaylistTrack, None]: """Get all playlist tracks for given playlist id.""" await self._check_oauth_token() # Grab the playlist id from the full url in case of personal playlists @@ -340,20 +342,17 @@ class YoutubeMusicProvider(MusicProvider): playlist_obj = await get_playlist(prov_playlist_id=prov_playlist_id, headers=self._headers) if "tracks" not in playlist_obj: return - for index, track in enumerate(playlist_obj["tracks"]): - if track["isAvailable"]: + for index, track_obj in enumerate(playlist_obj["tracks"]): + if track_obj["isAvailable"]: # Playlist tracks sometimes do not have a valid artist id # In that case, call the API for track details based on track id try: - track = await self._parse_track(track) - if track: - track.position = index + 1 + track_obj["position"] = index + 1 + if track := await self._parse_track(track_obj): yield track except InvalidDataError: - track = await self.get_track(track["videoId"]) - if track: - track.position = index + 1 - yield track + if track := await self.get_track(track_obj["videoId"]): + yield PlaylistTrack.from_dict({**track.to_dict(), "position": index + 1}) async def get_artist_albums(self, prov_artist_id) -> list[Album]: """Get a list of albums for the given artist.""" @@ -699,11 +698,30 @@ class YoutubeMusicProvider(MusicProvider): playlist.metadata.checksum = playlist_obj.get("checksum") return playlist - async def _parse_track(self, track_obj: dict) -> Track: + async def _parse_track(self, track_obj: dict) -> Track | AlbumTrack | PlaylistTrack: """Parse a YT Track response to a Track model object.""" if not track_obj.get("videoId"): raise InvalidDataError("Track is missing videoId") - track = Track(item_id=track_obj["videoId"], provider=self.domain, name=track_obj["title"]) + + if "position" in track_obj: + track_class = PlaylistTrack + extra_init_kwargs = {"position": track_obj["position"]} + elif "disc_number" in track_obj and "track_number" in track_obj: + track_class = AlbumTrack + extra_init_kwargs = { + "disc_number": track_obj["disc_number"], + "track_number": track_obj["track_number"], + } + else: + track_class = Track + extra_init_kwargs = {} + track = track_class( + item_id=track_obj["videoId"], + provider=self.domain, + name=track_obj["title"], + **extra_init_kwargs, + ) + if "artists" in track_obj and track_obj["artists"]: track.artists = [ self._get_artist_item_mapping(artist) diff --git a/music_assistant/server/server.py b/music_assistant/server/server.py index 078b0e6c..04929840 100644 --- a/music_assistant/server/server.py +++ b/music_assistant/server/server.py @@ -198,7 +198,7 @@ class MusicAssistant: @api_command("logging/get") async def get_application_log(self) -> str: """Return the application log from file.""" - logfile = os.path.join(self.storage_path, "logs", "musicassistant.log") + logfile = os.path.join(self.storage_path, "musicassistant.log") async with aiofiles.open(logfile, "r") as _file: return await _file.read() @@ -215,7 +215,7 @@ class MusicAssistant: if prov := self._providers.get(provider_instance_or_domain): if return_unavailable or prov.available: return prov - if prov.is_unique: + if not prov.is_streaming_provider: # no need to lookup other instances because this provider has unique data return None provider_instance_or_domain = prov.domain @@ -237,17 +237,6 @@ class MusicAssistant: """Signal event to subscribers.""" if self.closing: return - if ( - event - in ( - EventType.MEDIA_ITEM_ADDED, - EventType.MEDIA_ITEM_DELETED, - EventType.MEDIA_ITEM_UPDATED, - ) - and self.music.in_progress_syncs - ): - # ignore media item events while sync is running because it clutters too much - return if LOGGER.isEnabledFor(logging.DEBUG) and event != EventType.QUEUE_TIME_UPDATED: # do not log queue time updated events because that is too chatty diff --git a/pyproject.toml b/pyproject.toml index fa6ab82d..8f1a494a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,6 @@ warn_unused_ignores = true ignore_missing_imports = true module = [ "aiorun", - "coloredlogs", ] [tool.pytest.ini_options] diff --git a/script/example.py b/script/example.py index 68834298..b40f201c 100644 --- a/script/example.py +++ b/script/example.py @@ -8,7 +8,6 @@ from os.path import abspath, dirname from pathlib import Path from sys import path -import coloredlogs from aiorun import run path.insert(1, dirname(dirname(abspath(__file__)))) @@ -46,7 +45,6 @@ args = parser.parse_args() if __name__ == "__main__": # configure logging logging.basicConfig(level=args.log_level.upper()) - coloredlogs.install(level=args.log_level.upper()) # make sure storage path exists if not os.path.isdir(args.config):