discovery-by-llm/backend/app/adapters/spotify/client.py
Justin Visser e171dd6d80
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
feat: add Spotify client and auth routes
2026-08-10 10:53:14 +02:00

145 lines
5.1 KiB
Python

"""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 (
CreatedPlaylist,
CurrentUser,
parse_created_playlist,
parse_current_user,
parse_search_tracks,
)
from app.adapters.spotify.session import SpotifySession
from app.config import Settings
from app.domain.models import Track
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."""
response = await self._request(
"GET",
"/search",
params={"q": query, "type": "track", "limit": limit},
)
return parse_search_tracks(response.json())
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:
has_retried_authentication = False
has_retried_rate_limit = False
while True:
access_token = await self._access_token()
response = await self.http.request(
method,
f"{self.settings.spotify_api_base_url.rstrip('/')}{path}",
params=params,
json=json,
headers={"Authorization": f"Bearer {access_token}"},
)
if response.status_code == 401:
if has_retried_authentication:
raise SpotifyAuthenticationError("Spotify rejected refreshed authentication")
await self._refresh_if_current(access_token)
has_retried_authentication = True
continue
if response.status_code == 429:
retry_after_seconds = _parse_retry_after(response)
if (
method == "GET"
and not has_retried_rate_limit
and retry_after_seconds is not None
and retry_after_seconds <= self.settings.spotify_retry_after_cap_seconds
):
await asyncio.sleep(retry_after_seconds)
has_retried_rate_limit = True
continue
raise SpotifyRateLimitedError(retry_after_seconds)
if response.status_code >= 500:
raise SpotifyUnavailableError(
f"Spotify is unavailable with status {response.status_code}"
)
if response.status_code >= 400:
raise SpotifyRequestError(response.status_code)
return response
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