feat: add Spotify client and auth routes

This commit is contained in:
Justin Visser 2026-08-10 11:31:06 +02:00
parent cdab1b4dd5
commit 6769833f7e
11 changed files with 934 additions and 9 deletions

View file

@ -0,0 +1,231 @@
"""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:
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:
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,
)

View file

@ -0,0 +1,66 @@
"""Spotify login workflow independent of HTTP routing."""
import secrets
import httpx2
from app.adapters.spotify.auth import (
build_authorize_url,
derive_code_challenge,
exchange_authorization_code,
generate_code_verifier,
)
from app.adapters.spotify.client import SpotifyClient
from app.adapters.spotify.errors import SpotifyError
from app.adapters.spotify.session import PendingLogins, SessionStore, SpotifySession
from app.config import Settings
def begin_login(settings: Settings, pending_logins: PendingLogins) -> str:
"""Store a pending PKCE login and return its Spotify authorization URL."""
state = secrets.token_urlsafe(32)
code_verifier = generate_code_verifier()
pending_logins.add(state, code_verifier)
return build_authorize_url(
settings.spotify_client_id,
settings.spotify_redirect_uri,
state,
derive_code_challenge(code_verifier),
)
async def complete_login(
http: httpx2.AsyncClient,
settings: Settings,
pending_logins: PendingLogins,
session_store: SessionStore,
code: str,
state: str,
) -> str | None:
"""Complete a pending Spotify login and return its application session ID."""
code_verifier = pending_logins.pop(state)
if code_verifier is None:
return None
try:
tokens = await exchange_authorization_code(
http,
client_id=settings.spotify_client_id,
redirect_uri=settings.spotify_redirect_uri,
code=code,
code_verifier=code_verifier,
)
bootstrap_session = SpotifySession(tokens=tokens, account_id="", display_name="")
current_user = await SpotifyClient(
http,
bootstrap_session,
settings,
).fetch_current_user()
session = SpotifySession(
tokens=bootstrap_session.tokens,
account_id=current_user.account_id,
display_name=current_user.display_name,
)
return session_store.create(session)
except (SpotifyError, ValueError):
return None

View file

@ -0,0 +1,132 @@
"""Map Spotify response data into application-owned models."""
from collections.abc import Mapping
from dataclasses import dataclass
from typing import cast
from app.domain.models import Track
@dataclass(frozen=True)
class CurrentUser:
"""The stable identity fields returned for the current Spotify user."""
account_id: str
display_name: str
@dataclass(frozen=True)
class CreatedPlaylist:
"""The application-owned result of creating a Spotify playlist."""
id: str
url: str
def parse_search_tracks(payload: object) -> list[Track]:
"""Map valid Spotify search items and discard malformed entries."""
root = _as_mapping(payload)
tracks = _as_mapping(root.get("tracks")) if root is not None else None
items = tracks.get("items") if tracks is not None else None
if not isinstance(items, list):
return []
parsed_tracks: list[Track] = []
for item in items:
parsed_track = _parse_track(item)
if parsed_track is not None:
parsed_tracks.append(parsed_track)
return parsed_tracks
def parse_current_user(payload: object) -> CurrentUser:
"""Map a Spotify current-user response into stable identity fields."""
root = _as_mapping(payload)
account_id = _required_string(root, "account_id")
display_name = _required_string(root, "display_name")
if account_id is None or display_name is None:
raise ValueError("Spotify returned an invalid current-user response")
return CurrentUser(account_id=account_id, display_name=display_name)
def parse_created_playlist(payload: object) -> CreatedPlaylist:
"""Map a Spotify playlist creation response."""
root = _as_mapping(payload)
playlist_id = _required_string(root, "id")
external_urls = _as_mapping(root.get("external_urls")) if root is not None else None
url = _required_string(external_urls, "spotify")
if playlist_id is None or url is None:
raise ValueError("Spotify returned an invalid playlist response")
return CreatedPlaylist(id=playlist_id, url=url)
def _parse_track(payload: object) -> Track | None:
item = _as_mapping(payload)
track_id = _required_string(item, "id")
uri = _required_string(item, "uri")
title = _required_string(item, "name")
album = _as_mapping(item.get("album")) if item is not None else None
album_name = _required_string(album, "name")
artists = _parse_artists(item.get("artists")) if item is not None else None
if (
item is None
or track_id is None
or uri is None
or title is None
or album_name is None
or artists is None
):
return None
return Track(
id=track_id,
uri=uri,
title=title,
artists=artists,
album_name=album_name,
album_art_url=_parse_album_art_url(album),
external_url=_parse_external_url(item),
)
def _parse_artists(payload: object) -> tuple[str, ...] | None:
if not isinstance(payload, list) or not payload:
return None
names: list[str] = []
for artist_payload in payload:
name = _required_string(_as_mapping(artist_payload), "name")
if name is None:
return None
names.append(name)
return tuple(names)
def _parse_album_art_url(album: Mapping[str, object] | None) -> str | None:
images = album.get("images") if album is not None else None
if not isinstance(images, list) or not images:
return None
return _optional_string(_as_mapping(images[0]), "url")
def _parse_external_url(item: Mapping[str, object]) -> str | None:
return _optional_string(_as_mapping(item.get("external_urls")), "spotify")
def _as_mapping(payload: object) -> Mapping[str, object] | None:
if not isinstance(payload, Mapping):
return None
return cast(Mapping[str, object], payload)
def _required_string(payload: Mapping[str, object] | None, key: str) -> str | None:
value = _optional_string(payload, key)
return value if value else None
def _optional_string(payload: Mapping[str, object] | None, key: str) -> str | None:
if payload is None:
return None
value = payload.get(key)
return value if isinstance(value, str) else None