231 lines
7.1 KiB
Python
231 lines
7.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:
|
|
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,
|
|
)
|