89 lines
3.6 KiB
Python
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
|