The limit=200 fix only helped FRESH fetches. The metadata cache is persistent (SQLite, 30-day TTL), so any album whose tracklist was cached at 50 BEFORE the fix keeps returning 50 from cache in every window that loads it (line returned the cached entry without revalidating) — which is why the Standard-view add-album modal still showed 50 while a freshly-fetched album in the download window showed the full list. Same album, different cache state. Fix: get_album_tracks now marks freshly-fetched entries '_complete'. On a cache hit, a legacy entry without that flag is revalidated against the album's known trackCount (from collection metadata, unaffected by the bug) and re-fetched if short. The '_complete' flag makes the heal one-time and avoids a re-fetch loop on region-restricted albums where available tracks < trackCount. Tests: stale-truncated -> refetch+heal; _complete -> trusted; legacy-complete and unknown-trackCount -> trusted (no regression). Fresh fetch carries _complete.
1223 lines
53 KiB
Python
1223 lines
53 KiB
Python
import requests
|
|
from typing import Dict, List, Optional, Any
|
|
import time
|
|
import threading
|
|
from functools import wraps
|
|
from dataclasses import dataclass
|
|
from utils.logging_config import get_logger
|
|
from core.metadata.artist_album_cache import get_cached_artist_album_items, store_artist_album_items
|
|
from core.metadata.cache import get_metadata_cache
|
|
|
|
logger = get_logger("itunes_client")
|
|
|
|
# Global rate limiting variables
|
|
_last_api_call_time = 0
|
|
_api_call_lock = threading.Lock()
|
|
MIN_API_INTERVAL = 3.0 # iTunes has ~20 calls/minute limit = 1 call per 3 seconds
|
|
|
|
def rate_limited(func):
|
|
"""Decorator to enforce rate limiting on iTunes API calls"""
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
global _last_api_call_time
|
|
|
|
with _api_call_lock:
|
|
current_time = time.time()
|
|
time_since_last_call = current_time - _last_api_call_time
|
|
|
|
if time_since_last_call < MIN_API_INTERVAL:
|
|
sleep_time = MIN_API_INTERVAL - time_since_last_call
|
|
time.sleep(sleep_time)
|
|
|
|
_last_api_call_time = time.time()
|
|
|
|
from core.api_call_tracker import api_call_tracker
|
|
api_call_tracker.record_call('itunes')
|
|
|
|
try:
|
|
result = func(*args, **kwargs)
|
|
return result
|
|
except Exception as e:
|
|
# Implement exponential backoff for API errors
|
|
if "403" in str(e):
|
|
logger.warning(f"Rate limit hit, implementing backoff: {e}")
|
|
time.sleep(60.0) # Wait 60 seconds for iTunes rate limit
|
|
raise e
|
|
return wrapper
|
|
|
|
def _clean_itunes_album_name(album_name: str) -> str:
|
|
"""
|
|
Remove iTunes-specific suffixes like " - Single", " - EP" from album names.
|
|
iTunes API adds these suffixes but users don't want them displayed.
|
|
"""
|
|
if not album_name:
|
|
return album_name
|
|
|
|
# List of suffixes to remove
|
|
suffixes_to_remove = [' - Single', ' - EP']
|
|
|
|
for suffix in suffixes_to_remove:
|
|
if album_name.endswith(suffix):
|
|
return album_name[:-len(suffix)]
|
|
|
|
return album_name
|
|
|
|
@dataclass
|
|
class Track:
|
|
id: str
|
|
name: str
|
|
artists: List[str]
|
|
album: str
|
|
duration_ms: int
|
|
popularity: int
|
|
preview_url: Optional[str] = None
|
|
external_urls: Optional[Dict[str, str]] = None
|
|
image_url: Optional[str] = None
|
|
release_date: Optional[str] = None
|
|
track_number: Optional[int] = None
|
|
disc_number: Optional[int] = None
|
|
album_type: Optional[str] = None
|
|
total_tracks: Optional[int] = None
|
|
|
|
@classmethod
|
|
def from_itunes_track(cls, track_data: Dict[str, Any], clean_artist_name: Optional[str] = None) -> 'Track':
|
|
# Extract album image (highest quality)
|
|
album_image_url = None
|
|
if 'artworkUrl100' in track_data:
|
|
# Replace 100x100 with 3000x3000 for highest available quality
|
|
album_image_url = track_data['artworkUrl100'].replace('100x100bb', '3000x3000bb')
|
|
|
|
# Get artist name(s) - prefer clean name from ID lookup if available
|
|
if clean_artist_name:
|
|
artists = [clean_artist_name]
|
|
else:
|
|
artists = [track_data.get('artistName', 'Unknown Artist')]
|
|
|
|
# Build external URLs
|
|
external_urls = {}
|
|
if 'trackViewUrl' in track_data:
|
|
external_urls['itunes'] = track_data['trackViewUrl']
|
|
|
|
# Infer album type from track count
|
|
track_count = track_data.get('trackCount', 0)
|
|
if track_count <= 3:
|
|
album_type = 'single'
|
|
elif track_count <= 6:
|
|
album_type = 'ep'
|
|
else:
|
|
album_type = 'album'
|
|
|
|
return cls(
|
|
id=str(track_data.get('trackId', '')),
|
|
name=track_data.get('trackName', ''),
|
|
artists=artists,
|
|
album=_clean_itunes_album_name(track_data.get('collectionName', '')),
|
|
duration_ms=track_data.get('trackTimeMillis', 0),
|
|
popularity=0, # iTunes doesn't provide popularity
|
|
preview_url=track_data.get('previewUrl'),
|
|
external_urls=external_urls if external_urls else None,
|
|
image_url=album_image_url,
|
|
release_date=track_data.get('releaseDate', '').split('T')[0] if track_data.get('releaseDate') else None,
|
|
album_type=album_type,
|
|
total_tracks=track_count or None
|
|
)
|
|
|
|
@dataclass
|
|
class Artist:
|
|
id: str
|
|
name: str
|
|
popularity: int # iTunes doesn't provide this, will be 0
|
|
genres: List[str]
|
|
followers: int # iTunes doesn't provide this, will be 0
|
|
image_url: Optional[str] = None
|
|
external_urls: Optional[Dict[str, str]] = None
|
|
|
|
@classmethod
|
|
def from_itunes_artist(cls, artist_data: Dict[str, Any]) -> 'Artist':
|
|
# iTunes artist search doesn't reliably return images
|
|
image_url = None
|
|
if 'artworkUrl100' in artist_data:
|
|
image_url = artist_data['artworkUrl100'].replace('100x100bb', '3000x3000bb')
|
|
|
|
# Build external URLs
|
|
external_urls = {}
|
|
if 'artistViewUrl' in artist_data:
|
|
external_urls['itunes'] = artist_data['artistViewUrl']
|
|
|
|
# Get genre
|
|
genre = artist_data.get('primaryGenreName', '')
|
|
genres = [genre] if genre else []
|
|
|
|
return cls(
|
|
id=str(artist_data.get('artistId', '')),
|
|
name=artist_data.get('artistName', ''),
|
|
popularity=0, # iTunes doesn't provide popularity
|
|
genres=genres,
|
|
followers=0, # iTunes doesn't provide follower count
|
|
image_url=image_url,
|
|
external_urls=external_urls if external_urls else None
|
|
)
|
|
|
|
@dataclass
|
|
class Album:
|
|
id: str
|
|
name: str
|
|
artists: List[str]
|
|
release_date: str
|
|
total_tracks: int
|
|
album_type: str
|
|
image_url: Optional[str] = None
|
|
external_urls: Optional[Dict[str, str]] = None
|
|
explicit: Optional[bool] = None
|
|
|
|
@classmethod
|
|
def from_itunes_album(cls, album_data: Dict[str, Any]) -> 'Album':
|
|
# Get highest quality artwork
|
|
image_url = None
|
|
if album_data.get('artworkUrl100'):
|
|
image_url = album_data['artworkUrl100'].replace('100x100bb', '3000x3000bb')
|
|
|
|
# Build external URLs
|
|
external_urls = {}
|
|
if 'collectionViewUrl' in album_data:
|
|
external_urls['itunes'] = album_data['collectionViewUrl']
|
|
|
|
# Determine album type from collection type
|
|
track_count = album_data.get('trackCount', 0)
|
|
|
|
# iTunes doesn't clearly distinguish EPs, but we can infer:
|
|
# Singles typically have 1-3 tracks, EPs have 4-6, Albums have 7+
|
|
if track_count <= 3:
|
|
album_type = 'single'
|
|
elif track_count <= 6:
|
|
album_type = 'ep' # 4-6 tracks = EP
|
|
else:
|
|
album_type = 'album'
|
|
|
|
# Check if it's explicitly marked as compilation
|
|
collection_type = album_data.get('collectionType', 'Album')
|
|
if 'compilation' in collection_type.lower():
|
|
album_type = 'compilation'
|
|
|
|
# Store artistId for primary artist resolution (collab album support)
|
|
if album_data.get('artistId'):
|
|
external_urls['itunes_artist_id'] = str(album_data['artistId'])
|
|
|
|
return cls(
|
|
id=str(album_data.get('collectionId', '')),
|
|
name=_clean_itunes_album_name(album_data.get('collectionName', '')),
|
|
artists=[album_data.get('artistName', 'Unknown Artist')],
|
|
release_date=album_data.get('releaseDate', ''),
|
|
total_tracks=track_count,
|
|
album_type=album_type,
|
|
image_url=image_url,
|
|
external_urls=external_urls if external_urls else None,
|
|
explicit=album_data.get('collectionExplicitness') == 'explicit',
|
|
)
|
|
|
|
@dataclass
|
|
class Playlist:
|
|
id: str
|
|
name: str
|
|
description: Optional[str]
|
|
owner: str
|
|
public: bool
|
|
collaborative: bool
|
|
tracks: List[Track]
|
|
total_tracks: int
|
|
|
|
@classmethod
|
|
def from_itunes_playlist(cls, playlist_data: Dict[str, Any], tracks: List[Track]) -> 'Playlist':
|
|
# iTunes doesn't have playlists in the same way, but we maintain the structure
|
|
return cls(
|
|
id=playlist_data.get('id', ''),
|
|
name=playlist_data.get('name', ''),
|
|
description=playlist_data.get('description'),
|
|
owner='iTunes',
|
|
public=True,
|
|
collaborative=False,
|
|
tracks=tracks,
|
|
total_tracks=len(tracks)
|
|
)
|
|
|
|
class iTunesClient:
|
|
"""
|
|
iTunes Search API client for music metadata.
|
|
|
|
Provides full parity with SpotifyClient functionality.
|
|
Free, no authentication required.
|
|
Rate limit: ~20 calls/minute on /search, /lookup appears unlimited.
|
|
"""
|
|
|
|
SEARCH_URL = "https://itunes.apple.com/search"
|
|
LOOKUP_URL = "https://itunes.apple.com/lookup"
|
|
|
|
# Fallback storefronts to try when primary country returns no results
|
|
FALLBACK_COUNTRIES = ['US', 'GB', 'FR', 'DE', 'JP', 'AU', 'CA', 'BR', 'KR', 'SE']
|
|
|
|
def __init__(self, country: str = None):
|
|
self._fixed_country = country.upper() if country else None
|
|
self.session = requests.Session()
|
|
self.session.headers.update({
|
|
'User-Agent': 'SoulSync/1.0',
|
|
'Accept': 'application/json'
|
|
})
|
|
logger.info(f"iTunes client initialized for country: {self.country}")
|
|
|
|
@property
|
|
def country(self):
|
|
"""Read country from config dynamically so settings changes take effect immediately."""
|
|
if self._fixed_country:
|
|
return self._fixed_country
|
|
try:
|
|
from config.settings import config_manager
|
|
return (config_manager.get('itunes.country', 'US') or 'US').upper()
|
|
except Exception:
|
|
return 'US'
|
|
|
|
def is_authenticated(self) -> bool:
|
|
"""
|
|
Check if iTunes client is available (always True since no auth required)
|
|
"""
|
|
return True
|
|
|
|
@rate_limited
|
|
def _search(self, term: str, entity: str, limit: int = 50) -> List[Dict[str, Any]]:
|
|
"""Generic search method for iTunes API"""
|
|
try:
|
|
params = {
|
|
'term': term,
|
|
'country': self.country,
|
|
'media': 'music',
|
|
'entity': entity,
|
|
'limit': min(limit, 200), # iTunes max is 200
|
|
'explicit': 'Yes' # Include explicit content (prefer over clean versions)
|
|
}
|
|
|
|
response = self.session.get(
|
|
self.SEARCH_URL,
|
|
params=params,
|
|
timeout=30
|
|
)
|
|
|
|
if response.status_code == 403:
|
|
logger.warning("iTunes API rate limit hit")
|
|
time.sleep(60)
|
|
return []
|
|
|
|
if response.status_code != 200:
|
|
logger.error(f"iTunes search failed with status {response.status_code}")
|
|
return []
|
|
|
|
data = response.json()
|
|
results = data.get('results', [])
|
|
logger.info(f"iTunes search for '{term}' ({entity}) returned {len(results)} results")
|
|
return results
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error searching iTunes: {e}")
|
|
return []
|
|
|
|
def _lookup(self, **params) -> List[Dict[str, Any]]:
|
|
"""Generic lookup method with storefront fallback.
|
|
Tries the configured country first, then falls back to other storefronts
|
|
if the result is empty (album/track may be region-restricted)."""
|
|
try:
|
|
params['country'] = self.country
|
|
|
|
response = self.session.get(
|
|
self.LOOKUP_URL,
|
|
params=params,
|
|
timeout=30
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.error(f"iTunes lookup failed with status {response.status_code}")
|
|
return []
|
|
|
|
data = response.json()
|
|
results = data.get('results', [])
|
|
|
|
# If we got results, return them
|
|
if results:
|
|
return results
|
|
|
|
# No results — try fallback storefronts for ID-based lookups
|
|
# (only worth retrying when looking up a specific ID, not general searches)
|
|
if 'id' in params:
|
|
for fallback in self.FALLBACK_COUNTRIES:
|
|
if fallback == self.country:
|
|
continue
|
|
try:
|
|
params['country'] = fallback
|
|
response = self.session.get(
|
|
self.LOOKUP_URL,
|
|
params=params,
|
|
timeout=15
|
|
)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
results = data.get('results', [])
|
|
if results:
|
|
logger.info(f"iTunes lookup found results via fallback storefront: {fallback}")
|
|
return results
|
|
except Exception:
|
|
continue
|
|
|
|
return []
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in iTunes lookup: {e}")
|
|
return []
|
|
|
|
# ==================== Track Methods ====================
|
|
|
|
@rate_limited
|
|
def search_tracks(self, query: str, limit: int = 20) -> List[Track]:
|
|
"""Search for tracks using iTunes API"""
|
|
# Check search cache
|
|
cache = get_metadata_cache()
|
|
cached_results = cache.get_search_results('itunes', 'track', query, limit)
|
|
if cached_results is not None:
|
|
tracks = []
|
|
for raw in cached_results:
|
|
try:
|
|
tracks.append(Track.from_itunes_track(raw))
|
|
except Exception as e:
|
|
logger.debug("Track.from_itunes_track cache parse: %s", e)
|
|
if tracks:
|
|
return tracks
|
|
|
|
results = self._search(query, 'song', limit)
|
|
tracks = []
|
|
|
|
# Collect artist IDs for batch lookup
|
|
artist_ids = set()
|
|
for track_data in results:
|
|
if track_data.get('wrapperType') == 'track' and track_data.get('kind') == 'song':
|
|
artist_id = str(track_data.get('artistId', ''))
|
|
if artist_id:
|
|
artist_ids.add(artist_id)
|
|
|
|
# Batch lookup artist clean names
|
|
clean_artist_map = {}
|
|
if artist_ids:
|
|
clean_artist_map = self._get_clean_artist_names(list(artist_ids))
|
|
|
|
raw_items = []
|
|
for track_data in results:
|
|
if track_data.get('wrapperType') == 'track' and track_data.get('kind') == 'song':
|
|
artist_id = str(track_data.get('artistId', ''))
|
|
clean_artist = clean_artist_map.get(artist_id)
|
|
track = Track.from_itunes_track(track_data, clean_artist_name=clean_artist)
|
|
tracks.append(track)
|
|
raw_items.append(track_data)
|
|
|
|
# Cache individual tracks + search mapping
|
|
entries = [(str(td.get('trackId', '')), td) for td in raw_items if td.get('trackId')]
|
|
if entries:
|
|
cache.store_entities_bulk('itunes', 'track', entries)
|
|
cache.store_search_results('itunes', 'track', query, limit,
|
|
[str(td.get('trackId', '')) for td in raw_items if td.get('trackId')])
|
|
|
|
return tracks
|
|
|
|
def _get_clean_artist_names(self, artist_ids: List[str]) -> Dict[str, str]:
|
|
"""
|
|
Perform a batched lookup of artist IDs to get clean artist names.
|
|
Returns a map of {artist_id: clean_artist_name}
|
|
Checks cache first to avoid unnecessary API calls.
|
|
"""
|
|
if not artist_ids:
|
|
return {}
|
|
|
|
clean_names = {}
|
|
uncached_ids = []
|
|
|
|
# Check cache first
|
|
cache = get_metadata_cache()
|
|
for aid in artist_ids:
|
|
cached = cache.get_entity('itunes', 'artist', aid)
|
|
if cached and cached.get('artistName'):
|
|
clean_names[aid] = cached['artistName']
|
|
else:
|
|
uncached_ids.append(aid)
|
|
|
|
if not uncached_ids:
|
|
return clean_names
|
|
|
|
# iTunes lookup allows comma-separated IDs, but keep batch size reasonable (e.g. 50)
|
|
batch_size = 50
|
|
|
|
for i in range(0, len(uncached_ids), batch_size):
|
|
batch = uncached_ids[i:i+batch_size]
|
|
ids_str = ",".join(batch)
|
|
|
|
try:
|
|
# Lookup is fast/unlimited compared to search
|
|
results = self._lookup(id=ids_str)
|
|
|
|
for item in results:
|
|
if item.get('wrapperType') == 'artist':
|
|
a_id = str(item.get('artistId', ''))
|
|
a_name = item.get('artistName', '')
|
|
if a_id and a_name:
|
|
clean_names[a_id] = a_name
|
|
# Populate artist cache from lookup results
|
|
cache.store_entity('itunes', 'artist', a_id, item)
|
|
except Exception as e:
|
|
logger.warning(f"Failed batch artist lookup: {e}")
|
|
|
|
return clean_names
|
|
|
|
def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Get detailed track information including album data and track number"""
|
|
# Check cache for raw track data
|
|
cache = get_metadata_cache()
|
|
cached = cache.get_entity('itunes', 'track', str(track_id))
|
|
if cached:
|
|
# Reconstruct enhanced_data from cached raw track_data
|
|
# Try to get clean artist name from artist cache (avoids API call)
|
|
clean_artist_name = cached.get('artistName', 'Unknown Artist')
|
|
artist_id = str(cached.get('artistId', ''))
|
|
if artist_id:
|
|
cached_artist = cache.get_entity('itunes', 'artist', artist_id)
|
|
if cached_artist:
|
|
clean_artist_name = cached_artist.get('artistName', clean_artist_name)
|
|
# If no cached artist, use the track's artistName as-is (no API call)
|
|
|
|
return {
|
|
'id': str(cached.get('trackId', '')),
|
|
'name': cached.get('trackName', ''),
|
|
'track_number': cached.get('trackNumber', 0),
|
|
'disc_number': cached.get('discNumber', 1),
|
|
'duration_ms': cached.get('trackTimeMillis', 0),
|
|
'explicit': cached.get('trackExplicitness') == 'explicit',
|
|
'artists': [clean_artist_name],
|
|
'primary_artist': clean_artist_name,
|
|
'album': {
|
|
'id': str(cached.get('collectionId', '')),
|
|
'name': _clean_itunes_album_name(cached.get('collectionName', '')),
|
|
'total_tracks': cached.get('trackCount', 0),
|
|
'release_date': cached.get('releaseDate', ''),
|
|
'album_type': 'album',
|
|
'artists': [clean_artist_name]
|
|
},
|
|
'is_album_track': cached.get('trackCount', 0) > 1,
|
|
'raw_data': cached
|
|
}
|
|
|
|
results = self._lookup(id=track_id)
|
|
|
|
for track_data in results:
|
|
if track_data.get('wrapperType') == 'track':
|
|
# Enhance with additional useful metadata
|
|
# Enhance with additional useful metadata
|
|
# Get clean artist name
|
|
clean_artist_name = 'Unknown Artist'
|
|
artist_id = str(track_data.get('artistId', ''))
|
|
if artist_id:
|
|
clean_names = self._get_clean_artist_names([artist_id])
|
|
clean_artist_name = clean_names.get(artist_id, track_data.get('artistName', 'Unknown Artist'))
|
|
else:
|
|
clean_artist_name = track_data.get('artistName', 'Unknown Artist')
|
|
|
|
enhanced_data = {
|
|
'id': str(track_data.get('trackId', '')),
|
|
'name': track_data.get('trackName', ''),
|
|
'track_number': track_data.get('trackNumber', 0),
|
|
'disc_number': track_data.get('discNumber', 1),
|
|
'duration_ms': track_data.get('trackTimeMillis', 0),
|
|
'explicit': track_data.get('trackExplicitness') == 'explicit',
|
|
'artists': [clean_artist_name],
|
|
'primary_artist': clean_artist_name,
|
|
'album': {
|
|
'id': str(track_data.get('collectionId', '')),
|
|
'name': _clean_itunes_album_name(track_data.get('collectionName', '')),
|
|
'total_tracks': track_data.get('trackCount', 0),
|
|
'release_date': track_data.get('releaseDate', ''),
|
|
'album_type': 'album', # iTunes doesn't distinguish clearly
|
|
'artists': [clean_artist_name]
|
|
},
|
|
'is_album_track': track_data.get('trackCount', 0) > 1,
|
|
'raw_data': track_data
|
|
}
|
|
# Cache the raw track data
|
|
cache.store_entity('itunes', 'track', str(track_id), track_data)
|
|
return enhanced_data
|
|
|
|
return None
|
|
|
|
def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get track audio features (NOT SUPPORTED by iTunes API)
|
|
Returns None as iTunes doesn't provide audio features like Spotify
|
|
"""
|
|
logger.warning("iTunes API does not support audio features")
|
|
return None
|
|
|
|
# ==================== Album Methods ====================
|
|
|
|
@rate_limited
|
|
def search_albums(self, query: str, limit: int = 20) -> List[Album]:
|
|
"""Search for albums using iTunes API.
|
|
|
|
Filters out clean versions when explicit versions are available.
|
|
"""
|
|
# Check search cache
|
|
cache = get_metadata_cache()
|
|
cached_results = cache.get_search_results('itunes', 'album', query, limit)
|
|
if cached_results is not None:
|
|
albums = []
|
|
for raw in cached_results:
|
|
try:
|
|
albums.append(Album.from_itunes_album(raw))
|
|
except Exception as e:
|
|
logger.debug("Album.from_itunes_album cache parse: %s", e)
|
|
if albums:
|
|
return albums
|
|
|
|
results = self._search(query, 'album', limit * 2) # Fetch more to account for filtering
|
|
albums = []
|
|
seen_albums = {} # Track albums by normalized name to prefer explicit versions
|
|
|
|
for album_data in results:
|
|
if album_data.get('wrapperType') != 'collection':
|
|
continue
|
|
|
|
# Get album name and explicitness
|
|
# Clean album name before comparison for better deduplication
|
|
album_name = _clean_itunes_album_name(album_data.get('collectionName', '')).lower().strip()
|
|
artist_name = album_data.get('artistName', '').lower().strip()
|
|
is_explicit = album_data.get('collectionExplicitness') == 'explicit'
|
|
|
|
# Create a key for deduplication (album name + artist)
|
|
key = f"{album_name}|{artist_name}"
|
|
|
|
# If we've seen this album before
|
|
if key in seen_albums:
|
|
# Only replace if current one is explicit and previous was clean
|
|
if is_explicit and not seen_albums[key]['is_explicit']:
|
|
seen_albums[key] = {'data': album_data, 'is_explicit': is_explicit}
|
|
else:
|
|
seen_albums[key] = {'data': album_data, 'is_explicit': is_explicit}
|
|
|
|
# Convert to Album objects + collect raw items for caching
|
|
raw_items = []
|
|
for item in seen_albums.values():
|
|
album = Album.from_itunes_album(item['data'])
|
|
albums.append(album)
|
|
raw_items.append(item['data'])
|
|
|
|
result = albums[:limit]
|
|
|
|
# Cache individual albums + search mapping (skip if full data already cached)
|
|
entries = [(str(ad.get('collectionId', '')), ad) for ad in raw_items if ad.get('collectionId')]
|
|
if entries:
|
|
cache.store_entities_bulk('itunes', 'album', entries, skip_if_exists=True)
|
|
# Only cache IDs for the albums we're actually returning
|
|
result_ids = [str(ad.get('collectionId', '')) for ad in raw_items[:limit] if ad.get('collectionId')]
|
|
if result_ids:
|
|
cache.store_search_results('itunes', 'album', query, limit, result_ids)
|
|
|
|
return result
|
|
|
|
def get_album(self, album_id: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
|
|
"""Get album information with tracks - normalized to Spotify format.
|
|
|
|
Args:
|
|
album_id: iTunes album/collection ID
|
|
include_tracks: If True, also fetches and includes tracks (default True for Spotify compatibility)
|
|
"""
|
|
# Check cache for raw album data
|
|
cache = get_metadata_cache()
|
|
cached = cache.get_entity('itunes', 'album', str(album_id))
|
|
if cached:
|
|
# Reconstruct Spotify-compatible format from cached raw iTunes data
|
|
image_url = None
|
|
if cached.get('artworkUrl100'):
|
|
image_url = cached['artworkUrl100'].replace('100x100bb', '3000x3000bb')
|
|
|
|
images = []
|
|
if image_url:
|
|
images = [
|
|
{'url': image_url, 'height': 3000, 'width': 3000},
|
|
{'url': cached['artworkUrl100'].replace('100x100bb', '300x300bb'), 'height': 300, 'width': 300},
|
|
{'url': cached['artworkUrl100'], 'height': 100, 'width': 100}
|
|
]
|
|
|
|
track_count = cached.get('trackCount', 0)
|
|
if track_count <= 3:
|
|
album_type = 'single'
|
|
elif track_count <= 6:
|
|
album_type = 'ep'
|
|
else:
|
|
album_type = 'album'
|
|
|
|
album_result = {
|
|
'id': str(cached.get('collectionId', '')),
|
|
'name': _clean_itunes_album_name(cached.get('collectionName', '')),
|
|
'images': images,
|
|
'artists': [{'name': cached.get('artistName', 'Unknown Artist'), 'id': str(cached.get('artistId', ''))}],
|
|
'release_date': cached.get('releaseDate', '')[:10] if cached.get('releaseDate') else '',
|
|
'total_tracks': track_count,
|
|
'album_type': album_type,
|
|
'external_urls': {'itunes': cached.get('collectionViewUrl', '')},
|
|
'uri': f"itunes:album:{cached.get('collectionId', '')}",
|
|
'_source': 'itunes',
|
|
'_raw_data': cached
|
|
}
|
|
|
|
if include_tracks:
|
|
tracks_data = self.get_album_tracks(album_id)
|
|
if tracks_data and 'items' in tracks_data:
|
|
album_result['tracks'] = tracks_data
|
|
else:
|
|
album_result['tracks'] = {'items': [], 'total': 0}
|
|
|
|
return album_result
|
|
|
|
results = self._lookup(id=album_id)
|
|
|
|
for album_data in results:
|
|
if album_data.get('wrapperType') == 'collection':
|
|
# Normalize to Spotify-compatible format
|
|
image_url = None
|
|
if album_data.get('artworkUrl100'):
|
|
image_url = album_data['artworkUrl100'].replace('100x100bb', '3000x3000bb')
|
|
|
|
# Build images array like Spotify (multiple sizes)
|
|
images = []
|
|
if image_url:
|
|
images = [
|
|
{'url': image_url, 'height': 3000, 'width': 3000},
|
|
{'url': album_data['artworkUrl100'].replace('100x100bb', '300x300bb'), 'height': 300, 'width': 300},
|
|
{'url': album_data['artworkUrl100'], 'height': 100, 'width': 100}
|
|
]
|
|
|
|
# Determine album type
|
|
track_count = album_data.get('trackCount', 0)
|
|
if track_count <= 3:
|
|
album_type = 'single'
|
|
elif track_count <= 6:
|
|
album_type = 'ep' # 4-6 tracks = EP
|
|
else:
|
|
album_type = 'album'
|
|
|
|
album_result = {
|
|
'id': str(album_data.get('collectionId', '')),
|
|
'name': _clean_itunes_album_name(album_data.get('collectionName', '')),
|
|
'images': images,
|
|
'artists': [{'name': album_data.get('artistName', 'Unknown Artist'), 'id': str(album_data.get('artistId', ''))}],
|
|
'release_date': album_data.get('releaseDate', '')[:10] if album_data.get('releaseDate') else '', # YYYY-MM-DD format
|
|
'total_tracks': track_count,
|
|
'album_type': album_type,
|
|
'external_urls': {'itunes': album_data.get('collectionViewUrl', '')},
|
|
'uri': f"itunes:album:{album_data.get('collectionId', '')}",
|
|
'_source': 'itunes',
|
|
'_raw_data': album_data
|
|
}
|
|
|
|
# Cache the raw album data
|
|
cache.store_entity('itunes', 'album', str(album_id), album_data)
|
|
|
|
# Include tracks to match Spotify's get_album format
|
|
if include_tracks:
|
|
tracks_data = self.get_album_tracks(album_id)
|
|
if tracks_data and 'items' in tracks_data:
|
|
album_result['tracks'] = tracks_data
|
|
else:
|
|
album_result['tracks'] = {'items': [], 'total': 0}
|
|
|
|
return album_result
|
|
|
|
return None
|
|
|
|
def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Get album tracks - normalized to Spotify format"""
|
|
# Check cache for album tracks listing
|
|
cache = get_metadata_cache()
|
|
cached = cache.get_entity('itunes', 'album', f"{album_id}_tracks")
|
|
if cached and cached.get('items'):
|
|
# #918 follow-up: a tracks entry cached BEFORE the limit=200 fix is truncated
|
|
# to 50 and survives in the persistent cache (30-day TTL), so every window that
|
|
# loads this album from cache still shows 50 — not just the one path that was
|
|
# re-fetched fresh. Self-heal: entries written by the fixed fetch carry
|
|
# `_complete`; a legacy entry without it is re-validated against the album's
|
|
# known trackCount and re-fetched if it's short. (trackCount comes from the
|
|
# collection metadata and is unaffected by the tracks-limit bug.)
|
|
if cached.get('_complete'):
|
|
return cached
|
|
album_meta = cache.get_entity('itunes', 'album', str(album_id))
|
|
expected = (album_meta or {}).get('trackCount')
|
|
if not (isinstance(expected, int) and expected > len(cached['items'])):
|
|
return cached
|
|
logger.info(
|
|
"iTunes album %s tracks cache looks truncated (%d cached < %d trackCount) — refetching",
|
|
album_id, len(cached['items']), expected,
|
|
)
|
|
|
|
# #918: the iTunes Lookup API returns only 50 related entities unless `limit` is
|
|
# passed (max 200), so albums >50 tracks were truncated in the download window.
|
|
results = self._lookup(id=album_id, entity='song', limit=200)
|
|
|
|
if not results:
|
|
return None
|
|
|
|
# Check if results contain actual song tracks (not just collection metadata).
|
|
# iTunes sometimes returns only the collection/album info without songs
|
|
# (region-restricted tracks). The _lookup fallback only checks if results
|
|
# is empty, so a collection-only response bypasses fallback storefronts.
|
|
has_songs = any(
|
|
item.get('wrapperType') == 'track' and item.get('kind') == 'song'
|
|
for item in results
|
|
)
|
|
if not has_songs:
|
|
# Try fallback storefronts for actual song tracks
|
|
logger.info(f"Album {album_id} returned collection info but no songs, trying fallback storefronts")
|
|
for fallback in self.FALLBACK_COUNTRIES:
|
|
if fallback == self.country:
|
|
continue
|
|
try:
|
|
fb_results = self.session.get(
|
|
self.LOOKUP_URL,
|
|
params={'id': album_id, 'entity': 'song', 'country': fallback, 'limit': 200}, # #918
|
|
timeout=15
|
|
)
|
|
if fb_results.status_code == 200:
|
|
fb_data = fb_results.json().get('results', [])
|
|
if any(i.get('wrapperType') == 'track' and i.get('kind') == 'song' for i in fb_data):
|
|
logger.info(f"Found song tracks via fallback storefront: {fallback}")
|
|
results = fb_data
|
|
has_songs = True
|
|
break
|
|
except Exception:
|
|
continue
|
|
if not has_songs:
|
|
logger.warning(f"Album {album_id} has no song tracks in any storefront")
|
|
return None
|
|
|
|
# First result is usually the album/collection info
|
|
# Extract album information to include in each track (like Spotify does)
|
|
album_info = None
|
|
album_images = []
|
|
for item in results:
|
|
if item.get('wrapperType') == 'collection':
|
|
album_info = item
|
|
# Build album images array
|
|
if item.get('artworkUrl100'):
|
|
base_url = item['artworkUrl100'].replace('100x100bb', '{size}x{size}bb')
|
|
album_images = [
|
|
{'url': base_url.replace('{size}x{size}bb', '600x600bb'), 'height': 600, 'width': 600},
|
|
{'url': base_url.replace('{size}x{size}bb', '300x300bb'), 'height': 300, 'width': 300},
|
|
{'url': item['artworkUrl100'], 'height': 100, 'width': 100}
|
|
]
|
|
break
|
|
|
|
# Collect artist IDs for batch lookup
|
|
artist_ids = set()
|
|
for item in results:
|
|
if item.get('wrapperType') == 'track' and item.get('kind') == 'song':
|
|
artist_id = str(item.get('artistId', ''))
|
|
if artist_id:
|
|
artist_ids.add(artist_id)
|
|
|
|
# Batch lookup artist clean names
|
|
clean_artist_map = {}
|
|
if artist_ids:
|
|
clean_artist_map = self._get_clean_artist_names(list(artist_ids))
|
|
|
|
tracks = []
|
|
for item in results:
|
|
if item.get('wrapperType') == 'track' and item.get('kind') == 'song':
|
|
artist_id = str(item.get('artistId', ''))
|
|
clean_artist = clean_artist_map.get(artist_id, item.get('artistName', 'Unknown Artist'))
|
|
|
|
# Build album object for this track (like Spotify format)
|
|
track_album = {
|
|
'id': str(item.get('collectionId', album_id)),
|
|
'name': _clean_itunes_album_name(item.get('collectionName', 'Unknown Album')),
|
|
'images': album_images,
|
|
'release_date': item.get('releaseDate', '')[:10] if item.get('releaseDate') else ''
|
|
}
|
|
|
|
# Normalize each track to Spotify-compatible format
|
|
normalized_track = {
|
|
'id': str(item.get('trackId', '')),
|
|
'name': item.get('trackName', ''),
|
|
'artists': [{'name': clean_artist}], # List of dicts like Spotify
|
|
'album': track_album, # CRITICAL: Include album info like Spotify does
|
|
'duration_ms': item.get('trackTimeMillis', 0),
|
|
'track_number': item.get('trackNumber', 0),
|
|
'disc_number': item.get('discNumber', 1),
|
|
'explicit': item.get('trackExplicitness') == 'explicit',
|
|
'preview_url': item.get('previewUrl'),
|
|
'uri': f"itunes:track:{item.get('trackId', '')}", # Synthetic URI
|
|
'external_urls': {'itunes': item.get('trackViewUrl', '')},
|
|
'_source': 'itunes'
|
|
}
|
|
tracks.append(normalized_track)
|
|
|
|
# Sort by disc and track number
|
|
tracks.sort(key=lambda t: (t.get('disc_number', 1), t.get('track_number', 0)))
|
|
|
|
logger.info(f"Retrieved {len(tracks)} tracks for album {album_id}")
|
|
|
|
result = {
|
|
'items': tracks,
|
|
'total': len(tracks),
|
|
'limit': len(tracks),
|
|
'next': None,
|
|
# Marks this entry as fetched with the limit=200 query (#918) so the
|
|
# stale-cache self-heal above trusts it and never re-fetches in a loop —
|
|
# important for region-restricted albums where len(tracks) < trackCount.
|
|
'_complete': True,
|
|
}
|
|
|
|
# Cache the album tracks listing
|
|
cache.store_entity('itunes', 'album', f"{album_id}_tracks", result)
|
|
|
|
# Also cache individual tracks from the raw results (skip if full data already cached)
|
|
track_entries = []
|
|
for item in results:
|
|
if item.get('wrapperType') == 'track' and item.get('kind') == 'song' and item.get('trackId'):
|
|
track_entries.append((str(item['trackId']), item))
|
|
if track_entries:
|
|
cache.store_entities_bulk('itunes', 'track', track_entries, skip_if_exists=True)
|
|
|
|
return result
|
|
|
|
# ==================== Artist Methods ====================
|
|
|
|
def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]:
|
|
"""
|
|
Get artist image by fetching their first album's artwork.
|
|
iTunes doesn't reliably return artist images, so we use album art as fallback.
|
|
"""
|
|
try:
|
|
# Lookup is not rate-limited, so this is fast
|
|
results = self._lookup(id=artist_id, entity='album', limit=1)
|
|
|
|
for item in results:
|
|
if item.get('wrapperType') == 'collection' and item.get('artworkUrl100'):
|
|
# Return high-res version
|
|
return item['artworkUrl100'].replace('100x100bb', '600x600bb')
|
|
except Exception as e:
|
|
logger.debug(f"Could not fetch album art for artist {artist_id}: {e}")
|
|
|
|
return None
|
|
|
|
@rate_limited
|
|
def resolve_primary_artist(self, artist_id: str) -> Optional[str]:
|
|
"""Resolve an iTunes artist ID to the primary artist name.
|
|
For collab albums, iTunes uses the primary artist's ID but a combined display name.
|
|
Looking up the ID returns the real primary artist name.
|
|
e.g., artistId 675391681 → 'Larry June' (not 'Larry June, Curren$y & The Alchemist')"""
|
|
try:
|
|
results = self._lookup(id=artist_id)
|
|
for item in results:
|
|
if item.get('wrapperType') == 'artist' and item.get('artistName'):
|
|
return item['artistName']
|
|
except Exception as e:
|
|
logger.debug("itunes lookup artistId %s: %s", artist_id, e)
|
|
return None
|
|
|
|
def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
|
|
"""Search for artists using iTunes API.
|
|
|
|
Note: Artist images are not fetched during search to keep it fast.
|
|
Images are fetched when viewing artist details (get_artist method).
|
|
"""
|
|
# Check search cache
|
|
cache = get_metadata_cache()
|
|
cached_results = cache.get_search_results('itunes', 'artist', query, limit)
|
|
if cached_results is not None:
|
|
artists = []
|
|
for raw in cached_results:
|
|
try:
|
|
artists.append(Artist.from_itunes_artist(raw))
|
|
except Exception as e:
|
|
logger.debug("Artist.from_itunes_artist cache parse: %s", e)
|
|
if artists:
|
|
return artists
|
|
|
|
results = self._search(query, 'musicArtist', limit)
|
|
artists = []
|
|
raw_items = []
|
|
|
|
for artist_data in results:
|
|
if artist_data.get('wrapperType') == 'artist':
|
|
artist = Artist.from_itunes_artist(artist_data)
|
|
artists.append(artist)
|
|
raw_items.append(artist_data)
|
|
|
|
# Cache individual artists + search mapping
|
|
entries = [(str(ad.get('artistId', '')), ad) for ad in raw_items if ad.get('artistId')]
|
|
if entries:
|
|
cache.store_entities_bulk('itunes', 'artist', entries)
|
|
cache.store_search_results('itunes', 'artist', query, limit,
|
|
[str(ad.get('artistId', '')) for ad in raw_items if ad.get('artistId')])
|
|
|
|
return artists
|
|
|
|
def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get full artist details - normalized to Spotify format.
|
|
|
|
Args:
|
|
artist_id: iTunes artist ID
|
|
|
|
Returns:
|
|
Dictionary with artist data matching Spotify's format
|
|
"""
|
|
# Check cache — stores raw iTunes data, reconstruct Spotify-compatible format
|
|
cache = get_metadata_cache()
|
|
cached = cache.get_entity('itunes', 'artist', str(artist_id))
|
|
if cached and cached.get('wrapperType') == 'artist':
|
|
# Reconstruct Spotify-compatible format from cached raw iTunes data
|
|
# Don't call _get_artist_image_from_albums here — avoid API call on cache hit
|
|
# The image_url was already extracted during initial caching
|
|
images = []
|
|
artwork_url = cached.get('artworkUrl100')
|
|
if artwork_url:
|
|
images = [
|
|
{'url': artwork_url.replace('100x100bb', '600x600bb'), 'height': 600, 'width': 600},
|
|
{'url': artwork_url.replace('100x100bb', '300x300bb'), 'height': 300, 'width': 300},
|
|
{'url': artwork_url, 'height': 100, 'width': 100}
|
|
]
|
|
genres = []
|
|
if cached.get('primaryGenreName'):
|
|
genres = [cached['primaryGenreName']]
|
|
return {
|
|
'id': str(cached.get('artistId', '')),
|
|
'name': cached.get('artistName', ''),
|
|
'images': images,
|
|
'genres': genres,
|
|
'popularity': 0,
|
|
'followers': {'total': 0},
|
|
'external_urls': {'itunes': cached.get('artistViewUrl', '')},
|
|
'uri': f"itunes:artist:{cached.get('artistId', '')}",
|
|
'_source': 'itunes',
|
|
'_raw_data': cached
|
|
}
|
|
|
|
results = self._lookup(id=artist_id)
|
|
|
|
for artist_data in results:
|
|
if artist_data.get('wrapperType') == 'artist':
|
|
# Build images array - iTunes artist search doesn't reliably return images
|
|
# Use album art as fallback
|
|
images = []
|
|
artwork_url = artist_data.get('artworkUrl100')
|
|
|
|
# If no artist artwork, try to get from their first album
|
|
if not artwork_url:
|
|
album_art = self._get_artist_image_from_albums(str(artist_data.get('artistId', '')))
|
|
if album_art:
|
|
# Convert back to base URL format for building array
|
|
artwork_url = album_art.replace('600x600bb', '100x100bb')
|
|
# Store discovered artwork in raw data so cache hits will have it
|
|
artist_data['artworkUrl100'] = artwork_url
|
|
|
|
if artwork_url:
|
|
images = [
|
|
{'url': artwork_url.replace('100x100bb', '600x600bb'), 'height': 600, 'width': 600},
|
|
{'url': artwork_url.replace('100x100bb', '300x300bb'), 'height': 300, 'width': 300},
|
|
{'url': artwork_url, 'height': 100, 'width': 100}
|
|
]
|
|
|
|
# Get genre
|
|
genres = []
|
|
if artist_data.get('primaryGenreName'):
|
|
genres = [artist_data['primaryGenreName']]
|
|
|
|
result = {
|
|
'id': str(artist_data.get('artistId', '')),
|
|
'name': artist_data.get('artistName', ''),
|
|
'images': images,
|
|
'genres': genres,
|
|
'popularity': 0, # iTunes doesn't provide this
|
|
'followers': {'total': 0}, # iTunes doesn't provide this
|
|
'external_urls': {'itunes': artist_data.get('artistViewUrl', '')},
|
|
'uri': f"itunes:artist:{artist_data.get('artistId', '')}",
|
|
'_source': 'itunes',
|
|
'_raw_data': artist_data
|
|
}
|
|
# Cache the processed result (raw_data inside has original iTunes format)
|
|
cache.store_entity('itunes', 'artist', str(artist_id), artist_data)
|
|
return result
|
|
|
|
return None
|
|
|
|
def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 200) -> List[Album]:
|
|
"""
|
|
Get albums by artist ID
|
|
|
|
Note: iTunes doesn't support filtering by album_type in the same way as Spotify,
|
|
so we fetch all albums and can filter client-side if needed.
|
|
Prefers explicit versions over clean versions when both exist.
|
|
"""
|
|
import re
|
|
|
|
cache = get_metadata_cache()
|
|
cache_limit = min(limit, 200)
|
|
cached_items = get_cached_artist_album_items(cache, 'itunes', artist_id, album_type=album_type, limit=cache_limit)
|
|
cache_hit = False
|
|
if cached_items:
|
|
results = cached_items
|
|
cache_hit = True
|
|
else:
|
|
results = self._lookup(id=artist_id, entity='album', limit=cache_limit)
|
|
seen_albums = {} # Track albums by normalized name, prefer explicit versions
|
|
|
|
def normalize_album_name(name: str) -> str:
|
|
"""Normalize album name for deduplication (removes edition suffixes, etc.)"""
|
|
normalized = name.lower().strip()
|
|
# Remove common edition suffixes
|
|
normalized = re.sub(r'\s*[\(\[]\s*(deluxe|explicit|clean|remaster|expanded|anniversary|edition|version|bonus|special|standard).*?[\)\]]', '', normalized, flags=re.IGNORECASE)
|
|
# Remove trailing edition keywords without brackets
|
|
normalized = re.sub(r'\s*[-–—]\s*(deluxe|explicit|clean|remaster|expanded|anniversary|edition|version).*$', '', normalized, flags=re.IGNORECASE)
|
|
# Normalize whitespace
|
|
normalized = re.sub(r'\s+', ' ', normalized).strip()
|
|
return normalized
|
|
|
|
for album_data in results:
|
|
if album_data.get('wrapperType') != 'collection':
|
|
continue
|
|
|
|
# Check if explicit
|
|
is_explicit = album_data.get('collectionExplicitness') == 'explicit'
|
|
|
|
# Create album object
|
|
album = Album.from_itunes_album(album_data)
|
|
|
|
# Filter by album_type if specified (now includes 'ep')
|
|
if album_type != 'album,single':
|
|
requested_types = [t.strip() for t in album_type.split(',')]
|
|
# Also accept 'ep' when 'single' is requested (for backward compat)
|
|
if album.album_type not in requested_types:
|
|
if not (album.album_type == 'ep' and 'single' in requested_types):
|
|
continue
|
|
|
|
# Deduplicate by normalized name, prefer explicit versions
|
|
normalized_name = normalize_album_name(album.name)
|
|
|
|
logger.debug(f"Processing album: {album.name} (ID: {album.id}, explicit: {is_explicit}, normalized: {normalized_name})")
|
|
|
|
if normalized_name in seen_albums:
|
|
logger.debug(f" Found duplicate for: {normalized_name}")
|
|
# Only replace if current one is explicit and previous was clean
|
|
# BUT verify the explicit version actually has tracks (some iTunes albums are broken)
|
|
if is_explicit and not seen_albums[normalized_name]['is_explicit']:
|
|
logger.info(f" Attempting to replace clean with explicit for: {album.name}")
|
|
# Quick validation: check if this explicit album actually has tracks
|
|
try:
|
|
test_tracks = self._lookup(id=album.id, entity='song')
|
|
track_count = len([t for t in test_tracks if t.get('wrapperType') == 'track'])
|
|
|
|
if track_count > 0:
|
|
logger.debug(f"Replacing clean version with explicit: {album.name} (verified {track_count} tracks)")
|
|
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
|
|
else:
|
|
logger.warning(f"Skipping broken explicit album {album.name} (ID {album.id}): reports tracks but has 0")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to validate explicit album {album.name}: {e}, keeping clean version")
|
|
else:
|
|
logger.debug(f"Skipping duplicate album: {album.name} (normalized: {normalized_name})")
|
|
else:
|
|
logger.debug(f" First occurrence of: {normalized_name}")
|
|
|
|
# If this is an explicit album, validate it has tracks before keeping it
|
|
# (Some iTunes explicit albums are broken and return 0 tracks)
|
|
if is_explicit and not cache_hit:
|
|
try:
|
|
test_tracks = self._lookup(id=album.id, entity='song')
|
|
track_count = len([t for t in test_tracks if t.get('wrapperType') == 'track'])
|
|
|
|
if track_count > 0:
|
|
logger.debug(f" Verified explicit album has {track_count} tracks")
|
|
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
|
|
else:
|
|
logger.warning(f"Skipping broken explicit album {album.name} (ID {album.id}): reports tracks but has 0")
|
|
# Don't add to seen_albums so a clean version can be added later
|
|
except Exception as e:
|
|
logger.warning(f"Failed to validate explicit album {album.name}: {e}, skipping")
|
|
else:
|
|
# Clean versions - just add them
|
|
seen_albums[normalized_name] = {'album': album, 'is_explicit': is_explicit}
|
|
|
|
# Extract albums from dict
|
|
albums = [item['album'] for item in seen_albums.values()]
|
|
|
|
# Cache individual albums opportunistically (skip if full data already cached)
|
|
album_entries = []
|
|
for album_data in results:
|
|
if album_data.get('wrapperType') == 'collection' and album_data.get('collectionId'):
|
|
album_entries.append((str(album_data['collectionId']), album_data))
|
|
if album_entries:
|
|
cache.store_entities_bulk('itunes', 'album', album_entries, skip_if_exists=True)
|
|
if not cache_hit:
|
|
store_artist_album_items(cache, 'itunes', artist_id, results, album_type=album_type, limit=cache_limit)
|
|
|
|
logger.info(f"Retrieved {len(albums)} unique albums for artist {artist_id} (filtered from {len(results)} results)")
|
|
return albums[:limit]
|
|
|
|
# ==================== Playlist Methods ====================
|
|
|
|
def _get_playlist_tracks(self, playlist_id: str) -> List[Track]:
|
|
"""
|
|
Get playlist tracks (NOT SUPPORTED by iTunes API)
|
|
Internal helper method to match Spotify client structure
|
|
"""
|
|
logger.warning("iTunes API does not support playlists")
|
|
return []
|
|
|
|
def get_user_playlists(self) -> List[Playlist]:
|
|
"""
|
|
Get user playlists (NOT SUPPORTED by iTunes API)
|
|
iTunes doesn't have user playlists accessible via API
|
|
"""
|
|
logger.warning("iTunes API does not support user playlists")
|
|
return []
|
|
|
|
def get_user_playlists_metadata_only(self) -> List[Playlist]:
|
|
"""
|
|
Get playlists metadata only (NOT SUPPORTED by iTunes API)
|
|
"""
|
|
logger.warning("iTunes API does not support user playlists")
|
|
return []
|
|
|
|
def get_saved_tracks_count(self) -> int:
|
|
"""
|
|
Get saved tracks count (NOT SUPPORTED by iTunes API)
|
|
"""
|
|
logger.warning("iTunes API does not support saved/liked tracks")
|
|
return 0
|
|
|
|
def get_saved_tracks(self) -> List[Track]:
|
|
"""
|
|
Get saved/liked tracks (NOT SUPPORTED by iTunes API)
|
|
"""
|
|
logger.warning("iTunes API does not support saved/liked tracks")
|
|
return []
|
|
|
|
def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]:
|
|
"""
|
|
Get playlist by ID (NOT SUPPORTED by iTunes API)
|
|
"""
|
|
logger.warning("iTunes API does not support playlists")
|
|
return None
|
|
|
|
# ==================== User Methods ====================
|
|
|
|
def get_user_info(self) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get user info (NOT SUPPORTED by iTunes API - no authentication)
|
|
"""
|
|
logger.warning("iTunes API does not support user authentication")
|
|
return None
|
|
|
|
def reload_config(self):
|
|
"""Reload configuration (no-op for iTunes since no auth required)"""
|
|
logger.info("iTunes client config reload requested (no-op)")
|
|
pass
|