Fix spotify playlists
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 19 Dec 2025 08:10:29 +0000 (09:10 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 19 Dec 2025 08:10:29 +0000 (09:10 +0100)
music_assistant/providers/spotify/__init__.py
music_assistant/providers/spotify/parsers.py
music_assistant/providers/spotify/provider.py

index 7b86a1e1f1ece7ba65eb92f72296e563811e7672..d4b779eb60ecdeaa3b9f4126c85ec2020bfed8cc 100644 (file)
@@ -65,6 +65,8 @@ async def _handle_auth_actions(
     if action == CONF_ACTION_AUTH:
         refresh_token = await pkce_auth_flow(mass, cast("str", values["session_id"]), app_var(2))
         values[CONF_REFRESH_TOKEN_GLOBAL] = refresh_token
+        values[CONF_REFRESH_TOKEN_DEV] = None  # Clear dev token on new global auth
+        values[CONF_CLIENT_ID] = None  # Clear client ID on new global auth
 
     elif action == CONF_ACTION_AUTH_DEV:
         custom_client_id = values.get(CONF_CLIENT_ID)
index 9aac57caa245eb266cd4ed0aba17b31fb3a29221..557bde948978f19a4f075dfc1467f9b37bc76e7d 100644 (file)
@@ -195,15 +195,22 @@ def parse_track(
 
 def parse_playlist(playlist_obj: dict[str, Any], provider: SpotifyProvider) -> Playlist:
     """Parse spotify playlist object to generic layout."""
+    owner_id = playlist_obj["owner"].get("id", "")
     is_editable = (
-        provider._sp_user is not None and playlist_obj["owner"]["id"] == provider._sp_user["id"]
+        provider._sp_user is not None and owner_id == provider._sp_user["id"]
     ) or playlist_obj["collaborative"]
 
+    # Spotify-owned playlists (Daily Mix, Discover Weekly, etc.) are personalized per user
+    is_spotify_owned = owner_id.lower() == "spotify"
+
     # Get owner name with fallback
     owner_name = playlist_obj["owner"].get("display_name")
     if owner_name is None and provider._sp_user is not None:
         owner_name = provider._sp_user["display_name"]
 
+    # Mark as unique if user-owned/editable OR if it's a Spotify personalized playlist
+    is_unique = is_editable or is_spotify_owned
+
     playlist = Playlist(
         item_id=playlist_obj["id"],
         provider=provider.instance_id,
@@ -215,7 +222,7 @@ def parse_playlist(playlist_obj: dict[str, Any], provider: SpotifyProvider) -> P
                 provider_domain=provider.domain,
                 provider_instance=provider.instance_id,
                 url=playlist_obj["external_urls"]["spotify"],
-                is_unique=is_editable,  # user-owned playlists are unique
+                is_unique=is_unique,
             )
         },
         is_editable=is_editable,
index 23289e43d4b9a9bd1f3944804de68e78f3c4e84f..aee0b4c72383475c5275ec44f3aade4116201301 100644 (file)
@@ -212,9 +212,13 @@ class SpotifyProvider(MusicProvider):
                 yield audiobook
 
     async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
-        """Retrieve playlists from the provider."""
+        """Retrieve playlists from the provider.
+
+        Note: We use the global session here because playlists like "Daily Mix"
+        are only returned when using the non-dev (global) token.
+        """
         yield await self._get_liked_songs_playlist()
-        async for item in self._get_all_items("me/playlists"):
+        async for item in self._get_all_items("me/playlists", use_global_session=True):
             if item and item["id"]:
                 yield parse_playlist(item, self)
 
@@ -362,7 +366,12 @@ class SpotifyProvider(MusicProvider):
         if prov_playlist_id == self._get_liked_songs_playlist_id():
             return await self._get_liked_songs_playlist()
 
-        playlist_obj = await self._get_data(f"playlists/{prov_playlist_id}")
+        # 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)
 
     @use_cache()
@@ -570,15 +579,15 @@ class SpotifyProvider(MusicProvider):
             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=is_liked_songs
-        )
+        cache_checksum = await self._get_etag(uri, limit=1, offset=0, use_global_session=use_global)
 
         page_size = 50
         offset = page * page_size
         spotify_result = await self._get_data_with_caching(
-            uri, cache_checksum, limit=page_size, offset=offset, use_global_session=is_liked_songs
+            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"]):
@@ -865,6 +874,7 @@ class SpotifyProvider(MusicProvider):
             # Don't unload - we can still use the global session
             self.dev_session_active = False
             self.logger.warning(str(err))
+            raise
 
         # make sure that our updated creds get stored in memory + config
         self._auth_info_dev = auth_info
@@ -960,6 +970,31 @@ 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.
+
+        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 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
+
     async def _add_audiobook_chapters(self, audiobook: Audiobook) -> None:
         """Add chapter metadata to an audiobook from Spotify API data."""
         try: