168 lines
5.6 KiB
Python
168 lines
5.6 KiB
Python
"""Map Spotify response data into application-owned models."""
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from typing import cast
|
|
|
|
from app.domain.matching import track_key
|
|
from app.domain.models import CreatedPlaylist, Track
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CurrentUser:
|
|
"""The stable identity fields returned for the current Spotify user."""
|
|
|
|
account_id: str
|
|
display_name: str
|
|
|
|
|
|
def parse_search_tracks(payload: object) -> list[Track]:
|
|
"""Map valid Spotify search items and discard malformed entries."""
|
|
root = _as_mapping(payload)
|
|
tracks = _as_mapping(root.get("tracks")) if root is not None else None
|
|
items = tracks.get("items") if tracks is not None else None
|
|
if not isinstance(items, list):
|
|
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 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") or _required_string(root, "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")
|
|
return CurrentUser(account_id=account_id, display_name=display_name)
|
|
|
|
|
|
def parse_created_playlist(payload: object) -> CreatedPlaylist:
|
|
"""Map a Spotify playlist creation response."""
|
|
root = _as_mapping(payload)
|
|
playlist_id = _required_string(root, "id")
|
|
external_urls = _as_mapping(root.get("external_urls")) if root is not None else None
|
|
url = _required_string(external_urls, "spotify")
|
|
if playlist_id is None or url is None:
|
|
raise ValueError("Spotify returned an invalid playlist response")
|
|
return CreatedPlaylist(id=playlist_id, url=url)
|
|
|
|
|
|
def _parse_track(payload: object) -> Track | None:
|
|
item = _as_mapping(payload)
|
|
track_id = _required_string(item, "id")
|
|
uri = _required_string(item, "uri")
|
|
title = _required_string(item, "name")
|
|
album = _as_mapping(item.get("album")) if item is not None else None
|
|
album_name = _required_string(album, "name")
|
|
artists = _parse_artists(item.get("artists")) if item is not None else None
|
|
|
|
if (
|
|
item is None
|
|
or track_id is None
|
|
or uri is None
|
|
or title is None
|
|
or album_name is None
|
|
or artists is None
|
|
):
|
|
return None
|
|
|
|
return Track(
|
|
id=track_id,
|
|
uri=uri,
|
|
title=title,
|
|
artists=artists,
|
|
album_name=album_name,
|
|
album_art_url=_parse_album_art_url(album),
|
|
external_url=_parse_external_url(item),
|
|
)
|
|
|
|
|
|
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
|
|
|
|
names: list[str] = []
|
|
for artist_payload in payload:
|
|
name = _required_string(_as_mapping(artist_payload), "name")
|
|
if name is None:
|
|
return None
|
|
names.append(name)
|
|
return tuple(names)
|
|
|
|
|
|
def _parse_album_art_url(album: Mapping[str, object] | None) -> str | None:
|
|
images = album.get("images") if album is not None else None
|
|
if not isinstance(images, list) or not images:
|
|
return None
|
|
return _optional_string(_as_mapping(images[0]), "url")
|
|
|
|
|
|
def _parse_external_url(item: Mapping[str, object]) -> str | None:
|
|
return _optional_string(_as_mapping(item.get("external_urls")), "spotify")
|
|
|
|
|
|
def _as_mapping(payload: object) -> Mapping[str, object] | None:
|
|
if not isinstance(payload, Mapping):
|
|
return None
|
|
return cast(Mapping[str, object], payload)
|
|
|
|
|
|
def _required_string(payload: Mapping[str, object] | None, key: str) -> str | None:
|
|
value = _optional_string(payload, key)
|
|
return value if value else None
|
|
|
|
|
|
def _optional_string(payload: Mapping[str, object] | None, key: str) -> str | None:
|
|
if payload is None:
|
|
return None
|
|
value = payload.get(key)
|
|
return value if isinstance(value, str) else None
|