diff --git a/core/itunes_client.py b/core/itunes_client.py index 7f430d0a..e799fa74 100644 --- a/core/itunes_client.py +++ b/core/itunes_client.py @@ -141,13 +141,13 @@ class Album: # 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 = 'single' # iTunes calls EPs "albums" but we can mark shorter ones + album_type = 'ep' # 4-6 tracks = EP else: album_type = 'album' @@ -371,7 +371,7 @@ class iTunesClient: if track_count <= 3: album_type = 'single' elif track_count <= 6: - album_type = 'single' # EP treated as single + album_type = 'ep' # 4-6 tracks = EP else: album_type = 'album' @@ -433,17 +433,42 @@ class iTunesClient: # ==================== 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""" + """Search for artists using iTunes API - includes album art fallback for images""" 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) + + # If no artist image, try to get their first album's artwork + if not artist.image_url: + album_art = self._get_artist_image_from_albums(str(artist_data.get('artistId', ''))) + if album_art: + artist.image_url = album_art + artists.append(artist) - + return artists def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: @@ -461,14 +486,22 @@ class iTunesClient: for artist_data in results: if artist_data.get('wrapperType') == 'artist': # Build images array - iTunes artist search doesn't reliably return images - # but we include the structure for compatibility + # Use album art as fallback images = [] - if artist_data.get('artworkUrl100'): - artwork_base = artist_data['artworkUrl100'] + 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_base.replace('100x100bb', '600x600bb'), 'height': 600, 'width': 600}, - {'url': artwork_base.replace('100x100bb', '300x300bb'), 'height': 300, 'width': 300}, - {'url': artwork_base, 'height': 100, 'width': 100} + {'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 @@ -494,26 +527,49 @@ class iTunesClient: 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. """ + import re + results = self._lookup(id=artist_id, entity='album', limit=min(limit, 200)) albums = [] - + seen_albums = set() # Track normalized names to prevent duplicates + + 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': album = Album.from_itunes_album(album_data) - - # Filter by album_type if specified + + # Filter by album_type if specified (now includes 'ep') if album_type != 'album,single': - requested_types = album_type.split(',') + 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: - continue - + if not (album.album_type == 'ep' and 'single' in requested_types): + continue + + # Deduplicate by normalized name + normalized_name = normalize_album_name(album.name) + if normalized_name in seen_albums: + logger.debug(f"Skipping duplicate album: {album.name} (normalized: {normalized_name})") + continue + + seen_albums.add(normalized_name) albums.append(album) - - logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}") + + logger.info(f"Retrieved {len(albums)} unique albums for artist {artist_id} (filtered from {len(results)} results)") return albums[:limit] # ==================== Playlist Methods ==================== diff --git a/core/spotify_client.py b/core/spotify_client.py index e709be6f..304f39de 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -169,8 +169,18 @@ class SpotifyClient: def __init__(self): self.sp: Optional[spotipy.Spotify] = None self.user_id: Optional[str] = None + self._itunes_client = None # Lazy-loaded iTunes fallback self._setup_client() + @property + def _itunes(self): + """Lazy-load iTunes client for fallback when Spotify not authenticated""" + if self._itunes_client is None: + from core.itunes_client import iTunesClient + self._itunes_client = iTunesClient() + logger.info("iTunes fallback client initialized") + return self._itunes_client + def reload_config(self): """Reload configuration and re-initialize client""" self._setup_client() @@ -201,10 +211,23 @@ class SpotifyClient: self.sp = None def is_authenticated(self) -> bool: - """Check if Spotify client is authenticated and working""" + """ + Check if client can service metadata requests. + Returns True if Spotify is authenticated OR iTunes fallback is available. + For Spotify-specific auth check, use is_spotify_authenticated(). + """ + # If Spotify is authenticated, we're good + if self.is_spotify_authenticated(): + return True + + # iTunes fallback is always available + return True + + def is_spotify_authenticated(self) -> bool: + """Check if Spotify client is specifically authenticated (not just iTunes fallback)""" if self.sp is None: return False - + try: # Make a simple API call to verify authentication self.sp.current_user() @@ -228,7 +251,7 @@ class SpotifyClient: @rate_limited def get_user_playlists(self) -> List[Playlist]: - if not self.is_authenticated(): + if not self.is_spotify_authenticated(): logger.error("Not authenticated with Spotify") return [] @@ -262,7 +285,7 @@ class SpotifyClient: @rate_limited def get_user_playlists_metadata_only(self) -> List[Playlist]: """Get playlists without fetching all track details for faster loading""" - if not self.is_authenticated(): + if not self.is_spotify_authenticated(): logger.error("Not authenticated with Spotify") return [] @@ -312,7 +335,7 @@ class SpotifyClient: @rate_limited def get_saved_tracks_count(self) -> int: """Get the total count of user's saved/liked songs without fetching all tracks""" - if not self.is_authenticated(): + if not self.is_spotify_authenticated(): logger.error("Not authenticated with Spotify") return 0 @@ -331,7 +354,7 @@ class SpotifyClient: @rate_limited def get_saved_tracks(self) -> List[Track]: """Fetch all user's saved/liked songs from Spotify""" - if not self.is_authenticated(): + if not self.is_spotify_authenticated(): logger.error("Not authenticated with Spotify") return [] @@ -373,7 +396,7 @@ class SpotifyClient: @rate_limited def _get_playlist_tracks(self, playlist_id: str) -> List[Track]: - if not self.is_authenticated(): + if not self.is_spotify_authenticated(): return [] tracks = [] @@ -397,7 +420,7 @@ class SpotifyClient: @rate_limited def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]: - if not self.is_authenticated(): + if not self.is_spotify_authenticated(): return None try: @@ -411,104 +434,113 @@ class SpotifyClient: @rate_limited def search_tracks(self, query: str, limit: int = 20) -> List[Track]: - if not self.is_authenticated(): - return [] - - try: - results = self.sp.search(q=query, type='track', limit=limit) - tracks = [] - - for track_data in results['tracks']['items']: - track = Track.from_spotify_track(track_data) - tracks.append(track) - - return tracks - - except Exception as e: - logger.error(f"Error searching tracks: {e}") - return [] - + """Search for tracks - falls back to iTunes if Spotify not authenticated""" + if self.is_spotify_authenticated(): + try: + results = self.sp.search(q=query, type='track', limit=limit) + tracks = [] + + for track_data in results['tracks']['items']: + track = Track.from_spotify_track(track_data) + tracks.append(track) + + return tracks + + except Exception as e: + logger.error(f"Error searching tracks via Spotify: {e}") + # Fall through to iTunes fallback + + # iTunes fallback + logger.debug(f"Using iTunes fallback for track search: {query}") + return self._itunes.search_tracks(query, limit) + @rate_limited def search_artists(self, query: str, limit: int = 20) -> List[Artist]: - """Search for artists using Spotify API""" - if not self.is_authenticated(): - return [] - - try: - results = self.sp.search(q=query, type='artist', limit=limit) - artists = [] - - for artist_data in results['artists']['items']: - artist = Artist.from_spotify_artist(artist_data) - artists.append(artist) - - return artists - - except Exception as e: - logger.error(f"Error searching artists: {e}") - return [] - + """Search for artists - falls back to iTunes if Spotify not authenticated""" + if self.is_spotify_authenticated(): + try: + results = self.sp.search(q=query, type='artist', limit=limit) + artists = [] + + for artist_data in results['artists']['items']: + artist = Artist.from_spotify_artist(artist_data) + artists.append(artist) + + return artists + + except Exception as e: + logger.error(f"Error searching artists via Spotify: {e}") + # Fall through to iTunes fallback + + # iTunes fallback + logger.debug(f"Using iTunes fallback for artist search: {query}") + return self._itunes.search_artists(query, limit) + @rate_limited def search_albums(self, query: str, limit: int = 20) -> List[Album]: - """Search for albums using Spotify API""" - if not self.is_authenticated(): - return [] - - try: - results = self.sp.search(q=query, type='album', limit=limit) - albums = [] - - for album_data in results['albums']['items']: - album = Album.from_spotify_album(album_data) - albums.append(album) - - return albums - - except Exception as e: - logger.error(f"Error searching albums: {e}") - return [] + """Search for albums - falls back to iTunes if Spotify not authenticated""" + if self.is_spotify_authenticated(): + try: + results = self.sp.search(q=query, type='album', limit=limit) + albums = [] + + for album_data in results['albums']['items']: + album = Album.from_spotify_album(album_data) + albums.append(album) + + return albums + + except Exception as e: + logger.error(f"Error searching albums via Spotify: {e}") + # Fall through to iTunes fallback + + # iTunes fallback + logger.debug(f"Using iTunes fallback for album search: {query}") + return self._itunes.search_albums(query, limit) @rate_limited def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]: - """Get detailed track information including album data and track number""" - if not self.is_authenticated(): - return None - - try: - track_data = self.sp.track(track_id) - - # Enhance with additional useful metadata for our purposes - if track_data: - enhanced_data = { - 'id': track_data['id'], - 'name': track_data['name'], - 'track_number': track_data['track_number'], - 'disc_number': track_data['disc_number'], - 'duration_ms': track_data['duration_ms'], - 'explicit': track_data['explicit'], - 'artists': [artist['name'] for artist in track_data['artists']], - 'primary_artist': track_data['artists'][0]['name'] if track_data['artists'] else None, - 'album': { - 'id': track_data['album']['id'], - 'name': track_data['album']['name'], - 'total_tracks': track_data['album']['total_tracks'], - 'release_date': track_data['album']['release_date'], - 'album_type': track_data['album']['album_type'], - 'artists': [artist['name'] for artist in track_data['album']['artists']] - }, - 'is_album_track': track_data['album']['total_tracks'] > 1, - 'raw_data': track_data # Keep original for fallback - } - return enhanced_data - return track_data - - except Exception as e: - logger.error(f"Error fetching track details: {e}") - return None + """Get detailed track information - falls back to iTunes if Spotify not authenticated""" + if self.is_spotify_authenticated(): + try: + track_data = self.sp.track(track_id) + + # Enhance with additional useful metadata for our purposes + if track_data: + enhanced_data = { + 'id': track_data['id'], + 'name': track_data['name'], + 'track_number': track_data['track_number'], + 'disc_number': track_data['disc_number'], + 'duration_ms': track_data['duration_ms'], + 'explicit': track_data['explicit'], + 'artists': [artist['name'] for artist in track_data['artists']], + 'primary_artist': track_data['artists'][0]['name'] if track_data['artists'] else None, + 'album': { + 'id': track_data['album']['id'], + 'name': track_data['album']['name'], + 'total_tracks': track_data['album']['total_tracks'], + 'release_date': track_data['album']['release_date'], + 'album_type': track_data['album']['album_type'], + 'artists': [artist['name'] for artist in track_data['album']['artists']] + }, + 'is_album_track': track_data['album']['total_tracks'] > 1, + 'raw_data': track_data # Keep original for fallback + } + return enhanced_data + return track_data + + except Exception as e: + logger.error(f"Error fetching track details via Spotify: {e}") + # Fall through to iTunes fallback + + # iTunes fallback + logger.debug(f"Using iTunes fallback for track details: {track_id}") + return self._itunes.get_track_details(track_id) @rate_limited def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]: - if not self.is_authenticated(): + if not self.is_spotify_authenticated(): return None try: @@ -521,83 +553,89 @@ class SpotifyClient: @rate_limited def get_album(self, album_id: str) -> Optional[Dict[str, Any]]: - """Get album information including tracks""" - if not self.is_authenticated(): - return None - - try: - album_data = self.sp.album(album_id) - return album_data - - except Exception as e: - logger.error(f"Error fetching album: {e}") - return None + """Get album information - falls back to iTunes if Spotify not authenticated""" + if self.is_spotify_authenticated(): + try: + album_data = self.sp.album(album_id) + return album_data + + except Exception as e: + logger.error(f"Error fetching album via Spotify: {e}") + # Fall through to iTunes fallback + + # iTunes fallback + logger.debug(f"Using iTunes fallback for album: {album_id}") + return self._itunes.get_album(album_id) @rate_limited def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]: - """Get album tracks with pagination to fetch all tracks""" - if not self.is_authenticated(): - return None + """Get album tracks - falls back to iTunes if Spotify not authenticated""" + if self.is_spotify_authenticated(): + try: + # Get first page of tracks + first_page = self.sp.album_tracks(album_id) + if not first_page or 'items' not in first_page: + return None - try: - # Get first page of tracks - first_page = self.sp.album_tracks(album_id) - if not first_page or 'items' not in first_page: - return None - - # Collect all tracks starting with first page - all_tracks = first_page['items'][:] - - # Fetch remaining pages if they exist - next_page = first_page - while next_page.get('next'): - next_page = self.sp.next(next_page) - if next_page and 'items' in next_page: - all_tracks.extend(next_page['items']) - - # Log success - logger.info(f"Retrieved {len(all_tracks)} tracks for album {album_id}") - - # Return structure with all tracks - result = first_page.copy() - result['items'] = all_tracks - result['next'] = None # No more pages - result['limit'] = len(all_tracks) # Update to reflect all tracks fetched + # Collect all tracks starting with first page + all_tracks = first_page['items'][:] - return result + # Fetch remaining pages if they exist + next_page = first_page + while next_page.get('next'): + next_page = self.sp.next(next_page) + if next_page and 'items' in next_page: + all_tracks.extend(next_page['items']) - except Exception as e: - logger.error(f"Error fetching album tracks: {e}") - return None + # Log success + logger.info(f"Retrieved {len(all_tracks)} tracks for album {album_id}") + + # Return structure with all tracks + result = first_page.copy() + result['items'] = all_tracks + result['next'] = None # No more pages + result['limit'] = len(all_tracks) # Update to reflect all tracks fetched + + return result + + except Exception as e: + logger.error(f"Error fetching album tracks via Spotify: {e}") + # Fall through to iTunes fallback + + # iTunes fallback + logger.debug(f"Using iTunes fallback for album tracks: {album_id}") + return self._itunes.get_album_tracks(album_id) @rate_limited def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 50) -> List[Album]: - """Get albums by artist ID""" - if not self.is_authenticated(): - return [] - - try: - albums = [] - results = self.sp.artist_albums(artist_id, album_type=album_type, limit=limit) - - while results: - for album_data in results['items']: - album = Album.from_spotify_album(album_data) - albums.append(album) - - # Get next batch if available - results = self.sp.next(results) if results['next'] else None - - logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}") - return albums - - except Exception as e: - logger.error(f"Error fetching artist albums: {e}") - return [] + """Get albums by artist ID - falls back to iTunes if Spotify not authenticated""" + if self.is_spotify_authenticated(): + try: + albums = [] + results = self.sp.artist_albums(artist_id, album_type=album_type, limit=limit) + + while results: + for album_data in results['items']: + album = Album.from_spotify_album(album_data) + albums.append(album) + + # Get next batch if available + results = self.sp.next(results) if results['next'] else None + + logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}") + return albums + + except Exception as e: + logger.error(f"Error fetching artist albums via Spotify: {e}") + # Fall through to iTunes fallback + + # iTunes fallback + logger.debug(f"Using iTunes fallback for artist albums: {artist_id}") + return self._itunes.get_artist_albums(artist_id, album_type, limit) @rate_limited def get_user_info(self) -> Optional[Dict[str, Any]]: - if not self.is_authenticated(): + if not self.is_spotify_authenticated(): return None try: @@ -609,19 +647,21 @@ class SpotifyClient: @rate_limited def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: """ - Get full artist details from Spotify API. + Get full artist details - falls back to iTunes if Spotify not authenticated. Args: - artist_id: Spotify artist ID + artist_id: Artist ID (Spotify or iTunes depending on authentication) Returns: Dictionary with artist data including images, genres, popularity """ - if not self.is_authenticated(): - return None + if self.is_spotify_authenticated(): + try: + return self.sp.artist(artist_id) + except Exception as e: + logger.error(f"Error fetching artist via Spotify: {e}") + # Fall through to iTunes fallback - try: - return self.sp.artist(artist_id) - except Exception as e: - logger.error(f"Error fetching artist {artist_id}: {e}") - return None \ No newline at end of file + # iTunes fallback + logger.debug(f"Using iTunes fallback for artist: {artist_id}") + return self._itunes.get_artist(artist_id) \ No newline at end of file diff --git a/database/music_database.py b/database/music_database.py index 71f612cb..2f774d97 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -2700,64 +2700,89 @@ class MusicDatabase: return 0 # Watchlist operations - def add_artist_to_watchlist(self, spotify_artist_id: str, artist_name: str) -> bool: - """Add an artist to the watchlist for monitoring new releases""" + def add_artist_to_watchlist(self, artist_id: str, artist_name: str) -> bool: + """Add an artist to the watchlist for monitoring new releases. + + Automatically detects if artist_id is a Spotify ID (alphanumeric) or iTunes ID (numeric). + """ try: with self._get_connection() as conn: cursor = conn.cursor() - - cursor.execute(""" - INSERT OR REPLACE INTO watchlist_artists - (spotify_artist_id, artist_name, date_added, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """, (spotify_artist_id, artist_name)) - + + # Detect ID type: iTunes IDs are purely numeric, Spotify IDs are alphanumeric + is_itunes_id = artist_id.isdigit() + + if is_itunes_id: + cursor.execute(""" + INSERT OR REPLACE INTO watchlist_artists + (itunes_artist_id, artist_name, date_added, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, (artist_id, artist_name)) + logger.info(f"Added artist '{artist_name}' to watchlist (iTunes ID: {artist_id})") + else: + cursor.execute(""" + INSERT OR REPLACE INTO watchlist_artists + (spotify_artist_id, artist_name, date_added, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, (artist_id, artist_name)) + logger.info(f"Added artist '{artist_name}' to watchlist (Spotify ID: {artist_id})") + conn.commit() - logger.info(f"Added artist '{artist_name}' to watchlist (Spotify ID: {spotify_artist_id})") return True - + except Exception as e: logger.error(f"Error adding artist '{artist_name}' to watchlist: {e}") return False - def remove_artist_from_watchlist(self, spotify_artist_id: str) -> bool: - """Remove an artist from the watchlist""" + def remove_artist_from_watchlist(self, artist_id: str) -> bool: + """Remove an artist from the watchlist (checks both Spotify and iTunes IDs)""" try: with self._get_connection() as conn: cursor = conn.cursor() - - # Get artist name for logging - cursor.execute("SELECT artist_name FROM watchlist_artists WHERE spotify_artist_id = ?", (spotify_artist_id,)) + + # Get artist name for logging (check both ID columns) + cursor.execute(""" + SELECT artist_name FROM watchlist_artists + WHERE spotify_artist_id = ? OR itunes_artist_id = ? + """, (artist_id, artist_id)) result = cursor.fetchone() artist_name = result['artist_name'] if result else "Unknown" - - cursor.execute("DELETE FROM watchlist_artists WHERE spotify_artist_id = ?", (spotify_artist_id,)) - + + cursor.execute(""" + DELETE FROM watchlist_artists + WHERE spotify_artist_id = ? OR itunes_artist_id = ? + """, (artist_id, artist_id)) + if cursor.rowcount > 0: conn.commit() - logger.info(f"Removed artist '{artist_name}' from watchlist (Spotify ID: {spotify_artist_id})") + logger.info(f"Removed artist '{artist_name}' from watchlist (ID: {artist_id})") return True else: - logger.warning(f"Artist with Spotify ID {spotify_artist_id} not found in watchlist") + logger.warning(f"Artist with ID {artist_id} not found in watchlist") return False - + except Exception as e: - logger.error(f"Error removing artist from watchlist (Spotify ID: {spotify_artist_id}): {e}") + logger.error(f"Error removing artist from watchlist (ID: {artist_id}): {e}") return False - def is_artist_in_watchlist(self, spotify_artist_id: str) -> bool: - """Check if an artist is currently in the watchlist""" + def is_artist_in_watchlist(self, artist_id: str) -> bool: + """Check if an artist is currently in the watchlist (checks both Spotify and iTunes IDs)""" try: with self._get_connection() as conn: cursor = conn.cursor() - - cursor.execute("SELECT 1 FROM watchlist_artists WHERE spotify_artist_id = ? LIMIT 1", (spotify_artist_id,)) + + # Check both spotify_artist_id and itunes_artist_id columns + cursor.execute(""" + SELECT 1 FROM watchlist_artists + WHERE spotify_artist_id = ? OR itunes_artist_id = ? + LIMIT 1 + """, (artist_id, artist_id)) result = cursor.fetchone() - + return result is not None - + except Exception as e: - logger.error(f"Error checking if artist is in watchlist (Spotify ID: {spotify_artist_id}): {e}") + logger.error(f"Error checking if artist is in watchlist (ID: {artist_id}): {e}") return False def get_watchlist_artists(self) -> List[WatchlistArtist]: @@ -2839,8 +2864,8 @@ class MusicDatabase: logger.error(f"Error getting watchlist count: {e}") return 0 - def update_watchlist_artist_image(self, spotify_artist_id: str, image_url: str) -> bool: - """Update the image URL for a watchlist artist""" + def update_watchlist_artist_image(self, artist_id: str, image_url: str) -> bool: + """Update the image URL for a watchlist artist (checks both Spotify and iTunes IDs)""" try: with self._get_connection() as conn: cursor = conn.cursor() @@ -2856,8 +2881,8 @@ class MusicDatabase: cursor.execute(""" UPDATE watchlist_artists SET image_url = ?, updated_at = CURRENT_TIMESTAMP - WHERE spotify_artist_id = ? - """, (image_url, spotify_artist_id)) + WHERE spotify_artist_id = ? OR itunes_artist_id = ? + """, (image_url, artist_id, artist_id)) conn.commit() return cursor.rowcount > 0 diff --git a/web_server.py b/web_server.py index d83bb7b4..93b341bb 100644 --- a/web_server.py +++ b/web_server.py @@ -22684,8 +22684,11 @@ def get_spotify_artist_discography(artist_name): if album_type == 'single' or track_count <= 3: singles.append(release_data) - elif album_type == 'compilation' or (track_count <= 8 and track_count > 3): + elif album_type == 'ep' or (track_count >= 4 and track_count <= 6): eps.append(release_data) + elif album_type == 'compilation': + # Compilations go with albums + albums.append(release_data) else: albums.append(release_data) diff --git a/webui/static/script.js b/webui/static/script.js index 1ff3dc19..5cff6c6e 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -25411,7 +25411,7 @@ function createReleaseCard(release) { name: release.title, image_url: release.image_url, release_date: release.year ? `${release.year}-01-01` : '', - album_type: release.type || 'album', + album_type: release.album_type || release.type || 'album', total_tracks: (release.track_completion && typeof release.track_completion === 'object') ? release.track_completion.total_tracks : 1 }; @@ -25440,8 +25440,8 @@ function createReleaseCard(release) { throw new Error('No tracks found for this release'); } - // Determine album type based on release data - const albumType = release.type === 'single' ? 'singles' : 'albums'; + // Use the actual album type from release data + const albumType = release.album_type || release.type || 'album'; // Open the Add to Wishlist modal // Note: openAddToWishlistModal has its own loading overlay