From 1560726bbc226d8767a738de824e28351941b229 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Fri, 23 Jan 2026 14:25:26 -0800 Subject: [PATCH] rebuild discovery pool flow to allow multiprocessing of itunes and spotfiy. each getting their own pool. --- core/personalized_playlists.py | 201 +++++------ core/watchlist_scanner.py | 600 ++++++++++++++++++++------------- database/music_database.py | 251 ++++++++++---- web_server.py | 239 +++++++++---- webui/static/script.js | 20 +- 5 files changed, 856 insertions(+), 455 deletions(-) diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 7bf2fd99..557ee394 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -100,6 +100,39 @@ class PersonalizedPlaylistsService: self.database = database self.spotify_client = spotify_client + def _get_active_source(self) -> str: + """ + Determine which music source is active for discovery. + Returns 'spotify' if Spotify is authenticated, 'itunes' otherwise. + """ + if self.spotify_client and hasattr(self.spotify_client, 'is_spotify_authenticated'): + if self.spotify_client.is_spotify_authenticated(): + return 'spotify' + return 'itunes' + + def _build_track_dict(self, row, source: str) -> Dict: + """Build a standardized track dictionary from a database row.""" + track_data = row['track_data_json'] + if isinstance(track_data, str): + try: + track_data = json.loads(track_data) + except: + track_data = None + + return { + 'track_id': row.get('spotify_track_id') or row.get('itunes_track_id'), + 'spotify_track_id': row.get('spotify_track_id'), + 'itunes_track_id': row.get('itunes_track_id'), + 'track_name': row['track_name'], + 'artist_name': row['artist_name'], + 'album_name': row['album_name'], + 'album_cover_url': row['album_cover_url'], + 'duration_ms': row['duration_ms'], + 'popularity': row.get('popularity', 0), + 'track_data_json': track_data, + 'source': source + } + @staticmethod def get_parent_genre(spotify_genre: str) -> str: """ @@ -166,25 +199,30 @@ class PersonalizedPlaylistsService: logger.error(f"Error getting forgotten favorites: {e}") return [] - def get_decade_playlist(self, decade: int, limit: int = 100) -> List[Dict]: + def get_decade_playlist(self, decade: int, limit: int = 100, source: str = None) -> List[Dict]: """ Get tracks from a specific decade from discovery pool with diversity filtering. Args: decade: Decade year (e.g., 2020 for 2020s, 2010 for 2010s) limit: Maximum tracks to return + source: Optional source filter ('spotify' or 'itunes'), auto-detects if not provided """ try: start_year = decade end_year = decade + 9 + # Determine active source if not specified + active_source = source or self._get_active_source() + with self.database._get_connection() as conn: cursor = conn.cursor() - # Query discovery_pool - get 10x more for diversity filtering + # Query discovery_pool - get 10x more for diversity filtering, filtered by source cursor.execute(""" SELECT spotify_track_id, + itunes_track_id, track_name, artist_name, album_name, @@ -192,26 +230,20 @@ class PersonalizedPlaylistsService: duration_ms, popularity, release_date, - track_data_json + track_data_json, + source FROM discovery_pool WHERE release_date IS NOT NULL AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) BETWEEN ? AND ? + AND source = ? ORDER BY RANDOM() LIMIT ? - """, (start_year, end_year, limit * 10)) + """, (start_year, end_year, active_source, limit * 10)) rows = cursor.fetchall() all_tracks = [] for row in rows: - track_dict = dict(row) - # Parse track_data_json if available - if track_dict.get('track_data_json'): - try: - import json - track_dict['track_data_json'] = json.loads(track_dict['track_data_json']) - except: - pass - all_tracks.append(track_dict) + all_tracks.append(self._build_track_dict(row, active_source)) if not all_tracks: logger.warning(f"No tracks found for {decade}s") @@ -268,22 +300,25 @@ class PersonalizedPlaylistsService: logger.error(f"Error getting decade playlist for {decade}s: {e}") return [] - def get_available_genres(self) -> List[Dict]: + def get_available_genres(self, source: str = None) -> List[Dict]: """ Get list of consolidated parent genres with track counts from discovery pool. Uses cached artist genres from database (populated during discovery scan). Consolidates specific Spotify genres into broader parent categories. """ try: + # Determine active source if not specified + active_source = source or self._get_active_source() + with self.database._get_connection() as conn: cursor = conn.cursor() - # Get all tracks with genres from discovery pool + # Get all tracks with genres from discovery pool, filtered by source cursor.execute(""" SELECT artist_genres FROM discovery_pool - WHERE artist_genres IS NOT NULL - """) + WHERE artist_genres IS NOT NULL AND source = ? + """, (active_source,)) rows = cursor.fetchall() if not rows: @@ -327,20 +362,24 @@ class PersonalizedPlaylistsService: logger.error(f"Error getting available genres: {e}") return [] - def get_genre_playlist(self, genre: str, limit: int = 50) -> List[Dict]: + def get_genre_playlist(self, genre: str, limit: int = 50, source: str = None) -> List[Dict]: """ Get tracks from a specific genre with diversity filtering. Uses cached artist genres from database (populated during discovery scan). Supports both parent genres (e.g., "Electronic/Dance") and specific genres (e.g., "house"). """ try: + # Determine active source if not specified + active_source = source or self._get_active_source() + with self.database._get_connection() as conn: cursor = conn.cursor() - # Get all tracks with genres from discovery pool + # Get all tracks with genres from discovery pool, filtered by source cursor.execute(""" SELECT spotify_track_id, + itunes_track_id, track_name, artist_name, album_name, @@ -348,10 +387,12 @@ class PersonalizedPlaylistsService: duration_ms, popularity, artist_genres, - track_data_json + track_data_json, + source FROM discovery_pool WHERE artist_genres IS NOT NULL - """) + AND source = ? + """, (active_source,)) rows = cursor.fetchall() # Determine if this is a parent genre or specific genre @@ -372,7 +413,7 @@ class PersonalizedPlaylistsService: for row in rows: try: - artist_genres_json = row[7] # artist_genres column + artist_genres_json = row['artist_genres'] if artist_genres_json: genres = json.loads(artist_genres_json) @@ -388,23 +429,7 @@ class PersonalizedPlaylistsService: break if genre_match: - # Convert row to dict (exclude artist_genres from output) - track_dict = { - 'spotify_track_id': row[0], - 'track_name': row[1], - 'artist_name': row[2], - 'album_name': row[3], - 'album_cover_url': row[4], - 'duration_ms': row[5], - 'popularity': row[6] - } - # Parse track_data_json if available - if row[8]: # track_data_json column - try: - track_dict['track_data_json'] = json.loads(row[8]) - except: - pass - matching_tracks.append(track_dict) + matching_tracks.append(self._build_track_dict(row, active_source)) except Exception as e: logger.debug(f"Error parsing genres for track: {e}") continue @@ -475,39 +500,34 @@ class PersonalizedPlaylistsService: def get_popular_picks(self, limit: int = 50) -> List[Dict]: """Get high popularity tracks from discovery pool with diversity (max 2 tracks per album/artist)""" + # Determine active source + active_source = self._get_active_source() + try: with self.database._get_connection() as conn: cursor = conn.cursor() - # Get more tracks than needed to allow for filtering + # Get more tracks than needed to allow for filtering, filtered by source cursor.execute(""" SELECT spotify_track_id, + itunes_track_id, track_name, artist_name, album_name, album_cover_url, duration_ms, popularity, - track_data_json + track_data_json, + source FROM discovery_pool - WHERE popularity >= 60 + WHERE popularity >= 60 AND source = ? ORDER BY popularity DESC, RANDOM() LIMIT ? - """, (limit * 3,)) # Get 3x more for diversity filtering + """, (active_source, limit * 3)) rows = cursor.fetchall() - all_tracks = [] - for row in rows: - track_dict = dict(row) - # Parse track_data_json if available - if track_dict.get('track_data_json'): - try: - import json - track_dict['track_data_json'] = json.loads(track_dict['track_data_json']) - except: - pass - all_tracks.append(track_dict) + all_tracks = [self._build_track_dict(row, active_source) for row in rows] # Apply diversity constraint: max 2 tracks per album, max 3 per artist tracks_by_album = {} @@ -531,7 +551,7 @@ class PersonalizedPlaylistsService: if len(diverse_tracks) >= limit: break - logger.info(f"Popular Picks: Selected {len(diverse_tracks)} tracks with diversity") + logger.info(f"Popular Picks ({active_source}): Selected {len(diverse_tracks)} tracks with diversity") return diverse_tracks[:limit] except Exception as e: @@ -540,6 +560,9 @@ class PersonalizedPlaylistsService: def get_hidden_gems(self, limit: int = 50) -> List[Dict]: """Get low popularity (underground/indie) tracks from discovery pool""" + # Determine active source + active_source = self._get_active_source() + try: with self.database._get_connection() as conn: cursor = conn.cursor() @@ -547,32 +570,23 @@ class PersonalizedPlaylistsService: cursor.execute(""" SELECT spotify_track_id, + itunes_track_id, track_name, artist_name, album_name, album_cover_url, duration_ms, popularity, - track_data_json + track_data_json, + source FROM discovery_pool - WHERE popularity < 40 + WHERE popularity < 40 AND source = ? ORDER BY RANDOM() LIMIT ? - """, (limit,)) + """, (active_source, limit)) rows = cursor.fetchall() - tracks = [] - for row in rows: - track_dict = dict(row) - # Parse track_data_json if available - if track_dict.get('track_data_json'): - try: - import json - track_dict['track_data_json'] = json.loads(track_dict['track_data_json']) - except: - pass - tracks.append(track_dict) - return tracks + return [self._build_track_dict(row, active_source) for row in rows] except Exception as e: logger.error(f"Error getting hidden gems: {e}") @@ -584,6 +598,9 @@ class PersonalizedPlaylistsService: Different every time you call it! """ + # Determine active source + active_source = self._get_active_source() + try: with self.database._get_connection() as conn: cursor = conn.cursor() @@ -591,31 +608,23 @@ class PersonalizedPlaylistsService: cursor.execute(""" SELECT spotify_track_id, + itunes_track_id, track_name, artist_name, album_name, album_cover_url, duration_ms, popularity, - track_data_json + track_data_json, + source FROM discovery_pool + WHERE source = ? ORDER BY RANDOM() LIMIT ? - """, (limit,)) + """, (active_source, limit)) rows = cursor.fetchall() - tracks = [] - for row in rows: - track_dict = dict(row) - # Parse track_data_json if available - if track_dict.get('track_data_json'): - try: - import json - track_dict['track_data_json'] = json.loads(track_dict['track_data_json']) - except: - pass - tracks.append(track_dict) - return tracks + return [self._build_track_dict(row, active_source) for row in rows] except Exception as e: logger.error(f"Error getting discovery shuffle: {e}") @@ -769,6 +778,9 @@ class PersonalizedPlaylistsService: def _get_discovery_tracks_by_category(self, category: str, limit: int) -> List[Dict]: """Get tracks from discovery pool matching genre or artist""" + # Determine active source + active_source = self._get_active_source() + try: with self.database._get_connection() as conn: cursor = conn.cursor() @@ -776,32 +788,23 @@ class PersonalizedPlaylistsService: cursor.execute(""" SELECT spotify_track_id, + itunes_track_id, track_name, artist_name, album_name, album_cover_url, duration_ms, popularity, - track_data_json + track_data_json, + source FROM discovery_pool - WHERE artist_name LIKE ? OR track_name LIKE ? + WHERE (artist_name LIKE ? OR track_name LIKE ?) AND source = ? ORDER BY RANDOM() LIMIT ? - """, (f'%{category}%', f'%{category}%', limit)) + """, (f'%{category}%', f'%{category}%', active_source, limit)) rows = cursor.fetchall() - tracks = [] - for row in rows: - track_dict = dict(row) - # Parse track_data_json if available - if track_dict.get('track_data_json'): - try: - import json - track_dict['track_data_json'] = json.loads(track_dict['track_data_json']) - except: - pass - tracks.append(track_dict) - return tracks + return [self._build_track_dict(row, active_source) for row in rows] except Exception as e: logger.error(f"Error getting discovery tracks by category: {e}") diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index d79962f3..35b65bb6 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -557,20 +557,18 @@ class WatchlistScanner: self.update_artist_scan_timestamp(db_artist_id) # Fetch and store similar artists for discovery feature (with caching to avoid over-polling) - # Note: Similar artists feature only works with Spotify - if provider == 'spotify' and watchlist_artist.spotify_artist_id: - try: - # Check if we have fresh similar artists cached (< 30 days old) - if self.database.has_fresh_similar_artists(watchlist_artist.spotify_artist_id, days_threshold=30): - logger.info(f"Similar artists for {watchlist_artist.artist_name} are cached and fresh, skipping fetch") - else: - logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}...") - self.update_similar_artists(watchlist_artist) - logger.info(f"Similar artists updated for {watchlist_artist.artist_name}") - except Exception as similar_error: - logger.warning(f"Failed to update similar artists for {watchlist_artist.artist_name}: {similar_error}") - else: - logger.debug(f"Skipping similar artists for {watchlist_artist.artist_name} (iTunes doesn't support this feature)") + # Similar artists are fetched from MusicMap (works with any source) and matched to both Spotify and iTunes + source_artist_id = watchlist_artist.spotify_artist_id or watchlist_artist.itunes_artist_id or str(watchlist_artist.id) + try: + # Check if we have fresh similar artists cached (< 30 days old) + if self.database.has_fresh_similar_artists(source_artist_id, days_threshold=30): + logger.info(f"Similar artists for {watchlist_artist.artist_name} are cached and fresh, skipping fetch") + else: + logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}...") + self.update_similar_artists(watchlist_artist) + logger.info(f"Similar artists updated for {watchlist_artist.artist_name}") + except Exception as similar_error: + logger.warning(f"Failed to update similar artists for {watchlist_artist.artist_name}: {similar_error}") return ScanResult( artist_name=watchlist_artist.artist_name, @@ -1100,14 +1098,14 @@ class WatchlistScanner: def _fetch_similar_artists_from_musicmap(self, artist_name: str, limit: int = 20) -> List[Dict[str, Any]]: """ - Fetch similar artists from MusicMap and match them to Spotify. + Fetch similar artists from MusicMap and match them to both Spotify and iTunes. Args: artist_name: The artist name to find similar artists for limit: Maximum number of similar artists to return (default: 20) Returns: - List of matched artist dictionaries with Spotify data + List of matched artist dictionaries with both Spotify and iTunes IDs when available """ try: logger.info(f"Fetching similar artists from MusicMap for: {artist_name}") @@ -1151,52 +1149,94 @@ class WatchlistScanner: logger.info(f"Found {len(similar_artist_names)} similar artists from MusicMap") - # Get the searched artist's Spotify ID to exclude them - searched_artist_id = None - try: - searched_results = self.spotify_client.search_artists(artist_name, limit=1) - if searched_results and len(searched_results) > 0: - searched_artist_id = searched_results[0].id - except Exception as e: - logger.warning(f"Could not get searched artist ID: {e}") + # Get iTunes client for matching + from core.itunes_client import iTunesClient + itunes_client = iTunesClient() - # Match each artist to Spotify + # Get the searched artist's IDs to exclude them + searched_spotify_id = None + searched_itunes_id = None + try: + # Try Spotify search + if self.spotify_client and self.spotify_client.is_spotify_authenticated(): + searched_results = self.spotify_client.search_artists(artist_name, limit=1) + if searched_results and len(searched_results) > 0: + searched_spotify_id = searched_results[0].id + except Exception as e: + logger.debug(f"Could not get searched artist Spotify ID: {e}") + + try: + # Try iTunes search + itunes_results = itunes_client.search_artists(artist_name, limit=1) + if itunes_results and len(itunes_results) > 0: + searched_itunes_id = itunes_results[0].id + except Exception as e: + logger.debug(f"Could not get searched artist iTunes ID: {e}") + + # Match each artist to both Spotify and iTunes matched_artists = [] - seen_artist_ids = set() # Track seen artist IDs to prevent duplicates + seen_names = set() # Track seen artist names to prevent duplicates for artist_name_to_match in similar_artist_names[:limit]: try: - # Search Spotify for the artist - results = self.spotify_client.search_artists(artist_name_to_match, limit=1) + # Skip if we've already matched this artist name + name_lower = artist_name_to_match.lower().strip() + if name_lower in seen_names: + continue - if results and len(results) > 0: - spotify_artist = results[0] + artist_data = { + 'name': artist_name_to_match, + 'spotify_id': None, + 'itunes_id': None, + 'image_url': None, + 'genres': [], + 'popularity': 0 + } - # Skip if this is the searched artist - if spotify_artist.id == searched_artist_id: - continue + # Try to match on Spotify + if self.spotify_client and self.spotify_client.is_spotify_authenticated(): + try: + spotify_results = self.spotify_client.search_artists(artist_name_to_match, limit=1) + if spotify_results and len(spotify_results) > 0: + spotify_artist = spotify_results[0] + # Skip if this is the searched artist + if spotify_artist.id != searched_spotify_id: + artist_data['spotify_id'] = spotify_artist.id + artist_data['name'] = spotify_artist.name # Use canonical name + artist_data['image_url'] = spotify_artist.image_url if hasattr(spotify_artist, 'image_url') else None + artist_data['genres'] = spotify_artist.genres if hasattr(spotify_artist, 'genres') else [] + artist_data['popularity'] = spotify_artist.popularity if hasattr(spotify_artist, 'popularity') else 0 + except Exception as e: + logger.debug(f"Spotify match failed for {artist_name_to_match}: {e}") - # Skip if we've already seen this artist ID (deduplication) - if spotify_artist.id in seen_artist_ids: - continue + # Try to match on iTunes + try: + itunes_results = itunes_client.search_artists(artist_name_to_match, limit=1) + if itunes_results and len(itunes_results) > 0: + itunes_artist = itunes_results[0] + # Skip if this is the searched artist + if itunes_artist.id != searched_itunes_id: + artist_data['itunes_id'] = itunes_artist.id + # Use iTunes name if we don't have Spotify + if not artist_data['spotify_id']: + artist_data['name'] = itunes_artist.name + # Use iTunes genres if we don't have Spotify genres + if not artist_data['genres'] and hasattr(itunes_artist, 'genres'): + artist_data['genres'] = itunes_artist.genres + except Exception as e: + logger.debug(f"iTunes match failed for {artist_name_to_match}: {e}") - seen_artist_ids.add(spotify_artist.id) - - matched_artists.append({ - 'id': spotify_artist.id, - 'name': spotify_artist.name, - 'image_url': spotify_artist.image_url if hasattr(spotify_artist, 'image_url') else None, - 'genres': spotify_artist.genres if hasattr(spotify_artist, 'genres') else [], - 'popularity': spotify_artist.popularity if hasattr(spotify_artist, 'popularity') else 0 - }) - - logger.debug(f" Matched: {spotify_artist.name}") + # Only add if we got at least one ID + if artist_data['spotify_id'] or artist_data['itunes_id']: + seen_names.add(name_lower) + matched_artists.append(artist_data) + logger.debug(f" Matched: {artist_data['name']} (Spotify: {artist_data['spotify_id']}, iTunes: {artist_data['itunes_id']})") except Exception as match_error: logger.debug(f"Error matching {artist_name_to_match}: {match_error}") continue - logger.info(f"Matched {len(matched_artists)} similar artists to Spotify") + logger.info(f"Matched {len(matched_artists)} similar artists (Spotify + iTunes)") return matched_artists except requests.exceptions.RequestException as e: @@ -1210,12 +1250,12 @@ class WatchlistScanner: """ Fetch and store similar artists for a watchlist artist. Called after each artist scan to build discovery pool. - Uses MusicMap to find similar artists and matches them to Spotify. + Uses MusicMap to find similar artists and matches them to both Spotify and iTunes. """ try: logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}") - # Get similar artists from MusicMap (returns list of artist dicts) + # Get similar artists from MusicMap (returns list of artist dicts with both IDs) similar_artists = self._fetch_similar_artists_from_musicmap(watchlist_artist.artist_name, limit=limit) if not similar_artists: @@ -1224,21 +1264,25 @@ class WatchlistScanner: logger.info(f"Found {len(similar_artists)} similar artists for {watchlist_artist.artist_name}") + # Use consistent source artist ID (prefer Spotify, fall back to iTunes or internal ID) + source_artist_id = watchlist_artist.spotify_artist_id or watchlist_artist.itunes_artist_id or str(watchlist_artist.id) + # Store each similar artist in database stored_count = 0 for rank, similar_artist in enumerate(similar_artists, 1): try: - # similar_artist is a dict with 'id' and 'name' keys + # similar_artist has 'name', 'spotify_id', and 'itunes_id' keys success = self.database.add_or_update_similar_artist( - source_artist_id=watchlist_artist.spotify_artist_id, - similar_artist_spotify_id=similar_artist['id'], + source_artist_id=source_artist_id, similar_artist_name=similar_artist['name'], + similar_artist_spotify_id=similar_artist.get('spotify_id'), + similar_artist_itunes_id=similar_artist.get('itunes_id'), similarity_rank=rank ) if success: stored_count += 1 - logger.debug(f" #{rank}: {similar_artist['name']} (Spotify ID: {similar_artist['id']})") + logger.debug(f" #{rank}: {similar_artist['name']} (Spotify: {similar_artist.get('spotify_id')}, iTunes: {similar_artist.get('itunes_id')})") except Exception as e: logger.warning(f"Error storing similar artist {similar_artist.get('name', 'Unknown')}: {e}") @@ -1256,8 +1300,8 @@ class WatchlistScanner: Populate discovery pool with tracks from top similar artists. Called after watchlist scan completes. - IMPROVED: Larger pool for better discovery (50 artists x 10 releases = ~500 releases) - - Checks if pool was updated in last 24 hours (prevents over-polling Spotify) + Supports both Spotify and iTunes sources - populates for whichever is available. + - Checks if pool was updated in last 24 hours (prevents over-polling) - Includes albums, singles, and EPs for comprehensive coverage - Appends to existing pool instead of replacing it - Cleans up tracks older than 365 days (maintains 1 year rolling window) @@ -1266,13 +1310,27 @@ class WatchlistScanner: from datetime import datetime, timedelta import random - # Check if we should run (prevents over-polling Spotify) + # Check if we should run (prevents over-polling) if not self.database.should_populate_discovery_pool(hours_threshold=24): - logger.info("Discovery pool was populated recently (< 24 hours ago). Skipping to avoid over-polling Spotify.") + logger.info("Discovery pool was populated recently (< 24 hours ago). Skipping.") return logger.info("Populating discovery pool from similar artists...") + # Determine which sources are available + spotify_available = self.spotify_client and self.spotify_client.is_spotify_authenticated() + + # Import iTunes client for fallback + from core.itunes_client import iTunesClient + itunes_client = iTunesClient() + itunes_available = True # iTunes is always available (no auth needed) + + if not spotify_available and not itunes_available: + logger.warning("No music sources available to populate discovery pool") + return + + logger.info(f"Sources available - Spotify: {spotify_available}, iTunes: {itunes_available}") + # Get top similar artists across all watchlist (ordered by occurrence_count) similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit) @@ -1288,129 +1346,182 @@ class WatchlistScanner: try: logger.info(f"[{artist_idx}/{len(similar_artists)}] Processing {similar_artist.similar_artist_name} (occurrence: {similar_artist.occurrence_count})") - # Get artist's albums from Spotify - all_albums = self.spotify_client.get_artist_albums( - similar_artist.similar_artist_spotify_id, - album_type='album,single,ep', # Include albums, singles, and EPs for comprehensive discovery - limit=50 - ) + # Build list of sources to process for this artist + # iTunes is ALWAYS processed (baseline), Spotify is added if authenticated + sources_to_process = [] - if not all_albums: - logger.debug(f"No albums found for {similar_artist.similar_artist_name}") + # Always add iTunes first (baseline source) + if similar_artist.similar_artist_itunes_id: + sources_to_process.append(('itunes', similar_artist.similar_artist_itunes_id)) + + # Add Spotify if authenticated and we have an ID + if spotify_available and similar_artist.similar_artist_spotify_id: + sources_to_process.append(('spotify', similar_artist.similar_artist_spotify_id)) + + if not sources_to_process: + logger.debug(f"No valid IDs for {similar_artist.similar_artist_name}, skipping") continue - # Fetch artist genres once for all tracks of this artist - artist_genres = [] - try: - artist_data = self.spotify_client.get_artist(similar_artist.similar_artist_spotify_id) - if artist_data and 'genres' in artist_data: - artist_genres = artist_data['genres'] - except Exception as e: - logger.debug(f"Could not fetch genres for {similar_artist.similar_artist_name}: {e}") + logger.debug(f" Processing {len(sources_to_process)} source(s): {[s[0] for s in sources_to_process]}") - # IMPROVED: Smart selection mixing albums, singles, and EPs - # Prioritize recent releases and popular content - - # Separate by type for balanced selection - albums = [a for a in all_albums if hasattr(a, 'album_type') and a.album_type == 'album'] - singles_eps = [a for a in all_albums if hasattr(a, 'album_type') and a.album_type in ['single', 'ep']] - other = [a for a in all_albums if not hasattr(a, 'album_type')] - - # Select albums: latest releases + popular older content - selected_albums = [] - - # Always include 3 most recent releases (any type) - this captures new singles/EPs - latest_releases = all_albums[:3] - selected_albums.extend(latest_releases) - - # Add remaining slots with balanced mix - remaining_slots = albums_per_artist - len(selected_albums) - if remaining_slots > 0: - # Combine remaining albums and singles - remaining_content = all_albums[3:] - - if len(remaining_content) > remaining_slots: - # Randomly select from remaining content - random_selection = random.sample(remaining_content, remaining_slots) - selected_albums.extend(random_selection) - else: - selected_albums.extend(remaining_content) - - logger.info(f" Selected {len(selected_albums)} releases from {len(all_albums)} available (albums: {len(albums)}, singles/EPs: {len(singles_eps)})") - - # Process each selected album - for album_idx, album in enumerate(selected_albums, 1): + # Process each source for this artist + for source, artist_id in sources_to_process: try: - # Get full album data with tracks - album_data = self.spotify_client.get_album(album.id) + # Get artist's albums from this source + if source == 'spotify': + all_albums = self.spotify_client.get_artist_albums( + artist_id, + album_type='album,single,ep', + limit=50 + ) + else: # itunes + all_albums = itunes_client.get_artist_albums( + artist_id, + album_type='album,single', + limit=50 + ) - if not album_data or 'tracks' not in album_data: + if not all_albums: + logger.debug(f"No albums found for {similar_artist.similar_artist_name} on {source}") continue - tracks = album_data['tracks'].get('items', []) - logger.debug(f" Album {album_idx}: {album_data.get('name', 'Unknown')} ({len(tracks)} tracks)") - - # Determine if this is a new release (within last 30 days) - is_new = False + # Fetch artist genres for this source + artist_genres = [] try: - release_date_str = album_data.get('release_date', '') - if release_date_str: - if len(release_date_str) == 10: # Full date - release_date = datetime.strptime(release_date_str, "%Y-%m-%d") - days_old = (datetime.now() - release_date).days - is_new = days_old <= 30 - except: - pass + if source == 'spotify': + artist_data = self.spotify_client.get_artist(artist_id) + if artist_data and 'genres' in artist_data: + artist_genres = artist_data['genres'] + else: # iTunes - genres from artist lookup + artist_data = itunes_client.get_artist(artist_id) + if artist_data and 'genres' in artist_data: + artist_genres = artist_data['genres'] + except Exception as e: + logger.debug(f"Could not fetch genres for {similar_artist.similar_artist_name} on {source}: {e}") - # Add each track to discovery pool - for track in tracks: + # IMPROVED: Smart selection mixing albums, singles, and EPs + # Prioritize recent releases and popular content + + # Separate by type for balanced selection + albums = [a for a in all_albums if hasattr(a, 'album_type') and a.album_type == 'album'] + singles_eps = [a for a in all_albums if hasattr(a, 'album_type') and a.album_type in ['single', 'ep']] + other = [a for a in all_albums if not hasattr(a, 'album_type')] + + # Select albums: latest releases + popular older content + selected_albums = [] + + # Always include 3 most recent releases (any type) - this captures new singles/EPs + latest_releases = all_albums[:3] + selected_albums.extend(latest_releases) + + # Add remaining slots with balanced mix + remaining_slots = albums_per_artist - len(selected_albums) + if remaining_slots > 0: + # Combine remaining albums and singles + remaining_content = all_albums[3:] + + if len(remaining_content) > remaining_slots: + # Randomly select from remaining content + random_selection = random.sample(remaining_content, remaining_slots) + selected_albums.extend(random_selection) + else: + selected_albums.extend(remaining_content) + + logger.info(f" [{source}] Selected {len(selected_albums)} releases from {len(all_albums)} available (albums: {len(albums)}, singles/EPs: {len(singles_eps)})") + + # Process each selected album + for album_idx, album in enumerate(selected_albums, 1): try: - # Enhance track object with full album data (including album_type) - enhanced_track = { - **track, - 'album': { - 'id': album_data['id'], - 'name': album_data.get('name', 'Unknown Album'), - 'images': album_data.get('images', []), - 'release_date': album_data.get('release_date', ''), - 'album_type': album_data.get('album_type', 'album'), - 'total_tracks': album_data.get('total_tracks', 0) - } - } + # Get full album data with tracks from appropriate source + if source == 'spotify': + album_data = self.spotify_client.get_album(album.id) + if not album_data or 'tracks' not in album_data: + continue + tracks = album_data['tracks'].get('items', []) + else: # itunes + album_data = itunes_client.get_album(album.id) + if not album_data: + continue + # iTunes get_album doesn't include tracks inline, need separate call + tracks_data = itunes_client.get_album_tracks(album.id) + tracks = tracks_data.get('items', []) if tracks_data else [] - # Build track data for discovery pool - track_data = { - 'spotify_track_id': track['id'], - 'spotify_album_id': album_data['id'], - 'spotify_artist_id': similar_artist.similar_artist_spotify_id, - 'track_name': track['name'], - 'artist_name': similar_artist.similar_artist_name, - 'album_name': album_data.get('name', 'Unknown Album'), - 'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None, - 'duration_ms': track.get('duration_ms', 0), - 'popularity': album_data.get('popularity', 0), - 'release_date': album_data.get('release_date', ''), - 'is_new_release': is_new, - 'track_data_json': enhanced_track, # Store enhanced track with full album data - 'artist_genres': artist_genres # Add cached genres - } + logger.debug(f" Album {album_idx}: {album_data.get('name', 'Unknown')} ({len(tracks)} tracks)") - # Add to discovery pool - if self.database.add_to_discovery_pool(track_data): - total_tracks_added += 1 + # Determine if this is a new release (within last 30 days) + is_new = False + try: + release_date_str = album_data.get('release_date', '') + if release_date_str: + # Handle full date or year-only + if len(release_date_str) >= 10: + release_date = datetime.strptime(release_date_str[:10], "%Y-%m-%d") + days_old = (datetime.now() - release_date).days + is_new = days_old <= 30 + except: + pass - except Exception as track_error: - logger.debug(f"Error adding track to discovery pool: {track_error}") + # Add each track to discovery pool + for track in tracks: + try: + # Enhance track object with full album data (including album_type) + enhanced_track = { + **track, + 'album': { + 'id': album_data['id'], + 'name': album_data.get('name', 'Unknown Album'), + 'images': album_data.get('images', []), + 'release_date': album_data.get('release_date', ''), + 'album_type': album_data.get('album_type', 'album'), + 'total_tracks': album_data.get('total_tracks', 0) + }, + '_source': source + } + + # Build track data for discovery pool with source-specific IDs + track_data = { + 'track_name': track.get('name', 'Unknown Track'), + 'artist_name': similar_artist.similar_artist_name, + 'album_name': album_data.get('name', 'Unknown Album'), + 'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None, + 'duration_ms': track.get('duration_ms', 0), + 'popularity': album_data.get('popularity', 0), + 'release_date': album_data.get('release_date', ''), + 'is_new_release': is_new, + 'track_data_json': enhanced_track, + 'artist_genres': artist_genres + } + + # Add source-specific IDs + if source == 'spotify': + track_data['spotify_track_id'] = track.get('id') + track_data['spotify_album_id'] = album_data.get('id') + track_data['spotify_artist_id'] = similar_artist.similar_artist_spotify_id + else: # itunes + track_data['itunes_track_id'] = track.get('id') + track_data['itunes_album_id'] = album_data.get('id') + track_data['itunes_artist_id'] = similar_artist.similar_artist_itunes_id + + # Add to discovery pool with source + if self.database.add_to_discovery_pool(track_data, source=source): + total_tracks_added += 1 + + except Exception as track_error: + logger.debug(f"Error adding track to discovery pool: {track_error}") + continue + + # Small delay between albums + time.sleep(DELAY_BETWEEN_ALBUMS) + + except Exception as album_error: + logger.warning(f"Error processing album on {source}: {album_error}") continue - # Small delay between albums - time.sleep(DELAY_BETWEEN_ALBUMS) - - except Exception as album_error: - logger.warning(f"Error processing album: {album_error}") + except Exception as source_error: + logger.warning(f"Error processing {source} source for {similar_artist.similar_artist_name}: {source_error}") continue - # Delay between artists + # Delay between artists (after processing all sources for this artist) if artist_idx < len(similar_artists): time.sleep(DELAY_BETWEEN_ARTISTS) @@ -1441,76 +1552,115 @@ class WatchlistScanner: for db_idx, album_row in enumerate(db_albums, 1): try: - # Search for album on Spotify - query = f"album:{album_row['title']} artist:{album_row['artist_name']}" - search_results = self.spotify_client.search_albums(query, limit=1) + query = f"{album_row['title']} {album_row['artist_name']}" + album_data = None + tracks = [] + db_source = None + artist_id_for_genres = None - if search_results and len(search_results) > 0: - spotify_album = search_results[0] - album_data = self.spotify_client.get_album(spotify_album.id) + # Try Spotify first if available + if spotify_available: + try: + search_results = self.spotify_client.search_albums(f"album:{album_row['title']} artist:{album_row['artist_name']}", limit=1) + if search_results and len(search_results) > 0: + spotify_album = search_results[0] + album_data = self.spotify_client.get_album(spotify_album.id) + if album_data and 'tracks' in album_data: + tracks = album_data['tracks'].get('items', []) + db_source = 'spotify' + if album_data.get('artists'): + artist_id_for_genres = album_data['artists'][0]['id'] + except Exception as e: + logger.debug(f"Spotify search failed for {album_row['title']}: {e}") - if album_data and 'tracks' in album_data: - tracks = album_data['tracks'].get('items', []) + # Fall back to iTunes if Spotify didn't work + if not tracks and itunes_available: + try: + search_results = itunes_client.search_albums(query, limit=1) + if search_results and len(search_results) > 0: + itunes_album = search_results[0] + album_data = itunes_client.get_album(itunes_album.id) + if album_data: + tracks_data = itunes_client.get_album_tracks(itunes_album.id) + tracks = tracks_data.get('items', []) if tracks_data else [] + db_source = 'itunes' + # For iTunes, artist ID is in the album data + if album_data.get('artists'): + artist_id_for_genres = album_data['artists'][0].get('id') + except Exception as e: + logger.debug(f"iTunes search failed for {album_row['title']}: {e}") - # Fetch artist genres - artist_genres = [] - try: - if album_data.get('artists') and len(album_data['artists']) > 0: - artist_id = album_data['artists'][0]['id'] - artist_data = self.spotify_client.get_artist(artist_id) - if artist_data and 'genres' in artist_data: - artist_genres = artist_data['genres'] - except Exception as e: - logger.debug(f"Could not fetch genres for album artist: {e}") + if not tracks or not album_data: + continue - # Check if new release - is_new = False - try: - release_date_str = album_data.get('release_date', '') - if release_date_str and len(release_date_str) == 10: - release_date = datetime.strptime(release_date_str, "%Y-%m-%d") - days_old = (datetime.now() - release_date).days - is_new = days_old <= 30 - except: - pass + # Fetch artist genres + artist_genres = [] + try: + if artist_id_for_genres: + if db_source == 'spotify': + artist_data = self.spotify_client.get_artist(artist_id_for_genres) + else: + artist_data = itunes_client.get_artist(artist_id_for_genres) + if artist_data and 'genres' in artist_data: + artist_genres = artist_data['genres'] + except Exception as e: + logger.debug(f"Could not fetch genres for album artist: {e}") - for track in tracks: - try: - # Enhance track object with full album data (including album_type) - enhanced_track = { - **track, - 'album': { - 'id': album_data['id'], - 'name': album_row['title'], - 'images': album_data.get('images', []), - 'release_date': album_data.get('release_date', ''), - 'album_type': album_data.get('album_type', 'album'), - 'total_tracks': album_data.get('total_tracks', 0) - } - } + # Check if new release + is_new = False + try: + release_date_str = album_data.get('release_date', '') + if release_date_str and len(release_date_str) >= 10: + release_date = datetime.strptime(release_date_str[:10], "%Y-%m-%d") + days_old = (datetime.now() - release_date).days + is_new = days_old <= 30 + except: + pass - track_data = { - 'spotify_track_id': track['id'], - 'spotify_album_id': album_data['id'], - 'spotify_artist_id': album_data['artists'][0]['id'] if album_data.get('artists') else '', - 'track_name': track['name'], - 'artist_name': album_row['artist_name'], - 'album_name': album_row['title'], - 'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None, - 'duration_ms': track.get('duration_ms', 0), - 'popularity': album_data.get('popularity', 0), - 'release_date': album_data.get('release_date', ''), - 'is_new_release': is_new, - 'track_data_json': enhanced_track, # Store enhanced track with full album data - 'artist_genres': artist_genres - } + for track in tracks: + try: + enhanced_track = { + **track, + 'album': { + 'id': album_data['id'], + 'name': album_row['title'], + 'images': album_data.get('images', []), + 'release_date': album_data.get('release_date', ''), + 'album_type': album_data.get('album_type', 'album'), + 'total_tracks': album_data.get('total_tracks', 0) + }, + '_source': db_source + } - if self.database.add_to_discovery_pool(track_data): - total_tracks_added += 1 - except Exception as track_error: - continue + track_data = { + 'track_name': track.get('name', 'Unknown Track'), + 'artist_name': album_row['artist_name'], + 'album_name': album_row['title'], + 'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None, + 'duration_ms': track.get('duration_ms', 0), + 'popularity': album_data.get('popularity', 0), + 'release_date': album_data.get('release_date', ''), + 'is_new_release': is_new, + 'track_data_json': enhanced_track, + 'artist_genres': artist_genres + } - time.sleep(DELAY_BETWEEN_ALBUMS) + # Add source-specific IDs + if db_source == 'spotify': + track_data['spotify_track_id'] = track.get('id') + track_data['spotify_album_id'] = album_data.get('id') + track_data['spotify_artist_id'] = artist_id_for_genres or '' + else: # itunes + track_data['itunes_track_id'] = track.get('id') + track_data['itunes_album_id'] = album_data.get('id') + track_data['itunes_artist_id'] = artist_id_for_genres or '' + + if self.database.add_to_discovery_pool(track_data, source=db_source): + total_tracks_added += 1 + except Exception as track_error: + continue + + time.sleep(DELAY_BETWEEN_ALBUMS) except Exception as album_error: logger.debug(f"Error processing database album {album_row['title']}: {album_error}") continue diff --git a/database/music_database.py b/database/music_database.py index 2f774d97..9d198219 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -97,10 +97,11 @@ class WatchlistArtist: @dataclass class SimilarArtist: - """Similar artist recommendation from Spotify""" + """Similar artist recommendation from Spotify/iTunes""" id: int source_artist_id: str # Watchlist artist's database ID - similar_artist_spotify_id: str + similar_artist_spotify_id: Optional[str] # Spotify artist ID (may be None if iTunes-only) + similar_artist_itunes_id: Optional[str] # iTunes artist ID (may be None if Spotify-only) similar_artist_name: str similarity_rank: int # 1-10, where 1 is most similar occurrence_count: int # How many watchlist artists share this similar artist @@ -110,9 +111,13 @@ class SimilarArtist: class DiscoveryTrack: """Track in the discovery pool for recommendations""" id: int - spotify_track_id: str - spotify_album_id: str - spotify_artist_id: str + spotify_track_id: Optional[str] # Spotify track ID (None if iTunes source) + spotify_album_id: Optional[str] # Spotify album ID (None if iTunes source) + spotify_artist_id: Optional[str] # Spotify artist ID (None if iTunes source) + itunes_track_id: Optional[str] # iTunes track ID (None if Spotify source) + itunes_album_id: Optional[str] # iTunes album ID (None if Spotify source) + itunes_artist_id: Optional[str] # iTunes artist ID (None if Spotify source) + source: str # 'spotify' or 'itunes' track_name: str artist_name: str album_name: str @@ -121,7 +126,7 @@ class DiscoveryTrack: popularity: int release_date: str is_new_release: bool # Released within last 30 days - track_data_json: str # Full Spotify track object for modal + track_data_json: str # Full track object for modal (Spotify or iTunes format) added_date: datetime @dataclass @@ -129,7 +134,9 @@ class RecentRelease: """Recent album release from watchlist artist""" id: int watchlist_artist_id: int - album_spotify_id: str + album_spotify_id: Optional[str] # Spotify album ID (None if iTunes source) + album_itunes_id: Optional[str] # iTunes album ID (None if Spotify source) + source: str # 'spotify' or 'itunes' album_name: str release_date: str album_cover_url: Optional[str] @@ -450,26 +457,33 @@ class MusicDatabase: """Add tables for discovery feature: similar artists, discovery pool, and recent releases""" try: # Similar Artists table - stores similar artists for each watchlist artist + # Supports both Spotify and iTunes IDs for dual-source discovery cursor.execute(""" CREATE TABLE IF NOT EXISTS similar_artists ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_artist_id TEXT NOT NULL, - similar_artist_spotify_id TEXT NOT NULL, + similar_artist_spotify_id TEXT, + similar_artist_itunes_id TEXT, similar_artist_name TEXT NOT NULL, similarity_rank INTEGER DEFAULT 1, occurrence_count INTEGER DEFAULT 1, last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(source_artist_id, similar_artist_spotify_id) + UNIQUE(source_artist_id, similar_artist_name) ) """) # Discovery Pool table - rotating pool of 1000-2000 tracks for recommendations + # Supports both Spotify and iTunes sources for dual-source discovery cursor.execute(""" CREATE TABLE IF NOT EXISTS discovery_pool ( id INTEGER PRIMARY KEY AUTOINCREMENT, - spotify_track_id TEXT UNIQUE NOT NULL, - spotify_album_id TEXT NOT NULL, - spotify_artist_id TEXT NOT NULL, + spotify_track_id TEXT, + spotify_album_id TEXT, + spotify_artist_id TEXT, + itunes_track_id TEXT, + itunes_album_id TEXT, + itunes_artist_id TEXT, + source TEXT NOT NULL DEFAULT 'spotify', track_name TEXT NOT NULL, artist_name TEXT NOT NULL, album_name TEXT NOT NULL, @@ -479,38 +493,47 @@ class MusicDatabase: release_date TEXT, is_new_release BOOLEAN DEFAULT 0, track_data_json TEXT NOT NULL, - added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP + added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(spotify_track_id, itunes_track_id, source) ) """) # Recent Releases table - tracks new releases from watchlist artists + # Supports both Spotify and iTunes sources for dual-source discovery cursor.execute(""" CREATE TABLE IF NOT EXISTS recent_releases ( id INTEGER PRIMARY KEY AUTOINCREMENT, watchlist_artist_id INTEGER NOT NULL, - album_spotify_id TEXT NOT NULL, + album_spotify_id TEXT, + album_itunes_id TEXT, + source TEXT NOT NULL DEFAULT 'spotify', album_name TEXT NOT NULL, release_date TEXT NOT NULL, album_cover_url TEXT, track_count INTEGER DEFAULT 0, added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(watchlist_artist_id, album_spotify_id), + UNIQUE(watchlist_artist_id, album_spotify_id, album_itunes_id), FOREIGN KEY (watchlist_artist_id) REFERENCES watchlist_artists (id) ON DELETE CASCADE ) """) # Discovery Recent Albums cache - for discover page recent releases section + # Supports both Spotify and iTunes sources for dual-source discovery cursor.execute(""" CREATE TABLE IF NOT EXISTS discovery_recent_albums ( id INTEGER PRIMARY KEY AUTOINCREMENT, - album_spotify_id TEXT NOT NULL UNIQUE, + album_spotify_id TEXT, + album_itunes_id TEXT, + artist_spotify_id TEXT, + artist_itunes_id TEXT, + source TEXT NOT NULL DEFAULT 'spotify', album_name TEXT NOT NULL, artist_name TEXT NOT NULL, - artist_spotify_id TEXT NOT NULL, album_cover_url TEXT, release_date TEXT NOT NULL, album_type TEXT DEFAULT 'album', - cached_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP + cached_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(album_spotify_id, album_itunes_id, source) ) """) @@ -571,13 +594,20 @@ class MusicDatabase: # Create indexes for performance cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_source ON similar_artists (source_artist_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_spotify ON similar_artists (similar_artist_spotify_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_itunes ON similar_artists (similar_artist_itunes_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_occurrence ON similar_artists (occurrence_count)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_name ON similar_artists (similar_artist_name)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_spotify_track ON discovery_pool (spotify_track_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_itunes_track ON discovery_pool (itunes_track_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_artist ON discovery_pool (spotify_artist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_itunes_artist ON discovery_pool (itunes_artist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_source ON discovery_pool (source)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_added_date ON discovery_pool (added_date)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_is_new ON discovery_pool (is_new_release)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_watchlist ON recent_releases (watchlist_artist_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_date ON recent_releases (release_date)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_source ON recent_releases (source)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_source ON discovery_recent_albums (source)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_recent_albums_date ON discovery_recent_albums (release_date)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_playlists_type ON listenbrainz_playlists (playlist_type)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_listenbrainz_playlists_mbid ON listenbrainz_playlists (playlist_mbid)") @@ -593,6 +623,41 @@ class MusicDatabase: cursor.execute("ALTER TABLE discovery_pool ADD COLUMN artist_genres TEXT") logger.info("Added artist_genres column to discovery_pool table") + # Migration: Add iTunes columns to discovery_pool for dual-source discovery + if 'itunes_track_id' not in discovery_pool_columns: + cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_track_id TEXT") + cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_album_id TEXT") + cursor.execute("ALTER TABLE discovery_pool ADD COLUMN itunes_artist_id TEXT") + cursor.execute("ALTER TABLE discovery_pool ADD COLUMN source TEXT DEFAULT 'spotify'") + logger.info("Added iTunes columns to discovery_pool table for dual-source discovery") + + # Migration: Add iTunes ID to similar_artists for dual-source discovery + cursor.execute("PRAGMA table_info(similar_artists)") + similar_artists_columns = [column[1] for column in cursor.fetchall()] + + if 'similar_artist_itunes_id' not in similar_artists_columns: + cursor.execute("ALTER TABLE similar_artists ADD COLUMN similar_artist_itunes_id TEXT") + logger.info("Added similar_artist_itunes_id column to similar_artists table") + + # Migration: Add iTunes columns to recent_releases for dual-source discovery + cursor.execute("PRAGMA table_info(recent_releases)") + recent_releases_columns = [column[1] for column in cursor.fetchall()] + + if 'album_itunes_id' not in recent_releases_columns: + cursor.execute("ALTER TABLE recent_releases ADD COLUMN album_itunes_id TEXT") + cursor.execute("ALTER TABLE recent_releases ADD COLUMN source TEXT DEFAULT 'spotify'") + logger.info("Added iTunes columns to recent_releases table for dual-source discovery") + + # Migration: Add iTunes columns to discovery_recent_albums for dual-source discovery + cursor.execute("PRAGMA table_info(discovery_recent_albums)") + discovery_recent_albums_columns = [column[1] for column in cursor.fetchall()] + + if 'album_itunes_id' not in discovery_recent_albums_columns: + cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN album_itunes_id TEXT") + cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN artist_itunes_id TEXT") + cursor.execute("ALTER TABLE discovery_recent_albums ADD COLUMN source TEXT DEFAULT 'spotify'") + logger.info("Added iTunes columns to discovery_recent_albums table for dual-source discovery") + logger.info("Discovery tables created successfully") except Exception as e: @@ -2933,23 +2998,28 @@ class MusicDatabase: # === Discovery Feature Methods === - def add_or_update_similar_artist(self, source_artist_id: str, similar_artist_spotify_id: str, - similar_artist_name: str, similarity_rank: int = 1) -> bool: - """Add or update a similar artist recommendation""" + def add_or_update_similar_artist(self, source_artist_id: str, similar_artist_name: str, + similar_artist_spotify_id: Optional[str] = None, + similar_artist_itunes_id: Optional[str] = None, + similarity_rank: int = 1) -> bool: + """Add or update a similar artist recommendation (supports both Spotify and iTunes IDs)""" try: with self._get_connection() as conn: cursor = conn.cursor() + # Use artist name as the unique key (allows storing both IDs for same artist) cursor.execute(""" INSERT INTO similar_artists - (source_artist_id, similar_artist_spotify_id, similar_artist_name, similarity_rank, occurrence_count, last_updated) - VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP) - ON CONFLICT(source_artist_id, similar_artist_spotify_id) + (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name, similarity_rank, occurrence_count, last_updated) + VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(source_artist_id, similar_artist_name) DO UPDATE SET + similar_artist_spotify_id = COALESCE(excluded.similar_artist_spotify_id, similar_artist_spotify_id), + similar_artist_itunes_id = COALESCE(excluded.similar_artist_itunes_id, similar_artist_itunes_id), similarity_rank = excluded.similarity_rank, occurrence_count = occurrence_count + 1, last_updated = CURRENT_TIMESTAMP - """, (source_artist_id, similar_artist_spotify_id, similar_artist_name, similarity_rank)) + """, (source_artist_id, similar_artist_spotify_id, similar_artist_itunes_id, similar_artist_name, similarity_rank)) conn.commit() return True @@ -2975,6 +3045,7 @@ class MusicDatabase: id=row['id'], source_artist_id=row['source_artist_id'], similar_artist_spotify_id=row['similar_artist_spotify_id'], + similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None, similar_artist_name=row['similar_artist_name'], similarity_rank=row['similarity_rank'], occurrence_count=row['occurrence_count'], @@ -3026,13 +3097,14 @@ class MusicDatabase: SELECT MAX(id) as id, MAX(source_artist_id) as source_artist_id, - similar_artist_spotify_id, + MAX(similar_artist_spotify_id) as similar_artist_spotify_id, + MAX(similar_artist_itunes_id) as similar_artist_itunes_id, similar_artist_name, AVG(similarity_rank) as similarity_rank, SUM(occurrence_count) as occurrence_count, MAX(last_updated) as last_updated FROM similar_artists - GROUP BY similar_artist_spotify_id, similar_artist_name + GROUP BY similar_artist_name ORDER BY occurrence_count DESC, similarity_rank ASC LIMIT ? """, (limit,)) @@ -3042,6 +3114,7 @@ class MusicDatabase: id=row['id'], source_artist_id=row['source_artist_id'], similar_artist_spotify_id=row['similar_artist_spotify_id'], + similar_artist_itunes_id=row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None, similar_artist_name=row['similar_artist_name'], similarity_rank=int(row['similarity_rank']), occurrence_count=row['occurrence_count'], @@ -3052,15 +3125,24 @@ class MusicDatabase: logger.error(f"Error getting top similar artists: {e}") return [] - def add_to_discovery_pool(self, track_data: Dict[str, Any]) -> bool: - """Add a track to the discovery pool""" + def add_to_discovery_pool(self, track_data: Dict[str, Any], source: str = 'spotify') -> bool: + """Add a track to the discovery pool (supports both Spotify and iTunes sources)""" try: with self._get_connection() as conn: cursor = conn.cursor() - # Check if track already exists - cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE spotify_track_id = ?", - (track_data['spotify_track_id'],)) + # Check if track already exists based on source + if source == 'spotify' and track_data.get('spotify_track_id'): + cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE spotify_track_id = ? AND source = 'spotify'", + (track_data['spotify_track_id'],)) + elif source == 'itunes' and track_data.get('itunes_track_id'): + cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE itunes_track_id = ? AND source = 'itunes'", + (track_data['itunes_track_id'],)) + else: + # Fallback check by track name and artist + cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE track_name = ? AND artist_name = ? AND source = ?", + (track_data['track_name'], track_data['artist_name'], source)) + if cursor.fetchone()['count'] > 0: return True # Already in pool @@ -3070,14 +3152,19 @@ class MusicDatabase: cursor.execute(""" INSERT INTO discovery_pool - (spotify_track_id, spotify_album_id, spotify_artist_id, track_name, artist_name, - album_name, album_cover_url, duration_ms, popularity, release_date, - is_new_release, track_data_json, artist_genres, added_date) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + (spotify_track_id, spotify_album_id, spotify_artist_id, + itunes_track_id, itunes_album_id, itunes_artist_id, + source, track_name, artist_name, album_name, album_cover_url, + duration_ms, popularity, release_date, is_new_release, track_data_json, artist_genres, added_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """, ( - track_data['spotify_track_id'], - track_data['spotify_album_id'], - track_data['spotify_artist_id'], + track_data.get('spotify_track_id'), + track_data.get('spotify_album_id'), + track_data.get('spotify_artist_id'), + track_data.get('itunes_track_id'), + track_data.get('itunes_album_id'), + track_data.get('itunes_artist_id'), + source, track_data['track_name'], track_data['artist_name'], track_data['album_name'], @@ -3124,32 +3211,45 @@ class MusicDatabase: except Exception as e: logger.error(f"Error rotating discovery pool: {e}") - def get_discovery_pool_tracks(self, limit: int = 100, new_releases_only: bool = False) -> List[DiscoveryTrack]: - """Get tracks from discovery pool""" + def get_discovery_pool_tracks(self, limit: int = 100, new_releases_only: bool = False, source: Optional[str] = None) -> List[DiscoveryTrack]: + """Get tracks from discovery pool, optionally filtered by source ('spotify' or 'itunes')""" try: with self._get_connection() as conn: cursor = conn.cursor() + # Build query with optional source filter + where_clauses = [] + params = [] + if new_releases_only: - cursor.execute(""" - SELECT * FROM discovery_pool - WHERE is_new_release = 1 - ORDER BY added_date DESC - LIMIT ? - """, (limit,)) - else: - cursor.execute(""" - SELECT * FROM discovery_pool - ORDER BY added_date DESC - LIMIT ? - """, (limit,)) + where_clauses.append("is_new_release = 1") + + if source: + where_clauses.append("source = ?") + params.append(source) + + where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + params.append(limit) + + cursor.execute(f""" + SELECT * FROM discovery_pool + {where_sql} + ORDER BY added_date DESC + LIMIT ? + """, params) rows = cursor.fetchall() + row_keys = rows[0].keys() if rows else [] + return [DiscoveryTrack( id=row['id'], spotify_track_id=row['spotify_track_id'], spotify_album_id=row['spotify_album_id'], spotify_artist_id=row['spotify_artist_id'], + itunes_track_id=row['itunes_track_id'] if 'itunes_track_id' in row_keys else None, + itunes_album_id=row['itunes_album_id'] if 'itunes_album_id' in row_keys else None, + itunes_artist_id=row['itunes_artist_id'] if 'itunes_artist_id' in row_keys else None, + source=row['source'] if 'source' in row_keys else 'spotify', track_name=row['track_name'], artist_name=row['artist_name'], album_name=row['album_name'], @@ -3166,21 +3266,25 @@ class MusicDatabase: logger.error(f"Error getting discovery pool tracks: {e}") return [] - def cache_discovery_recent_album(self, album_data: Dict[str, Any]) -> bool: - """Cache a recent album for the discover page (from watchlist or similar artists)""" + def cache_discovery_recent_album(self, album_data: Dict[str, Any], source: str = 'spotify') -> bool: + """Cache a recent album for the discover page (supports both Spotify and iTunes sources)""" try: with self._get_connection() as conn: cursor = conn.cursor() cursor.execute(""" INSERT OR REPLACE INTO discovery_recent_albums - (album_spotify_id, album_name, artist_name, artist_spotify_id, album_cover_url, release_date, album_type, cached_date) - VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + (album_spotify_id, album_itunes_id, artist_spotify_id, artist_itunes_id, source, + album_name, artist_name, album_cover_url, release_date, album_type, cached_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """, ( - album_data['album_spotify_id'], + album_data.get('album_spotify_id'), + album_data.get('album_itunes_id'), + album_data.get('artist_spotify_id'), + album_data.get('artist_itunes_id'), + source, album_data['album_name'], album_data['artist_name'], - album_data['artist_spotify_id'], album_data.get('album_cover_url'), album_data['release_date'], album_data.get('album_type', 'album') @@ -3193,27 +3297,40 @@ class MusicDatabase: logger.error(f"Error caching discovery recent album: {e}") return False - def get_discovery_recent_albums(self, limit: int = 10) -> List[Dict[str, Any]]: - """Get cached recent albums for discover page""" + def get_discovery_recent_albums(self, limit: int = 10, source: Optional[str] = None) -> List[Dict[str, Any]]: + """Get cached recent albums for discover page, optionally filtered by source""" try: with self._get_connection() as conn: cursor = conn.cursor() - cursor.execute(""" - SELECT * FROM discovery_recent_albums - ORDER BY release_date DESC - LIMIT ? - """, (limit,)) + if source: + cursor.execute(""" + SELECT * FROM discovery_recent_albums + WHERE source = ? + ORDER BY release_date DESC + LIMIT ? + """, (source, limit)) + else: + cursor.execute(""" + SELECT * FROM discovery_recent_albums + ORDER BY release_date DESC + LIMIT ? + """, (limit,)) rows = cursor.fetchall() + row_keys = rows[0].keys() if rows else [] + return [{ 'album_spotify_id': row['album_spotify_id'], + 'album_itunes_id': row['album_itunes_id'] if 'album_itunes_id' in row_keys else None, 'album_name': row['album_name'], 'artist_name': row['artist_name'], 'artist_spotify_id': row['artist_spotify_id'], + 'artist_itunes_id': row['artist_itunes_id'] if 'artist_itunes_id' in row_keys else None, 'album_cover_url': row['album_cover_url'], 'release_date': row['release_date'], - 'album_type': row['album_type'] + 'album_type': row['album_type'], + 'source': row['source'] if 'source' in row_keys else 'spotify' } for row in rows] except Exception as e: diff --git a/web_server.py b/web_server.py index 753a598d..5995ed27 100644 --- a/web_server.py +++ b/web_server.py @@ -5046,19 +5046,73 @@ def get_artist_image(artist_id): def get_artist_discography(artist_id): """Get an artist's complete discography (albums and singles)""" try: - if not spotify_client or not spotify_client.is_authenticated(): - return jsonify({"error": "Spotify not authenticated"}), 401 - - print(f"🎤 Fetching discography for artist: {artist_id}") - - # Get artist's albums and singles (temporarily include appears_on for debugging) - albums = spotify_client.get_artist_albums(artist_id, album_type='album,single', limit=50) - print(f"📊 Raw albums returned from Spotify: {len(albums)}") + # Get optional artist name for fallback searches + artist_name = request.args.get('artist_name', '') + + # Determine which source to use + spotify_available = spotify_client and spotify_client.is_spotify_authenticated() + + # Import iTunes client for fallback + from core.itunes_client import iTunesClient + itunes_client = iTunesClient() + + print(f"🎤 Fetching discography for artist: {artist_id} (name: {artist_name}, spotify: {spotify_available})") + + albums = [] + active_source = None + + # Try to get albums from the appropriate source + # Check if the ID looks like Spotify (alphanumeric) or iTunes (numeric only) + is_numeric_id = artist_id.isdigit() + + if spotify_available and not is_numeric_id: + # Try Spotify first for alphanumeric IDs + try: + albums = spotify_client.get_artist_albums(artist_id, album_type='album,single', limit=50) + if albums: + active_source = 'spotify' + print(f"📊 Got {len(albums)} albums from Spotify") + except Exception as e: + print(f"Spotify lookup failed: {e}") + + # Try iTunes if Spotify didn't work or if it's a numeric ID + if not albums: + try: + if is_numeric_id: + # It's an iTunes ID, use directly + albums = itunes_client.get_artist_albums(artist_id, album_type='album,single', limit=50) + if albums: + active_source = 'itunes' + print(f"📊 Got {len(albums)} albums from iTunes (direct ID)") + elif artist_name: + # Search iTunes by name + print(f"🔄 Trying iTunes search by name: '{artist_name}'") + itunes_artists = itunes_client.search_artists(artist_name, limit=5) + if itunes_artists: + # Find best match + best_match = None + for artist in itunes_artists: + if artist.name.lower() == artist_name.lower(): + best_match = artist + break + if not best_match: + best_match = itunes_artists[0] + + print(f"✅ Found iTunes artist: {best_match.name} (ID: {best_match.id})") + albums = itunes_client.get_artist_albums(best_match.id, album_type='album,single', limit=50) + if albums: + active_source = 'itunes' + print(f"📊 Got {len(albums)} albums from iTunes (name search)") + except Exception as e: + print(f"iTunes lookup failed: {e}") + + print(f"📊 Total albums returned: {len(albums)} (source: {active_source})") if not albums: return jsonify({ "albums": [], - "singles": [] + "singles": [], + "source": active_source or "unknown" }) # Separate albums from singles/EPs @@ -5073,23 +5127,18 @@ def get_artist_discography(artist_id): if album.id in seen_albums: continue seen_albums.add(album.id) - - # Debug: Check artist information - print(f"🔍 Checking album: {album.name}") + + # Handle artist matching for both Spotify (objects) and iTunes (strings) formats if hasattr(album, 'artists') and album.artists: - primary_artist_id = album.artists[0].id if hasattr(album.artists[0], 'id') else None - primary_artist_name = album.artists[0].name if hasattr(album.artists[0], 'name') else None - print(f" Primary artist: {primary_artist_name} (ID: {primary_artist_id})") - print(f" Requested artist ID: {artist_id}") - - # Skip if the primary artist doesn't match our requested artist - if primary_artist_id and primary_artist_id != artist_id: - print(f"🚫 Skipping '{album.name}' - primary artist mismatch") - continue - elif not primary_artist_id: - print(f"⚠️ No primary artist ID found for '{album.name}' - including anyway") - else: - print(f"⚠️ No artists found for '{album.name}' - including anyway") + first_artist = album.artists[0] + # Check if artists are objects (Spotify) or strings (iTunes) + if hasattr(first_artist, 'id'): + # Spotify format: artist is an object with .id and .name + primary_artist_id = first_artist.id + # Skip if the primary artist doesn't match our requested artist + if primary_artist_id and primary_artist_id != artist_id: + continue + # iTunes format: artist is a string, can't verify ID match so include all album_data = { "id": album.id, @@ -5138,9 +5187,10 @@ def get_artist_discography(artist_id): return jsonify({ "albums": album_list, - "singles": singles_list + "singles": singles_list, + "source": active_source or "spotify" }) - + except Exception as e: print(f"❌ Error fetching artist discography: {e}") import traceback @@ -18178,84 +18228,141 @@ metadata_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix= # == DISCOVER PAGE ENDPOINTS == # =============================== +def _get_active_discovery_source(): + """ + Determine which music source is active for discovery. + Returns 'spotify' if Spotify is authenticated, 'itunes' otherwise. + """ + if spotify_client and spotify_client.is_spotify_authenticated(): + return 'spotify' + return 'itunes' + + @app.route('/api/discover/hero', methods=['GET']) def get_discover_hero(): """Get featured similar artists for hero slideshow""" try: database = get_database() + # Determine active source + active_source = _get_active_discovery_source() + print(f"🎵 Discover hero using source: {active_source}") + # Get top similar artists (by occurrence count) - get 20 for variety similar_artists = database.get_top_similar_artists(limit=20) if not similar_artists: - return jsonify({"success": True, "artists": []}) + return jsonify({"success": True, "artists": [], "source": active_source}) + + # Filter to artists that have the appropriate ID for the active source + valid_artists = [] + for artist in similar_artists: + if active_source == 'spotify' and artist.similar_artist_spotify_id: + valid_artists.append(artist) + elif active_source == 'itunes' and artist.similar_artist_itunes_id: + valid_artists.append(artist) + # If we have both IDs, include regardless of source + elif artist.similar_artist_spotify_id and artist.similar_artist_itunes_id: + valid_artists.append(artist) # Shuffle for variety and take top 10 import random - shuffled = list(similar_artists) + shuffled = list(valid_artists) random.shuffle(shuffled) similar_artists = shuffled[:10] - # Convert to JSON format with Spotify data enrichment + # Import iTunes client for fallback + from core.itunes_client import iTunesClient + itunes_client = iTunesClient() + + # Convert to JSON format with data enrichment from appropriate source hero_artists = [] for artist in similar_artists: + # Use the ID for the active source, falling back to the other if needed + if active_source == 'spotify': + artist_id = artist.similar_artist_spotify_id or artist.similar_artist_itunes_id + else: + artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id + artist_data = { "spotify_artist_id": artist.similar_artist_spotify_id, + "itunes_artist_id": artist.similar_artist_itunes_id, + "artist_id": artist_id, # The ID for the current active source "artist_name": artist.similar_artist_name, "occurrence_count": artist.occurrence_count, - "similarity_rank": artist.similarity_rank + "similarity_rank": artist.similarity_rank, + "source": active_source } - # Try to get artist image from Spotify + # Try to get artist image from the active source try: - if spotify_client and spotify_client.is_authenticated(): - sp_artist = spotify_client.get_artist(artist.similar_artist_spotify_id) - if sp_artist and sp_artist.get('images'): - artist_data['image_url'] = sp_artist['images'][0]['url'] if sp_artist['images'] else None - artist_data['genres'] = sp_artist.get('genres', []) - artist_data['popularity'] = sp_artist.get('popularity', 0) - except: - pass + if active_source == 'spotify' and artist.similar_artist_spotify_id: + if spotify_client and spotify_client.is_authenticated(): + sp_artist = spotify_client.get_artist(artist.similar_artist_spotify_id) + if sp_artist and sp_artist.get('images'): + artist_data['image_url'] = sp_artist['images'][0]['url'] if sp_artist['images'] else None + artist_data['genres'] = sp_artist.get('genres', []) + artist_data['popularity'] = sp_artist.get('popularity', 0) + elif active_source == 'itunes' and artist.similar_artist_itunes_id: + itunes_artist = itunes_client.get_artist(artist.similar_artist_itunes_id) + if itunes_artist: + artist_data['image_url'] = itunes_artist.get('images', [{}])[0].get('url') if itunes_artist.get('images') else None + artist_data['genres'] = itunes_artist.get('genres', []) + artist_data['popularity'] = itunes_artist.get('popularity', 0) + except Exception as img_err: + print(f"Could not fetch artist image: {img_err}") hero_artists.append(artist_data) - return jsonify({"success": True, "artists": hero_artists}) + return jsonify({"success": True, "artists": hero_artists, "source": active_source}) except Exception as e: print(f"Error getting discover hero: {e}") return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/discover/recent-releases', methods=['GET']) def get_discover_recent_releases(): """Get cached recent albums from watchlist and similar artists""" try: database = get_database() - # Get cached recent albums (max 20) - albums = database.get_discovery_recent_albums(limit=20) + # Determine active source + active_source = _get_active_discovery_source() - return jsonify({"success": True, "albums": albums}) + # Get cached recent albums filtered by source (max 20) + albums = database.get_discovery_recent_albums(limit=20, source=active_source) + + return jsonify({"success": True, "albums": albums, "source": active_source}) except Exception as e: print(f"Error getting recent releases: {e}") return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/discover/release-radar', methods=['GET']) def get_discover_release_radar(): """Get release radar playlist - curated selection that stays consistent until next update""" try: database = get_database() - if not spotify_client or not spotify_client.is_authenticated(): - return jsonify({"success": True, "tracks": []}) + # Determine active source - release radar works with any source now + active_source = _get_active_discovery_source() # Try to get curated playlist first curated_track_ids = database.get_curated_playlist('release_radar') if curated_track_ids: - # Use curated selection - fetch track data from discovery pool (same as Discovery Weekly) - discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False) - tracks_by_id = {track.spotify_track_id: track for track in discovery_tracks} + # Use curated selection - fetch track data from discovery pool filtered by source + discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source) + + # Build lookup dict with source-appropriate IDs + tracks_by_id = {} + for track in discovery_tracks: + if active_source == 'spotify' and track.spotify_track_id: + tracks_by_id[track.spotify_track_id] = track + elif active_source == 'itunes' and track.itunes_track_id: + tracks_by_id[track.itunes_track_id] = track selected_tracks = [] for track_id in curated_track_ids: @@ -18271,19 +18378,22 @@ def get_discover_release_radar(): track_data = None selected_tracks.append({ + "track_id": track.spotify_track_id or track.itunes_track_id, "spotify_track_id": track.spotify_track_id, + "itunes_track_id": track.itunes_track_id, "track_name": track.track_name, "artist_name": track.artist_name, "album_name": track.album_name, "album_cover_url": track.album_cover_url, "duration_ms": track.duration_ms, - "track_data_json": track_data # Now properly parsed + "track_data_json": track_data, + "source": track.source }) - return jsonify({"success": True, "tracks": selected_tracks}) + return jsonify({"success": True, "tracks": selected_tracks, "source": active_source}) # Fallback: no curated playlist exists (shouldn't happen after first scan) - return jsonify({"success": True, "tracks": []}) + return jsonify({"success": True, "tracks": [], "source": active_source}) except Exception as e: print(f"Error getting release radar: {e}") @@ -18297,13 +18407,23 @@ def get_discover_weekly(): try: database = get_database() + # Determine active source + active_source = _get_active_discovery_source() + # Try to get curated playlist first curated_track_ids = database.get_curated_playlist('discovery_weekly') if curated_track_ids: - # Use curated selection - fetch track data from discovery pool - discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False) - tracks_by_id = {track.spotify_track_id: track for track in discovery_tracks} + # Use curated selection - fetch track data from discovery pool filtered by source + discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source) + + # Build lookup dict with source-appropriate IDs + tracks_by_id = {} + for track in discovery_tracks: + if active_source == 'spotify' and track.spotify_track_id: + tracks_by_id[track.spotify_track_id] = track + elif active_source == 'itunes' and track.itunes_track_id: + tracks_by_id[track.itunes_track_id] = track selected_tracks = [] for track_id in curated_track_ids: @@ -18319,19 +18439,22 @@ def get_discover_weekly(): track_data = None selected_tracks.append({ + "track_id": track.spotify_track_id or track.itunes_track_id, "spotify_track_id": track.spotify_track_id, + "itunes_track_id": track.itunes_track_id, "track_name": track.track_name, "artist_name": track.artist_name, "album_name": track.album_name, "album_cover_url": track.album_cover_url, "duration_ms": track.duration_ms, - "track_data_json": track_data # Now properly parsed + "track_data_json": track_data, + "source": track.source }) - return jsonify({"success": True, "tracks": selected_tracks}) + return jsonify({"success": True, "tracks": selected_tracks, "source": active_source}) # Fallback: no curated playlist exists (shouldn't happen after first scan) - return jsonify({"success": True, "tracks": []}) + return jsonify({"success": True, "tracks": [], "source": active_source}) except Exception as e: print(f"Error getting discovery weekly: {e}") diff --git a/webui/static/script.js b/webui/static/script.js index 5aa92be5..88969d05 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -20000,15 +20000,17 @@ async function selectArtistForDetail(artist) { // Update artist info in header updateArtistDetailHeader(artist); - // Load discography - await loadArtistDiscography(artist.id); + // Load discography (pass artist name for cross-source fallback) + await loadArtistDiscography(artist.id, artist.name); } /** - * Load artist's discography from Spotify + * Load artist's discography from Spotify or iTunes + * @param {string} artistId - Artist ID (Spotify or iTunes format) + * @param {string} [artistName] - Optional artist name for fallback searches */ -async function loadArtistDiscography(artistId) { - console.log(`💿 Loading discography for artist: ${artistId}`); +async function loadArtistDiscography(artistId, artistName = null) { + console.log(`💿 Loading discography for artist: ${artistId} (name: ${artistName})`); // Check cache first if (artistsPageState.cache.discography[artistId]) { @@ -20030,8 +20032,14 @@ async function loadArtistDiscography(artistId) { // Show loading states showDiscographyLoading(); + // Build URL with optional artist name for fallback + let url = `/api/artist/${artistId}/discography`; + if (artistName) { + url += `?artist_name=${encodeURIComponent(artistName)}`; + } + // Call the real API endpoint - const response = await fetch(`/api/artist/${artistId}/discography`); + const response = await fetch(url); if (!response.ok) { if (response.status === 401) {