feat: add recommendation service adapters

This commit is contained in:
Justin Visser 2026-08-10 12:05:59 +02:00
parent e970bdf542
commit 751391e6a2
9 changed files with 676 additions and 26 deletions

View file

@ -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