Fix more issues with multi instance and unique flags
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 20 Dec 2025 18:48:26 +0000 (19:48 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 20 Dec 2025 18:48:26 +0000 (19:48 +0100)
music_assistant/controllers/media/albums.py
music_assistant/controllers/media/audiobooks.py
music_assistant/controllers/media/base.py
music_assistant/controllers/media/tracks.py
music_assistant/controllers/music.py
music_assistant/helpers/compare.py
music_assistant/models/music_provider.py
music_assistant/providers/spotify/provider.py

index 36842784322e239f3010835ef76f431b0d539ff5..4a7a6c884915ad1f0708f2b600d12c70ca158bab 100644 (file)
@@ -57,7 +57,8 @@ class AlbumsController(MediaControllerBase[Album]):
                         'audio_format', json(provider_mappings.audio_format),
                         'url', provider_mappings.url,
                         'details', provider_mappings.details,
-                        'in_library', provider_mappings.in_library
+                        'in_library', provider_mappings.in_library,
+                        'is_unique', provider_mappings.is_unique
                 )) FROM provider_mappings WHERE provider_mappings.item_id = albums.item_id AND media_type = 'album') AS provider_mappings,
             (SELECT JSON_GROUP_ARRAY(
                 json_object(
index c35fcb691f079ea163d16a6380886d0cdd5603bc..84d42da0d500ec2e31f62370a62af9fb3033e29a 100644 (file)
@@ -47,7 +47,8 @@ class AudiobooksController(MediaControllerBase[Audiobook]):
                         'audio_format', json(provider_mappings.audio_format),
                         'url', provider_mappings.url,
                         'details', provider_mappings.details,
-                        'in_library', provider_mappings.in_library
+                        'in_library', provider_mappings.in_library,
+                        'is_unique', provider_mappings.is_unique
                 )) FROM provider_mappings WHERE provider_mappings.item_id = audiobooks.item_id AND media_type = 'audiobook') AS provider_mappings,
             playlog.fully_played AS fully_played,
             playlog.seconds_played AS seconds_played,
index 34c03560d0bfdde1aa087ee568a7f75815f65475..a82d9392591c9e71731af53c411720c8c99ef7f0 100644 (file)
@@ -94,7 +94,8 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
                         'audio_format', json(provider_mappings.audio_format),
                         'url', provider_mappings.url,
                         'details', provider_mappings.details,
-                        'in_library', provider_mappings.in_library
+                        'in_library', provider_mappings.in_library,
+                        'is_unique', provider_mappings.is_unique
                 )) FROM provider_mappings WHERE provider_mappings.item_id = {self.db_table}.item_id
                     AND provider_mappings.media_type = '{self.media_type.value}') AS provider_mappings
             FROM {self.db_table} """  # noqa: E501
@@ -623,11 +624,7 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
         except MediaNotFoundError:
             # edge case: already deleted / race condition
             return
-        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
-        }
+
         # update provider_mappings table
         await self.mass.music.database.delete(
             DB_TABLE_PROVIDER_MAPPINGS,
@@ -647,6 +644,11 @@ class MediaControllerBase[ItemCls: "MediaItemType"](metaclass=ABCMeta):
                 "provider": provider_instance_id,
             },
         )
+        library_item.provider_mappings = {
+            x
+            for x in library_item.provider_mappings
+            if not (x.provider_instance == provider_instance_id and x.item_id == provider_item_id)
+        }
         if library_item.provider_mappings:
             self.logger.debug(
                 "removed provider_mapping %s/%s from item id %s",
index d34f0f0b7a53c2e6e4dfe5ae87147ffeabb8b73a..9dd2192c3b328176267c39e4810318b4e872977e 100644 (file)
@@ -65,7 +65,8 @@ class TracksController(MediaControllerBase[Track]):
                         'audio_format', json(provider_mappings.audio_format),
                         'url', provider_mappings.url,
                         'details', provider_mappings.details,
-                        'in_library', provider_mappings.in_library
+                        'in_library', provider_mappings.in_library,
+                        'is_unique', provider_mappings.is_unique
                 )) FROM provider_mappings WHERE provider_mappings.item_id = tracks.item_id AND media_type = 'track') AS provider_mappings,
 
             (SELECT JSON_GROUP_ARRAY(
index 855ffa91e13ee2b5c8ec464b29a834c363a8037f..254c07e5e09e6c353f48caa150bb0e9d959ebf83 100644 (file)
@@ -2180,8 +2180,7 @@ class MusicController(CoreController):
             )
             await self._database.execute(
                 f"UPDATE {DB_TABLE_PROVIDER_MAPPINGS} SET in_library = 1 "
-                "WHERE media_type IN "
-                "('radio', 'playlist');"
+                "WHERE media_type = 'radio';"
             )
 
         # save changes
index db5db23bdf69e1ace5f3b2a11b0743d6867e66c5..10bc55cf134e3b92a63f24957a35c540d327518b 100644 (file)
@@ -447,7 +447,7 @@ def compare_item_ids(
         assert isinstance(base_item, MediaItem)  # for type checking
         for prov_l in base_item.provider_mappings:
             if (
-                prov_l.provider_domain == compare_item.provider
+                prov_l.provider_instance == compare_item.provider
                 and prov_l.item_id == compare_item.item_id
             ):
                 return True
@@ -455,7 +455,10 @@ def compare_item_ids(
     if compare_prov_ids is not None:
         assert isinstance(compare_item, MediaItem)  # for type checking
         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:
+            if (
+                prov_r.provider_instance == base_item.provider
+                and prov_r.item_id == base_item.item_id
+            ):
                 return True
 
     if base_prov_ids is not None and compare_prov_ids is not None:
@@ -465,6 +468,10 @@ def compare_item_ids(
             for prov_r in compare_item.provider_mappings:
                 if prov_l.provider_domain != prov_r.provider_domain:
                     continue
+                if (
+                    prov_l.is_unique or prov_r.is_unique
+                ) and prov_l.provider_instance != prov_r.provider_instance:
+                    continue
                 if prov_l.item_id == prov_r.item_id:
                     return True
     return False
index 27d528361fc60efd5dd1b47322a46d5e6c43c8ac..f0d83775e2213f1b31c90d1eaf015797ac408a04 100644 (file)
@@ -749,6 +749,8 @@ class MusicProvider(Provider):
             try:
                 if not library_item:
                     # add item to the library
+                    for prov_map in prov_item.provider_mappings:
+                        prov_map.in_library = True
                     library_item = await self.mass.music.artists.add_item_to_library(prov_item)
                 elif not self._check_provider_mappings(library_item, prov_item, True):
                     # existing library item but provider mapping doesn't match
@@ -784,6 +786,8 @@ class MusicProvider(Provider):
             try:
                 if not library_item:
                     # add item to the library
+                    for prov_map in prov_item.provider_mappings:
+                        prov_map.in_library = True
                     library_item = await self.mass.music.albums.add_item_to_library(prov_item)
                 elif not self._check_provider_mappings(library_item, prov_item, True):
                     # existing library item but provider mapping doesn't match
@@ -819,6 +823,8 @@ class MusicProvider(Provider):
             try:
                 if not library_track:
                     # add item to the library
+                    for prov_map in prov_track.provider_mappings:
+                        prov_map.in_library = True
                     library_track = await self.mass.music.tracks.add_item_to_library(prov_track)
                 elif not self._check_provider_mappings(library_track, prov_track, True):
                     # existing library track but provider mapping doesn't match
@@ -844,6 +850,8 @@ class MusicProvider(Provider):
             try:
                 if not library_item:
                     # add item to the library
+                    for prov_map in prov_item.provider_mappings:
+                        prov_map.in_library = True
                     library_item = await self.mass.music.audiobooks.add_item_to_library(prov_item)
                 elif not self._check_provider_mappings(library_item, prov_item, True):
                     # existing library item but provider mapping doesn't match
@@ -893,6 +901,8 @@ class MusicProvider(Provider):
             try:
                 if not library_item:
                     # add item to the library
+                    for prov_map in prov_item.provider_mappings:
+                        prov_map.in_library = True
                     library_item = await self.mass.music.playlists.add_item_to_library(prov_item)
                 elif not self._check_provider_mappings(library_item, prov_item, True):
                     # existing library item but provider mapping doesn't match
@@ -932,6 +942,8 @@ class MusicProvider(Provider):
             try:
                 if not library_track:
                     # add item to the library
+                    for prov_map in prov_track.provider_mappings:
+                        prov_map.in_library = True
                     library_track = await self.mass.music.tracks.add_item_to_library(prov_track)
                 elif not self._check_provider_mappings(library_track, prov_track, True):
                     # existing library track but provider mapping doesn't match
@@ -965,6 +977,8 @@ class MusicProvider(Provider):
                     continue
                 if not library_item:
                     # add item to the library
+                    for prov_map in prov_item.provider_mappings:
+                        prov_map.in_library = True
                     library_item = await self.mass.music.tracks.add_item_to_library(prov_item)
                 elif not self._check_provider_mappings(library_item, prov_item, True):
                     # existing library item but provider mapping doesn't match
@@ -996,6 +1010,8 @@ class MusicProvider(Provider):
             try:
                 if not library_item:
                     # add item to the library
+                    for prov_map in prov_item.provider_mappings:
+                        prov_map.in_library = True
                     library_item = await self.mass.music.podcasts.add_item_to_library(prov_item)
                 elif not self._check_provider_mappings(library_item, prov_item, True):
                     # existing library item but provider mapping doesn't match
@@ -1033,6 +1049,8 @@ class MusicProvider(Provider):
             try:
                 if not library_item:
                     # add item to the library
+                    for prov_map in prov_item.provider_mappings:
+                        prov_map.in_library = True
                     library_item = await self.mass.music.radio.add_item_to_library(prov_item)
                 elif not self._check_provider_mappings(library_item, prov_item, True):
                     # existing library item but provider mapping doesn't match
index aee0b4c72383475c5275ec44f3aade4116201301..2652481d052d7e966f52a131980a71727c2a55c5 100644 (file)
@@ -366,13 +366,28 @@ class SpotifyProvider(MusicProvider):
         if prov_playlist_id == self._get_liked_songs_playlist_id():
             return await self._get_liked_songs_playlist()
 
-        # Use global session for Spotify-owned playlists (e.g., Daily Mix)
-        # as they may not be accessible via the dev token
-        use_global = await self._is_spotify_owned_playlist(prov_playlist_id)
-        playlist_obj = await self._get_data(
-            f"playlists/{prov_playlist_id}", use_global_session=use_global
-        )
-        return parse_playlist(playlist_obj, self)
+        # Check cache to see if this playlist requires global token
+        use_global = await self._playlist_requires_global_token(prov_playlist_id)
+        if use_global:
+            playlist_obj = await self._get_data(
+                f"playlists/{prov_playlist_id}", use_global_session=True
+            )
+            return parse_playlist(playlist_obj, self)
+
+        # Try with dev token first (if available), fallback to global on 400 error
+        # Some playlists like Spotify-owned (Daily Mix) or Liked Songs only work with global token
+        try:
+            playlist_obj = await self._get_data(f"playlists/{prov_playlist_id}")
+            return parse_playlist(playlist_obj, self)
+        except aiohttp.ClientResponseError as err:
+            if err.status == 400 and self.dev_session_active:
+                # Remember that this playlist requires global token
+                await self._set_playlist_requires_global_token(prov_playlist_id)
+                playlist_obj = await self._get_data(
+                    f"playlists/{prov_playlist_id}", use_global_session=True
+                )
+                return parse_playlist(playlist_obj, self)
+            raise
 
     @use_cache()
     async def get_podcast(self, prov_podcast_id: str) -> Podcast:
@@ -572,27 +587,32 @@ class SpotifyProvider(MusicProvider):
     @use_cache(2600 * 3)  # 3 hours
     async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
-        result: list[Track] = []
         is_liked_songs = prov_playlist_id == self._get_liked_songs_playlist_id()
-        uri = (
-            "me/tracks"
-            if prov_playlist_id == self._get_liked_songs_playlist_id()
-            else f"playlists/{prov_playlist_id}/tracks"
-        )
-        # Use global session for liked songs or Spotify-owned playlists (e.g., Daily Mix)
-        use_global = is_liked_songs or await self._is_spotify_owned_playlist(prov_playlist_id)
-        # do single request to get the etag (which we use as checksum for caching)
-        cache_checksum = await self._get_etag(uri, limit=1, offset=0, use_global_session=use_global)
+        uri = "me/tracks" if is_liked_songs else f"playlists/{prov_playlist_id}/tracks"
 
+        # Liked songs always require global session
+        # For other playlists, call get_playlist first to trigger the fallback logic
+        # and populate the cache for which token to use
+        if is_liked_songs:
+            use_global = True
+        else:
+            # This call is cached and will determine/cache if global token is needed
+            await self.get_playlist(prov_playlist_id)
+            use_global = await self._playlist_requires_global_token(prov_playlist_id)
+
+        result: list[Track] = []
         page_size = 50
         offset = page * page_size
+
+        # Get etag for caching
+        cache_checksum = await self._get_etag(uri, limit=1, offset=0, use_global_session=use_global)
+
         spotify_result = await self._get_data_with_caching(
             uri, cache_checksum, limit=page_size, offset=offset, use_global_session=use_global
         )
         for index, item in enumerate(spotify_result["items"], 1):
             if not (item and item["track"] and item["track"]["id"]):
                 continue
-            # use count as position
             track = parse_track(item["track"], self)
             track.position = offset + index
             result.append(track)
@@ -970,30 +990,23 @@ class SpotifyProvider(MusicProvider):
 
         return liked_songs
 
-    @use_cache(86400 * 90)
-    async def _is_spotify_owned_playlist(self, prov_playlist_id: str) -> bool:
-        """Check if a playlist is owned by Spotify.
+    async def _playlist_requires_global_token(self, prov_playlist_id: str) -> bool:
+        """Check if a playlist requires global token (cached).
 
-        Spotify-owned playlists (e.g., Daily Mix, Discover Weekly) are only accessible
-        via the global token, not through developer API tokens.
+        :param prov_playlist_id: The Spotify playlist ID.
+        :returns: True if the playlist requires global token.
+        """
+        cache_key = f"playlist_global_token_{prov_playlist_id}"
+        return bool(await self.mass.cache.get(cache_key, provider=self.instance_id))
+
+    async def _set_playlist_requires_global_token(self, prov_playlist_id: str) -> None:
+        """Mark a playlist as requiring global token in cache.
 
         :param prov_playlist_id: The Spotify playlist ID.
-        :returns: True if the playlist is owned by Spotify.
         """
-        if prov_playlist_id == self._get_liked_songs_playlist_id():
-            return False
-        try:
-            # We need to use global session here to actually get the playlist info
-            # because if it's a Spotify-owned playlist, the dev session won't have access
-            playlist_obj = await self._get_data(
-                f"playlists/{prov_playlist_id}",
-                fields="owner.id",
-                use_global_session=True,
-            )
-            owner_id = playlist_obj.get("owner", {}).get("id", "").lower()
-            return bool(owner_id == "spotify")
-        except MediaNotFoundError:
-            return False
+        cache_key = f"playlist_global_token_{prov_playlist_id}"
+        # Cache for 90 days - playlist ownership doesn't change
+        await self.mass.cache.set(cache_key, True, provider=self.instance_id, expiration=86400 * 90)
 
     async def _add_audiobook_chapters(self, audiobook: Audiobook) -> None:
         """Add chapter metadata to an audiobook from Spotify API data."""