"""Spotify catalog adapter backed by one recorded demo cassette.""" from collections import defaultdict from urllib.parse import parse_qs, urlparse from app.adapters.demo.cassette import DemoCassette, RecordedResponse from app.adapters.demo.scenario import normalize_text from app.adapters.spotify.mapping import ( parse_saved_track_page, parse_search_tracks, parse_top_artists, parse_track_page, ) from app.domain.models import Track from app.ports.protocols import TimeRange class DemoCatalog: """Replay recorded Spotify search and taste responses without HTTP.""" def __init__(self, cassette: DemoCassette) -> None: """Index one scenario cassette for deterministic request replay.""" self.cassette = cassette self._search_pages = _index_search_pages(cassette.spotify_search_responses) self._search_cursors: dict[str, int] = defaultdict(int) async def search_tracks(self, query: str, limit: int = 10) -> list[Track]: """Return the next recorded search page for a normalized query.""" normalized_query = normalize_text(query) pages = self._search_pages.get(normalized_query, ()) cursor = self._search_cursors[normalized_query] if cursor >= len(pages): return [] self._search_cursors[normalized_query] += 1 return parse_search_tracks(pages[cursor].json_body())[:limit] async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]: """Return the recorded synthetic top artists for a time range.""" response = self._taste_response("/me/top/artists", time_range) return parse_top_artists(response.json_body())[:limit] async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]: """Return the recorded synthetic top tracks for a time range.""" response = self._taste_response("/me/top/tracks", time_range) return parse_track_page(response.json_body())[:limit] async def fetch_saved_tracks(self, limit: int) -> list[Track]: """Return recorded synthetic saved-track pages in offset order.""" responses = sorted( ( response for response in self.cassette.spotify_taste_responses if urlparse(response.url).path.endswith("/me/tracks") ), key=_saved_track_offset, ) tracks: list[Track] = [] for response in responses: tracks.extend(parse_saved_track_page(response.json_body())) if len(tracks) >= limit: break return tracks[:limit] def _taste_response(self, endpoint: str, time_range: TimeRange) -> RecordedResponse: for response in self.cassette.spotify_taste_responses: parsed_url = urlparse(response.url) query = parse_qs(parsed_url.query) if parsed_url.path.endswith(endpoint) and query.get("time_range") == [time_range]: return response raise ValueError(f"Cassette lacks {endpoint} for {time_range}") def _index_search_pages( responses: tuple[RecordedResponse, ...], ) -> dict[str, tuple[RecordedResponse, ...]]: pages: dict[str, list[RecordedResponse]] = defaultdict(list) for response in responses: query = parse_qs(urlparse(response.url).query).get("q") if query: pages[normalize_text(query[0])].append(response) return {key: tuple(value) for key, value in pages.items()} def _saved_track_offset(response: RecordedResponse) -> int: value = parse_qs(urlparse(response.url).query).get("offset", ["0"])[0] try: return int(value) except ValueError: return 0