28 KiB
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
@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
@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
@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
@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_tracksfromtracks.totaloritems.total - iTunes:
owner="iTunes",total_tracks=len(tracks) trackscan be[](e.g. fromget_user_playlists_metadata_only)descriptionisOptional[str]but has no default — callers must always pass it (even ifNone)
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
Trueif Spotify is authenticated OR iTunes fallback is available (alwaysTruein practice). - iTunesClient: Always returns
True(no auth required).
is_spotify_authenticated() -> bool
- SpotifyClient only: Returns
Trueonly if the Spotify API is actually authenticated (callssp.current_user()to verify). ReturnsFalseifself.spisNoneor if the auth check fails.
SpotifyClient Auth Setup
_setup_client() (called by __init__ and reload_config()):
- Reads config via
config_manager.get_spotify_config()— needsclient_idandclient_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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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
{
"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:
{
"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
{
"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(fetcheslimit*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_typefiltering: 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 populatedtracks - Returns
[]if not authenticated
get_user_playlists_metadata_only() -> List[Playlist]
- Spotify only (no iTunes fallback)
- Returns
List[Playlist]withtracks = [](empty) - Returns
[]if not authenticated - Patches missing owner data: if
owneris missing, uses"Unknown Owner"; ifdisplay_nameis 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
0if not authenticated - Fetches just the first page (
limit=1) and readsresults['total']
get_saved_tracks() -> List[Track]
- Spotify only
- Returns
[]if not authenticated - Skips items where
item['track']oritem['track']['id']is falsy
get_playlist_by_id(playlist_id) -> Optional[Playlist]
- Spotify only (no iTunes fallback)
- Returns
Noneif not authenticated - Fetches playlist metadata + all tracks via
_get_playlist_tracks() - Returns a fully populated
Playlistdataclass
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_limiteddecorator 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_limiteddecorator)_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
iTunesClient(country: str = "US")
countryis passed to every_search()and_lookup()call — determines which regional iTunes catalog is queried- Creates a
requests.SessionwithUser-Agent: SoulSync/1.0header - 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
limitat 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:
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, returnsNone/[]— 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[...]returnNoneon error get_saved_tracks_count()returns0on 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'andkind == '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:
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.