import base64 import httpx from fansifter_common.httpclient import HTTPClient, HTTPClientError from pydantic import BaseModel TOKEN_URL = "https://accounts.spotify.com/api/token" API_BASE = "https://api.spotify.com/v1" TIMEOUT = 10.0 # --------------------------------------------------------------------------- # Response types # --------------------------------------------------------------------------- class SpotifyTokenResponse(BaseModel): access_token: str token_type: str scope: str expires_in: int refresh_token: str | None = None class SpotifyProfileResponse(BaseModel): id: str email: str | None = None display_name: str | None = None country: str | None = None product: str | None = None class SpotifyArtist(BaseModel): id: str name: str popularity: int genres: list[str] type: str uri: str class SpotifyTopArtistsResponse(BaseModel): items: list[SpotifyArtist] total: int limit: int offset: int href: str next: str | None = None previous: str | None = None class SpotifyTrack(BaseModel): id: str name: str popularity: int type: str uri: str class SpotifyContext(BaseModel): type: str uri: str href: str class SpotifyCursors(BaseModel): after: str | None = None before: str | None = None class SpotifyPlayHistoryItem(BaseModel): track: SpotifyTrack played_at: str context: SpotifyContext | None = None class SpotifyRecentlyPlayedResponse(BaseModel): items: list[SpotifyPlayHistoryItem] next: str | None = None cursors: SpotifyCursors | None = None limit: int href: str class SpotifyPlaylistTracks(BaseModel): href: str total: int class SpotifyPlaylist(BaseModel): id: str name: str description: str | None = None public: bool | None = None collaborative: bool snapshot_id: str type: str uri: str tracks: SpotifyPlaylistTracks class SpotifyPlaylistsResponse(BaseModel): items: list[SpotifyPlaylist] total: int limit: int offset: int href: str next: str | None = None previous: str | None = None class SpotifyAlbumArtist(BaseModel): id: str name: str type: str uri: str class SpotifyAlbum(BaseModel): id: str name: str album_type: str total_tracks: int release_date: str release_date_precision: str type: str uri: str artists: list[SpotifyAlbumArtist] label: str popularity: int class SpotifySavedAlbum(BaseModel): added_at: str album: SpotifyAlbum class SpotifySavedAlbumsResponse(BaseModel): items: list[SpotifySavedAlbum] total: int limit: int offset: int href: str next: str | None = None previous: str | None = None class SpotifyTrackAlbum(BaseModel): id: str name: str album_type: str release_date: str release_date_precision: str type: str uri: str artists: list[SpotifyAlbumArtist] class SpotifyFullTrack(BaseModel): id: str name: str popularity: int type: str uri: str duration_ms: int explicit: bool track_number: int disc_number: int album: SpotifyTrackAlbum artists: list[SpotifyAlbumArtist] class SpotifySavedTrack(BaseModel): added_at: str track: SpotifyFullTrack class SpotifySavedTracksResponse(BaseModel): items: list[SpotifySavedTrack] total: int limit: int offset: int href: str next: str | None = None previous: str | None = None class SpotifyTopTracksResponse(BaseModel): items: list[SpotifyFullTrack] total: int limit: int offset: int href: str next: str | None = None previous: str | None = None class SpotifyFollowedArtistsPage(BaseModel): items: list[SpotifyArtist] total: int limit: int href: str next: str | None = None cursors: SpotifyCursors | None = None class SpotifyFollowedArtistsResponse(BaseModel): artists: SpotifyFollowedArtistsPage # --------------------------------------------------------------------------- # Exceptions # --------------------------------------------------------------------------- class SpotifyError(Exception): """Base exception for Spotify client errors.""" class SpotifyTokenError(SpotifyError): """Raised when an OAuth token refresh fails (transient).""" class SpotifyTokenRevokedError(SpotifyTokenError): """Raised when the refresh token is permanently invalid (invalid_grant).""" class SpotifyAPIError(HTTPClientError): """Raised when a Spotify API call fails.""" def __init__( self, message: str = "HTTP client error.", *, request: httpx.Request | None = None, response: httpx.Response | None = None, retry_after: int | None = None, ) -> None: super().__init__(message, request=request, response=response) self.retry_after = retry_after # --------------------------------------------------------------------------- # 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 = SpotifyAPIError def __init__(self, client_id: str, client_secret: str) -> 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}" return base64.b64encode(raw.encode()).decode() def handle_response[T]( self, response: httpx.Response, type: type[T] | None = None ) -> httpx.Response | T: if response.status_code == 429: retry_after: int | None = None try: retry_after = int(response.headers["Retry-After"]) except KeyError, ValueError: pass raise SpotifyAPIError( "Rate limited by Spotify API", request=response.request, response=response, retry_after=retry_after, ) return super().handle_response(response, type=type) def refresh_token(self, refresh_token: str) -> SpotifyTokenResponse: try: response = self.client.post( TOKEN_URL, data={"grant_type": "refresh_token", "refresh_token": refresh_token}, 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) if is_revoked: raise SpotifyTokenRevokedError(msg) from exc raise SpotifyTokenError(msg) from exc except httpx.HTTPError as exc: raise SpotifyTokenError(str(exc)) from exc return SpotifyTokenResponse.model_validate_json(response.content) def get_current_user_profile(self, access_token: str) -> SpotifyProfileResponse: return self.request( "GET", f"{API_BASE}/me", headers={"Authorization": f"Bearer {access_token}"}, type=SpotifyProfileResponse, ) def get_current_user_top_artists( self, access_token: str, *, time_range: str = "medium_term" ) -> list[SpotifyArtist]: items: list[SpotifyArtist] = [] offset = 0 while True: page = self.request( "GET", f"{API_BASE}/me/top/artists", params={"limit": 50, "time_range": time_range, "offset": offset}, headers={"Authorization": f"Bearer {access_token}"}, type=SpotifyTopArtistsResponse, ) items.extend(page.items) if page.next is None or len(items) >= page.total: break offset += page.limit return items def get_current_user_top_tracks( self, access_token: str, *, time_range: str = "medium_term" ) -> list[SpotifyFullTrack]: items: list[SpotifyFullTrack] = [] offset = 0 while True: page = self.request( "GET", f"{API_BASE}/me/top/tracks", params={"limit": 50, "time_range": time_range, "offset": offset}, headers={"Authorization": f"Bearer {access_token}"}, type=SpotifyTopTracksResponse, ) items.extend(page.items) if page.next is None or len(items) >= page.total: break offset += page.limit return items def get_current_user_recently_played( self, access_token: str, *, after: int | None = None ) -> list[SpotifyPlayHistoryItem]: items: list[SpotifyPlayHistoryItem] = [] cursor: str | None = str(after) if after is not None else None while True: params: dict[str, str | int] = {"limit": 50} if cursor is not None: params["after"] = cursor page = self.request( "GET", f"{API_BASE}/me/player/recently-played", params=params, headers={"Authorization": f"Bearer {access_token}"}, type=SpotifyRecentlyPlayedResponse, ) items.extend(page.items) if page.next is None or page.cursors is None or page.cursors.after is None: break cursor = page.cursors.after return items def get_current_user_playlists(self, access_token: str) -> list[SpotifyPlaylist]: items: list[SpotifyPlaylist] = [] offset = 0 while True: page = self.request( "GET", f"{API_BASE}/me/playlists", params={"limit": 50, "offset": offset}, headers={"Authorization": f"Bearer {access_token}"}, type=SpotifyPlaylistsResponse, ) items.extend(page.items) if page.next is None or len(items) >= page.total: break offset += page.limit return items def get_current_user_saved_albums( self, access_token: str ) -> list[SpotifySavedAlbum]: items: list[SpotifySavedAlbum] = [] offset = 0 while True: page = self.request( "GET", f"{API_BASE}/me/albums", params={"limit": 50, "offset": offset}, headers={"Authorization": f"Bearer {access_token}"}, type=SpotifySavedAlbumsResponse, ) items.extend(page.items) if page.next is None or len(items) >= page.total: break offset += page.limit return items def get_current_user_saved_tracks( self, access_token: str ) -> list[SpotifySavedTrack]: items: list[SpotifySavedTrack] = [] offset = 0 while True: page = self.request( "GET", f"{API_BASE}/me/tracks", params={"limit": 50, "offset": offset}, headers={"Authorization": f"Bearer {access_token}"}, type=SpotifySavedTracksResponse, ) items.extend(page.items) if page.next is None or len(items) >= page.total: break offset += page.limit return items def get_current_user_followed_artists( self, access_token: str ) -> list[SpotifyArtist]: items: list[SpotifyArtist] = [] cursor: str | None = None while True: params: dict[str, str | int] = {"type": "artist", "limit": 50} if cursor is not None: params["after"] = cursor raw = self.request( "GET", f"{API_BASE}/me/following", params=params, headers={"Authorization": f"Bearer {access_token}"}, type=SpotifyFollowedArtistsResponse, ) page = raw.artists items.extend(page.items) if page.next is None or page.cursors is None or page.cursors.after is None: break cursor = page.cursors.after return items