Refactor library storage (#781)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 19 Jul 2023 18:43:02 +0000 (20:43 +0200)
committerGitHub <noreply@github.com>
Wed, 19 Jul 2023 18:43:02 +0000 (20:43 +0200)
* refactor in_library to favorites and db to library

* fix missing parts

* adjust library

* change is_unique -> is_streaming_provider

* finishing touches

35 files changed:
music_assistant/__main__.py
music_assistant/client/music.py
music_assistant/common/helpers/util.py
music_assistant/common/models/media_items.py
music_assistant/constants.py
music_assistant/server/controllers/cache.py
music_assistant/server/controllers/media/albums.py
music_assistant/server/controllers/media/artists.py
music_assistant/server/controllers/media/base.py
music_assistant/server/controllers/media/playlists.py
music_assistant/server/controllers/media/radio.py
music_assistant/server/controllers/media/tracks.py
music_assistant/server/controllers/metadata.py
music_assistant/server/controllers/music.py
music_assistant/server/helpers/compare.py
music_assistant/server/helpers/tags.py
music_assistant/server/models/music_provider.py
music_assistant/server/providers/deezer/__init__.py
music_assistant/server/providers/fanarttv/__init__.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/filesystem_smb/__init__.py
music_assistant/server/providers/musicbrainz/__init__.py
music_assistant/server/providers/plex/__init__.py
music_assistant/server/providers/qobuz/__init__.py
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/slimproto/cli.py
music_assistant/server/providers/soundcloud/__init__.py
music_assistant/server/providers/spotify/__init__.py
music_assistant/server/providers/theaudiodb/__init__.py
music_assistant/server/providers/tidal/__init__.py
music_assistant/server/providers/url/__init__.py
music_assistant/server/providers/ytmusic/__init__.py
music_assistant/server/server.py
pyproject.toml
script/example.py

index 41dd76fa421d9467a1556cac7814b89a88fee5e0..5e1f3c4c015893675cea4d01a70ee4e7754cbd6b 100644 (file)
@@ -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):
index f5b3dca7cea526e612dff8c968c68ffb097cdb4e..281e9867aa0a739e70a98845692c3c48f10f4660 100644 (file)
@@ -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(
index 55ad431add0cb4ec1c635def2fe4e4b51e197a14..343cbd4d7b32fcd10874d3204d93ecf3926c97c7 100755 (executable)
@@ -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()
index b8e7a489d55ef8c219d5b7f3f073bd156214d620..f8a3d377f205064f17a98be7dd026e138d7d8615 100755 (executable)
@@ -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
index e0bbf0b2fc804c183688173c4f30489d8cdcc884..8ed067acb07279c57005020a3ae6b0dc1cca51a8 100755 (executable)
@@ -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"
index e1b12653baf65ca3d9071f52ec56eb501494f392..7e84dce25a8fa89f1658da958d9b0aa062ef5e79 100644 (file)
@@ -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):
index 3fcfb3251def38ff45424c90a0d16c14ce818562..2b06409030e20e2d5f7cd409bd72bfecd0450561 100644 (file)
@@ -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)
index ed8939a3639156aacbf08574b202ccd20810735a..530dae78a449a0b30b2cb37b761b3dc871d423ed 100644 (file)
@@ -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
index e29bb3d0e29f1313a07a67f44c008b79c1aaa6ee..216a344cecc86677dcb7ce093afd15e63dd04dc1 100644 (file)
@@ -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.adddetails, 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)
index 376f3d3d6f03d8d2ba4ec669f93b76afb3745ba7..5b741a582c0f6e510049142f00690c75abcd7960 100644 (file)
@@ -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 []
index 8adc68b03269398941b903907229a9a67dcdb91c..79c554c4f0ec2fd0f29009fcf716c56f8bfc0849 100644 (file)
@@ -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,
index 267d3afb562eb3d3f24f09adadf0fb58d7e4bcb2..26f5ab54a1886a9998403e9c5d1390462b27fdae 100644 (file)
@@ -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,
+                },
+            )
index ed8181d29a6cc0f7ed67269698e6dc745ab538ec..4ab8ce52f62b0daf483d8ee96210e640248259da 100755 (executable)
@@ -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"
index c5bc5621ba456b161e0753a2c1d41060f4859294..c61c12c229ed754188373e17f17341ac6498883f 100755 (executable)
@@ -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);")
index d7c6354da0e2efc77cec7a7a70c46d4269b29a2b..a981856d9092fa63033eb16391751a504711fc5b 100644 (file)
@@ -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
index 5c9adf046d95527fa2bb53d61b71129eaf3ccf47..d309173d6c5821bcb3805f1924d1f837e44322d4 100644 (file)
@@ -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]:
index 0ce032203a48da3bc8ea68c1015288a10723b6cf..d9c2b16d504b2fb1fdab8eb08266ce6ab9f41307 100644 (file)
@@ -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
index c85362996ef06275b99b5554f7ffccafa64cec82..e74bb338e069208e66e8a691e3f4d435cbf153d7 100644 (file)
@@ -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 ###
index 09631a84d876d0e5832bc47f0c7847367cdeed6b..f3b00077d427d7ae94c58954f029c6fddfc4745d 100644 (file)
@@ -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():
index ae5bbcd9d46aa4fe0f9fbf7ebed0cd86680f4422..2984112fedb22ae2691ef70c33b086b370cbcde4 100644 (file)
@@ -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"):
index bd2c988e0e2d971993ecab4ef261da0548a07c62..9f0537b47b7bf7954da922460153001347264374 100644 (file)
@@ -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
index 494394f5f28b0c1dbe0d1c73a3a62e09f0840c7a..e146b6b8989727a6160fd79a0dd8e9410149fb67 100644 (file)
@@ -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
 
index 82bcf72b8c7f30cb112625e040e7b4c3d2edbe5e..979123b573eadc45c63f6b0bdff451f5d63d82b3 100644 (file)
@@ -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]:
index feff3ab3910f7e05aed7161f47501c18ae01fac7..dae1b2f872fb123649e5f58bb48a771f86f955b7 100644 (file)
@@ -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
index 26941e8efa244c7639ec2dc2a914cba19d3f0bbe..f5443de4738fb2c81c84d5896fe13d88d3d117a5 100644 (file)
@@ -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"):
index 84bec41f753538f8cc3df04d231cadbb583430f2..0e172ff364592b4859ebc01c009be075595e3ef6 100644 (file)
@@ -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 = [
index ddc5f8761d63e47298be2cfed2d4488c6438b8e4..f6199dda4e3b74e7b0b88e5262edb9755b14ad94 100644 (file)
@@ -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)
index 8d350399750f4db7cb528429f1300b425e2dea1e..cc7a360dff1ee07be33d6cb1911900bbb74997a3 100644 (file)
@@ -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
index 21a159c1d34a9afbbdd67dee98d7c54555b1cb4b..6ef2c54735d49cdbabf198a5c6773c5b3ea39c56 100644 (file)
@@ -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."""
index 66077c6fc883a0f2605a95649e6426c7e05b3090..b393cbe1a40c2bfb18dca7069b9d3e301a6ddc1f 100644 (file)
@@ -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,
             )
index 77cbd38e7945c61493ea6f8f29e06b1548f9df84..9c54e091223d476d355f306ead7e61a65cc65357 100644 (file)
@@ -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)
             },
index 274993aba45afa01903dc922b061ad4c84bbca12..e5820045319157fb2e7376470eeccc6c7e596900 100644 (file)
@@ -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)
index 078b0e6cd909bf864a3231e3a39e84aac2a02b41..049298409b54312584019ed5990c11cb4f2e8ad0 100644 (file)
@@ -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
index fa6ab82d85990b25ffbbfcbf724784a8606475fa..8f1a494a3a0d19624483a474be4816f8a01f2794 100644 (file)
@@ -91,7 +91,6 @@ warn_unused_ignores = true
 ignore_missing_imports = true
 module = [
   "aiorun",
-  "coloredlogs",
 ]
 
 [tool.pytest.ini_options]
index 688342989980e8af1d8af7cc768b0c016bf8cee0..b40f201c402df8b8875f2056042ea68952ad45db 100644 (file)
@@ -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):