"""Thin asynchronous client for the Spotify Web API.""" import asyncio import httpx2 from app.adapters.spotify.auth import refresh_access_token from app.adapters.spotify.errors import ( SpotifyAuthenticationError, SpotifyRateLimitedError, SpotifyRequestError, SpotifyUnavailableError, ) from app.adapters.spotify.mapping import ( CurrentUser, parse_created_playlist, parse_current_user, parse_saved_track_page, parse_search_tracks, parse_top_artists, parse_track_page, ) from app.adapters.spotify.session import SpotifySession from app.config import Settings from app.domain.models import CreatedPlaylist, Track from app.observability.timing import increment_spotify_calls from app.ports.protocols import TimeRange SPOTIFY_SEARCH_LIMIT = 10 SPOTIFY_PAGE_LIMIT = 50 class SpotifyClient: """Call the supported Spotify endpoints with bounded retry behavior.""" def __init__( self, http: httpx2.AsyncClient, session: SpotifySession, settings: Settings, ) -> None: """Bind the shared transport to one authenticated Spotify session.""" self.http = http self.session = session self.settings = settings async def search_tracks(self, query: str, limit: int = 10) -> list[Track]: """Search Spotify tracks and return only valid mapped results.""" if not 1 <= limit <= SPOTIFY_SEARCH_LIMIT: raise ValueError("Spotify search limit must be between 1 and 10") response = await self._request( "GET", "/search", params={"q": query, "type": "track", "limit": limit}, ) return parse_search_tracks(response.json()) async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]: """Fetch the user's top artists for a supported time range.""" response = await self._request( "GET", "/me/top/artists", params={"time_range": time_range, "limit": limit}, ) return parse_top_artists(response.json()) async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]: """Fetch the user's top tracks for a supported time range.""" response = await self._request( "GET", "/me/top/tracks", params={"time_range": time_range, "limit": limit}, ) return parse_track_page(response.json()) async def fetch_saved_tracks(self, limit: int) -> list[Track]: """Fetch a bounded saved-track sample across Spotify pages.""" tracks: list[Track] = [] while len(tracks) < limit: page_limit = min(SPOTIFY_PAGE_LIMIT, limit - len(tracks)) response = await self._request( "GET", "/me/tracks", params={"limit": page_limit, "offset": len(tracks)}, ) page = parse_saved_track_page(response.json()) tracks.extend(page) if len(page) < page_limit: break return tracks async def fetch_current_user(self) -> CurrentUser: """Fetch the authenticated Spotify user's stable identity.""" response = await self._request("GET", "/me") return parse_current_user(response.json()) async def create_playlist(self, name: str, description: str) -> CreatedPlaylist: """Create a private playlist for the authenticated user.""" response = await self._request( "POST", "/me/playlists", json={"name": name, "description": description, "public": False}, ) return parse_created_playlist(response.json()) async def add_tracks_to_playlist(self, playlist_id: str, track_uris: list[str]) -> None: """Add tracks to a playlist without retrying an ambiguous write.""" await self._request( "POST", f"/playlists/{playlist_id}/items", json={"uris": track_uris}, ) async def _request( self, method: str, path: str, *, params: dict[str, str | int] | None = None, json: dict[str, object] | None = None, ) -> httpx2.Response: access_token = await self._access_token() response = await self._send( method, path, access_token, params=params, json=json, ) response = await self._retry_once_if_unauthorized( response, method, path, access_token, params=params, json=json, ) response = await self._retry_once_if_rate_limited( response, method, path, params=params, json=json, ) return self._raise_for_error(response) async def _send( self, method: str, path: str, access_token: str, *, params: dict[str, str | int] | None, json: dict[str, object] | None, ) -> httpx2.Response: increment_spotify_calls() return await self.http.request( method, f"{self.settings.spotify_api_base_url.rstrip('/')}{path}", params=params, json=json, headers={"Authorization": f"Bearer {access_token}"}, ) async def _retry_once_if_unauthorized( self, response: httpx2.Response, method: str, path: str, access_token: str, *, params: dict[str, str | int] | None, json: dict[str, object] | None, ) -> httpx2.Response: if response.status_code != 401: return response await self._refresh_if_current(access_token) return await self._send( method, path, self.session.tokens.access_token, params=params, json=json, ) async def _retry_once_if_rate_limited( self, response: httpx2.Response, method: str, path: str, *, params: dict[str, str | int] | None, json: dict[str, object] | None, ) -> httpx2.Response: if response.status_code != 429: return response retry_after_seconds = _parse_retry_after(response) _, reason = _parse_error_details(response) if ( method != "GET" or reason == "QUOTA_EXCEEDED" or retry_after_seconds is None or retry_after_seconds > self.settings.spotify_retry_after_cap_seconds ): return response await asyncio.sleep(retry_after_seconds) return await self._send( method, path, self.session.tokens.access_token, params=params, json=json, ) def _raise_for_error(self, response: httpx2.Response) -> httpx2.Response: if response.status_code < 400: return response message, reason = _parse_error_details(response) if response.status_code == 401: raise SpotifyAuthenticationError("Spotify rejected refreshed authentication") if response.status_code == 429: raise SpotifyRateLimitedError(_parse_retry_after(response), reason) if response.status_code >= 500: raise SpotifyUnavailableError(response.status_code, message) raise SpotifyRequestError(response.status_code, message) async def _access_token(self) -> str: access_token = self.session.tokens.access_token if self.session.tokens.is_expired: await self._refresh_if_current(access_token) return self.session.tokens.access_token async def _refresh_if_current(self, access_token: str) -> None: async with self.session.refresh_lock: if self.session.tokens.access_token != access_token: return self.session.tokens = await refresh_access_token( self.http, client_id=self.settings.spotify_client_id, tokens=self.session.tokens, ) def _parse_retry_after(response: httpx2.Response) -> float | None: value = response.headers.get("Retry-After") if value is None: return None try: retry_after_seconds = float(value) except ValueError: return None return retry_after_seconds if retry_after_seconds >= 0 else None def _parse_error_details(response: httpx2.Response) -> tuple[str | None, str | None]: try: payload: object = response.json() except ValueError: return None, None if not isinstance(payload, dict): return None, None error = payload.get("error") if not isinstance(error, dict): return None, None message = error.get("message") reason = error.get("reason") return ( message if isinstance(message, str) else None, reason if isinstance(reason, str) else None, )