discovery-by-llm/backend/app/adapters/demo/catalog.py

89 lines
3.6 KiB
Python

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