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 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() 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 @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 600x600 for higher quality album_image_url = track_data['artworkUrl100'].replace('100x100bb', '600x600bb') # 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'] 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 ) @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', '600x600bb') # 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 @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', '600x600bb') # 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' 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 ) @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" def __init__(self, country: str = "US"): self.country = country self.session = requests.Session() self.session.headers.update({ 'User-Agent': 'SoulSync/1.0', 'Accept': 'application/json' }) logger.info(f"iTunes client initialized for country: {country}") 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 (not rate limited)""" 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() return data.get('results', []) 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""" 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)) 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) 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} """ if not artist_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(artist_ids), batch_size): batch = artist_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 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""" 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 } 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. """ 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 for item in seen_albums.values(): album = Album.from_itunes_album(item['data']) albums.append(album) return albums[:limit] 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) """ 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', '600x600bb') # Build images array like Spotify (multiple sizes) images = [] if image_url: images = [ {'url': image_url, 'height': 600, 'width': 600}, {'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 } # 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""" results = self._lookup(id=album_id, entity='song') if not results: 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}") return { 'items': tracks, 'total': len(tracks), 'limit': len(tracks), 'next': None } # ==================== 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 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). """ results = self._search(query, 'musicArtist', limit) artists = [] for artist_data in results: if artist_data.get('wrapperType') == 'artist': artist = Artist.from_itunes_artist(artist_data) artists.append(artist) 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 """ 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') 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']] return { '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 } return None def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> 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 results = self._lookup(id=artist_id, entity='album', limit=min(limit, 200)) 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: 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()] 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