import base64 from collections.abc import Callable from typing import Any, Literal, NotRequired, TypedDict import httpx from fansifter_common.httpclient import HTTPClient, HTTPClientError from pydantic import SecretStr, TypeAdapter TOKEN_URL = "https://accounts.spotify.com/api/token" API_BASE = "https://api.spotify.com/v1" TIMEOUT = 10.0 # --------------------------------------------------------------------------- # Response types # --------------------------------------------------------------------------- class SpotifyTokenResponse(TypedDict): access_token: SecretStr refresh_token: NotRequired[SecretStr | None] scope: NotRequired[str | None] class SpotifyCursors(TypedDict): after: NotRequired[str | None] before: NotRequired[str | None] class _SpotifyPageBase(TypedDict): items: list[dict[str, Any] | None] limit: int next: NotRequired[str | None] class SpotifyOffsetPage(_SpotifyPageBase): total: int offset: NotRequired[int] class SpotifyCursorPage(_SpotifyPageBase): cursors: NotRequired[SpotifyCursors | None] class SpotifyFollowedArtistsResponse(TypedDict): artists: SpotifyCursorPage _TOKEN_RESPONSE_ADAPTER = TypeAdapter(SpotifyTokenResponse) # --------------------------------------------------------------------------- # Exceptions # --------------------------------------------------------------------------- class SpotifyHTTPError(HTTPClientError): """HTTPClientError that appends Spotify's JSON error message to the text. May carry an explicit status_code so an OAuth refresh failure (HTTP 400 + body code) can signal standard semantics (401 revoked) for the gateway to translate.""" def __init__( self, message: str = "HTTP client error.", *, status_code: int | None = None, request: httpx.Request | None = None, response: httpx.Response | None = None, ) -> None: super().__init__(message, request=request, response=response) self._status_code = status_code @property def status_code(self) -> int: return ( self._status_code if self._status_code is not None else super().status_code ) @property def detail(self) -> str | None: """Spotify's error text from a Web API JSON body, e.g. `{"error": {"status": 403, "message": "Insufficient client scope"}}`.""" if self.response is None: return None try: error = self.response.json().get("error") except ValueError, AttributeError: return None if isinstance(error, dict): return error.get("message") if isinstance(error, str): return error return None def __str__(self) -> str: return f"{self.message}: {self.detail}" if self.detail else self.message # --------------------------------------------------------------------------- # Client # --------------------------------------------------------------------------- _REVOKED_TOKEN_ERRORS = {"invalid_grant", "unauthorized_client"} _SAFE_TOKEN_ERRORS = _REVOKED_TOKEN_ERRORS | { "invalid_client", "invalid_request", "unsupported_grant_type", } def _sanitize_token_error(exc: httpx.HTTPStatusError) -> tuple[str, bool]: """Returns (message, is_revoked).""" try: body = exc.response.json() error_type = body.get("error", "") if error_type in _SAFE_TOKEN_ERRORS: msg = body.get("error_description") or error_type return msg, error_type in _REVOKED_TOKEN_ERRORS except Exception: pass return f"HTTP {exc.response.status_code}", False class SpotifyClient(HTTPClient): exception_class = SpotifyHTTPError def __init__(self, client_id: str, client_secret: SecretStr) -> None: super().__init__(client_options={"timeout": TIMEOUT}) self._client_id = client_id self._client_secret = client_secret self.start() def _basic_auth(self) -> str: raw = f"{self._client_id}:{self._client_secret.get_secret_value()}" return base64.b64encode(raw.encode()).decode() def refresh_token(self, refresh_token: SecretStr) -> SpotifyTokenResponse: try: response = self.client.post( TOKEN_URL, data={ "grant_type": "refresh_token", "refresh_token": refresh_token.get_secret_value(), }, headers={ "Authorization": f"Basic {self._basic_auth()}", "Content-Type": "application/x-www-form-urlencoded", }, ) response.raise_for_status() except httpx.HTTPStatusError as exc: msg, is_revoked = _sanitize_token_error(exc) # Map a revoked refresh token to 401 so the gateway raises TokenRevoked; # other token errors keep their real status (generic failure). status = 401 if is_revoked else exc.response.status_code raise SpotifyHTTPError(msg, status_code=status) from exc except httpx.HTTPError as exc: raise SpotifyHTTPError(str(exc)) from exc return _TOKEN_RESPONSE_ADAPTER.validate_json(response.content) def _auth_headers(self, access_token: SecretStr) -> dict[str, str]: # The single unwrap point — access_token stays a SecretStr everywhere else. return {"Authorization": f"Bearer {access_token.get_secret_value()}"} def _paginate_offset( self, request_page: Callable[[int, int], SpotifyOffsetPage], *, max_items: int, ) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] offset = 0 while len(items) < max_items: page = request_page(offset, min(50, max_items - len(items))) items.extend(item for item in page["items"] if item is not None) if page.get("next") is None or len(items) >= page["total"]: break offset += page["limit"] return items[:max_items] def _paginate_cursor( self, request_page: Callable[[str | None, int], SpotifyCursorPage], *, max_items: int, initial_after: str | None = None, ) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] cursor = initial_after while len(items) < max_items: page = request_page(cursor, min(50, max_items - len(items))) items.extend(item for item in page["items"] if item is not None) cursors = page.get("cursors") next_after = cursors.get("after") if cursors is not None else None if page.get("next") is None or next_after is None: break cursor = next_after return items[:max_items] def get_current_user_profile(self, access_token: SecretStr) -> dict[str, Any]: return self.request( "GET", f"{API_BASE}/me", headers=self._auth_headers(access_token), type=dict[str, Any], ) def get_current_user_top_artists( self, access_token: SecretStr, *, time_range: str = "medium_term", max_items: int = 50, ) -> list[dict[str, Any]]: return self._get_current_user_top_items( access_token, items_type="artists", time_range=time_range, max_items=max_items, ) def get_current_user_top_tracks( self, access_token: SecretStr, *, time_range: str = "medium_term", max_items: int = 50, ) -> list[dict[str, Any]]: return self._get_current_user_top_items( access_token, items_type="tracks", time_range=time_range, max_items=max_items, ) def _get_current_user_top_items( self, access_token: SecretStr, *, items_type: Literal["tracks", "artists"], time_range: str = "medium_term", max_items: int = 50, ) -> list[dict[str, Any]]: def request_page(offset: int, limit: int) -> SpotifyOffsetPage: return self.request( "GET", f"{API_BASE}/me/top/{items_type}", params={"limit": limit, "time_range": time_range, "offset": offset}, headers=self._auth_headers(access_token), type=SpotifyOffsetPage, ) return self._paginate_offset(request_page, max_items=max_items) def get_current_user_recently_played( self, access_token: SecretStr, *, after: int | None = None, max_items: int = 50, ) -> list[dict[str, Any]]: def request_page(cursor: str | None, limit: int) -> SpotifyCursorPage: params: dict[str, str | int] = {"limit": limit} if cursor is not None: params["after"] = cursor return self.request( "GET", f"{API_BASE}/me/player/recently-played", params=params, headers=self._auth_headers(access_token), type=SpotifyCursorPage, ) return self._paginate_cursor( request_page, max_items=max_items, initial_after=str(after) if after is not None else None, ) def get_current_user_playlists( self, access_token: SecretStr, *, max_items: int = 200 ) -> list[dict[str, Any]]: def request_page(offset: int, limit: int) -> SpotifyOffsetPage: return self.request( "GET", f"{API_BASE}/me/playlists", params={"limit": limit, "offset": offset}, headers=self._auth_headers(access_token), type=SpotifyOffsetPage, ) return self._paginate_offset(request_page, max_items=max_items) def get_current_user_saved_albums( self, access_token: SecretStr, *, max_items: int = 500 ) -> list[dict[str, Any]]: def request_page(offset: int, limit: int) -> SpotifyOffsetPage: return self.request( "GET", f"{API_BASE}/me/albums", params={"limit": limit, "offset": offset}, headers=self._auth_headers(access_token), type=SpotifyOffsetPage, ) return self._paginate_offset(request_page, max_items=max_items) def get_current_user_saved_tracks( self, access_token: SecretStr, *, max_items: int = 1000 ) -> list[dict[str, Any]]: def request_page(offset: int, limit: int) -> SpotifyOffsetPage: return self.request( "GET", f"{API_BASE}/me/tracks", params={"limit": limit, "offset": offset}, headers=self._auth_headers(access_token), type=SpotifyOffsetPage, ) return self._paginate_offset(request_page, max_items=max_items) def get_current_user_followed_artists( self, access_token: SecretStr, *, max_items: int = 500 ) -> list[dict[str, Any]]: def request_page(cursor: str | None, limit: int) -> SpotifyCursorPage: params: dict[str, str | int] = {"type": "artist", "limit": limit} if cursor is not None: params["after"] = cursor raw = self.request( "GET", f"{API_BASE}/me/following", params=params, headers=self._auth_headers(access_token), type=SpotifyFollowedArtistsResponse, ) return raw["artists"] return self._paginate_cursor(request_page, max_items=max_items)