Allow configuration of developer token in Spotify provider (#2818)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 15 Dec 2025 21:50:53 +0000 (22:50 +0100)
committerGitHub <noreply@github.com>
Mon, 15 Dec 2025 21:50:53 +0000 (22:50 +0100)
music_assistant/controllers/media/playlists.py
music_assistant/providers/spotify/__init__.py
music_assistant/providers/spotify/constants.py
music_assistant/providers/spotify/helpers.py
music_assistant/providers/spotify/provider.py

index b4ec18a71750a2dc9719aa3a6092260038039546..8bc7d98b81c9c819ae0fd80ae43de300f79dd619 100644 (file)
@@ -461,7 +461,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
 
         This is used to link objects of different providers/qualities together.
         """
-        raise NotImplementedError
+        self.logger.debug("Matching providers for playlists is not possible, ignoring request")
 
     def _refresh_playlist_tracks(self, playlist: Playlist) -> None:
         """Refresh playlist tracks by forcing a cache refresh."""
index 2f8439ab90558a5c346bd0095e996f46d09cf239..7b86a1e1f1ece7ba65eb92f72296e563811e7672 100644 (file)
@@ -3,26 +3,27 @@
 from __future__ import annotations
 
 from typing import TYPE_CHECKING, cast
-from urllib.parse import urlencode
 
-import pkce
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
 from music_assistant_models.enums import ConfigEntryType, ProviderFeature
-from music_assistant_models.errors import InvalidDataError, SetupFailedError
+from music_assistant_models.errors import InvalidDataError, LoginFailed
 
 from music_assistant.helpers.app_vars import app_var  # type: ignore[attr-defined]
-from music_assistant.helpers.auth import AuthenticationHelper
 
 from .constants import (
     CALLBACK_REDIRECT_URL,
     CONF_ACTION_AUTH,
+    CONF_ACTION_AUTH_DEV,
     CONF_ACTION_CLEAR_AUTH,
+    CONF_ACTION_CLEAR_AUTH_DEV,
     CONF_CLIENT_ID,
-    CONF_REFRESH_TOKEN,
+    CONF_REFRESH_TOKEN_DEPRECATED,
+    CONF_REFRESH_TOKEN_DEV,
+    CONF_REFRESH_TOKEN_GLOBAL,
     CONF_SYNC_AUDIOBOOK_PROGRESS,
     CONF_SYNC_PODCAST_PROGRESS,
-    SCOPE,
 )
+from .helpers import pkce_auth_flow
 from .provider import SpotifyProvider
 
 if TYPE_CHECKING:
@@ -52,19 +53,43 @@ SUPPORTED_FEATURES = {
 }
 
 
+async def _handle_auth_actions(
+    mass: MusicAssistant,
+    action: str | None,
+    values: dict[str, ConfigValueType] | None,
+) -> None:
+    """Handle authentication-related actions for config entries."""
+    if values is None:
+        return
+
+    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
+
+    elif action == CONF_ACTION_AUTH_DEV:
+        custom_client_id = values.get(CONF_CLIENT_ID)
+        if not custom_client_id:
+            raise InvalidDataError("Client ID is required for developer authentication")
+        refresh_token = await pkce_auth_flow(
+            mass, cast("str", values["session_id"]), cast("str", custom_client_id)
+        )
+        values[CONF_REFRESH_TOKEN_DEV] = refresh_token
+
+    elif action == CONF_ACTION_CLEAR_AUTH:
+        values[CONF_REFRESH_TOKEN_GLOBAL] = None
+
+    elif action == CONF_ACTION_CLEAR_AUTH_DEV:
+        values[CONF_REFRESH_TOKEN_DEV] = None
+        values[CONF_CLIENT_ID] = None
+
+
 async def get_config_entries(
     mass: MusicAssistant,
     instance_id: str | None = None,
     action: str | None = None,
     values: dict[str, ConfigValueType] | None = None,
 ) -> tuple[ConfigEntry, ...]:
-    """
-    Return Config entries to setup this provider.
-
-    instance_id: id of an existing provider instance (None if new instance setup).
-    action: [optional] action key called from config entries UI.
-    values: the (intermediate) raw values for config entries sent with the action.
-    """
+    """Return Config entries to setup this provider."""
     # Check if audiobooks are supported by existing provider instance
     audiobooks_supported = (
         instance_id
@@ -72,90 +97,57 @@ async def get_config_entries(
         and getattr(prov_instance, "audiobooks_supported", False)
     )
 
-    if action == CONF_ACTION_AUTH:
-        # spotify PKCE auth flow
-        # https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
-
-        if values is None:
-            raise InvalidDataError("values cannot be None for authentication action")
-
-        code_verifier, code_challenge = pkce.generate_pkce_pair()
-        async with AuthenticationHelper(mass, cast("str", values["session_id"])) as auth_helper:
-            params = {
-                "response_type": "code",
-                "client_id": values.get(CONF_CLIENT_ID) or app_var(2),
-                "scope": " ".join(SCOPE),
-                "code_challenge_method": "S256",
-                "code_challenge": code_challenge,
-                "redirect_uri": CALLBACK_REDIRECT_URL,
-                "state": auth_helper.callback_url,
-            }
-            query_string = urlencode(params)
-            url = f"https://accounts.spotify.com/authorize?{query_string}"
-            result = await auth_helper.authenticate(url)
-            authorization_code = result["code"]
-        # now get the access token
-        params = {
-            "grant_type": "authorization_code",
-            "code": authorization_code,
-            "redirect_uri": CALLBACK_REDIRECT_URL,
-            "client_id": values.get(CONF_CLIENT_ID) or app_var(2),
-            "code_verifier": code_verifier,
-        }
-        async with mass.http_session.post(
-            "https://accounts.spotify.com/api/token", data=params
-        ) as response:
-            result = await response.json()
-            values[CONF_REFRESH_TOKEN] = result["refresh_token"]
-
-    # handle action clear authentication
-    if action == CONF_ACTION_CLEAR_AUTH:
-        if values is None:
-            raise InvalidDataError("values cannot be None for clear auth action")
-        values[CONF_REFRESH_TOKEN] = None
-
-    auth_required = (values or {}).get(CONF_REFRESH_TOKEN) in (None, "")
-
-    if auth_required and values is not None:
-        values[CONF_CLIENT_ID] = None
+    # Handle any authentication actions
+    await _handle_auth_actions(mass, action, values)
+
+    # Determine authentication states from current values
+    # Note: encrypted values are sent as placeholder text, which indicates value IS set
+    global_token = (values or {}).get(CONF_REFRESH_TOKEN_GLOBAL)
+    dev_token = (values or {}).get(CONF_REFRESH_TOKEN_DEV)
+    global_authenticated = global_token not in (None, "")
+    dev_authenticated = dev_token not in (None, "")
+
+    # Build label text based on state - these are dynamic based on current values
+    if not global_authenticated:
         label_text = (
             "You need to authenticate to Spotify. Click the authenticate button below "
             "to start the authentication process which will open in a new (popup) window, "
             "so make sure to disable any popup blockers.\n\n"
-            "Also make sure to perform this action from your local network"
+            "Also make sure to perform this action from your local network."
         )
     elif action == CONF_ACTION_AUTH:
-        label_text = "Authenticated to Spotify. Press save to complete setup."
+        label_text = "Authenticated to Spotify. Don't forget to save to complete setup."
     else:
         label_text = "Authenticated to Spotify. No further action required."
 
+    # Build dev label text
+    if action == CONF_ACTION_AUTH_DEV:
+        dev_label_text = "Developer session authenticated. Don't forget to save to complete setup."
+    elif dev_authenticated:
+        dev_label_text = (
+            "Developer API session authenticated. "
+            "This session will be used for most API requests to avoid rate limits."
+        )
+    else:
+        dev_label_text = (
+            "Optionally, enter your own Spotify Developer Client ID to speed up performance."
+        )
+
     return (
+        # Global authentication section
         ConfigEntry(
             key="label_text",
             type=ConfigEntryType.LABEL,
             label=label_text,
         ),
         ConfigEntry(
-            key=CONF_REFRESH_TOKEN,
+            key=CONF_REFRESH_TOKEN_GLOBAL,
             type=ConfigEntryType.SECURE_STRING,
-            label=CONF_REFRESH_TOKEN,
+            label=CONF_REFRESH_TOKEN_GLOBAL,
             hidden=True,
             required=True,
-            value=values.get(CONF_REFRESH_TOKEN) if values else None,
-        ),
-        ConfigEntry(
-            key=CONF_CLIENT_ID,
-            type=ConfigEntryType.SECURE_STRING,
-            label="Client ID (optional)",
-            description="By default, a generic client ID is used which is (heavy) rate limited. "
-            "To speedup performance, it is advised that you create your own Spotify Developer "
-            "account and use that client ID here, but this comes at the cost of some features "
-            "due to Spotify policies. For example, Radio mode/recommendations and featured "
-            "playlists will not work with a custom client ID. \n\n"
-            f"Use {CALLBACK_REDIRECT_URL} as callback URL.",
-            required=False,
-            value=values.get(CONF_CLIENT_ID) if values else None,
-            hidden=not auth_required,
+            default_value="",
+            value=values.get(CONF_REFRESH_TOKEN_GLOBAL, "") if values else "",
         ),
         ConfigEntry(
             key=CONF_ACTION_AUTH,
@@ -163,7 +155,8 @@ async def get_config_entries(
             label="Authenticate with Spotify",
             description="This button will redirect you to Spotify to authenticate.",
             action=CONF_ACTION_AUTH,
-            hidden=not auth_required,
+            # Show only when not authenticated
+            hidden=global_authenticated,
         ),
         ConfigEntry(
             key=CONF_ACTION_CLEAR_AUTH,
@@ -173,8 +166,66 @@ async def get_config_entries(
             action=CONF_ACTION_CLEAR_AUTH,
             action_label="Clear authentication",
             required=False,
-            hidden=auth_required,
+            # Show only when authenticated
+            hidden=not global_authenticated,
         ),
+        # Developer API section
+        ConfigEntry(
+            key="dev_label_text",
+            type=ConfigEntryType.LABEL,
+            label=dev_label_text,
+            category="Developer Token",
+            # Show only when global auth is complete
+            hidden=not global_authenticated,
+        ),
+        ConfigEntry(
+            key=CONF_CLIENT_ID,
+            type=ConfigEntryType.SECURE_STRING,
+            label="Client ID (optional)",
+            description="Enter your own Spotify Developer Client ID to speed up performance "
+            "by avoiding global rate limits. Some features like recommendations and similar "
+            "tracks will continue to use the global session due to Spotify API restrictions.\n\n"
+            f"Use {CALLBACK_REDIRECT_URL} as callback URL in your Spotify Developer app.",
+            required=False,
+            default_value="",
+            value=values.get(CONF_CLIENT_ID, "") if values else "",
+            category="Developer Token",
+            # Show only when global auth is complete
+            hidden=not global_authenticated or dev_authenticated,
+        ),
+        ConfigEntry(
+            key=CONF_REFRESH_TOKEN_DEV,
+            type=ConfigEntryType.SECURE_STRING,
+            label=CONF_REFRESH_TOKEN_DEV,
+            hidden=True,
+            required=False,
+            default_value="",
+            value=values.get(CONF_REFRESH_TOKEN_DEV, "") if values else "",
+        ),
+        ConfigEntry(
+            key=CONF_ACTION_AUTH_DEV,
+            type=ConfigEntryType.ACTION,
+            label="Authenticate Developer Session",
+            description="Authenticate with your custom Client ID.",
+            action=CONF_ACTION_AUTH_DEV,
+            category="Developer Token",
+            # Show only when global is authenticated and dev is NOT authenticated
+            # The client_id dependency is checked at action time, not visibility
+            hidden=not global_authenticated or dev_authenticated,
+        ),
+        ConfigEntry(
+            key=CONF_ACTION_CLEAR_AUTH_DEV,
+            type=ConfigEntryType.ACTION,
+            label="Clear Developer Authentication",
+            description="Clear the developer session authentication and client ID.",
+            action=CONF_ACTION_CLEAR_AUTH_DEV,
+            action_label="Clear developer authentication",
+            required=False,
+            category="Developer Token",
+            # Show when dev token is set
+            hidden=not global_authenticated or not dev_authenticated,
+        ),
+        # Sync options
         ConfigEntry(
             key=CONF_SYNC_PODCAST_PROGRESS,
             type=ConfigEntryType.BOOLEAN,
@@ -186,6 +237,7 @@ async def get_config_entries(
             default_value=False,
             value=values.get(CONF_SYNC_PODCAST_PROGRESS, True) if values else True,
             category="sync_options",
+            hidden=not global_authenticated,
         ),
         ConfigEntry(
             key=CONF_SYNC_AUDIOBOOK_PROGRESS,
@@ -198,7 +250,7 @@ async def get_config_entries(
             default_value=False,
             value=values.get(CONF_SYNC_AUDIOBOOK_PROGRESS, False) if values else False,
             category="sync_options",
-            hidden=not audiobooks_supported,
+            hidden=not global_authenticated or not audiobooks_supported,
         ),
     )
 
@@ -207,7 +259,30 @@ async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
     """Initialize provider(instance) with given configuration."""
-    if config.get_value(CONF_REFRESH_TOKEN) in (None, ""):
+    # Migration: handle legacy refresh_token
+    legacy_token = config.get_value(CONF_REFRESH_TOKEN_DEPRECATED)
+    global_token = config.get_value(CONF_REFRESH_TOKEN_GLOBAL)
+
+    if legacy_token and not global_token:
+        # Migrate legacy token to appropriate new key
+        if config.get_value(CONF_CLIENT_ID):
+            # Had custom client ID, migrate to dev token
+            mass.config.set_raw_provider_config_value(
+                config.instance_id, CONF_REFRESH_TOKEN_DEV, legacy_token, encrypted=True
+            )
+        else:
+            # No custom client ID, migrate to global token
+            mass.config.set_raw_provider_config_value(
+                config.instance_id, CONF_REFRESH_TOKEN_GLOBAL, legacy_token, encrypted=True
+            )
+        # Remove the deprecated legacy token from config
+        mass.config.set_raw_provider_config_value(
+            config.instance_id, CONF_REFRESH_TOKEN_DEPRECATED, None
+        )
+        # Re-fetch the updated config value
+        global_token = config.get_value(CONF_REFRESH_TOKEN_GLOBAL)
+
+    if global_token in (None, ""):
         msg = "Re-Authentication required"
-        raise SetupFailedError(msg)
+        raise LoginFailed(msg)
     return SpotifyProvider(mass, manifest, config, SUPPORTED_FEATURES)
index 342875006d2bbbae71deb6b324cde24d0617751e..bf674629b89391da10a40c4d9a2a359021dd6e8c 100644 (file)
@@ -5,8 +5,12 @@ from __future__ import annotations
 # Configuration Keys
 CONF_CLIENT_ID = "client_id"
 CONF_ACTION_AUTH = "auth"
-CONF_REFRESH_TOKEN = "refresh_token"
+CONF_ACTION_AUTH_DEV = "auth_dev"
+CONF_REFRESH_TOKEN_DEPRECATED = "refresh_token"  # Legacy key for migration, will be removed
+CONF_REFRESH_TOKEN_GLOBAL = "refresh_token_global"  # Token authenticated with MA's client ID
+CONF_REFRESH_TOKEN_DEV = "refresh_token_dev"  # Token authenticated with user's custom client ID
 CONF_ACTION_CLEAR_AUTH = "clear_auth"
+CONF_ACTION_CLEAR_AUTH_DEV = "clear_auth_dev"
 CONF_SYNC_PODCAST_PROGRESS = "sync_podcast_progress"
 CONF_SYNC_AUDIOBOOK_PROGRESS = "sync_audiobook_progress"
 
index c8347abb5d40bd4b26b06fcb0ff61b9e57cb8d13..fe7af6485a2c82eb86a0369f1a5d3201defb717a 100644 (file)
@@ -2,11 +2,26 @@
 
 from __future__ import annotations
 
+import asyncio
 import os
 import platform
+import time
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlencode
 
+import pkce
+from music_assistant_models.errors import LoginFailed
+
+from music_assistant.helpers.auth import AuthenticationHelper
 from music_assistant.helpers.process import check_output
 
+from .constants import CALLBACK_REDIRECT_URL, SCOPE
+
+if TYPE_CHECKING:
+    import aiohttp
+
+    from music_assistant import MusicAssistant
+
 
 async def get_librespot_binary() -> str:
     """Find the correct librespot binary belonging to the platform."""
@@ -31,3 +46,92 @@ async def get_librespot_binary() -> str:
 
     msg = f"Unable to locate Librespot for {system}/{architecture}"
     raise RuntimeError(msg)
+
+
+async def get_spotify_token(
+    http_session: aiohttp.ClientSession,
+    client_id: str,
+    refresh_token: str,
+    session_name: str = "spotify",
+) -> dict[str, Any]:
+    """Refresh Spotify access token using refresh token.
+
+    :param http_session: aiohttp client session.
+    :param client_id: Spotify client ID.
+    :param refresh_token: Spotify refresh token.
+    :param session_name: Name for logging purposes.
+    :return: Auth info dict with access_token, refresh_token, expires_at.
+    :raises LoginFailed: If token refresh fails.
+    """
+    params = {
+        "grant_type": "refresh_token",
+        "refresh_token": refresh_token,
+        "client_id": client_id,
+    }
+    err = "Unknown error"
+    for _ in range(2):
+        async with http_session.post(
+            "https://accounts.spotify.com/api/token", data=params
+        ) as response:
+            if response.status != 200:
+                err = await response.text()
+                if "revoked" in err:
+                    raise LoginFailed(f"Token revoked for {session_name}: {err}")
+                # the token failed to refresh, we allow one retry
+                await asyncio.sleep(2)
+                continue
+            # if we reached this point, the token has been successfully refreshed
+            auth_info: dict[str, Any] = await response.json()
+            auth_info["expires_at"] = int(auth_info["expires_in"] + time.time())
+            return auth_info
+
+    raise LoginFailed(f"Failed to refresh {session_name} access token: {err}")
+
+
+async def pkce_auth_flow(
+    mass: MusicAssistant,
+    session_id: str,
+    client_id: str,
+) -> str:
+    """Perform Spotify PKCE auth flow and return refresh token.
+
+    :param mass: MusicAssistant instance.
+    :param session_id: Session ID for the authentication helper.
+    :param client_id: The client ID to use for authentication.
+    :return: Refresh token string.
+    """
+    # spotify PKCE auth flow
+    # https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
+    code_verifier, code_challenge = pkce.generate_pkce_pair()
+    async with AuthenticationHelper(mass, session_id) as auth_helper:
+        params = {
+            "response_type": "code",
+            "client_id": client_id,
+            "scope": " ".join(SCOPE),
+            "code_challenge_method": "S256",
+            "code_challenge": code_challenge,
+            "redirect_uri": CALLBACK_REDIRECT_URL,
+            "state": auth_helper.callback_url,
+        }
+        query_string = urlencode(params)
+        url = f"https://accounts.spotify.com/authorize?{query_string}"
+        result = await auth_helper.authenticate(url)
+        authorization_code = result["code"]
+
+    # now get the access token
+    token_params = {
+        "grant_type": "authorization_code",
+        "code": authorization_code,
+        "redirect_uri": CALLBACK_REDIRECT_URL,
+        "client_id": client_id,
+        "code_verifier": code_verifier,
+    }
+    async with mass.http_session.post(
+        "https://accounts.spotify.com/api/token", data=token_params
+    ) as response:
+        if response.status != 200:
+            error_text = await response.text()
+            raise LoginFailed(f"Failed to get access token: {error_text}")
+        token_result = await response.json()
+
+    return str(token_result["refresh_token"])
index c2d0db5249dba2ee451494802f3aeef4af63cdf3..2f7fffaa0ac46525f2ddd820dab6df66a72600be 100644 (file)
@@ -2,7 +2,6 @@
 
 from __future__ import annotations
 
-import asyncio
 import os
 import time
 from collections.abc import AsyncGenerator
@@ -51,12 +50,13 @@ from music_assistant.models.music_provider import MusicProvider
 
 from .constants import (
     CONF_CLIENT_ID,
-    CONF_REFRESH_TOKEN,
+    CONF_REFRESH_TOKEN_DEV,
+    CONF_REFRESH_TOKEN_GLOBAL,
     CONF_SYNC_AUDIOBOOK_PROGRESS,
     CONF_SYNC_PODCAST_PROGRESS,
     LIKED_SONGS_FAKE_PLAYLIST_ID_PREFIX,
 )
-from .helpers import get_librespot_binary
+from .helpers import get_librespot_binary, get_spotify_token
 from .parsers import (
     parse_album,
     parse_artist,
@@ -76,26 +76,46 @@ class NotModifiedError(Exception):
 class SpotifyProvider(MusicProvider):
     """Implementation of a Spotify MusicProvider."""
 
-    _auth_info: dict[str, Any] | None = None
+    # Global session (MA's client ID) - always present
+    _auth_info_global: dict[str, Any] | None = None
+    # Developer session (user's custom client ID) - optional
+    _auth_info_dev: dict[str, Any] | None = None
     _sp_user: dict[str, Any] | None = None
     _librespot_bin: str | None = None
     _audiobooks_supported = False
-    custom_client_id_active: bool = False
+    # True if user has configured a custom client ID with valid authentication
+    dev_session_active: bool = False
     throttler: ThrottlerManager
 
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
         self.cache_dir = os.path.join(self.mass.cache_path, self.instance_id)
+        # Default throttler for global session (heavy rate limited)
         self.throttler = ThrottlerManager(rate_limit=1, period=2)
         self.streamer = LibrespotStreamer(self)
-        if self.config.get_value(CONF_CLIENT_ID):
-            # loosen the throttler a bit when a custom client id is used
-            self.throttler = ThrottlerManager(rate_limit=45, period=30)
-            self.custom_client_id_active = True
+
         # check if we have a librespot binary for this arch
         self._librespot_bin = await get_librespot_binary()
-        # try login which will raise if it fails
+        # try login which will raise if it fails (logs in global session)
         await self.login()
+
+        # Check if user has a custom client ID with valid dev token
+        client_id = self.config.get_value(CONF_CLIENT_ID)
+        dev_token = self.config.get_value(CONF_REFRESH_TOKEN_DEV)
+
+        if client_id and dev_token and self._sp_user:
+            await self.login_dev()
+            # Verify user matches
+            userinfo = await self._get_data("me", use_global_session=False)
+            if userinfo["id"] != self._sp_user["id"]:
+                raise LoginFailed(
+                    "Developer session must use the same Spotify account as the main session."
+                )
+            # loosen the throttler when a custom client id is used
+            self.throttler = ThrottlerManager(rate_limit=45, period=30)
+            self.dev_session_active = True
+            self.logger.info("Developer Spotify session active.")
+
         self._audiobooks_supported = await self._test_audiobook_support()
         if not self._audiobooks_supported:
             self.logger.info(
@@ -128,10 +148,6 @@ class SpotifyProvider(MusicProvider):
         if self.audiobooks_supported:
             features.add(ProviderFeature.LIBRARY_AUDIOBOOKS)
             features.add(ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT)
-        if not self.custom_client_id_active:
-            # Spotify has killed the similar tracks api for developers
-            # https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api
-            return {*features, ProviderFeature.SIMILAR_TRACKS}
         return features
 
     @property
@@ -548,18 +564,21 @@ class SpotifyProvider(MusicProvider):
     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"
         )
         # 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)
+        cache_checksum = await self._get_etag(
+            uri, limit=1, offset=0, use_global_session=is_liked_songs
+        )
 
         page_size = 50
         offset = page * page_size
         spotify_result = await self._get_data_with_caching(
-            uri, cache_checksum, limit=page_size, offset=offset
+            uri, cache_checksum, limit=page_size, offset=offset, use_global_session=is_liked_songs
         )
         for index, item in enumerate(spotify_result["items"], 1):
             if not (item and item["track"] and item["track"]["id"]):
@@ -658,8 +677,12 @@ class SpotifyProvider(MusicProvider):
     @use_cache(86400 * 14)  # 14 days
     async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
         """Retrieve a dynamic list of tracks based on the provided item."""
+        # Recommendations endpoint is only available on global session (not developer API)
+        # https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api
         endpoint = "recommendations"
-        items = await self._get_data(endpoint, seed_tracks=prov_track_id, limit=limit)
+        items = await self._get_data(
+            endpoint, seed_tracks=prov_track_id, limit=limit, use_global_session=True
+        )
         return [parse_track(item, self) for item in items["tracks"] if (item and item["id"])]
 
     async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
@@ -752,62 +775,115 @@ class SpotifyProvider(MusicProvider):
 
     @lock
     async def login(self, force_refresh: bool = False) -> dict[str, Any]:
-        """Log-in Spotify and return Auth/token info."""
+        """Log-in Spotify global session and return Auth/token info.
+
+        This uses MA's global client ID which has full API access but heavy rate limits.
+        """
         # return existing token if we have one in memory
         if (
             not force_refresh
-            and self._auth_info
-            and (self._auth_info["expires_at"] > (time.time() - 600))
+            and self._auth_info_global
+            and (self._auth_info_global["expires_at"] > (time.time() - 600))
         ):
-            return self._auth_info
+            return self._auth_info_global
         # request new access token using the refresh token
-        if not (refresh_token := self.config.get_value(CONF_REFRESH_TOKEN)):
+        if not (refresh_token := self.config.get_value(CONF_REFRESH_TOKEN_GLOBAL)):
             raise LoginFailed("Authentication required")
 
-        client_id = self.config.get_value(CONF_CLIENT_ID) or app_var(2)
-        params = {
-            "grant_type": "refresh_token",
-            "refresh_token": refresh_token,
-            "client_id": client_id,
-        }
-        err = "Unknown error"
-        for _ in range(2):
-            async with self.mass.http_session.post(
-                "https://accounts.spotify.com/api/token", data=params
-            ) as response:
-                if response.status != 200:
-                    err = await response.text()
-                    if "revoked" in err:
-                        err_msg = f"Failed to refresh access token: {err}"
-                        # clear refresh token if it's invalid
-                        self.update_config_value(CONF_REFRESH_TOKEN, None)
-                        if self.available:
-                            # If we're already loaded, we need to unload and set an error
-                            self.unload_with_error(err_msg)
-                        raise LoginFailed(err_msg)
-                    # the token failed to refresh, we allow one retry
-                    await asyncio.sleep(2)
-                    continue
-                # if we reached this point, the token has been successfully refreshed
-                auth_info: dict[str, Any] = await response.json()
-                auth_info["expires_at"] = int(auth_info["expires_in"] + time.time())
-                self.logger.debug("Successfully refreshed access token")
-                break
-        else:
-            if self.available:
+        try:
+            auth_info = await get_spotify_token(
+                self.mass.http_session,
+                app_var(2),  # Always use MA's global client ID
+                cast("str", refresh_token),
+                "global",
+            )
+            self.logger.debug("Successfully refreshed global access token")
+        except LoginFailed as err:
+            if "revoked" in str(err):
+                # clear refresh token if it's invalid
+                self.update_config_value(CONF_REFRESH_TOKEN_GLOBAL, None)
+                if self.available:
+                    self.unload_with_error(str(err))
+            elif self.available:
                 self.mass.create_task(
-                    self.mass.unload_provider_with_error(
-                        self.instance_id, f"Failed to refresh access token: {err}"
-                    )
+                    self.mass.unload_provider_with_error(self.instance_id, str(err))
                 )
-            raise LoginFailed(f"Failed to refresh access token: {err}")
+            raise
+
+        # make sure that our updated creds get stored in memory + config
+        self._auth_info_global = auth_info
+        self.update_config_value(
+            CONF_REFRESH_TOKEN_GLOBAL, auth_info["refresh_token"], encrypted=True
+        )
+
+        # Setup librespot with global token only if dev token is not configured
+        # (if dev token exists, librespot will be set up in login_dev instead)
+        if not self.config.get_value(CONF_REFRESH_TOKEN_DEV):
+            await self._setup_librespot_auth(auth_info["access_token"])
+
+        # get logged-in user info
+        if not self._sp_user:
+            self._sp_user = userinfo = await self._get_data(
+                "me", auth_info=auth_info, use_global_session=True
+            )
+            self.mass.metadata.set_default_preferred_language(userinfo["country"])
+            self.logger.info("Successfully logged in to Spotify as %s", userinfo["display_name"])
+        return auth_info
+
+    @lock
+    async def login_dev(self, force_refresh: bool = False) -> dict[str, Any]:
+        """Log-in Spotify developer session and return Auth/token info.
+
+        This uses the user's custom client ID which has less rate limits but limited API access.
+        """
+        # return existing token if we have one in memory
+        if (
+            not force_refresh
+            and self._auth_info_dev
+            and (self._auth_info_dev["expires_at"] > (time.time() - 600))
+        ):
+            return self._auth_info_dev
+        # request new access token using the refresh token
+        refresh_token = self.config.get_value(CONF_REFRESH_TOKEN_DEV)
+        client_id = self.config.get_value(CONF_CLIENT_ID)
+        if not refresh_token or not client_id:
+            raise LoginFailed("Developer authentication not configured")
+
+        try:
+            auth_info = await get_spotify_token(
+                self.mass.http_session,
+                cast("str", client_id),
+                cast("str", refresh_token),
+                "developer",
+            )
+            self.logger.debug("Successfully refreshed developer access token")
+        except LoginFailed as err:
+            if "revoked" in str(err):
+                # clear refresh token if it's invalid
+                self.update_config_value(CONF_REFRESH_TOKEN_DEV, None)
+            # 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 = auth_info
-        self.update_config_value(CONF_REFRESH_TOKEN, auth_info["refresh_token"], encrypted=True)
-        # check if librespot still has valid auth
+        self._auth_info_dev = auth_info
+        self.update_config_value(CONF_REFRESH_TOKEN_DEV, auth_info["refresh_token"], encrypted=True)
+
+        # Setup librespot with dev token (preferred over global token)
+        await self._setup_librespot_auth(auth_info["access_token"])
+
+        self.logger.info("Successfully logged in to Spotify developer session")
+        return auth_info
+
+    async def _setup_librespot_auth(self, access_token: str) -> None:
+        """Set up librespot authentication with the given access token.
+
+        :param access_token: Spotify access token to use for librespot authentication.
+        """
         if self._librespot_bin is None:
             raise LoginFailed("Librespot binary not available")
+
         args = [
             self._librespot_bin,
             "--cache",
@@ -821,20 +897,29 @@ class SpotifyProvider(MusicProvider):
             # librespot will then get its own token from spotify (somehow) and cache that.
             args += [
                 "--access-token",
-                auth_info["access_token"],
+                access_token,
             ]
             ret_code, stdout = await check_output(*args)
             if ret_code != 0:
                 # this should not happen, but guard it just in case
-                err = stdout.decode("utf-8").strip()
-                raise LoginFailed(f"Failed to verify credentials on Librespot: {err}")
+                err_str = stdout.decode("utf-8").strip()
+                raise LoginFailed(f"Failed to verify credentials on Librespot: {err_str}")
 
-        # get logged-in user info
-        if not self._sp_user:
-            self._sp_user = userinfo = await self._get_data("me", auth_info=auth_info)
-            self.mass.metadata.set_default_preferred_language(userinfo["country"])
-            self.logger.info("Successfully logged in to Spotify as %s", userinfo["display_name"])
-        return auth_info
+    async def _get_auth_info(self, use_global_session: bool = False) -> dict[str, Any]:
+        """Get auth info for API requests, preferring dev session if available.
+
+        :param use_global_session: Force use of global session (for features not available on dev).
+        """
+        if use_global_session or not self.dev_session_active:
+            return await self.login()
+
+        # Try dev session first
+        try:
+            return await self.login_dev()
+        except LoginFailed:
+            # Fall back to global session
+            self.logger.debug("Falling back to global session after dev session failure")
+            return await self.login()
 
     def _get_liked_songs_playlist_id(self) -> str:
         return f"{LIKED_SONGS_FAKE_PLAYLIST_ID_PREFIX}-{self.instance_id}"
@@ -1006,12 +1091,17 @@ class SpotifyProvider(MusicProvider):
 
     @throttle_with_retries
     async def _get_data(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
-        """Get data from api."""
+        """Get data from api.
+
+        :param endpoint: API endpoint to call.
+        :param use_global_session: Force use of global session (for features not available on dev).
+        """
         url = f"https://api.spotify.com/v1/{endpoint}"
         kwargs["market"] = "from_token"
         kwargs["country"] = "from_token"
+        use_global_session = kwargs.pop("use_global_session", False)
         if not (auth_info := kwargs.pop("auth_info", None)):
-            auth_info = await self.login()
+            auth_info = await self._get_auth_info(use_global_session=use_global_session)
         headers = {"Authorization": f"Bearer {auth_info['access_token']}"}
         locale = self.mass.metadata.locale.replace("_", "-")
         language = locale.split("-")[0]
@@ -1038,7 +1128,10 @@ class SpotifyProvider(MusicProvider):
             # handle token expired, raise ResourceTemporarilyUnavailable
             # so it will be retried (and the token refreshed)
             if response.status == 401:
-                self._auth_info = None
+                if use_global_session or not self.dev_session_active:
+                    self._auth_info_global = None
+                else:
+                    self._auth_info_dev = None
                 raise ResourceTemporarilyUnavailable("Token expired", backoff_time=1)
 
             # handle 404 not found, convert to MediaNotFoundError
@@ -1054,7 +1147,9 @@ class SpotifyProvider(MusicProvider):
     async def _delete_data(self, endpoint: str, data: Any = None, **kwargs: Any) -> None:
         """Delete data from api."""
         url = f"https://api.spotify.com/v1/{endpoint}"
-        auth_info = kwargs.pop("auth_info", await self.login())
+        use_global_session = kwargs.pop("use_global_session", False)
+        if not (auth_info := kwargs.pop("auth_info", None)):
+            auth_info = await self._get_auth_info(use_global_session=use_global_session)
         headers = {"Authorization": f"Bearer {auth_info['access_token']}"}
         async with self.mass.http_session.delete(
             url, headers=headers, params=kwargs, json=data, ssl=True
@@ -1068,7 +1163,10 @@ class SpotifyProvider(MusicProvider):
             # handle token expired, raise ResourceTemporarilyUnavailable
             # so it will be retried (and the token refreshed)
             if response.status == 401:
-                self._auth_info = None
+                if use_global_session or not self.dev_session_active:
+                    self._auth_info_global = None
+                else:
+                    self._auth_info_dev = None
                 raise ResourceTemporarilyUnavailable("Token expired", backoff_time=1)
             # handle temporary server error
             if response.status in (502, 503):
@@ -1079,7 +1177,9 @@ class SpotifyProvider(MusicProvider):
     async def _put_data(self, endpoint: str, data: Any = None, **kwargs: Any) -> None:
         """Put data on api."""
         url = f"https://api.spotify.com/v1/{endpoint}"
-        auth_info = kwargs.pop("auth_info", await self.login())
+        use_global_session = kwargs.pop("use_global_session", False)
+        if not (auth_info := kwargs.pop("auth_info", None)):
+            auth_info = await self._get_auth_info(use_global_session=use_global_session)
         headers = {"Authorization": f"Bearer {auth_info['access_token']}"}
         async with self.mass.http_session.put(
             url, headers=headers, params=kwargs, json=data, ssl=True
@@ -1093,7 +1193,10 @@ class SpotifyProvider(MusicProvider):
             # handle token expired, raise ResourceTemporarilyUnavailable
             # so it will be retried (and the token refreshed)
             if response.status == 401:
-                self._auth_info = None
+                if use_global_session or not self.dev_session_active:
+                    self._auth_info_global = None
+                else:
+                    self._auth_info_dev = None
                 raise ResourceTemporarilyUnavailable("Token expired", backoff_time=1)
 
             # handle temporary server error
@@ -1107,7 +1210,9 @@ class SpotifyProvider(MusicProvider):
     ) -> dict[str, Any]:
         """Post data on api."""
         url = f"https://api.spotify.com/v1/{endpoint}"
-        auth_info = kwargs.pop("auth_info", await self.login())
+        use_global_session = kwargs.pop("use_global_session", False)
+        if not (auth_info := kwargs.pop("auth_info", None)):
+            auth_info = await self._get_auth_info(use_global_session=use_global_session)
         headers = {"Authorization": f"Bearer {auth_info['access_token']}"}
         async with self.mass.http_session.post(
             url, headers=headers, params=kwargs, json=data, ssl=True
@@ -1121,7 +1226,10 @@ class SpotifyProvider(MusicProvider):
             # handle token expired, raise ResourceTemporarilyUnavailable
             # so it will be retried (and the token refreshed)
             if response.status == 401:
-                self._auth_info = None
+                if use_global_session or not self.dev_session_active:
+                    self._auth_info_global = None
+                else:
+                    self._auth_info_dev = None
                 raise ResourceTemporarilyUnavailable("Token expired", backoff_time=1)
             # handle temporary server error
             if response.status in (502, 503):