You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/docs/api-response-shapes.md

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_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:

{
    "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 (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

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:

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:

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.