# SoulSync API Response Shapes Reference for the expected response shapes from `SpotifyClient` (which delegates to iTunes when Spotify is not authenticated). Both `core/spotify_client.py` and `core/itunes_client.py` define identical dataclasses (`Track`, `Artist`, `Album`, `Playlist`). The Spotify client returns Spotify-module versions; iTunes fallback returns iTunes-module versions with the same field names and types. --- ## Dataclasses ### Track ```python @dataclass class Track: id: str name: str artists: List[str] # Artist name strings, not dicts album: str # Album name string, not a dict duration_ms: int popularity: int preview_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None image_url: Optional[str] = None ``` #### Spotify construction (`Track.from_spotify_track`) | Field | Source | Nullable/Edge Cases | |---|---|---| | `id` | `track_data['id']` | Never null from Spotify API | | `name` | `track_data['name']` | Never null | | `artists` | `[a['name'] for a in track_data['artists']]` | Can be multi-element list | | `album` | `track_data['album']['name']` | Never null | | `duration_ms` | `track_data['duration_ms']` | | | `popularity` | `track_data.get('popularity', 0)` | Defaults to `0` | | `preview_url` | `track_data.get('preview_url')` | **Can be `None`** | | `external_urls` | `track_data.get('external_urls')` | Usually `{"spotify": "https://..."}` | | `image_url` | `track_data['album']['images'][1]['url']` (medium) or `[0]` | **`None` if album has no images** | #### iTunes construction (`Track.from_itunes_track`) | Field | Source | Nullable/Edge Cases | |---|---|---| | `id` | `str(track_data.get('trackId', ''))` | **Can be empty string** | | `name` | `track_data.get('trackName', '')` | **Can be empty string** | | `artists` | `[clean_artist_name]` or `[artistName]` | Always single-element list | | `album` | Cleaned `collectionName` (strips " - Single", " - EP") | **Can be empty string** | | `duration_ms` | `track_data.get('trackTimeMillis', 0)` | Defaults to `0` | | `popularity` | Always `0` | iTunes doesn't track popularity | | `preview_url` | `track_data.get('previewUrl')` | **Can be `None`** | | `external_urls` | `{"itunes": trackViewUrl}` | **`None` if no trackViewUrl** | | `image_url` | `artworkUrl100` upscaled to 600x600 | **`None` if no artwork** | --- ### Artist ```python @dataclass class Artist: id: str name: str popularity: int genres: List[str] followers: int image_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None ``` #### Spotify construction (`Artist.from_spotify_artist`) | Field | Source | Nullable/Edge Cases | |---|---|---| | `id` | `artist_data['id']` | | | `name` | `artist_data['name']` | | | `popularity` | `artist_data.get('popularity', 0)` | Defaults to `0` | | `genres` | `artist_data.get('genres', [])` | **Can be `[]`** | | `followers` | `artist_data.get('followers', {}).get('total', 0)` | Defaults to `0` | | `image_url` | `artist_data['images'][0]['url']` (largest) | **`None` if no images** | | `external_urls` | `artist_data.get('external_urls')` | Usually `{"spotify": "https://..."}` | #### iTunes construction (`Artist.from_itunes_artist`) | Field | Source | Nullable/Edge Cases | |---|---|---| | `id` | `str(artistId)` | **Can be empty string** | | `name` | `artistName` | **Can be empty string** | | `popularity` | Always `0` | | | `genres` | `[primaryGenreName]` | **`[]` if no genre** | | `followers` | Always `0` | | | `image_url` | `artworkUrl100` upscaled | **Usually `None`** (artist search rarely returns artwork) | | `external_urls` | `{"itunes": artistViewUrl}` | **`None` if no URL** | --- ### Album ```python @dataclass class Album: id: str name: str artists: List[str] # Artist name strings release_date: str total_tracks: int album_type: str image_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None ``` #### Spotify construction (`Album.from_spotify_album`) | Field | Source | Nullable/Edge Cases | |---|---|---| | `id` | `album_data['id']` | | | `name` | `album_data['name']` | | | `artists` | `[a['name'] for a in album_data['artists']]` | | | `release_date` | `album_data.get('release_date', '')` | **Can be `''`**; format varies: `"YYYY"`, `"YYYY-MM"`, `"YYYY-MM-DD"` | | `total_tracks` | `album_data.get('total_tracks', 0)` | Defaults to `0` | | `album_type` | `album_data.get('album_type', 'album')` | `"album"`, `"single"`, `"compilation"` | | `image_url` | `album_data['images'][0]['url']` (largest) | **`None` if no images** | | `external_urls` | `album_data.get('external_urls')` | | #### iTunes construction (`Album.from_itunes_album`) | Field | Source | Nullable/Edge Cases | |---|---|---| | `id` | `str(collectionId)` | **Can be empty string** | | `name` | Cleaned `collectionName` | **Can be empty string** | | `artists` | `[artistName]` | Always single-element | | `release_date` | `releaseDate` (full ISO 8601: `"2023-06-02T07:00:00Z"`) | **Not truncated** in dataclass (but truncated in `get_album()` dict) | | `total_tracks` | `trackCount` | Defaults to `0` | | `album_type` | Inferred: 1-3 tracks = `"single"`, 4-6 = `"ep"`, 7+ = `"album"`, or `"compilation"` | | | `image_url` | `artworkUrl100` upscaled to 600x600 | **`None` if no artwork** | | `external_urls` | `{"itunes": collectionViewUrl}` | **`None` if no URL** | --- ### Playlist ```python @dataclass class Playlist: id: str name: str description: Optional[str] # NOTE: No default value — required positional arg owner: str public: bool collaborative: bool tracks: List[Track] total_tracks: int ``` **Notable differences from other dataclasses**: No `image_url` or `external_urls` fields. - **Spotify**: `owner` = `playlist_data['owner']['display_name']`, `total_tracks` from `tracks.total` or `items.total` - **iTunes**: `owner` = `"iTunes"`, `total_tracks` = `len(tracks)` - `tracks` can be `[]` (e.g. from `get_user_playlists_metadata_only`) - `description` is `Optional[str]` but has **no default** — callers must always pass it (even if `None`) --- ## Utility Methods ### `reload_config()` - **SpotifyClient**: Calls `_setup_client()` to re-read Spotify config and re-authenticate. - **iTunesClient**: No-op (no auth required). ### `is_authenticated() -> bool` - **SpotifyClient**: Returns `True` if Spotify is authenticated OR iTunes fallback is available (always `True` in practice). - **iTunesClient**: Always returns `True` (no auth required). ### `is_spotify_authenticated() -> bool` - **SpotifyClient only**: Returns `True` only if the Spotify API is actually authenticated (calls `sp.current_user()` to verify). Returns `False` if `self.sp` is `None` or if the auth check fails. ### SpotifyClient Auth Setup `_setup_client()` (called by `__init__` and `reload_config()`): - Reads config via `config_manager.get_spotify_config()` — needs `client_id` and `client_secret` - OAuth scopes: `"user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email"` - Cache path: `config/.spotify_cache` - Default redirect URI: `http://127.0.0.1:8888/callback` - On failure: `self.sp = None` (graceful degradation — all methods fall through to iTunes or return empty) - User ID is NOT fetched at startup (lazy via `_ensure_user_id()` when needed for playlist methods) --- ## Raw Dict Methods These methods return **raw dicts**, NOT dataclass instances. ### `get_track_features(track_id) -> Optional[Dict]` Spotify only (no iTunes equivalent). Returns Spotify audio features: ```python { "danceability": float, # 0.0-1.0 "energy": float, # 0.0-1.0 "key": int, # 0-11 (pitch class) "loudness": float, # dB (typically -60 to 0) "mode": int, # 0 = minor, 1 = major "speechiness": float, # 0.0-1.0 "acousticness": float, # 0.0-1.0 "instrumentalness": float, # 0.0-1.0 "liveness": float, # 0.0-1.0 "valence": float, # 0.0-1.0 (musical positiveness) "tempo": float, # BPM "duration_ms": int, "time_signature": int, # 3, 4, 5, etc. "id": str, "uri": str, "track_href": str, "analysis_url": str, "type": "audio_features" } ``` Returns `None` if not Spotify-authenticated or on error. iTunes stub always returns `None`. --- ### `get_user_info() -> Optional[Dict]` Spotify only. Returns raw Spotify `current_user()` response: ```python { "id": str, # Spotify user ID "display_name": str, "email": str, "images": [{"url": str, ...}], "product": str, # "premium", "free", etc. "country": str, # ISO 3166-1 alpha-2 "followers": {"total": int}, "external_urls": {"spotify": str}, "uri": str, "type": "user" } ``` Returns `None` if not Spotify-authenticated or on error. iTunes stub always returns `None`. --- ### `get_track_details(track_id) -> Optional[Dict]` Returns an "enhanced" dict with the same shape from both sources: ```python { "id": str, # Track ID "name": str, # Track name "track_number": int, # Spotify: track_number; iTunes: trackNumber (default 0) "disc_number": int, # Spotify: disc_number; iTunes: discNumber (default 1) "duration_ms": int, "explicit": bool, # Spotify: explicit field; iTunes: trackExplicitness == "explicit" "artists": List[str], # Spotify: multiple; iTunes: single-element "primary_artist": Optional[str], # First artist name; None if artists list empty (Spotify only edge case) "album": { "id": str, "name": str, # iTunes: cleaned (strips " - Single", " - EP") "total_tracks": int, "release_date": str, # Spotify: "YYYY-MM-DD" etc; iTunes: full ISO datetime (NOT truncated) "album_type": str, # Spotify: "album"/"single"/"compilation"; iTunes: ALWAYS "album" "artists": List[str] }, "is_album_track": bool, # total_tracks > 1 "raw_data": Dict # Complete raw API response } ``` **Fallback**: iTunes only used if `track_id` is numeric. Returns `None` if Spotify ID without Spotify auth. **Key difference**: iTunes hardcodes `album.album_type` to `"album"` — does NOT infer from track count like the `Album` dataclass does. **iTunes artist name handling**: The iTunes path performs a `_get_clean_artist_names()` lookup for each call to get the canonical artist name. Both `artists` and `primary_artist` use this cleaned name, not the raw `artistName` from the search result. --- ### `get_album(album_id) -> Optional[Dict]` #### Spotify return Raw Spotify album object (from `spotipy`). Key fields: ```python { "id": str, "name": str, "artists": [{"name": str, "id": str, ...}], # List of artist DICTS (not strings) "images": [{"url": str, "height": int, "width": int}, ...], # Largest first "release_date": str, # "YYYY-MM-DD" or "YYYY" "total_tracks": int, "album_type": str, # "album", "single", "compilation" "tracks": {"items": [...], "total": int}, # Included by full album endpoint "external_urls": {"spotify": str}, "uri": str, "type": "album" } ``` #### iTunes return Normalized to approximate Spotify format: ```python { "id": str, # collectionId as string "name": str, # Cleaned collection name "images": [ # 3 fabricated sizes, or [] if no artwork {"url": str, "height": 600, "width": 600}, {"url": str, "height": 300, "width": 300}, {"url": str, "height": 100, "width": 100} ], "artists": [{"name": str, "id": str}], # Single artist dict in a list "release_date": str, # Truncated to "YYYY-MM-DD" (unlike dataclass) "total_tracks": int, "album_type": str, # "single"/"ep"/"album" (inferred from track count) "external_urls": {"itunes": str}, "uri": "itunes:album:{collectionId}", "_source": "itunes", "_raw_data": Dict, # Original iTunes response "tracks": { # Only if include_tracks=True (default) "items": [...], # Normalized track dicts (see get_album_tracks) "total": int } } ``` **Fallback**: iTunes only used if `album_id` is numeric. Returns `None` if Spotify ID without Spotify auth. --- ### `get_album_tracks(album_id) -> Optional[Dict]` #### Spotify return Modified paging object with ALL tracks collected across pages: ```python { "items": [ { "id": str, "name": str, "artists": [{"name": str, "id": str, ...}], # List of artist DICTS "track_number": int, "disc_number": int, "duration_ms": int, "explicit": bool, "preview_url": str or None, # NOTE: NO "album" sub-object on Spotify's album_tracks endpoint # NOTE: NO "popularity" field }, ... ], "total": int, "limit": int, # Set to len(all_tracks) "next": None # Always None (all pages collected) } ``` #### iTunes return ```python { "items": [ { "id": str, # trackId as string "name": str, # trackName "artists": [{"name": str}], # Single-element list of DICTS (Spotify-compatible) "album": { # PRESENT (unlike Spotify's album_tracks!) "id": str, "name": str, # Cleaned "images": [...], # 3-size image array "release_date": str # "YYYY-MM-DD" }, "duration_ms": int, "track_number": int, "disc_number": int, # Defaults to 1 "explicit": bool, "preview_url": str or None, "uri": "itunes:track:{trackId}", "external_urls": {"itunes": str}, "_source": "itunes" }, ... ], "total": int, "limit": int, "next": None } ``` **Critical difference**: iTunes track items include an `album` sub-object. Spotify's `album_tracks` endpoint does NOT. Code that accesses `item['album']` will fail on Spotify results. Items sorted by `(disc_number, track_number)` for iTunes. **iTunes artist name handling**: Artist names in each track item come from `_get_clean_artist_names()` batch lookup (not raw `artistName`), falling back to `'Unknown Artist'` if lookup fails. --- ### `get_artist(artist_id) -> Optional[Dict]` #### Spotify return Raw Spotify artist object: ```python { "id": str, "name": str, "images": [{"url": str, "height": int, "width": int}, ...], "genres": [str, ...], # Can be [] "popularity": int, # 0-100 "followers": {"total": int}, "external_urls": {"spotify": str}, "uri": str, "type": "artist" } ``` #### iTunes return ```python { "id": str, # artistId as string "name": str, "images": [ # 3 sizes, or [] if no artwork found {"url": str, "height": 600, "width": 600}, {"url": str, "height": 300, "width": 300}, {"url": str, "height": 100, "width": 100} ], # Falls back to first album's artwork "genres": [str], # [primaryGenreName] or [] "popularity": 0, # Always 0 "followers": {"total": 0}, # Always 0 "external_urls": {"itunes": str}, "uri": "itunes:artist:{artistId}", "_source": "itunes", "_raw_data": Dict } ``` --- ## Dataclass Methods (return dataclass instances) ### `search_tracks(query, limit=20) -> List[Track]` - Spotify: `GET /v1/search?type=track` - iTunes fallback: `GET itunes.apple.com/search?entity=song` - Returns `List[Track]` dataclass instances - Returns `[]` on failure ### `search_albums(query, limit=20) -> List[Album]` - Spotify: `GET /v1/search?type=album` - iTunes fallback: `GET itunes.apple.com/search?entity=album` (fetches `limit*2`, deduplicates, prefers explicit) - Returns `List[Album]` dataclass instances ### `search_artists(query, limit=20) -> List[Artist]` - Spotify: `GET /v1/search?type=artist` - iTunes fallback: `GET itunes.apple.com/search?entity=musicArtist` - Returns `List[Artist]` dataclass instances ### `get_artist_albums(artist_id, album_type='album,single', limit=50) -> List[Album]` - Spotify: `GET /v1/artists/{id}/albums` (paginated) - iTunes fallback: `GET itunes.apple.com/lookup?entity=album` (deduplicates, prefers explicit) - Returns `List[Album]` dataclass instances - Returns `[]` if Spotify ID without Spotify auth - iTunes `album_type` filtering: when not the default `'album,single'`, parses comma-separated types and filters. Accepts `'ep'` when `'single'` is requested (backward compatibility). ### `get_user_playlists() -> List[Playlist]` - Spotify only (no iTunes fallback) - Returns `List[Playlist]` with fully populated `tracks` - Returns `[]` if not authenticated ### `get_user_playlists_metadata_only() -> List[Playlist]` - Spotify only (no iTunes fallback) - Returns `List[Playlist]` with `tracks = []` (empty) - Returns `[]` if not authenticated - Patches missing owner data: if `owner` is missing, uses `"Unknown Owner"`; if `display_name` is missing, uses `"Unknown"`. (`get_user_playlists()` does NOT do this patching.) - Returns partial results if an error occurs mid-pagination (unlike all other methods which return `[]` on error) ### `get_saved_tracks_count() -> int` - Spotify only (no iTunes fallback) - Returns `0` if not authenticated - Fetches just the first page (`limit=1`) and reads `results['total']` ### `get_saved_tracks() -> List[Track]` - Spotify only - Returns `[]` if not authenticated - Skips items where `item['track']` or `item['track']['id']` is falsy ### `get_playlist_by_id(playlist_id) -> Optional[Playlist]` - Spotify only (no iTunes fallback) - Returns `None` if not authenticated - Fetches playlist metadata + all tracks via `_get_playlist_tracks()` - Returns a fully populated `Playlist` dataclass --- ## Fallback Rules | Method | iTunes Fallback? | Condition | |---|---|---| | `search_tracks` | Yes | Always (tries Spotify first, falls through on failure) | | `search_albums` | Yes | Always | | `search_artists` | Yes | Always | | `get_track_details` | Yes | Only if `track_id` is numeric | | `get_album` | Yes | Only if `album_id` is numeric | | `get_album_tracks` | Yes | Only if `album_id` is numeric | | `get_artist` | Yes | Only if `artist_id` is numeric | | `get_artist_albums` | Yes | Only if `artist_id` is numeric | | `get_user_playlists` | No | Returns `[]` | | `get_user_playlists_metadata_only` | No | Returns `[]` | | `get_playlist_by_id` | No | Returns `None` | | `get_saved_tracks` | No | Returns `[]` | | `get_saved_tracks_count` | No | Returns `0` | | `get_track_features` | No | Returns `None` | | `get_user_info` | No | Returns `None` | **ID format detection**: Spotify IDs are alphanumeric (base62, contain letters). iTunes IDs are purely numeric. `_is_itunes_id()` checks `id_str.isdigit()`. --- ## Return Type Inconsistencies | Method | Returns | |---|---| | `search_tracks/albums/artists` | **Dataclass** instances (`Track`, `Album`, `Artist`) | | `get_track_details` | **Dict** (enhanced, same shape both sources) | | `get_album` | **Dict** (Spotify raw / iTunes normalized) | | `get_album_tracks` | **Dict** with `items` list | | `get_artist` | **Dict** (Spotify raw / iTunes normalized) | | `get_artist_albums` | **Dataclass** instances (`List[Album]`) | Consumers must handle both: - Dataclass attribute access: `track.name`, `track.artists` - Dict key access: `track_details['name']`, `track_details['album']['name']` --- ## Rate Limiting ### Spotify - `MIN_API_INTERVAL = 0.2` (200ms between calls) - `rate_limited` decorator enforces interval via global lock - On HTTP 429 (rate limit): 3s backoff, then re-raises - On HTTP 502/503 (service error): 2s backoff, then re-raises ### iTunes - `MIN_API_INTERVAL = 3.0` (3s between calls, ~20 calls/minute limit) - `_search()` IS rate-limited (uses `@rate_limited` decorator) - `_lookup()` is **NOT rate-limited** (appears unlimited per Apple docs) - On HTTP 403 (rate limit): 60s backoff, then re-raises --- ## iTunes-Specific Internals ### iTunes API Base URLs ``` SEARCH_URL = "https://itunes.apple.com/search" LOOKUP_URL = "https://itunes.apple.com/lookup" ``` ### iTunes Constructor ```python iTunesClient(country: str = "US") ``` - `country` is passed to every `_search()` and `_lookup()` call — determines which regional iTunes catalog is queried - Creates a `requests.Session` with `User-Agent: SoulSync/1.0` header - No authentication required ### `_clean_itunes_album_name(album_name)` Strips only two suffixes: - `" - Single"` → removed - `" - EP"` → removed No other transformations. Applied in: `Track.from_itunes_track`, `Album.from_itunes_album`, `get_track_details`, `get_album`, `get_album_tracks`. ### iTunes `_search()` Behavior - Always passes `'explicit': 'Yes'` in query params — includes explicit content in results (prefers over clean) - Caps `limit` at 200 (iTunes API max): `min(limit, 200)` - Returns `[]` on HTTP 403 (after a 60s sleep) or any other non-200 status ### iTunes `_lookup()` Behavior - NOT rate-limited (no decorator) - Accepts keyword args that become query params (e.g. `id=`, `entity=`, `limit=`) - Returns `[]` on any error ### Double Rate Limiting on iTunes Search Methods **Gotcha**: `search_tracks()`, `search_albums()`, and `search_artists()` on iTunesClient each have their own `@rate_limited` decorator AND they call `self._search()` which also has `@rate_limited`. This means each call sleeps **twice** — minimum 6s per search call instead of the expected 3s. `get_track_details()` and `get_album_tracks()` avoid this because they use `_lookup()` (not rate-limited) instead of `_search()`. ### `_get_clean_artist_names(artist_ids)` Batch lookup of artist IDs via `_lookup()` (not rate-limited) to get canonical artist names. iTunes search results sometimes append featured artists to the artist name field (e.g. `"Drake & 21 Savage"`), but the lookup endpoint returns the canonical name. Used by `search_tracks()` and `get_album_tracks()`. - Batches up to 50 IDs per lookup call (comma-separated) - Returns `{artist_id: clean_name}` dict ### `get_artist_albums()` Deduplication Heavy dedup logic: normalizes album names (strips edition suffixes, brackets), then deduplicates. Prefers explicit versions over clean — but validates explicit albums by actually looking up their tracks via `_lookup()` to confirm they have tracks (some iTunes explicit albums are broken and report 0 tracks). ### iTunes Stub Methods These methods exist for API parity with SpotifyClient but always return empty/None: | Method | Returns | Reason | |---|---|---| | `get_user_playlists()` | `[]` | iTunes has no user playlists API | | `get_user_playlists_metadata_only()` | `[]` | Same | | `get_playlist_by_id()` | `None` | Same | | `get_saved_tracks_count()` | `0` | iTunes has no saved tracks concept | | `get_saved_tracks()` | `[]` | Same | | `get_user_info()` | `None` | iTunes has no authentication | | `get_track_features()` | `None` | iTunes has no audio features API | | `reload_config()` | `None` | No-op (no auth to reload) | --- ## Implementing a New Metadata Source ### Required Interface A new client must implement all of these public methods. Use iTunesClient as the reference — it shows both the real implementations and the stubs for unsupported features. ``` # Constructor __init__(self, ...) is_authenticated(self) -> bool reload_config(self) # Search (return dataclass instances) search_tracks(query, limit=20) -> List[Track] search_albums(query, limit=20) -> List[Album] search_artists(query, limit=20) -> List[Artist] # Detail lookups (return raw dicts — see shapes above) get_track_details(track_id) -> Optional[Dict] get_album(album_id) -> Optional[Dict] # See note on include_tracks get_album_tracks(album_id) -> Optional[Dict] get_artist(artist_id) -> Optional[Dict] get_track_features(track_id) -> Optional[Dict] get_user_info() -> Optional[Dict] # Collection methods (return dataclass instances) get_artist_albums(artist_id, album_type='album,single', limit=50) -> List[Album] get_user_playlists() -> List[Playlist] get_user_playlists_metadata_only() -> List[Playlist] get_playlist_by_id(playlist_id) -> Optional[Playlist] get_saved_tracks() -> List[Track] get_saved_tracks_count() -> int ``` ### Classmethod Signatures for Dataclasses If you add `from_yoursource_*` classmethods to the dataclasses: ```python Track.from_yoursource_track(cls, track_data: Dict, clean_artist_name: Optional[str] = None) -> Track Artist.from_yoursource_artist(cls, artist_data: Dict) -> Artist Album.from_yoursource_album(cls, album_data: Dict) -> Album Playlist.from_yoursource_playlist(cls, playlist_data: Dict, tracks: List[Track]) -> Playlist # ^^^^^^^^^^^^^^^^^^^^ # NOTE: Playlist takes tracks as a SEPARATE arg (not inside the dict) ``` `Track.from_itunes_track` accepts an optional `clean_artist_name` parameter — when provided, it overrides `artistName` from the raw data. This exists because iTunes search results sometimes append featured artists to the name. If your source has clean artist names, you don't need this parameter. ### Method Signature Differences to Watch | Detail | SpotifyClient | iTunesClient | |---|---|---| | `get_album()` signature | `get_album(album_id)` | `get_album(album_id, include_tracks=True)` | | `get_album()` when called via SpotifyClient fallback | — | Always called without `include_tracks` (defaults to `True`) | SpotifyClient always returns tracks as part of the album object from the Spotify API. iTunesClient has an `include_tracks` param because it requires a separate lookup call. When SpotifyClient delegates to iTunes, it calls `self._itunes.get_album(album_id)` without passing `include_tracks`, so the default `True` applies. ### Delegation Pattern `SpotifyClient` has a lazy `_itunes` property that instantiates `iTunesClient()` on first access. Fallback delegation works like this: - **Search methods** (`search_tracks`, `search_albums`, `search_artists`): Try Spotify first, fall through to iTunes on **any exception** (even if Spotify is authenticated). - **ID-based methods** (`get_track_details`, `get_album`, `get_album_tracks`, `get_artist`, `get_artist_albums`): Try Spotify first, then fall through to iTunes **only if the ID is numeric** (`id_str.isdigit()`). If the ID looks like a Spotify ID (alphanumeric) but Spotify auth failed, returns `None`/`[]` — does NOT try iTunes. - **User-specific methods** (`get_user_playlists`, `get_saved_tracks`, etc.): Spotify only, no fallback. ### Error Handling Convention - Methods returning `List[...]` return `[]` on error - Methods returning `Optional[...]` return `None` on error - `get_saved_tracks_count()` returns `0` on error - Exception: `get_user_playlists_metadata_only()` returns **partial results** if an error occurs mid-pagination (unique to this method) ### Result Filtering iTunes API returns mixed result types. All iTunes methods filter by `wrapperType`: - Track methods: `wrapperType == 'track'` and `kind == 'song'` - Album methods: `wrapperType == 'collection'` - Artist methods: `wrapperType == 'artist'` If your source returns clean typed results, you won't need this filtering. ### Playlist Track Item Quirk The internal `_get_playlist_tracks()` in SpotifyClient handles a Spotify API change (Feb 2026) where playlist items may use either `item['track']` or `item['item']` as the key for the track object: ```python track_data = item.get('track') or item.get('item') ``` Items where the track data is falsy (e.g., local files) are silently skipped. ### Artist Name Defaults | Source | Default when artist name is missing | |---|---| | iTunes `Track.from_itunes_track` | `'Unknown Artist'` (via `track_data.get('artistName', 'Unknown Artist')`) | | iTunes `get_track_details` | `'Unknown Artist'` | | iTunes `get_album_tracks` | `'Unknown Artist'` | | Spotify | No default — relies on Spotify always providing artist names | ### iTunes Album Type Inconsistency The `Album.from_itunes_album` dataclass constructor infers album type from track count AND checks `collectionType` for compilation. But `get_album()` (raw dict method) only infers from track count — it does **not** check `collectionType`. So the same iTunes album can produce different `album_type` values depending on which code path created it.