feat: add key-free demo mode replaying recorded fixtures
This commit is contained in:
parent
0664cc2d27
commit
401a0ddea7
21 changed files with 1040 additions and 31 deletions
89
backend/app/adapters/demo/catalog.py
Normal file
89
backend/app/adapters/demo/catalog.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue