feat: add recommendation service adapters
This commit is contained in:
parent
e970bdf542
commit
751391e6a2
9 changed files with 676 additions and 26 deletions
|
|
@ -12,15 +12,22 @@ from app.adapters.spotify.errors import (
|
|||
SpotifyUnavailableError,
|
||||
)
|
||||
from app.adapters.spotify.mapping import (
|
||||
CreatedPlaylist,
|
||||
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 Track
|
||||
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:
|
||||
|
|
@ -39,6 +46,8 @@ class SpotifyClient:
|
|||
|
||||
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",
|
||||
|
|
@ -46,6 +55,40 @@ class SpotifyClient:
|
|||
)
|
||||
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")
|
||||
|
|
@ -110,6 +153,7 @@ class SpotifyClient:
|
|||
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}",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""Typed failures raised by the Spotify adapter."""
|
||||
|
||||
from app.ports.protocols import CatalogQuotaExhaustedError
|
||||
|
||||
|
||||
class SpotifyError(Exception):
|
||||
"""Base class for Spotify adapter failures."""
|
||||
|
|
@ -9,7 +11,7 @@ class SpotifyAuthenticationError(SpotifyError):
|
|||
"""Spotify rejected authentication or token refresh."""
|
||||
|
||||
|
||||
class SpotifyRateLimitedError(SpotifyError):
|
||||
class SpotifyRateLimitedError(SpotifyError, CatalogQuotaExhaustedError):
|
||||
"""Spotify rate limited a request that could not be retried."""
|
||||
|
||||
def __init__(self, retry_after_seconds: float | None, reason: str | None = None) -> None:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ from collections.abc import Mapping
|
|||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from app.domain.models import Track
|
||||
from app.domain.matching import track_key
|
||||
from app.domain.models import CreatedPlaylist, Track
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -15,14 +16,6 @@ class CurrentUser:
|
|||
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)
|
||||
|
|
@ -32,17 +25,54 @@ def parse_search_tracks(payload: object) -> list[Track]:
|
|||
return []
|
||||
|
||||
parsed_tracks: list[Track] = []
|
||||
seen_ids: set[str] = set()
|
||||
seen_keys: set[str] = set()
|
||||
for item in items:
|
||||
parsed_track = _parse_track(item)
|
||||
if parsed_track is not None:
|
||||
parsed_tracks.append(parsed_track)
|
||||
if parsed_track is None:
|
||||
continue
|
||||
normalized_key = track_key(parsed_track)
|
||||
if parsed_track.id in seen_ids or normalized_key in seen_keys:
|
||||
continue
|
||||
seen_ids.add(parsed_track.id)
|
||||
seen_keys.add(normalized_key)
|
||||
parsed_tracks.append(parsed_track)
|
||||
return parsed_tracks
|
||||
|
||||
|
||||
def parse_top_artists(payload: object) -> list[str]:
|
||||
"""Map a top-artists page to valid artist names."""
|
||||
root = _as_mapping(payload)
|
||||
items = root.get("items") if root is not None else None
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
return [
|
||||
name for item in items if (name := _required_string(_as_mapping(item), "name")) is not None
|
||||
]
|
||||
|
||||
|
||||
def parse_track_page(payload: object) -> list[Track]:
|
||||
"""Map a direct Spotify track page to domain tracks."""
|
||||
root = _as_mapping(payload)
|
||||
items = root.get("items") if root is not None else None
|
||||
return _parse_track_items(items)
|
||||
|
||||
|
||||
def parse_saved_track_page(payload: object) -> list[Track]:
|
||||
"""Map a saved-track wrapper page to domain tracks."""
|
||||
root = _as_mapping(payload)
|
||||
items = root.get("items") if root is not None else None
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
return _parse_track_items(
|
||||
[wrapper.get("track") for item in items if (wrapper := _as_mapping(item)) is not None]
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
account_id = _required_string(root, "id") or _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")
|
||||
|
|
@ -90,6 +120,12 @@ def _parse_track(payload: object) -> Track | None:
|
|||
)
|
||||
|
||||
|
||||
def _parse_track_items(payload: object) -> list[Track]:
|
||||
if not isinstance(payload, list):
|
||||
return []
|
||||
return [track for item in payload if (track := _parse_track(item)) is not None]
|
||||
|
||||
|
||||
def _parse_artists(payload: object) -> tuple[str, ...] | None:
|
||||
if not isinstance(payload, list) or not payload:
|
||||
return None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue